diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 19251131..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..35255d95 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.gif filter=lfs diff=lfs merge=lfs -text +*.mov filter=lfs diff=lfs merge=lfs -text +*.mp4 filter=lfs diff=lfs merge=lfs -text \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..5ad25d85 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: Ebullioscopic +buy_me_a_coffee: kryoscopic \ No newline at end of file diff --git a/.github/assets/Calendar-v1.2.gif b/.github/assets/Calendar-v1.2.gif new file mode 100644 index 00000000..3e28d47b --- /dev/null +++ b/.github/assets/Calendar-v1.2.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:346ceacfc561f0f93f936e0944808f5e2552067231145066813a0866a817d2e3 +size 2454995 diff --git a/.github/assets/CircularLockScreenWidgets.png b/.github/assets/CircularLockScreenWidgets.png new file mode 100644 index 00000000..c46613db Binary files /dev/null and b/.github/assets/CircularLockScreenWidgets.png differ diff --git a/.github/assets/DND-v1.2.gif b/.github/assets/DND-v1.2.gif new file mode 100644 index 00000000..711c1cbf --- /dev/null +++ b/.github/assets/DND-v1.2.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ed3dca40694a253c355b53498f297f4bca380c342b433f781e984935d81561d +size 1642827 diff --git a/.github/assets/Lockscreen-v1.2.mp4 b/.github/assets/Lockscreen-v1.2.mp4 new file mode 100644 index 00000000..878e7a33 --- /dev/null +++ b/.github/assets/Lockscreen-v1.2.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9481927fd3ee69fe45b46eaecd4cd36959962973773633cf5223ee5f2947fab2 +size 9439130 diff --git a/.github/assets/Minimalistic-v1.2.gif b/.github/assets/Minimalistic-v1.2.gif new file mode 100644 index 00000000..cf18fe36 --- /dev/null +++ b/.github/assets/Minimalistic-v1.2.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:376532eda734d309d3cd590193ee705a66ecb184fd61e3119e330d37aa4c9faa +size 5027057 diff --git a/.github/assets/Non-minimalistic-v1.2.gif b/.github/assets/Non-minimalistic-v1.2.gif new file mode 100644 index 00000000..30063f70 --- /dev/null +++ b/.github/assets/Non-minimalistic-v1.2.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:898ff0070cb0ab2ed816b8e6c243fb1cbd5eb44970114c5f542f37193df3d3ab +size 22260232 diff --git a/.github/assets/Timer-v1.2.gif b/.github/assets/Timer-v1.2.gif new file mode 100644 index 00000000..1093e6b5 --- /dev/null +++ b/.github/assets/Timer-v1.2.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca91dbb5f2f735b56b367d98c0925a863c30dea6496560a15e42d41eae4105d0 +size 3922966 diff --git a/.github/assets/arrow.right.png b/.github/assets/arrow.right.png new file mode 100644 index 00000000..20cc4387 Binary files /dev/null and b/.github/assets/arrow.right.png differ diff --git a/.github/assets/atoll-clipboard.png b/.github/assets/atoll-clipboard.png new file mode 100644 index 00000000..09b3605c Binary files /dev/null and b/.github/assets/atoll-clipboard.png differ diff --git a/.github/assets/atoll-colorpicker.png b/.github/assets/atoll-colorpicker.png new file mode 100644 index 00000000..dc9adbc0 Binary files /dev/null and b/.github/assets/atoll-colorpicker.png differ diff --git a/.github/assets/atoll-demo.gif b/.github/assets/atoll-demo.gif new file mode 100644 index 00000000..7e13136e --- /dev/null +++ b/.github/assets/atoll-demo.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a33f7162af40679fe5250196fb6e79d6ee9e08f3a1f69b9af113fb83d55f1939 +size 27231469 diff --git a/.github/assets/atoll-logo.png b/.github/assets/atoll-logo.png new file mode 100644 index 00000000..e13b268e Binary files /dev/null and b/.github/assets/atoll-logo.png differ diff --git a/.github/assets/atoll-media.png b/.github/assets/atoll-media.png new file mode 100644 index 00000000..14de26aa Binary files /dev/null and b/.github/assets/atoll-media.png differ diff --git a/.github/assets/atoll-stats.png b/.github/assets/atoll-stats.png new file mode 100644 index 00000000..ad6deab4 Binary files /dev/null and b/.github/assets/atoll-stats.png differ diff --git a/.github/assets/background.png b/.github/assets/background.png new file mode 100644 index 00000000..f6ff2323 Binary files /dev/null and b/.github/assets/background.png differ diff --git a/.github/assets/background.with-arrow.png b/.github/assets/background.with-arrow.png new file mode 100644 index 00000000..9062b238 Binary files /dev/null and b/.github/assets/background.with-arrow.png differ diff --git a/.github/assets/dynamicisland.png b/.github/assets/dynamicisland.png new file mode 100644 index 00000000..96031471 Binary files /dev/null and b/.github/assets/dynamicisland.png differ diff --git a/.github/assets/iosdevcentre.jpeg b/.github/assets/iosdevcentre.jpeg new file mode 100644 index 00000000..ca9258f6 Binary files /dev/null and b/.github/assets/iosdevcentre.jpeg differ diff --git a/.github/assets/lockscreen-v1.2.gif b/.github/assets/lockscreen-v1.2.gif new file mode 100644 index 00000000..cc17a0cf --- /dev/null +++ b/.github/assets/lockscreen-v1.2.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbbff3ac2fc835503542947eeef9b06fb09bfb5c02d1a76f2b02eee58eb2d781 +size 63448889 diff --git a/.github/assets/lockscreen.gif b/.github/assets/lockscreen.gif new file mode 100644 index 00000000..e7357518 --- /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 00000000..529f31ae --- /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 00000000..bb361ff0 --- /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/.github/workflows/mirror-release.yml b/.github/workflows/mirror-release.yml new file mode 100644 index 00000000..711439fa --- /dev/null +++ b/.github/workflows/mirror-release.yml @@ -0,0 +1,125 @@ +name: Mirror Release To Organization + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + mirror: + runs-on: ubuntu-latest + + steps: + - name: Checkout source repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + #################################################################### + # Sync Git repository + #################################################################### + + - name: Configure mirror remote + run: | + git remote add org \ + https://x-access-token:${{ secrets.ORG_REPO_TOKEN }}@github.com/Atoll-Labs/Atoll.git + + - name: Push branches + run: | + git push org --all + + - name: Push tags + run: | + git push org --tags + + #################################################################### + # Install GitHub CLI + #################################################################### + + - name: Install GitHub CLI + run: | + sudo apt-get update + sudo apt-get install gh -y + + #################################################################### + # Download release assets + #################################################################### + + - name: Download release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p release-assets + + gh release download \ + "${{ github.event.release.tag_name }}" \ + --repo "${{ github.repository }}" \ + --dir release-assets + + #################################################################### + # Save release notes + #################################################################### + + - name: Save release notes + run: | + cat > release_notes.md <<'EOF' + ${{ github.event.release.body }} + EOF + + #################################################################### + # Create or update release + #################################################################### + + - name: Create release if missing + env: + GH_TOKEN: ${{ secrets.ORG_REPO_TOKEN }} + run: | + TAG="${{ github.event.release.tag_name }}" + + if gh release view "$TAG" \ + --repo Atoll-Labs/Atoll >/dev/null 2>&1; then + + echo "Release already exists." + + else + + gh release create "$TAG" \ + --repo Atoll-Labs/Atoll \ + --title "${{ github.event.release.name }}" \ + --notes-file release_notes.md + + fi + + - name: Update release metadata + env: + GH_TOKEN: ${{ secrets.ORG_REPO_TOKEN }} + run: | + gh release edit \ + "${{ github.event.release.tag_name }}" \ + --repo Atoll-Labs/Atoll \ + --title "${{ github.event.release.name }}" \ + --notes-file release_notes.md + + #################################################################### + # Upload assets + #################################################################### + + - name: Upload assets + env: + GH_TOKEN: ${{ secrets.ORG_REPO_TOKEN }} + run: | + shopt -s nullglob + + files=(release-assets/*) + + if [ ${#files[@]} -gt 0 ]; then + gh release upload \ + "${{ github.event.release.tag_name }}" \ + release-assets/* \ + --repo Atoll-Labs/Atoll \ + --clobber + else + echo "No release assets found." + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index 64e1baa7..3bf470c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,63 @@ +# xcede +.xcede/ +.xcrc +buildServer.json + +.DS_Store +# Xcode +*.xcuserstate +*.xcuserdata +*.xcuserdatad +*.pbxuser +*.xccheckout +*.xcplayground +*.xcuserdatad +*.xctest +*.xcuserdata +*.xcodeproj/xcshareddata/WorkspaceSettings.xcsettings +*.xcsettings +# CocoaPods +Pods/ +podfile.lock + +# Carthage +Carthage/Build/ +/temp/ +# Swift Package Manager +.swiftpm/ +.build/ + +# Derived data +DerivedData/ + +# Build output +build/ +*.app +*.dSYM +*.dmg + +# Temporary files +*.swp +*.swo +*.tmp +*.log + +# Other +*.DS_Store +*.vscode/ +*.idea/ + +# User-specific files +*.xcuserdatad/ +*.xcodeproj/xcuserdata +*.xcodeproj/project.xcuserdata + +# Build artifacts +*.ipa +*.xcarchive +*.dSYM + + # Xcode # build/ @@ -60,5 +120,42 @@ integration.json # GitHub instructions and agent files # -.github/ -rough_note.md \ No newline at end of file +.github/instructions/ +.github/assets/ +rough_note.md +Solid +SettingsView.swift.md +logic.md +TheBoringWorker-HUD +boringnotch + meteo + airpods + traduzione modificabile +segmentedhud.swift.ref +gemini-apireference.md + +ScreenshotApp-main +/stats/ +boring.notch +referenceimages + +PrivateWorks + +*.py +*.sh +comparison_report.json +*.txt +ANIMATION_DEBUG_GUIDE.md +MIGRATION_SUMMARY.md +ONBOARDING_ENHANCEMENT.md +SHEET_PRESENTATION_FIX.md +lock_mode_reference.md +liquid_glass.md +huds.md +borrow_migrations.md +borrowing/ +MoveToSky.xcodeproj +/MoveToSky/ +mew-notch/ + +/References/ +/builds/ +/appdmg.json +COMPREHENSIVE_DOCUMENTATION.md \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..4440578d --- /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 00000000..05e6febe --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,97 @@ +

+ Atoll logo +

+ +# 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/COPYRIGHT_ASSETS b/COPYRIGHT_ASSETS new file mode 100644 index 00000000..181b8f32 --- /dev/null +++ b/COPYRIGHT_ASSETS @@ -0,0 +1,8 @@ +Atoll (DynamicIsland) - Assets Copyright + +Unless otherwise noted, the original assets created specifically for Atoll +(DynamicIsland) are Copyright (C) 2024-2026 Atoll Contributors. + +This includes original media, artwork, animations, and other creative assets +unique to Atoll. Third-party assets retain their original licenses and +copyrights; see NOTICE and any THIRD_PARTY_LICENSES files for details. diff --git a/Contents/Helpers/NowPlayingTestClient b/Contents/Helpers/NowPlayingTestClient new file mode 100755 index 00000000..750e43be Binary files /dev/null and b/Contents/Helpers/NowPlayingTestClient differ diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 90b5436e..dc769530 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -7,24 +7,103 @@ objects = { /* Begin PBXBuildFile section */ + 7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */ = {isa = PBXBuildFile; productRef = 7E61181E2E24071E0050006C /* SkyLightWindow */; }; + 7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7E288D112F1659EF0020C117 /* AtollExtensionKit */; }; 7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 14D032192C68F32E0096E6A1 /* LaunchAtLogin */; }; 7E6118152E24071E0050006C /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 14D0321C2C68F3350096E6A1 /* Sparkle */; }; 7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = B18654382C6F4990000B926A /* KeyboardShortcuts */; }; 7E6118172E24071E0050006C /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 9A987A0F2C73CA8D005CA465 /* Collections */; }; - 7E6118182E24071E0050006C /* TheBoringWorkerNotifier in Frameworks */ = {isa = PBXBuildFile; productRef = 1485442E2C8F049C00943381 /* TheBoringWorkerNotifier */; }; 7E6118192E24071E0050006C /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = B19016212CC15B3D00E3F12E /* Defaults */; }; 7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */; }; 7E61181B2E24071E0050006C /* LottieUI in Frameworks */ = {isa = PBXBuildFile; productRef = B194240A2CD10017003E5DC2 /* LottieUI */; }; 7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 146BC4362D366E9B0047C66A /* MacroVisionKit */; }; + 7E6856482EA963FD00D261F5 /* Lock.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E6856472EA963FD00D261F5 /* Lock.json */; }; + 7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */; }; + 7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D62E9A346700C5CB8C /* Dog waiting.json */; }; + 7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D82E9A347000C5CB8C /* Moody Dog.json */; }; + 7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */; }; + 7E9911B02EFD7D1B002EDFD5 /* Reindeer.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */; }; + 7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; }; + 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; + 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; + 7EF10C122F817ECF004229A0 /* Accelerate.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EF10C112F817ECF004229A0 /* Accelerate.framework */; }; + 7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 7EF19C162F577040005B9929 /* SwiftTerm */; }; /* End PBXBuildFile section */ +/* Begin PBXCopyFilesBuildPhase section */ + 7EA69B1B2F5AE13500EB2D16 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 8; + dstPath = Contents/Helpers; + dstSubfolderSpec = 1; + files = ( + ); + runOnlyForDeploymentPostprocessing = 1; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ - F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DynamicIsland.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7E25ECA02E6C6C3800700E72 /* CoreBrightness.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBrightness.framework; path = /System/Library/PrivateFrameworks/CoreBrightness.framework; sourceTree = ""; }; + 7E25ECA22E6C6E2600700E72 /* OSD.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OSD.framework; path = /System/Library/PrivateFrameworks/OSD.framework; sourceTree = ""; }; + 7E25ECA42E6C6E7800700E72 /* DisplayServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DisplayServices.framework; path = /System/Library/PrivateFrameworks/DisplayServices.framework; sourceTree = ""; }; + 7E6856472EA963FD00D261F5 /* Lock.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Lock.json; sourceTree = ""; }; + 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */ = {isa = PBXFileReference; lastKnownFileType = text.script.perl; path = "mediaremote-adapter.pl"; sourceTree = ""; }; + 7E8840D62E9A346700C5CB8C /* Dog waiting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Dog waiting.json"; sourceTree = ""; }; + 7E8840D82E9A347000C5CB8C /* Moody Dog.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Moody Dog.json"; sourceTree = ""; }; + 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Orange Cat Peeping.json"; sourceTree = ""; }; + 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = ""; }; + 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; }; + 7EF10C112F817ECF004229A0 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; + F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; }; + F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 7EA69B1E2F5AE61200EB2D16 /* Exceptions for "Contents" folder in "DynamicIsland" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Helpers/NowPlayingTestClient, + ); + target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */; + }; + 7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + 7EA69B202F5AE63200EB2D16 /* Exceptions for "Contents" folder in "Copy Files" phase from "DynamicIsland" target */ = { + isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; + attributesByRelativePath = { + Helpers/NowPlayingTestClient = (CodeSignOnCopy, ); + }; + buildPhase = 7EA69B1B2F5AE13500EB2D16 /* CopyFiles */; + membershipExceptions = ( + Helpers/NowPlayingTestClient, + ); + }; +/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ + 7EA69B1C2F5AE15C00EB2D16 /* Contents */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7EA69B1E2F5AE61200EB2D16 /* Exceptions for "Contents" folder in "DynamicIsland" target */, + 7EA69B202F5AE63200EB2D16 /* Exceptions for "Contents" folder in "Copy Files" phase from "DynamicIsland" target */, + ); + path = Contents; + sourceTree = ""; + }; F54406E92CD4987A00C7AFD9 /* DynamicIsland */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */, + ); path = DynamicIsland; sourceTree = ""; }; @@ -35,14 +114,19 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */, + 7EF10C122F817ECF004229A0 /* Accelerate.framework in Frameworks */, + 7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */, + 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */, + 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */, 7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */, 7E61181B2E24071E0050006C /* LottieUI in Frameworks */, 7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */, 7E6118192E24071E0050006C /* Defaults in Frameworks */, - 7E6118182E24071E0050006C /* TheBoringWorkerNotifier in Frameworks */, 7E6118172E24071E0050006C /* Collections in Frameworks */, 7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */, 7E6118152E24071E0050006C /* Sparkle in Frameworks */, + 7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */, 7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -50,9 +134,47 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = { + isa = PBXGroup; + children = ( + 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */, + F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */, + ); + path = "mediaremote-adapter"; + sourceTree = ""; + }; + 7E81304F2E3FB42700E08F3F /* Frameworks */ = { + isa = PBXGroup; + children = ( + 7EF10C112F817ECF004229A0 /* Accelerate.framework */, + 7E25ECA02E6C6C3800700E72 /* CoreBrightness.framework */, + 7E25ECA42E6C6E7800700E72 /* DisplayServices.framework */, + 7E25ECA22E6C6E2600700E72 /* OSD.framework */, + 7EE1D73E2E6B1E1200997219 /* IOKit.framework */, + 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 7E8840D52E9A341F00C5CB8C /* LottieAnimations */ = { + isa = PBXGroup; + children = ( + 7E6856472EA963FD00D261F5 /* Lock.json */, + 7E8840D62E9A346700C5CB8C /* Dog waiting.json */, + 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */, + 7E8840D82E9A347000C5CB8C /* Moody Dog.json */, + 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */, + ); + path = LottieAnimations; + sourceTree = ""; + }; F54406DE2CD4987A00C7AFD9 = { isa = PBXGroup; children = ( + 7EA69B1C2F5AE15C00EB2D16 /* Contents */, + 7E8840D52E9A341F00C5CB8C /* LottieAnimations */, + 7E81304F2E3FB42700E08F3F /* Frameworks */, + 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */, F54406E92CD4987A00C7AFD9 /* DynamicIsland */, F54406E82CD4987A00C7AFD9 /* Products */, ); @@ -61,7 +183,7 @@ F54406E82CD4987A00C7AFD9 /* Products */ = { isa = PBXGroup; children = ( - F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */, + F54406E72CD4987A00C7AFD9 /* Atoll.app */, ); name = Products; sourceTree = ""; @@ -76,12 +198,15 @@ F54406E32CD4987A00C7AFD9 /* Sources */, F54406E42CD4987A00C7AFD9 /* Frameworks */, F54406E52CD4987A00C7AFD9 /* Resources */, + 7EA69B1B2F5AE13500EB2D16 /* CopyFiles */, + 7EFAA8AA2F6F12F3000A99E8 /* ShellScript */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( + 7EA69B1C2F5AE15C00EB2D16 /* Contents */, F54406E92CD4987A00C7AFD9 /* DynamicIsland */, ); name = DynamicIsland; @@ -90,14 +215,16 @@ 14D0321C2C68F3350096E6A1 /* Sparkle */, B18654382C6F4990000B926A /* KeyboardShortcuts */, 9A987A0F2C73CA8D005CA465 /* Collections */, - 1485442E2C8F049C00943381 /* TheBoringWorkerNotifier */, B19016212CC15B3D00E3F12E /* Defaults */, B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */, B194240A2CD10017003E5DC2 /* LottieUI */, 146BC4362D366E9B0047C66A /* MacroVisionKit */, + 7E61181E2E24071E0050006C /* SkyLightWindow */, + 7E288D112F1659EF0020C117 /* AtollExtensionKit */, + 7EF19C162F577040005B9929 /* SwiftTerm */, ); productName = DynamicIsland; - productReference = F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */; + productReference = F54406E72CD4987A00C7AFD9 /* Atoll.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -120,7 +247,11 @@ hasScannedForEncodings = 0; knownRegions = ( en, + tr, Base, + hi, + ta, + gu, ); mainGroup = F54406DE2CD4987A00C7AFD9; minimizedProjectReferenceProxies = 1; @@ -130,11 +261,14 @@ 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */, B18654372C6F4990000B926A /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */, 9A987A112C73CAA1005CA465 /* XCRemoteSwiftPackageReference "Pow" */, - 1485442D2C8F049C00943381 /* XCRemoteSwiftPackageReference "TheBoringWorkerNotifier" */, B19016202CC15B3D00E3F12E /* XCRemoteSwiftPackageReference "Defaults" */, B1628B902CC260C0003D8DF3 /* XCRemoteSwiftPackageReference "swiftui-introspect" */, B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */, 146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */, + 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */, + 7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */, + 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */, + 7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */, ); preferredProjectObjectVersion = 77; productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */; @@ -151,11 +285,39 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */, + 7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */, + 7E6856482EA963FD00D261F5 /* Lock.json in Resources */, + 7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */, + 7E9911B02EFD7D1B002EDFD5 /* Reindeer.json in Resources */, + 7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */, + 7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 7EFAA8AA2F6F12F3000A99E8 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"$CONFIGURATION\" != \"Release\" ] || [ \"$ACTION\" != \"install\" ]; then\n\texit 0\nfi\n\nAPP=\"$CODESIGNING_FOLDER_PATH\"\nSPARKLE=\"$APP/Contents/Frameworks/Sparkle.framework\"\nXPC_DIR=\"$SPARKLE/Versions/Current/XPCServices\"\n\nif [ ! -d \"$XPC_DIR\" ]; then\n\texit 0\nfi\n\nfor xpc in \"$XPC_DIR\"/*.xpc; do\n\t/usr/bin/codesign --force --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" --timestamp --options runtime --preserve-metadata=identifier,entitlements \"$xpc\"\ndone\n\n/usr/bin/codesign --force --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" --timestamp --options runtime --preserve-metadata=identifier,entitlements \"$SPARKLE\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ F54406E32CD4987A00C7AFD9 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -204,7 +366,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -267,7 +429,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -290,25 +452,70 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; + DEVELOPMENT_TEAM = 9Y64TRM77N; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = YES; + ENABLE_RESOURCE_ACCESS_CAMERA = YES; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = YES; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)", + "$(PROJECT_DIR)/Frameworks", + "$(PROJECT_DIR)/mediaremote-adapter", + ); GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music"; + INFOPLIST_FILE = DynamicIsland/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Atoll; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_MetalCaptureEnabled = YES; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "Atoll uses AppleScripts to control Spotify and Apple Music"; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Atoll 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 = "Atoll needs access to save screenshots to your desktop."; + INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Atoll needs access to save and read shelf dragged files."; + INFOPLIST_KEY_NSDownloadsFolderUsageDescription = "Atoll needs access to read files in the downloads folder."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSLocationUsageDescription = "Atoll uses your location to display accurate lock screen weather."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Atoll uses your location to display accurate lock screen weather."; + INFOPLIST_KEY_NSRemindersUsageDescription = "Atoll needs Reminders access to show your reminders"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland; - PRODUCT_NAME = "$(TARGET_NAME)"; + MACOSX_DEPLOYMENT_TARGET = 14.6; + MARKETING_VERSION = 2.2.0; + PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll.dev; + PRODUCT_NAME = Atoll; + PROVISIONING_PROFILE_SPECIFIER = ""; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "DynamicIsland/DynamicIsland-Bridging-Header.h"; SWIFT_VERSION = 5.0; + SYSTEM_FRAMEWORK_SEARCH_PATHS = ( + "$(SDKROOT)/System/Library/Frameworks", + "$(SDKROOT)/System/Library/PrivateFrameworks", + ); }; name = Debug; }; @@ -317,25 +524,71 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = YES; + ENABLE_RESOURCE_ACCESS_CAMERA = YES; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = YES; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)", + "$(PROJECT_DIR)/Frameworks", + "$(PROJECT_DIR)/mediaremote-adapter", + ); GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music"; + INFOPLIST_FILE = DynamicIsland/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Atoll; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_MetalCaptureEnabled = YES; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "Atoll uses AppleScripts to control Spotify and Apple Music"; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Atoll 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 = "Atoll needs access to save screenshots to your desktop."; + INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Atoll needs access to save and read shelf dragged files."; + INFOPLIST_KEY_NSDownloadsFolderUsageDescription = "Atoll needs access to read files in the downloads folder."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSLocationUsageDescription = "Atoll uses your location to display accurate lock screen weather."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Atoll uses your location to display accurate lock screen weather."; + INFOPLIST_KEY_NSRemindersUsageDescription = "Atoll needs Reminders access to show your reminders"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland; - PRODUCT_NAME = "$(TARGET_NAME)"; + MACOSX_DEPLOYMENT_TARGET = 14.6; + MARKETING_VERSION = 2.2.0; + PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll; + PRODUCT_NAME = Atoll; + PROVISIONING_PROFILE_SPECIFIER = ""; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "DynamicIsland/DynamicIsland-Bridging-Header.h"; SWIFT_VERSION = 5.0; + SYSTEM_FRAMEWORK_SEARCH_PATHS = ( + "$(SDKROOT)/System/Library/Frameworks", + "$(SDKROOT)/System/Library/PrivateFrameworks", + ); }; name = Release; }; @@ -371,28 +624,52 @@ minimumVersion = 0.1.0; }; }; - 1485442D2C8F049C00943381 /* XCRemoteSwiftPackageReference "TheBoringWorkerNotifier" */ = { + 14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/TheBoredTeam/TheBoringWorkerNotifier.git"; + repositoryURL = "https://github.com/sindresorhus/LaunchAtLogin-Modern"; requirement = { branch = main; kind = branch; }; }; - 14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */ = { + 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/sindresorhus/LaunchAtLogin-Modern"; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + 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; + }; + }; + 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Ebullioscopic/AtollExtensionKit"; requirement = { branch = main; kind = branch; }; }; - 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/sparkle-project/Sparkle"; + repositoryURL = "https://github.com/Lakr233/SkyLightWindow"; + requirement = { + branch = main; + kind = branch; + }; + }; + 7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/migueldeicaza/SwiftTerm"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.6.4; + minimumVersion = 1.5.0; }; }; 9A987A112C73CAA1005CA465 /* XCRemoteSwiftPackageReference "Pow" */ = { @@ -451,11 +728,6 @@ package = 146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */; productName = MacroVisionKit; }; - 1485442E2C8F049C00943381 /* TheBoringWorkerNotifier */ = { - isa = XCSwiftPackageProductDependency; - package = 1485442D2C8F049C00943381 /* XCRemoteSwiftPackageReference "TheBoringWorkerNotifier" */; - productName = TheBoringWorkerNotifier; - }; 14D032192C68F32E0096E6A1 /* LaunchAtLogin */ = { isa = XCSwiftPackageProductDependency; package = 14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */; @@ -466,6 +738,21 @@ package = 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + 7E288D112F1659EF0020C117 /* AtollExtensionKit */ = { + isa = XCSwiftPackageProductDependency; + package = 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */; + productName = AtollExtensionKit; + }; + 7E61181E2E24071E0050006C /* SkyLightWindow */ = { + isa = XCSwiftPackageProductDependency; + package = 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */; + productName = SkyLightWindow; + }; + 7EF19C162F577040005B9929 /* SwiftTerm */ = { + isa = XCSwiftPackageProductDependency; + package = 7EF19C152F577040005B9929 /* XCRemoteSwiftPackageReference "SwiftTerm" */; + productName = SwiftTerm; + }; 9A987A0F2C73CA8D005CA465 /* Collections */ = { isa = XCSwiftPackageProductDependency; package = F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */; diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d8400f26..d6b56aae 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "2741287b65cf0758abd89baadf314f5f8cbfcb46cb8d236a83f98731e5501ee3", + "originHash" : "d05fd8c8292418b6563db3202a99d4ef453b0f70d83d4d4cf879984c14452017", "pins" : [ + { + "identity" : "atollextensionkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ebullioscopic/AtollExtensionKit", + "state" : { + "branch" : "main", + "revision" : "e2d30afedbbcc259bec7eeb1bea0757e52f4ca01" + } + }, { "identity" : "defaults", "kind" : "remoteSourceControl", @@ -34,7 +43,7 @@ "location" : "https://github.com/airbnb/lottie-spm.git", "state" : { "branch" : "main", - "revision" : "90fa25ba0feb39c22915d41b55226cc95955dfcc" + "revision" : "04f2fd18cc9404a0a0917265a449002674f24ec9" } }, { @@ -55,6 +64,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", @@ -64,6 +82,15 @@ "version" : "1.0.5" } }, + { + "identity" : "skylightwindow", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/SkyLightWindow", + "state" : { + "branch" : "main", + "revision" : "4a7e8628c85e6b2ec429b57657a556a2171a4864" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", @@ -73,6 +100,15 @@ "version" : "2.7.0" } }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -92,21 +128,21 @@ } }, { - "identity" : "swiftui-introspect", + "identity" : "swiftterm", "kind" : "remoteSourceControl", - "location" : "https://github.com/siteline/swiftui-introspect", + "location" : "https://github.com/migueldeicaza/SwiftTerm", "state" : { - "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", - "version" : "1.3.0" + "revision" : "8e7a1e154f470e19c709a00a8768df348ba5fc43", + "version" : "1.13.0" } }, { - "identity" : "theboringworkernotifier", + "identity" : "swiftui-introspect", "kind" : "remoteSourceControl", - "location" : "https://github.com/TheBoredTeam/TheBoringWorkerNotifier.git", + "location" : "https://github.com/siteline/swiftui-introspect", "state" : { - "branch" : "main", - "revision" : "bc2b292c88035dcfc41fc7e3d8021689e624d137" + "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", + "version" : "1.3.0" } } ], diff --git a/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme b/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme new file mode 100644 index 00000000..4a6513ad --- /dev/null +++ b/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 00000000..3cd3539c 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 00000000..3cd3539c 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 00000000..bc0d91f0 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 00000000..bc0d91f0 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 00000000..b5d2a7e3 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 00000000..5b365dc0 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 00000000..569ebed5 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 00000000..569ebed5 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 00000000..dbf4bc7d 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 00000000..e13b268e 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 519bc192..dfb15684 100644 --- a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,61 +1,61 @@ { "images" : [ { - "filename" : "notch-stage-icon2 2.png", + "filename" : "Atoll_1024 (6).png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { - "filename" : "notch-stage-icon2 5.png", + "filename" : "Atoll_1024 (5) 1.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { - "filename" : "notch-stage-icon2 6.png", + "filename" : "Atoll_1024 (5).png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { - "filename" : "notch-stage-icon2 11.png", + "filename" : "Atoll_1024 (4).png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { - "filename" : "notch-stage-icon2 12.png", + "filename" : "Atoll_1024 (3).png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { - "filename" : "notch-stage-icon2 13.png", + "filename" : "Atoll_1024 (2) 1.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { - "filename" : "notch-stage-icon2 7.png", + "filename" : "Atoll_1024 (2).png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { - "filename" : "notch-stage-icon2 8.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 f114f258..00000000 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 11.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 11.png deleted file mode 100644 index 166997a0..00000000 Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 11.png and /dev/null differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 12.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 12.png deleted file mode 100644 index e0ab74d1..00000000 Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 12.png and /dev/null differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 13.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 13.png deleted file mode 100644 index c2d2b438..00000000 Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 13.png and /dev/null differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 2.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 2.png deleted file mode 100644 index 2b897b30..00000000 Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 2.png and /dev/null differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 5.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 5.png deleted file mode 100644 index 394b128e..00000000 Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 5.png and /dev/null differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 6.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 6.png deleted file mode 100644 index 394b128e..00000000 Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 6.png and /dev/null differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 7.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 7.png deleted file mode 100644 index c2d2b438..00000000 Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 7.png and /dev/null differ diff --git a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 8.png b/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 8.png deleted file mode 100644 index 770e71c7..00000000 Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 8.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 770e71c7..00000000 Binary files a/DynamicIsland/Assets.xcassets/AppIcon.appiconset/notch-stage-icon2 9.png and /dev/null differ diff --git a/DynamicIsland/Assets.xcassets/LinkedIn.imageset/Contents.json b/DynamicIsland/Assets.xcassets/LinkedIn.imageset/Contents.json new file mode 100644 index 00000000..4b54c4b9 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/LinkedIn.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "LinkedIn_logo_initials.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/LinkedIn.imageset/LinkedIn_logo_initials.png b/DynamicIsland/Assets.xcassets/LinkedIn.imageset/LinkedIn_logo_initials.png new file mode 100644 index 00000000..1d878e60 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/LinkedIn.imageset/LinkedIn_logo_initials.png differ diff --git a/DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json b/DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json new file mode 100644 index 00000000..463ed06c --- /dev/null +++ b/DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "localsend.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png b/DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png new file mode 100644 index 00000000..5a5f3c18 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png differ diff --git a/DynamicIsland/Assets.xcassets/battery.100percent.slash.symbolset/Contents.json b/DynamicIsland/Assets.xcassets/battery.100percent.slash.symbolset/Contents.json new file mode 100644 index 00000000..67a74cdb --- /dev/null +++ b/DynamicIsland/Assets.xcassets/battery.100percent.slash.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "battery.100percent.slash.svg", + "idiom" : "universal" + } + ] +} diff --git a/DynamicIsland/Assets.xcassets/battery.100percent.slash.symbolset/battery.100percent.slash.svg b/DynamicIsland/Assets.xcassets/battery.100percent.slash.symbolset/battery.100percent.slash.svg new file mode 100644 index 00000000..f7776105 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/battery.100percent.slash.symbolset/battery.100percent.slash.svg @@ -0,0 +1,121 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.7.0 + Requires Xcode 17 or greater + Generated from + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DynamicIsland/Assets.xcassets/desktop.imageset/Contents.json b/DynamicIsland/Assets.xcassets/desktop.imageset/Contents.json new file mode 100644 index 00000000..a0602d51 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/desktop.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "desktop.jpeg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/desktop.imageset/desktop.jpeg b/DynamicIsland/Assets.xcassets/desktop.imageset/desktop.jpeg new file mode 100644 index 00000000..8a68f43b Binary files /dev/null and b/DynamicIsland/Assets.xcassets/desktop.imageset/desktop.jpeg differ diff --git a/DynamicIsland/Assets.xcassets/dynamicisland.imageset/TheBoringTeam.svg b/DynamicIsland/Assets.xcassets/dynamicisland.imageset/TheBoringTeam.svg deleted file mode 100644 index 032a961d..00000000 --- a/DynamicIsland/Assets.xcassets/dynamicisland.imageset/TheBoringTeam.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/DynamicIsland/Assets.xcassets/ebullioscopic.imageset/819b59197be07c800478c728143326f1.png b/DynamicIsland/Assets.xcassets/ebullioscopic.imageset/819b59197be07c800478c728143326f1.png new file mode 100644 index 00000000..3dd45898 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/ebullioscopic.imageset/819b59197be07c800478c728143326f1.png differ diff --git a/DynamicIsland/Assets.xcassets/dynamicisland.imageset/Contents.json b/DynamicIsland/Assets.xcassets/ebullioscopic.imageset/Contents.json similarity index 67% rename from DynamicIsland/Assets.xcassets/dynamicisland.imageset/Contents.json rename to DynamicIsland/Assets.xcassets/ebullioscopic.imageset/Contents.json index d6b705b9..0b02b6d9 100644 --- a/DynamicIsland/Assets.xcassets/dynamicisland.imageset/Contents.json +++ b/DynamicIsland/Assets.xcassets/ebullioscopic.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "TheBoringTeam.svg", + "filename" : "819b59197be07c800478c728143326f1.png", "idiom" : "universal" } ], diff --git a/DynamicIsland/Assets.xcassets/glassdesktop.imageset/6EDAFE63-F31B-44D9-A167-0D7EC8B02C66_1_201_a.jpeg b/DynamicIsland/Assets.xcassets/glassdesktop.imageset/6EDAFE63-F31B-44D9-A167-0D7EC8B02C66_1_201_a.jpeg new file mode 100644 index 00000000..2cdaa7d5 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/glassdesktop.imageset/6EDAFE63-F31B-44D9-A167-0D7EC8B02C66_1_201_a.jpeg differ diff --git a/DynamicIsland/Assets.xcassets/glassdesktop.imageset/Contents.json b/DynamicIsland/Assets.xcassets/glassdesktop.imageset/Contents.json new file mode 100644 index 00000000..f47328b1 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/glassdesktop.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "6EDAFE63-F31B-44D9-A167-0D7EC8B02C66_1_201_a.jpeg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/logo.imageset/256-mac 1.png b/DynamicIsland/Assets.xcassets/logo.imageset/256-mac 1.png deleted file mode 100644 index 3bc82b07..00000000 Binary files a/DynamicIsland/Assets.xcassets/logo.imageset/256-mac 1.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 00000000..e89683e2 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/logo.imageset/Atoll.png differ diff --git a/DynamicIsland/Assets.xcassets/logo.imageset/Contents.json b/DynamicIsland/Assets.xcassets/logo.imageset/Contents.json index e05b766d..67d91aef 100644 --- a/DynamicIsland/Assets.xcassets/logo.imageset/Contents.json +++ b/DynamicIsland/Assets.xcassets/logo.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "256-mac 1.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 00000000..e13b268e Binary files /dev/null and b/DynamicIsland/Assets.xcassets/logo2.imageset/Atoll_1024.png differ diff --git a/DynamicIsland/Assets.xcassets/logo2.imageset/BoringNotch icon.png b/DynamicIsland/Assets.xcassets/logo2.imageset/BoringNotch icon.png deleted file mode 100644 index d233e5f7..00000000 Binary files a/DynamicIsland/Assets.xcassets/logo2.imageset/BoringNotch icon.png and /dev/null differ diff --git a/DynamicIsland/Assets.xcassets/logo2.imageset/Contents.json b/DynamicIsland/Assets.xcassets/logo2.imageset/Contents.json index 37d84e39..181d5ebe 100644 --- a/DynamicIsland/Assets.xcassets/logo2.imageset/Contents.json +++ b/DynamicIsland/Assets.xcassets/logo2.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "BoringNotch icon.png", + "filename" : "Atoll_1024.png", "idiom" : "universal" } ], diff --git a/DynamicIsland/BluetoothHUDAnimations/airpods.mov b/DynamicIsland/BluetoothHUDAnimations/airpods.mov new file mode 100755 index 00000000..96e69a19 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpods.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54332f65534c9af667a302e9c2df17e4420bffb94e93cecd0091b11a05e70efc +size 351501 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsGen3.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsGen3.mov new file mode 100755 index 00000000..37b635e3 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsGen3.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:329be57b63f437e3329772b688440b825ae4b27ece16ac33c483d43b3725d0cf +size 391452 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsGen4.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsGen4.mov new file mode 100644 index 00000000..089fda03 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsGen4.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04b9bce0be1bb910c10d7466b795e0909cbf70d6b62e5f227124d7c67bc9b53a +size 352641 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsMax.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsMax.mov new file mode 100755 index 00000000..1cab6caf --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsMax.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b587c84649e0f3986d59b46fab527a15b2c8146eb9c57a523a2e899afe41ac20 +size 476065 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsPro.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsPro.mov new file mode 100755 index 00000000..ee763592 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsPro.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2be0bc5150dd21d85c9d64a18ec5a782279e30980996e726a6c8d3a8cecb775 +size 463126 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsPro3.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsPro3.mov new file mode 100644 index 00000000..e03ff48e --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsPro3.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df3275c52a9599dbd5768aba279c8904037925bbed6a02a2166a0a01fb730875 +size 398759 diff --git a/DynamicIsland/BluetoothHUDAnimations/beatssolo.mov b/DynamicIsland/BluetoothHUDAnimations/beatssolo.mov new file mode 100644 index 00000000..5e1cbb76 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/beatssolo.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c59f43e4f3446a163dde871035971eb1482475051dbc67e5f6a771b5fb04f13 +size 393932 diff --git a/DynamicIsland/BluetoothHUDAnimations/beatsstudio.mov b/DynamicIsland/BluetoothHUDAnimations/beatsstudio.mov new file mode 100644 index 00000000..2a772ba4 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/beatsstudio.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:835ba4373e8f3dd85e2ff77655eb9464ed07a58687b3b79b51a4535c5febd26a +size 443917 diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index ff587ec6..2487d17a 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -1,18 +1,40 @@ -// -// ContentView.swift -// DynamicIslandApp -// -// Created by Harsh Vardhan Goswami on 02/08/24 -// Modified by Richard Kunkli on 24/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import AVFoundation import Combine import Defaults +import Foundation import KeyboardShortcuts import SwiftUI import SwiftUIIntrospect +import AtollExtensionKit +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif +@MainActor struct ContentView: View { @EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var webcamManager: WebcamManager @@ -20,159 +42,817 @@ struct ContentView: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var musicManager = MusicManager.shared @ObservedObject var timerManager = TimerManager.shared + @ObservedObject var reminderManager = ReminderLiveActivityManager.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared + @ObservedObject var statsManager = StatsManager.shared + @ObservedObject var recordingManager = ScreenRecordingManager.shared + @ObservedObject var privacyManager = PrivacyIndicatorManager.shared + @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared + @ObservedObject var lockScreenManager = LockScreenManager.shared + @ObservedObject var capsLockManager = CapsLockManager.shared + @ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared + @ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared + @ObservedObject var localSendService = LocalSendService.shared + @State private var downloadManager = DownloadManager.shared + @ObservedObject var shelfState = ShelfStateViewModel.shared + @Default(.enableStatsFeature) var enableStatsFeature + @Default(.showCpuGraph) var showCpuGraph + @Default(.showMemoryGraph) var showMemoryGraph + @Default(.showGpuGraph) var showGpuGraph + @Default(.showNetworkGraph) var showNetworkGraph + @Default(.showDiskGraph) var showDiskGraph + @Default(.enableReminderLiveActivity) var enableReminderLiveActivity + @Default(.enableTimerFeature) var enableTimerFeature + @Default(.timerDisplayMode) var timerDisplayMode + @Default(.enableHorizontalMusicGestures) var enableHorizontalMusicGestures + @Default(.reminderPresentationStyle) var reminderPresentationStyle + @Default(.timerShowsCountdown) var timerShowsCountdown + @Default(.timerShowsProgress) var timerShowsProgress + @Default(.timerProgressStyle) var timerProgressStyle + @Default(.timerIconColorMode) var timerIconColorMode + @Default(.timerSolidColor) var timerSolidColor + @Default(.timerPresets) var timerPresets + @Default(.showCapsLockLabel) var showCapsLockLabel + @Default(.capsLockIndicatorTintMode) var capsLockTintMode + @Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection + @Default(.showDoNotDisturbIndicator) var showDoNotDisturbIndicator + @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection + @Default(.enableCapsLockIndicator) var enableCapsLockIndicator + @Default(.enableExtensionLiveActivities) var enableExtensionLiveActivities + @Default(.showStandardMediaControls) var showStandardMediaControls + @Default(.externalDisplayStyle) var externalDisplayStyle + @Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover + @Default(.terminalStickyMode) var terminalStickyMode + + // Battery settings reactivity + @Default(.showPowerStatusNotifications) var showPowerStatusNotifications + @Default(.showChargingBatteryHUD) var showChargingBatteryHUD + @Default(.showLowBatteryHUD) var showLowBatteryHUD + @Default(.showFullBatteryHUD) var showFullBatteryHUD + @Default(.showOnAllDisplays) var showOnAllDisplays + @Default(.lowBatteryHUDStyle) var lowBatteryHUDStyle + @Default(.fullBatteryHUDStyle) var fullBatteryHUDStyle + + // Dynamic sizing based on view type and graph count with smooth transitions + var dynamicNotchSize: CGSize { + let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize + + // When inline sneak peek is active in closed notch, use the wider inline width + // so the outer maxWidth frame doesn't clip the expanded content + let inlineSneakPeekActive = vm.notchState == .closed + && coordinator.expandingView.show + && (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) + && Defaults[.enableSneakPeek] + && Defaults[.sneakPeekStyles] == .inline + if inlineSneakPeekActive { + let inlineWidth: CGFloat = 460 + return CGSize(width: max(baseSize.width, inlineWidth), height: baseSize.height) + } + + // Handle battery HUD expansion sizing + if vm.notchState == .closed && + coordinator.expandingView.show && + coordinator.expandingView.type == .battery && + isBatteryHUDVisibleOnCurrentScreen { + + if let kind = batteryModel.activeTemporaryHUDKind { + let style: BatteryNotificationStyle = { + switch kind { + case .charging: return .compact + case .lowBattery: return Defaults[.lowBatteryHUDStyle] + case .fullBattery: return Defaults[.fullBatteryHUDStyle] + } + }() + + var width = vm.closedNotchSize.width + var height = vm.effectiveClosedNotchHeight + + switch (kind, style) { + case (.charging, _), (.lowBattery, .compact), (.fullBattery, .compact): + width += 180 + case (.lowBattery, .standard): + width += 100 + height += 75 + case (.fullBattery, .standard): + width += 80 + height += 70 + } + + return CGSize(width: width, height: height) + } + } + + if coordinator.currentView == .timer { + return CGSize(width: baseSize.width, height: 250) // Extra height for timer presets + } + + if coordinator.currentView == .notes || coordinator.currentView == .clipboard { + let preferredHeight = coordinator.notesLayoutState.preferredHeight + let resolvedHeight = max(baseSize.height, preferredHeight) + return CGSize(width: baseSize.width, height: resolvedHeight) + } - @State private var isHovering: Bool = false - @State private var hoverWorkItem: DispatchWorkItem? - @State private var debounceWorkItem: DispatchWorkItem? + if coordinator.currentView == .terminal { + // Dynamic height: up to terminalMaxHeightFraction of screen, min 300pt + let screenHeight = NSScreen.main?.visibleFrame.height ?? 800 + let maxFraction = Defaults[.terminalMaxHeightFraction] + let terminalHeight = min(screenHeight * maxFraction, max(300, screenHeight * maxFraction)) + return CGSize(width: baseSize.width, height: terminalHeight) + } + + if coordinator.currentView == .extensionExperience { + if let preferredHeight = extensionTabPreferredHeight(baseSize: baseSize) { + return CGSize(width: baseSize.width, height: preferredHeight) + } + return baseSize + } + + if enableMinimalisticUI, + coordinator.currentView == .home, + let preferredHeight = extensionMinimalisticPreferredHeight(baseSize: baseSize) { + return CGSize(width: baseSize.width, height: preferredHeight) + } + + guard coordinator.currentView == .stats else { + return baseSize + } + + let rows = statsRowCount() + if rows <= 1 { + return baseSize + } + + let additionalRows = max(rows - 1, 0) + let extraHeight = CGFloat(additionalRows) * statsAdditionalRowHeight + return CGSize(width: baseSize.width, height: baseSize.height + extraHeight) + } - @State private var isHoverStateChanging: Bool = false + + @State private var hoverTask: Task? + @State private var isHovering: Bool = false + @State private var lastHapticTime: Date = Date() + @State private var hoverClickMonitor: Any? + @State private var hoverClickLocalMonitor: Any? + @State private var stickyTerminalClickMonitor: Any? + @State private var hiddenEdgeHoverPollingTask: Task? + @State private var isHoveringClosedMusicWaveformControl: Bool = false @State private var gestureProgress: CGFloat = .zero + @State private var skipGestureActiveDirection: MusicManager.SkipDirection? + @State private var isMusicControlWindowVisible = false + @State private var pendingMusicControlTask: Task? + @State private var musicControlHideTask: Task? + @State private var musicControlVisibilityDeadline: Date? + @State private var isMusicControlWindowSuppressed = false + @State private var hasPendingMusicControlSync = false + @State private var pendingMusicControlForceRefresh = false + @State private var musicControlSuppressionTask: Task? @State private var haptics: Bool = false @Namespace var albumArtNamespace @Default(.useMusicVisualizer) var useMusicVisualizer - + @Default(.musicControlWindowEnabled) var musicControlWindowEnabled @Default(.showNotHumanFace) var showNotHumanFace @Default(.useModernCloseAnimation) var useModernCloseAnimation + @Default(.enableMinimalisticUI) var enableMinimalisticUI + + private static let musicControlLogFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter + }() + + private func logMusicControlEvent(_ message: String) { +#if DEBUG + let timestamp = Self.musicControlLogFormatter.string(from: Date()) + print("[MusicControl] \(timestamp): \(message)") +#endif + } - private let extendedHoverPadding: CGFloat = 30 + private func runAfter(_ delay: TimeInterval, _ action: @escaping @Sendable @MainActor () -> Void) { + guard delay >= 0 else { return } + Task { @MainActor in + let nanoseconds = UInt64(delay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + action() + } + } + + private func requestMusicControlWindowSyncIfHidden(forceRefresh: Bool = false, delay: TimeInterval = 0) { + guard !isMusicControlWindowVisible else { return } + enqueueMusicControlWindowSync(forceRefresh: forceRefresh, delay: delay) + } + private var dynamicNotchResizeAnimation: Animation? { + nil + } + private let zeroHeightHoverPadding: CGFloat = 10 + private let statsAdditionalRowHeight: CGFloat = statsSecondRowContentHeight + statsGridSpacingHeight + private let musicControlPauseGrace: TimeInterval = 5 + private let musicControlResumeDelay: TimeInterval = 0.24 + + // MARK: - Tab switch direction for smooth transitions + + private var tabSwitchTransition: AnyTransition { + if coordinator.tabSwitchForward { + return .asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + ) + } else { + return .asymmetric( + insertion: .move(edge: .leading).combined(with: .opacity), + removal: .move(edge: .trailing).combined(with: .opacity) + ) + } + } + + private var standardMediaControlsActive: Bool { + showStandardMediaControls && !enableMinimalisticUI + } + + private var closedMusicContentEnabled: Bool { + enableMinimalisticUI || showStandardMediaControls + } + + private var isMusicHUDDeferredAfterUnlock: Bool { + lockScreenManager.shouldDelayPostUnlockMusicHUD + } + + private var interactionsEnabled: Bool { + !lockScreenManager.isLocked + } + + private var isIslandMode: Bool { + isDynamicIslandMode + } + + private var notchHorizontalPadding: CGFloat { + guard vm.notchState == .open else { + return activeCornerRadiusInsets.closed.bottom + } + if Defaults[.cornerRadiusScaling] { + return activeCornerRadiusInsets.opened.top - 5 + } + return activeCornerRadiusInsets.opened.bottom - 5 + } + + private var bodyHoverAreaPadding: CGFloat { + if vm.notchState == .open && Defaults[.extendHoverArea] { + return 0 + } + return vm.effectiveClosedNotchHeight == 0 ? zeroHeightHoverPadding : 0 + } + + private var notchBottomPadding: CGFloat { + currentShadowPadding + bodyHoverAreaPadding + } + + private var pillTopOffset: CGFloat { + isIslandMode ? dynamicIslandTopOffset : 0 + } + + private func closedMusicPairingEligible(hasActiveMusicSnapshot: Bool) -> Bool { + vm.notchState == .closed + && hasActiveMusicSnapshot + && coordinator.musicLiveActivityEnabled + && closedMusicContentEnabled + && !vm.hideOnClosed + && !lockScreenManager.isLocked + && !isMusicHUDDeferredAfterUnlock + } + + private var closedLiveActivitySwapTransition: AnyTransition { + .asymmetric( + insertion: .opacity + .combined(with: .scale(scale: 0.965, anchor: .center)) + .animation(.spring(response: 0.34, dampingFraction: 0.88)), + removal: .opacity + .combined(with: .scale(scale: 0.92, anchor: .center)) + .animation(.smooth(duration: 0.22)) + ) + } + + // Use minimalistic corner radius ONLY when opened, keep normal when closed + private var activeCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) { + if enableMinimalisticUI { + // Keep normal closed corner radius, use minimalistic when opened + return (opened: minimalisticCornerRadiusInsets.opened, closed: cornerRadiusInsets.closed) + } + return cornerRadiusInsets + } + + private var currentShadowPadding: CGFloat { + notchShadowPaddingValue(isMinimalistic: enableMinimalisticUI) + } + + private var currentNotchShape: NotchShape { + let topRadius = (vm.notchState == .open && Defaults[.cornerRadiusScaling]) + ? activeCornerRadiusInsets.opened.top + : activeCornerRadiusInsets.closed.top + let bottomRadius = (vm.notchState == .open && Defaults[.cornerRadiusScaling]) + ? activeCornerRadiusInsets.opened.bottom + : activeCornerRadiusInsets.closed.bottom + return NotchShape(topCornerRadius: topRadius, bottomCornerRadius: bottomRadius) + } + + /// Whether the current screen should render as a Dynamic Island pill + /// rather than the standard notch shape. Always false on physical notch screens. + private var isDynamicIslandMode: Bool { + shouldUseDynamicIslandMode(for: currentScreenName) + } + + private var currentScreenName: String { + vm.screen ?? coordinator.selectedScreen + } + + /// Whether the current screen lacks a physical notch. + private var isNonNotchScreen: Bool { + guard let screen = NSScreen.screens.first(where: { $0.localizedName == currentScreenName }) else { + return true + } + return screen.safeAreaInsets.top <= 0 + } + + /// Whether the global sneak peek is visible on this specific screen. + private var isSneakPeekVisibleOnCurrentScreen: Bool { + guard coordinator.sneakPeek.show else { return false } + guard Defaults[.showOnAllDisplays] else { return true } + guard let targetScreenName = coordinator.sneakPeek.targetScreenName else { return true } + return currentScreenName == targetScreenName + } + + /// Whether the notch/island should hide off-screen when closed on a non-notch display. + /// Temporarily reveals the notch when a sneakPeek HUD (volume, brightness, music, etc.) is active. + private var shouldHideUntilHover: Bool { + hideNonNotchUntilHover && isNonNotchScreen && vm.notchState == .closed && !isSneakPeekVisibleOnCurrentScreen + } + + /// Whether the fallback top-edge hover detector should run. + /// This is only needed when the notch is fully hidden off-screen and + /// regular `.onHover` hit-testing may not trigger reliably. + private var shouldUseHiddenEdgeHoverPolling: Bool { + shouldHideUntilHover && !lockScreenManager.isLocked + } + + /// Whether the LocalSend live activity should be shown + private var localSendLiveActivityActive: Bool { + localSendService.isSending || + localSendService.transferState == .completed || + isLocalSendFailedOrRejected + } + + private var isLocalSendFailedOrRejected: Bool { + if case .failed = localSendService.transferState { return true } + if case .rejected = localSendService.transferState { return true } + return false + } + + /// Pill shape for Dynamic Island mode with animated corner radius transitions. + private var currentPillShape: DynamicIslandPillShape { + let radius: CGFloat + if vm.notchState == .open { + radius = enableMinimalisticUI + ? minimalisticCornerRadiusInsets.opened.top + : dynamicIslandPillCornerRadiusInsets.opened + } else { + // Use half the closed height for a true capsule shape + radius = max(vm.closedNotchSize.height / 2, dynamicIslandPillCornerRadiusInsets.closed.standard) + } + return DynamicIslandPillShape(cornerRadius: radius) + } + + private var isBatteryHUDVisibleOnCurrentScreen: Bool { + guard coordinator.expandingView.show, coordinator.expandingView.type == .battery else { return false } + guard showPowerStatusNotifications else { return false } + guard batteryModel.activeTemporaryHUDKind != nil else { return false } + if showOnAllDisplays { return true } + guard let targetScreenName = batteryModel.activeTemporaryHUDTargetScreenName else { return true } + return currentScreenName == targetScreenName + } + + private var isCurrentScreenExpansionVisible: Bool { + guard coordinator.expandingView.show else { return false } + if coordinator.expandingView.type == .battery { + return isBatteryHUDVisibleOnCurrentScreen + } + return true + } + + private var currentScreenExpansionType: SneakContentType? { + isCurrentScreenExpansionVisible ? coordinator.expandingView.type : nil + } + + private var displayedBatteryHUDLevel: Int { + let resolvedLevel = batteryModel.activeTemporaryHUDLevelOverride + ?? Int(batteryModel.levelBattery.rounded()) + return min(max(resolvedLevel, 0), 100) + } + + private var displayedBatteryHUDUsesLowPowerMode: Bool { + batteryModel.activeTemporaryHUDLowPowerModeOverride ?? batteryModel.isInLowPowerMode + } + + + private var activeClosedBatterySurfaceShape: AnyShape? { + guard vm.notchState == .closed else { return nil } + guard isBatteryHUDVisibleOnCurrentScreen else { return nil } + guard let kind = batteryModel.activeTemporaryHUDKind else { return nil } + + if isDynamicIslandMode { + let radius = dynamicIslandPillCornerRadiusInsets.opened + return AnyShape(DynamicIslandPillShape(cornerRadius: radius)) + } else { + let topRadius = activeCornerRadiusInsets.closed.top + let bottomRadius: CGFloat = { + switch resolvedBatteryNotificationStyle(for: kind) { + case .compact: + return activeCornerRadiusInsets.closed.bottom + case .standard: + return kind == .fullBattery ? 36 : 40 + } + }() + return AnyShape(NotchShape(topCornerRadius: topRadius, bottomCornerRadius: bottomRadius)) + } + } + + private func resolvedBatteryNotificationStyle(for kind: BatteryTemporaryHUDKind) -> BatteryNotificationStyle { + switch kind { + case .charging: + return .compact + case .lowBattery: + return lowBatteryHUDStyle + case .fullBattery: + return fullBatteryHUDStyle + } + } + + + /// Resolves the clip/content shape per-screen: pill on non-notch screens + /// when dynamic island mode is active, standard notch shape otherwise. + private var resolvedClipShape: AnyShape { + if let activeClosedBatterySurfaceShape { + return activeClosedBatterySurfaceShape + } + if isDynamicIslandMode { + return AnyShape(currentPillShape) + } + return AnyShape(currentNotchShape) + } var body: some View { - ZStack(alignment: .top) { - let mainLayout = NotchLayout() - .frame(alignment: .top) - .padding(.horizontal, vm.notchState == .open - ? Defaults[.cornerRadiusScaling] ? (cornerRadiusInsets.opened.top - 5) : (cornerRadiusInsets.opened.bottom - 5) - : cornerRadiusInsets.closed.bottom - ) - .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) - .background(.black) - .mask { - ((vm.notchState == .open) && Defaults[.cornerRadiusScaling]) - ? NotchShape(topCornerRadius: cornerRadiusInsets.opened.top, bottomCornerRadius: cornerRadiusInsets.opened.bottom) - .drawingGroup() - : NotchShape(topCornerRadius: cornerRadiusInsets.closed.top, bottomCornerRadius: cornerRadiusInsets.closed.bottom) - .drawingGroup() - } - .padding(.bottom, vm.notchState == .open && Defaults[.extendHoverArea] ? 0 : (vm.effectiveClosedNotchHeight == 0) - ? zeroHeightHoverPadding - : 0 - ) + installRootLifecycleHandlers(on: rootBodyView) + } - mainLayout - .conditionalModifier(!useModernCloseAnimation) { view in - let hoverAnimationAnimation = Animation.bouncy.speed(1.2) - let notchStateAnimation = Animation.spring.speed(1.2) - return view - .animation(hoverAnimationAnimation, value: isHovering) - .animation(notchStateAnimation, value: vm.notchState) - .animation(.smooth, value: gestureProgress) - .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) - } - .conditionalModifier(useModernCloseAnimation) { view in - let hoverAnimationAnimation = Animation.bouncy.speed(1.2) - let notchStateAnimation = Animation.spring.speed(1.2) - return view - .animation(hoverAnimationAnimation, value: isHovering) - .animation(notchStateAnimation, value: vm.notchState) - } - .conditionalModifier(Defaults[.openNotchOnHover]) { view in - view.onHover { hovering in + private var mainLayoutBase: some View { + NotchLayout() + .frame(alignment: .top) + .padding(.horizontal, notchHorizontalPadding) + .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) + .background(.black) + .clipShape(resolvedClipShape) + .compositingGroup() + .shadow( + color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) + ? .black.opacity(0.6) + : .clear, + radius: Defaults[.cornerRadiusScaling] ? 10 : 5 + ) + // Extra horizontal inset for Dynamic Island mode so the shadow + // is not clipped by the outer frame constraint + .padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0) + .padding(.top, pillTopOffset) + } + + private var configuredMainLayout: some View { + mainLayoutBase + .conditionalModifier(!useModernCloseAnimation) { view in + let hoverAnimation = Animation.bouncy.speed(1.2) + let notchStateAnimation = Animation.spring.speed(1.2) + return view + .animation(hoverAnimation, value: isHovering) + .animation(notchStateAnimation, value: vm.notchState) + .animation(.smooth, value: gestureProgress) + .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) + } + .conditionalModifier(useModernCloseAnimation) { view in + let hoverAnimation = Animation.bouncy.speed(1.2) + let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0) + let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0) + let notchAnimation = vm.notchState == .open ? openAnimation : closeAnimation + return view + .animation(hoverAnimation, value: isHovering) + .animation(notchAnimation, value: vm.notchState) + .animation(.smooth, value: gestureProgress) + } + .conditionalModifier(interactionsEnabled) { view in + view + .contentShape(resolvedClipShape) + .onHover { hovering in handleHover(hovering) } - } - .conditionalModifier(!Defaults[.openNotchOnHover]) { view in - view - .onHover { hovering in - withAnimation(vm.animation) { - isHovering = hovering + .onTapGesture { + if handleClosedMusicWaveformTapIfNeeded() { + return + } + if vm.notchState == .closed && Defaults[.enableHaptics] { + triggerHapticIfAllowed() + } + openNotch() + } + .conditionalModifier(Defaults[.enableGestures]) { view in + view + .panGesture(direction: .down) { translation, phase in + handleDownGesture(translation: translation, phase: phase) } - - // Only close if mouse leaves and the notch is open - if !hovering && vm.notchState == .open { - vm.close() + .panGesture(direction: .left) { translation, phase in + handleSkipGesture(direction: .forward, translation: translation, phase: phase) } - } - .onTapGesture { - if (vm.notchState == .closed) && Defaults[.enableHaptics] { - haptics.toggle() + .panGesture(direction: .right) { translation, phase in + handleSkipGesture(direction: .backward, translation: translation, phase: phase) } - doOpen() - } - .conditionalModifier(Defaults[.enableGestures]) { view in - view - .panGesture(direction: .down) { translation, phase in - handleDownGesture(translation: translation, phase: phase) - } + } + } + .conditionalModifier((Defaults[.closeGestureEnabled] || Defaults[.reverseScrollGestures]) && Defaults[.enableGestures] && interactionsEnabled) { view in + view + .panGesture(direction: .up) { translation, phase in + handleUpGesture(translation: translation, phase: phase) + } + } + // Shadow bottom padding and hide-until-hover offset applied AFTER + // interaction modifiers so .contentShape / .onHover only covers + // the actual notch content, not the shadow clearance below it. + .padding(.bottom, notchBottomPadding) + .offset(y: shouldHideUntilHover && !isHovering + ? -(vm.closedNotchSize.height + pillTopOffset + currentShadowPadding + 10) + : 0 + ) + .onAppear(perform: { + if coordinator.firstLaunch { + // Single open during first launch; closeHello() handles the timed close. + runAfter(1) { + withAnimation(vm.animation) { + openNotch() } + } } - .conditionalModifier(Defaults[.closeGestureEnabled] && Defaults[.enableGestures]) { view in - view - .panGesture(direction: .up) { translation, phase in - handleUpGesture(translation: translation, phase: phase) - } + }) + .onChange(of: vm.notchState) { _, newState in + // Update smart monitoring based on notch state + if enableStatsFeature { + let currentViewString = coordinator.currentView == .stats ? "stats" : "other" + statsManager.updateMonitoringState( + notchIsOpen: newState == .open, + currentView: currentViewString + ) } - .onAppear(perform: { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - withAnimation(vm.animation) { - if coordinator.firstLaunch { - doOpen() - } - } + + // Reset hover state when notch state changes + if newState == .closed && isHovering { + withAnimation { + isHovering = false } - }) - .onChange(of: vm.notchState) { _, newState in - // Reset hover state when notch state changes - if newState == .closed && isHovering { - // Only reset visually, without triggering the hover logic again - isHoverStateChanging = true - withAnimation { - isHovering = false - } - // Reset the flag after the animation completes - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isHoverStateChanging = false - } + } + if newState != .closed { + isHoveringClosedMusicWaveformControl = false + } + if newState == .closed { + removeStickyTerminalClickMonitor() + } else { + // Install the outside-click monitor for terminal opens that don't + // change `currentView` (e.g. shortcut re-opening with the terminal + // tab already selected, where the cursor never enters the notch). + syncStickyTerminalOutsideClickMonitor() + } + #if os(macOS) + if newState == .open { + TimerControlWindowManager.shared.hide() + } + #endif + } + .onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in + runAfter(0.1) { + if !newPopoverState && !isHovering && vm.notchState == .open && !shouldPreventAutoClose() { + vm.close() } } - .onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if !newPopoverState && !isHovering && vm.notchState == .open { - vm.close() + } + .onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in + runAfter(0.1) { + if !newPopoverState && !isHovering && vm.notchState == .open && !shouldPreventAutoClose() { + vm.close() + } + } + } + .onChange(of: vm.shouldRecheckHover) { _, _ in + // Recheck hover state when popovers are closed + runAfter(0.1) { + if vm.notchState == .open && !shouldPreventAutoClose() && !isHovering { + vm.close() + } + } + } + .onReceive(NotificationCenter.default.publisher(for: .sharingDidFinish)) { _ in + runAfter(0.1) { + if vm.notchState == .open && !isHovering && !shouldPreventAutoClose() { + vm.close() + } + } + } + .onChange(of: coordinator.sneakPeek.show) { _, sneakPeekShowing in + // When sneak peek finishes, check if user is still hovering and open notch if needed + if !sneakPeekShowing { + runAfter(0.2) { + if isHovering && vm.notchState == .closed && !coordinator.isHoverOpenSuppressed { + openNotch() } } } - .sensoryFeedback(.alignment, trigger: haptics) - .contextMenu { - SettingsLink(label: { - Text("Settings") - }) - .keyboardShortcut(KeyEquivalent(","), modifiers: .command) -// Button("Edit") { // Doesnt work.... -// let dn = DynamicNotch(content: EditPanelView()) -// dn.toggle() -// } -// #if DEBUG -// .disabled(false) -// #else -// .disabled(true) -// #endif -// .keyboardShortcut("E", modifiers: .command) - } - } - .frame(maxWidth: openNotchSize.width, maxHeight: openNotchSize.height, alignment: .top) - .shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5) + } + .onChange(of: coordinator.currentView) { _, newValue in + if enableStatsFeature { + let currentViewString = newValue == .stats ? "stats" : "other" + statsManager.updateMonitoringState( + notchIsOpen: vm.notchState == .open, + currentView: currentViewString + ) + } + syncStickyTerminalOutsideClickMonitor() + } + .sensoryFeedback(.alignment, trigger: haptics) + .contextMenu { + Button("Settings") { + SettingsWindowController.shared.showWindow() + } +// Button("Edit") { // Doesnt work.... +// let dn = DynamicNotch(content: EditPanelView()) +// dn.toggle() +// } +// #if DEBUG +// .disabled(false) +// #else +// .disabled(true) +// #endif +// .keyboardShortcut("E", modifiers: .command) + } + } + + private var rootBodyView: some View { + ZStack(alignment: .top) { + configuredMainLayout + } + .frame( + maxWidth: dynamicNotchSize.width + (isDynamicIslandMode ? dynamicIslandShadowInset * 2 : 0), + maxHeight: dynamicNotchSize.height + currentShadowPadding + (isDynamicIslandMode ? dynamicIslandTopOffset : 0), + alignment: .top + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .environmentObject(privacyManager) .background(dragDetector) .environmentObject(vm) .environmentObject(webcamManager) } + private func installRootLifecycleHandlers(on view: Content) -> some View { + installSecondaryRootLifecycleHandlers( + on: installPrimaryRootLifecycleHandlers(on: view) + ) + } + + private func installPrimaryRootLifecycleHandlers(on view: Content) -> some View { + view + .onAppear { + isMusicControlWindowSuppressed = vm.notchState != .closed + || lockScreenManager.isLocked + || isMusicHUDDeferredAfterUnlock + if musicManager.isPlaying || !musicManager.isPlayerIdle { + clearMusicControlVisibilityDeadline() + } + if let deadline = musicControlVisibilityDeadline, Date() > deadline { + clearMusicControlVisibilityDeadline() + } + enqueueMusicControlWindowSync(forceRefresh: true) + startHiddenEdgeHoverPolling() + } + .onChange(of: terminalStickyMode) { _, _ in + syncStickyTerminalOutsideClickMonitor() + } + .onChange(of: vm.notchState) { _, state in + if state == .open { + suppressMusicControlWindowUpdates() + cancelMusicControlWindowSync() + hideMusicControlWindow() + } else { + releaseMusicControlWindowUpdates(after: musicControlResumeDelay) + enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + .onChange(of: musicControlWindowEnabled) { _, enabled in + if enabled { + if musicManager.isPlaying || !musicManager.isPlayerIdle { + clearMusicControlVisibilityDeadline() + } + enqueueMusicControlWindowSync(forceRefresh: true) + } else { + cancelMusicControlWindowSync() + hideMusicControlWindow() + clearMusicControlVisibilityDeadline() + hasPendingMusicControlSync = false + pendingMusicControlForceRefresh = false + } + } + .onChange(of: coordinator.musicLiveActivityEnabled) { _, enabled in + if enabled { + enqueueMusicControlWindowSync(forceRefresh: true) + } else { + cancelMusicControlWindowSync() + hideMusicControlWindow() + clearMusicControlVisibilityDeadline() + hasPendingMusicControlSync = false + pendingMusicControlForceRefresh = false + } + } + .onChange(of: vm.hideOnClosed) { _, hidden in + if hidden { + cancelMusicControlWindowSync() + hideMusicControlWindow() + } else { + enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + .onChange(of: lockScreenManager.isLocked) { _, locked in + if locked { + suppressMusicControlWindowUpdates() + cancelMusicControlWindowSync() + hideMusicControlWindow() + } else { + releaseMusicControlWindowUpdates(after: musicControlResumeDelay) + enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + .onChange(of: lockScreenManager.shouldDelayPostUnlockMusicHUD) { _, deferred in + if deferred { + suppressMusicControlWindowUpdates() + cancelMusicControlWindowSync() + hideMusicControlWindow() + } else { + releaseMusicControlWindowUpdates(after: 0) + enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + } + + private func installSecondaryRootLifecycleHandlers(on view: Content) -> some View { + view + .onChange(of: showStandardMediaControls) { _, _ in + handleStandardMediaControlsAvailabilityChange() + } + .onChange(of: enableMinimalisticUI) { _, _ in + handleStandardMediaControlsAvailabilityChange() + } + .onChange(of: gestureProgress) { _, _ in + if shouldShowMusicControlWindow() { + enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + .onChange(of: isHovering) { _, hovering in + if shouldShowMusicControlWindow() { + enqueueMusicControlWindowSync(forceRefresh: true, delay: hovering ? 0.05 : 0.12) + } + } + .onChange(of: musicManager.isPlaying) { _, isPlaying in + handleMusicControlPlaybackChange(isPlaying: isPlaying) + } + .onChange(of: musicManager.isPlayerIdle) { _, isIdle in + handleMusicControlIdleChange(isIdle: isIdle) + } + .onChange(of: vm.closedNotchSize) { _, _ in + if shouldShowMusicControlWindow() { + enqueueMusicControlWindowSync(forceRefresh: true) + } + } + .onChange(of: vm.effectiveClosedNotchHeight) { _, _ in + if shouldShowMusicControlWindow() { + enqueueMusicControlWindowSync(forceRefresh: true) + } + } + .onDisappear { + hoverTask?.cancel() + stopHoverClickMonitor() + removeStickyTerminalClickMonitor() + stopHiddenEdgeHoverPolling() + cancelMusicControlWindowSync() + hideMusicControlWindow() + cancelMusicControlVisibilityTimer() + clearMusicControlVisibilityDeadline() + musicControlSuppressionTask?.cancel() + isHoveringClosedMusicWaveformControl = false + } + } + @ViewBuilder func NotchLayout() -> some View { VStack(alignment: .leading) { @@ -185,50 +865,116 @@ struct ContentView: View { .padding(.top, 40) Spacer() } else { - if coordinator.expandingView.type == .battery && coordinator.expandingView.show && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] { - HStack(spacing: 0) { - HStack { - Text(batteryModel.statusText) - .font(.subheadline) - } + let hasMusicMetadata = !musicManager.songTitle.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty + || !musicManager.artistName.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty + let hasActiveMusicSnapshot: Bool = { + if musicManager.isPlaying { return true } + return !musicManager.isPlayerIdle && hasMusicMetadata + }() + let musicPairingEligible = closedMusicPairingEligible(hasActiveMusicSnapshot: hasActiveMusicSnapshot) + let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible) + let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary) + let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID) + let activeSneakPeekStyle = resolvedSneakPeekStyle() + let expansionMatchesSecondary: Bool = { + guard let musicSecondary else { return false } + switch musicSecondary { + case .timer: + return currentScreenExpansionType == .timer + case .reminder: + return currentScreenExpansionType == .reminder + case .recording: + return currentScreenExpansionType == .recording + case .focus: + return currentScreenExpansionType == .doNotDisturb + case .capsLock: + return false + case .extensionPayload: + return false + case .shelf: + return false + } + }() + let canShowMusicDuringExpansion = !isCurrentScreenExpansionVisible + || currentScreenExpansionType == .music + || expansionMatchesSecondary - Rectangle() - .fill(.black) - .frame(width: vm.closedNotchSize.width + 10) - - HStack { - DynamicIslandBatteryView( - batteryWidth: 30, - isCharging: batteryModel.isCharging, - isInLowPowerMode: batteryModel.isInLowPowerMode, - isPluggedIn: batteryModel.isPluggedIn, - levelBattery: batteryModel.levelBattery, - isForNotification: true - ) - } - .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) { + if currentScreenExpansionType == .battery + && isBatteryHUDVisibleOnCurrentScreen + && vm.notchState == .closed + && Defaults[.showPowerStatusNotifications] + && batteryModel.activeTemporaryHUDKind != nil { + BatteryTemporaryActivityView( + kind: batteryModel.activeTemporaryHUDKind ?? .charging, + batteryLevel: displayedBatteryHUDLevel, + isLowPowerMode: displayedBatteryHUDUsesLowPowerMode, + closedNotchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0), + baseHeight: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), + isDynamicIslandMode: isDynamicIslandMode, + topCornerRadius: activeCornerRadiusInsets.closed.top, + styleOverride: batteryModel.activeTemporaryHUDKind.map { resolvedBatteryNotificationStyle(for: $0) } + ) + .id(batteryModel.activeTemporaryHUDToken) + } else if isSneakPeekVisibleOnCurrentScreen && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && !coordinator.sneakPeek.type.isExtensionPayload && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || 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 { - MusicLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { + .transition( + coordinator.sneakPeek.type == .capsLock + ? AnyTransition.move(edge: .trailing).combined(with: .opacity) + : AnyTransition.opacity + ) + } else if vm.notchState == .closed && capsLockManager.isCapsLockActive && Defaults[.enableCapsLockIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked { + InlineHUD(type: .constant(.capsLock), value: .constant(1.0), icon: .constant(""), hoverAnimation: $isHovering, gestureProgress: $gestureProgress) + .transition(AnyTransition.move(edge: .trailing).combined(with: .opacity)) + } else if canShowMusicDuringExpansion && musicPairingEligible { + MusicLiveActivity(secondary: musicSecondary) + .id("closed-music-live-activity") + .transition(closedLiveActivitySwapTransition) + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { TimerLiveActivity() + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed { + ReminderLiveActivity() + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed && !musicPairingEligible { + RecordingLiveActivity() + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { + DownloadLiveActivity() + .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) + } else if !isCurrentScreenExpansionVisible && vm.notchState == .closed && localSendLiveActivityActive && !vm.hideOnClosed { + LocalSendLiveActivity() + .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || doNotDisturbManager.isFocusToastDismissing) && !vm.hideOnClosed && !lockScreenManager.isLocked { + DoNotDisturbLiveActivity() + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { + LockScreenLiveActivity() + .id("lock-screen-live-activity") + .transition(closedLiveActivitySwapTransition) + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { + PrivacyLiveActivity() + } else if let extensionPayload = extensionStandalonePayload { + let layout = extensionStandaloneLayout( + for: extensionPayload, + notchHeight: vm.effectiveClosedNotchHeight, + isHovering: isHovering + ) + ExtensionLiveActivityStandaloneView( + payload: extensionPayload, + layout: layout, + isHovering: isHovering + ) + } else if !coordinator.expandingView.show && vm.notchState == .closed && !shelfState.isEmpty && !vm.hideOnClosed && !lockScreenManager.isLocked && !enableMinimalisticUI { + ShelfInlineLiveActivity() + .transition(.opacity.animation(.smooth(duration: 0.25))) } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { + } else if !isCurrentScreenExpansionVisible && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) } else if vm.notchState == .open { DynamicIslandHeader() .frame(height: max(24, vm.effectiveClosedNotchHeight)) - .blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0) - .animation(.spring(response: 1, dampingFraction: 1, blendDuration: 0.8), value: vm.notchState) } else { Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: vm.effectiveClosedNotchHeight) } - if coordinator.sneakPeek.show { - if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] { + if isSneakPeekVisibleOnCurrentScreen { + if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && (coordinator.sneakPeek.type != .capsLock) && !coordinator.sneakPeek.type.isExtensionPayload && !Defaults[.inlineHUD] && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) { SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in // }) @@ -238,7 +984,7 @@ struct ContentView: View { } // Old sneak peek music else if coordinator.sneakPeek.type == .music { - if vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { + if vm.notchState == .closed && !vm.hideOnClosed && activeSneakPeekStyle == .standard { HStack(alignment: .center) { Image(systemName: "music.note") GeometryReader { geo in @@ -251,7 +997,7 @@ struct ContentView: View { } // Timer sneak peek else if coordinator.sneakPeek.type == .timer { - if !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { + if !vm.hideOnClosed && activeSneakPeekStyle == .standard { HStack(alignment: .center) { Image(systemName: "timer") GeometryReader { geo in @@ -262,10 +1008,58 @@ struct ContentView: View { .padding(.bottom, 10) } } + else if coordinator.sneakPeek.type == .reminder { + if !vm.hideOnClosed && activeSneakPeekStyle == .standard, let reminder = reminderManager.activeReminder { + GeometryReader { geo in + let chipColor = Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7) + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 2) + .fill(chipColor) + .frame(width: 8, height: 12) + MarqueeText( + .constant(reminderSneakPeekText(for: reminder, now: reminderManager.currentDate)), + textColor: reminderColor(for: reminder, now: reminderManager.currentDate), + minDuration: 1, + frameWidth: max(0, geo.size.width - 14) + ) + } + } + .padding(.bottom, 10) + } + } + // Extension live activity sneak peek + else if case let .extensionLiveActivity(bundleID, activityID) = coordinator.sneakPeek.type { + if !vm.hideOnClosed && activeSneakPeekStyle == .standard { + let payload = extensionLiveActivityManager.payload(bundleIdentifier: bundleID, activityID: activityID) + let descriptor = payload?.descriptor + let accent = (descriptor?.accentColor.swiftUIColor ?? coordinator.sneakPeek.accentColor ?? .gray) + .ensureMinimumBrightness(factor: 0.7) + GeometryReader { geo in + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 2) + .fill(accent) + .frame(width: 8, height: 12) + MarqueeText( + .constant( + extensionSneakPeekText( + preferredTitle: coordinator.sneakPeek.title, + preferredSubtitle: coordinator.sneakPeek.subtitle, + descriptor: descriptor + ) + ), + textColor: accent, + minDuration: 1, + frameWidth: max(0, geo.size.width - 14) + ) + } + } + .padding(.bottom, 10) + } + } } } } - .conditionalModifier((coordinator.sneakPeek.show && coordinator.sneakPeek.type == .music && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.sneakPeek.show && coordinator.sneakPeek.type == .timer && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.sneakPeek.show && (coordinator.sneakPeek.type != .music && coordinator.sneakPeek.type != .timer) && (vm.notchState == .closed))) { view in + .conditionalModifier(shouldFixSizeForSneakPeek()) { view in view .fixedSize() } @@ -273,122 +1067,766 @@ struct ContentView: View { ZStack { if vm.notchState == .open { - switch coordinator.currentView { - case .home: - NotchHomeView(albumArtNamespace: albumArtNamespace) - case .shelf: - NotchShelfView() - case .timer: - NotchTimerView() + Group { + switch coordinator.currentView { + case .home: + NotchHomeView(albumArtNamespace: albumArtNamespace) + case .shelf: + NotchShelfView() + case .timer: + NotchTimerView() + case .stats: + NotchStatsView() + case .colorPicker: + NotchColorPickerView() + case .notes: + NotchNotesView() + case .clipboard: + NotchNotesView() + case .terminal: + NotchTerminalView() + case .extensionExperience: + if let payload = currentExtensionTabPayload() { + ExtensionNotchExperienceTabView(payload: payload) + } else { + NotchHomeView(albumArtNamespace: albumArtNamespace) + } + } } + .id(coordinator.currentView) + .transition(tabSwitchTransition) } } .zIndex(1) .allowsHitTesting(vm.notchState == .open) .blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0) .opacity(abs(gestureProgress) > 0.3 ? min(abs(gestureProgress * 2), 0.8) : 1) + .animation(.smooth(duration: 0.3), value: coordinator.currentView) } } + private func reminderColor(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Color { + if isReminderCritical(reminder, now: now) { + return .red + } + return Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7) + } + + private func reminderSneakPeekText(for entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + let title = entry.event.title.isEmpty ? "Upcoming Reminder" : entry.event.title + let remaining = max(entry.event.start.timeIntervalSince(now), 0) + let window = TimeInterval(Defaults[.reminderSneakPeekDuration]) + + if window > 0 && remaining <= window { + return "\(title) • \(String(format: String(localized: "now")))" + } + + let minutes = Int(ceil(remaining / 60)) + let timeString = reminderTimeFormatter.string(from: entry.event.start) + + if minutes <= 0 { + return "\(title) • \(String(format: String(localized: "now"))) • \(timeString)" + } else if minutes == 1 { + return "\(title) • \(String(format: String(localized: "in %@"), String(localized: "1 min"))) • \(timeString)" + } else { + return "\(title) • \(String(format: String(localized: "in %lld"), (minutes))) \(String(format: String(localized: "min plural"))) • \(timeString)" + } + } + + private func extensionSneakPeekText(preferredTitle: String, preferredSubtitle: String?, descriptor: AtollLiveActivityDescriptor?) -> String { + let trimmedPreferredTitle = preferredTitle.trimmingCharacters(in: .whitespacesAndNewlines) + let descriptorTitle = descriptor?.title.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Extension" + let title = trimmedPreferredTitle.isEmpty ? descriptorTitle : trimmedPreferredTitle + + let trimmedPreferredSubtitle = preferredSubtitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let descriptorSubtitle = descriptor?.subtitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let subtitle = !trimmedPreferredSubtitle.isEmpty ? trimmedPreferredSubtitle : descriptorSubtitle + + guard !subtitle.isEmpty else { return title } + return "\(title) • \(subtitle)" + } + + private let reminderTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + @ViewBuilder func DynamicIslandFaceAnimation() -> some View { + let sideSize = max(0, vm.effectiveClosedNotchHeight - 12) HStack { HStack { Rectangle() .fill(.clear) - .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) + .frame(width: sideSize, height: sideSize) Rectangle() .fill(.black) .frame(width: vm.closedNotchSize.width - 20) - MinimalFaceFeatures() + IdleAnimationView() + .frame(width: sideSize, height: sideSize) } }.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) } @ViewBuilder - func MusicLiveActivity() -> some View { - HStack { - HStack { + private func MusicLiveActivity(secondary preResolvedSecondary: MusicSecondaryLiveActivity? = nil) -> some View { + let secondary = preResolvedSecondary ?? resolveMusicSecondaryLiveActivity() + let notchContentHeight = max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + let wingBaseWidth = max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) + let rawCenterBaseWidth = vm.closedNotchSize.width + (isHovering ? 8 : 0) + let centerBaseWidth = max(rawCenterBaseWidth, 96) + let inlineSneakPeekActive = ( + coordinator.expandingView.show && + (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) && + Defaults[.enableSneakPeek] && + Defaults[.sneakPeekStyles] == .inline + ) + let rightWingWidth = resolvedRightWingWidth( + for: secondary, + baseWidth: wingBaseWidth, + centerBaseWidth: centerBaseWidth, + notchHeight: notchContentHeight + ) + let effectiveCenterWidth = inlineSneakPeekActive ? 380 : centerBaseWidth + let notchWidth = wingBaseWidth + effectiveCenterWidth + rightWingWidth + let badgeBaseSize = max(13, notchContentHeight * 0.36) + let badgeDisplaySize = badgeDisplaySize(for: secondary, baseSize: badgeBaseSize) + let badgeOffset = badgeOverlayOffset(for: secondary, badgeSize: badgeDisplaySize) + + HStack(spacing: 0) { + ZStack(alignment: .bottomTrailing) { Color.clear .aspectRatio(1, contentMode: .fit) .background( Image(nsImage: musicManager.albumArt) .resizable() - .aspectRatio(contentMode: .fill) + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.closed/3.0 : MusicPlayerImageSizes.cornerRadiusInset.closed)) ) .clipped() - .clipShape(RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed)) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) - .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) + .albumArtFlip(angle: musicManager.flipAngle) + albumArtBadge(for: secondary, badgeSize: badgeDisplaySize) + .offset(x: badgeOffset.width, y: badgeOffset.height) + .id(secondary?.id ?? "music-badge") + .contentTransition(.symbolEffect(.replace)) } - .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))) + .frame(width: wingBaseWidth, height: notchContentHeight) Rectangle() .fill(.black) + .frame(width: effectiveCenterWidth, height: notchContentHeight) .overlay( - HStack(alignment: .top){ + HStack(alignment: .top) { if(coordinator.expandingView.show && coordinator.expandingView.type == .music) { - MarqueeText( - .constant(musicManager.songTitle), + MusicTitleMarqueeView( + text: musicManager.songTitle, + isExplicit: musicManager.isCurrentTrackExplicit, textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, minDuration: 0.4, - frameWidth: 100 + frameWidth: max(0, (effectiveCenterWidth - vm.closedNotchSize.width) / 2 - 12), + badgeHeight: 13 ) + .padding(.leading, 8) .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) Spacer(minLength: vm.closedNotchSize.width) - // Song Artist Text(musicManager.artistName) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray) + .padding(.trailing, 8) .opacity((coordinator.expandingView.show && coordinator.expandingView.type == .music && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) } else if(coordinator.expandingView.show && coordinator.expandingView.type == .timer) { MarqueeText( .constant(timerManager.timerName), textColor: timerManager.timerColor, minDuration: 0.4, - frameWidth: 100 + frameWidth: max(0, (effectiveCenterWidth - vm.closedNotchSize.width) / 2 - 12) ) + .padding(.leading, 8) .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) Spacer(minLength: vm.closedNotchSize.width) - // Timer Status Text(timerManager.formattedRemainingTime()) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(timerManager.timerColor) + .padding(.trailing, 8) .opacity((coordinator.expandingView.show && coordinator.expandingView.type == .timer && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) + } else if Defaults[.showSongMetadataInClosedNotch] && isNonNotchScreen && !musicManager.songTitle.isEmpty { + MarqueeText( + .constant("\(musicManager.songTitle) • \(musicManager.artistName)"), + textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, + minDuration: 3, + frameWidth: max(0, effectiveCenterWidth - 16) + ) + .padding(.horizontal, 8) } } + .clipped() ) - .frame(width: (coordinator.expandingView.show && (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : vm.closedNotchSize.width + (isHovering ? 8 : 0)) - - HStack { - if useMusicVisualizer { - Rectangle() - .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient) - .frame(width: 50, alignment: .center) - .matchedGeometryEffect(id: "spectrum", in: albumArtNamespace) - .mask { - AudioSpectrumView(isPlaying: $musicManager.isPlaying) - .frame(width: 16, height: 12) + musicRightWing(for: secondary, notchHeight: notchContentHeight, trailingWidth: rightWingWidth) + .frame(width: rightWingWidth, height: notchContentHeight, alignment: .center) + .contentShape(Rectangle()) + .onHover { hovering in + guard shouldShowClosedMusicWaveformPlayPauseOverlay(for: secondary) else { + if isHoveringClosedMusicWaveformControl { + isHoveringClosedMusicWaveformControl = false } - .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), - height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) - } else { - LottieAnimationView() - .frame(maxWidth: .infinity, maxHeight: .infinity) + return + } + withAnimation(.smooth(duration: 0.16)) { + isHoveringClosedMusicWaveformControl = hovering + } } - } - .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), - height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) + .id(secondary?.id ?? "music-spectrum") + .contentTransition(.symbolEffect(.replace)) } + .frame(width: notchWidth, height: notchContentHeight) .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) + .animation(.smooth(duration: 0.25), value: secondary?.id) + } + + private func resolveMusicSecondaryLiveActivity(isMusicPairingEligible: Bool = true) -> MusicSecondaryLiveActivity? { + if coordinator.timerLiveActivityEnabled && timerManager.isTimerActive { + return .timer + } + + if enableReminderLiveActivity, reminderManager.isActive, let reminder = reminderManager.activeReminder { + return .reminder(reminder) + } + + if enableScreenRecordingDetection && (recordingManager.isRecording || !recordingManager.isRecorderIdle) { + return .recording + } + + if enableDoNotDisturbDetection && showDoNotDisturbIndicator && doNotDisturbManager.isDoNotDisturbActive { + let mode = FocusModeType.resolve(identifier: doNotDisturbManager.currentFocusModeIdentifier, name: doNotDisturbManager.currentFocusModeName) + return .focus(mode) + } + + if enableCapsLockIndicator && capsLockManager.isCapsLockActive { + return .capsLock(showLabel: showCapsLockLabel) + } + + if isMusicPairingEligible, let extensionPayload = resolvedExtensionMusicPayload() { + return .extensionPayload(extensionPayload) + } + + // Shelf: show file count as lowest-priority secondary + if !shelfState.isEmpty && !lockScreenManager.isLocked && !enableMinimalisticUI { + return .shelf(count: shelfState.items.count) + } + + return nil + } + + private func resolvedRightWingWidth(for secondary: MusicSecondaryLiveActivity?, baseWidth: CGFloat, centerBaseWidth: CGFloat, notchHeight: CGFloat) -> CGFloat { + guard let secondary else { return baseWidth } + + switch secondary { + case .timer: + return timerRightWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth) + case .reminder(let entry): + return reminderRightWingWidth(for: entry, baseWidth: baseWidth, notchHeight: notchHeight, now: reminderManager.currentDate) + case .capsLock(let showLabel): + return showLabel ? scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.4, extra: 12) : baseWidth + case .focus: + return focusRightWingWidth(baseWidth: baseWidth) + case .recording: + return recordingRightWingWidth(baseWidth: baseWidth) + case .extensionPayload(let payload): + let maxWidth = baseWidth + centerBaseWidth * 0.6 + return ExtensionLayoutMetrics.trailingWidth(for: payload, baseWidth: baseWidth, maxWidth: maxWidth) + case .shelf: + return baseWidth + } + } + + private func timerRightWingWidth(baseWidth: CGFloat, centerBaseWidth: CGFloat) -> CGFloat { + if timerShowsCountdown { + return timerCountdownWingWidth(baseWidth: baseWidth) + } + + let showsProgress = timerShowsProgress + let usesRingProgress = timerProgressStyle == .ring + + switch (showsProgress, usesRingProgress) { + case (true, true): + return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.46, extra: 18) + case (true, false): + return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.52, extra: 24) + case (false, _): + return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.38, extra: 12) + } + } + + private func timerCountdownWingWidth(baseWidth: CGFloat) -> CGFloat { + let padding: CGFloat = 18 + let ringWidth: CGFloat = (timerShowsProgress && timerProgressStyle == .ring) ? 30 : 0 + let spacing: CGFloat = (ringWidth > 0) ? 8 : 0 + let countdownText = timerManager.formattedRemainingTime() + let countdownWidth = TimerSupplementMetrics.countdownFrameWidth(for: countdownText) + return max(baseWidth, padding + ringWidth + spacing + countdownWidth) + } + + private func reminderRightWingWidth(for entry: ReminderLiveActivityManager.ReminderEntry, baseWidth: CGFloat, notchHeight: CGFloat, now: Date) -> CGFloat { + let padding: CGFloat = 16 + switch reminderPresentationStyle { + case .ringCountdown: + let diameter = ReminderSupplementMetrics.ringDiameter(for: notchHeight) + return max(baseWidth, padding + diameter) + case .digital: + let countdownText = ReminderSupplementMetrics.digitalCountdownText(for: entry, now: now) + let width = ReminderSupplementMetrics.digitalFrameWidth(for: countdownText) + return max(baseWidth, padding + width) + case .minutes: + let minutesText = ReminderSupplementMetrics.minutesCountdownText(for: entry, now: now) + let width = ReminderSupplementMetrics.minutesFrameWidth(for: minutesText) + return max(baseWidth, padding + width) + } + } + + private func focusRightWingWidth(baseWidth: CGFloat) -> CGFloat { + // Focus pairings now mirror the default music spectrum width to keep the notch compact. + return baseWidth + } + + private func recordingRightWingWidth(baseWidth: CGFloat) -> CGFloat { + // Keep recording pairings compact by reducing the width relative to the notch height. + let absoluteMin: CGFloat = 38 + let preferredWidth = max(baseWidth * 0.6, 0) + let maxWidth = min(baseWidth - 6, 52) + let clampedPreferred = min(preferredWidth, maxWidth) + return min(baseWidth, max(absoluteMin, clampedPreferred)) + } + + private func scaledWingWidth(baseWidth: CGFloat, centerBaseWidth: CGFloat, factor: CGFloat, extra: CGFloat) -> CGFloat { + max(baseWidth, max(centerBaseWidth * factor, baseWidth + extra)) + } + + @ViewBuilder + private func albumArtBadge(for secondary: MusicSecondaryLiveActivity?, badgeSize: CGFloat) -> some View { + if let secondary, badgeSize > 0 { + ZStack { + Circle() + .fill(Color.black) + + switch secondary { + case .timer: + Image(systemName: "timer") + .font(.system(size: badgeSize * 0.55, weight: .semibold)) + .foregroundStyle(timerAccentColor) + case .reminder(let entry): + let accent = reminderColor(for: entry, now: reminderManager.currentDate) + Image(systemName: "clock") + .font(.system(size: badgeSize * 0.55, weight: .semibold)) + .foregroundStyle(accent) + case .focus(let mode): + mode.resolvedActiveIcon(usePrivateSymbol: true) + .renderingMode(.template) + .font(.system(size: badgeSize * 0.5, weight: .semibold)) + .foregroundStyle(mode.accentColor) + case .recording: + Circle() + .fill(Color.red) + .frame(width: badgeSize * 0.45, height: badgeSize * 0.45) + .modifier(PulsingModifier()) + case .capsLock: + Image(systemName: "capslock.fill") + .font(.system(size: badgeSize * 0.5, weight: .semibold)) + .foregroundStyle(capsLockTintMode.color) + case .extensionPayload(let payload): + ExtensionBadgeIconView( + descriptor: payload.descriptor.leadingIcon, + accent: payload.descriptor.accentColor.swiftUIColor, + size: badgeSize + ) + case .shelf: + Image(systemName: "tray.and.arrow.down.fill") + .font(.system(size: badgeSize * 0.50, weight: .semibold)) + .foregroundStyle(.white) + } + } + .frame(width: badgeSize, height: badgeSize) + .shadow(color: .black.opacity(0.35), radius: 3, x: 0, y: 1) + .transition(.opacity.combined(with: .scale)) + } else { + EmptyView() + } + } + + private func badgeDisplaySize(for secondary: MusicSecondaryLiveActivity?, baseSize: CGFloat) -> CGFloat { + guard let secondary else { return baseSize } + switch secondary { + default: + return baseSize + } + } + + private func badgeOverlayOffset(for secondary: MusicSecondaryLiveActivity?, badgeSize: CGFloat) -> CGSize { + guard let secondary else { return CGSize(width: badgeSize * 0.2, height: badgeSize * 0.25) } + switch secondary { + default: + return CGSize(width: badgeSize * 0.2, height: badgeSize * 0.25) + } + } + + @ViewBuilder + private func musicRightWing(for secondary: MusicSecondaryLiveActivity?, notchHeight: CGFloat, trailingWidth: CGFloat) -> some View { + switch secondary { + case .timer: + MusicTimerSupplementView( + timerManager: timerManager, + accentColor: timerAccentColor, + showsCountdown: timerShowsCountdown, + showsProgress: timerShowsProgress, + progressStyle: timerProgressStyle, + notchHeight: notchHeight + ) + case .reminder(let entry): + MusicReminderSupplementView( + entry: entry, + now: reminderManager.currentDate, + style: reminderPresentationStyle, + accent: reminderColor(for: entry, now: reminderManager.currentDate), + notchHeight: notchHeight + ) + case .capsLock(let showLabel): + if showLabel { + MusicCapsLockLabelView(color: capsLockTintMode.color) + } else { + spectrumView(forceSpectrum: true) + } + case .focus: + spectrumView(forceSpectrum: true) + case .recording: + spectrumView(forceSpectrum: true, trailingInset: 6) + case .extensionPayload(let payload): + ExtensionMusicWingView(payload: payload, notchHeight: notchHeight, trailingWidth: trailingWidth) + case .shelf(let count): + // File count badge: bold white number, like a minimal pill + Text("\(count)") + .font(.system(.callout, design: .rounded, weight: .bold)) + .foregroundStyle(.white) + .contentTransition(.numericText(countsDown: false)) + .animation(.smooth(duration: 0.3), value: count) + .frame(alignment: .center) + case .none: + spectrumView( + forceSpectrum: false, + enableClosedPlayPauseOverlay: shouldShowClosedMusicWaveformPlayPauseOverlay(for: secondary) + ) + } + } + + @ViewBuilder + private func spectrumView( + forceSpectrum: Bool, + trailingInset: CGFloat = 0, + enableClosedPlayPauseOverlay: Bool = false + ) -> some View { + if useMusicVisualizer || forceSpectrum { + Rectangle() + .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient) + .frame(width: 50, alignment: .center) + .matchedGeometryEffect(id: "spectrum", in: albumArtNamespace) + .mask { + AudioVisualizerView(isPlaying: $musicManager.isPlaying) + .frame(width: 16, height: 12) + } + .blur(radius: (enableClosedPlayPauseOverlay && isHoveringClosedMusicWaveformControl) ? 2.4 : 0) + .overlay { + if enableClosedPlayPauseOverlay { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(Color.black.opacity(isHoveringClosedMusicWaveformControl ? 0.24 : 0.02)) + + Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white.opacity(isHoveringClosedMusicWaveformControl ? 0.98 : 0.0)) + .contentTransition(.symbolEffect(.replace)) + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + .allowsHitTesting(false) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.trailing, trailingInset) + .animation(.smooth(duration: 0.16), value: isHoveringClosedMusicWaveformControl) + .animation(.smooth(duration: 0.2), value: musicManager.isPlaying) + } else { + LottieAnimationView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var timerAccentColor: Color { + switch timerIconColorMode { + case .adaptive: + if let presetId = timerManager.activePresetId, + let preset = timerPresets.first(where: { $0.id == presetId }) { + return preset.color + } + return timerManager.timerColor + case .solid: + return timerSolidColor + } + } + + private func reminderIconName(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + isReminderCritical(reminder, now: now) ? ReminderLiveActivityManager.criticalIconName : ReminderLiveActivityManager.standardIconName + } + + private func isReminderCritical(_ reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Bool { + let window = TimeInterval(Defaults[.reminderSneakPeekDuration]) + guard window > 0 else { return false } + let remaining = reminder.event.start.timeIntervalSince(now) + return remaining > 0 && remaining <= window + } + + private func extensionSecondaryPayloadID(for secondary: MusicSecondaryLiveActivity?) -> String? { + guard case let .extensionPayload(payload) = secondary else { return nil } + return payload.id + } + + private func resolvedExtensionMusicPayload() -> ExtensionLiveActivityPayload? { + let candidates = extensionLiveActivityManager.sortedActivities(for: true) + guard let payload = candidates.first else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "no eligible coexistence payloads", + pendingCount: candidates.count + ) + ExtensionRoutingDiagnostics.shared.reset(.music) + return nil + } + + guard enableExtensionLiveActivities else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "feature toggle disabled", + pendingCount: candidates.count + ) + return nil + } + + guard closedMusicContentEnabled else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "music content disabled", + pendingCount: candidates.count + ) + return nil + } + + guard vm.notchState == .closed else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "notch is \(vm.notchState)", + pendingCount: candidates.count + ) + return nil + } + + guard !vm.hideOnClosed else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "hideOnClosed engaged (fullscreen)", + pendingCount: candidates.count + ) + return nil + } + + guard !lockScreenManager.isLocked else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "lock screen currently active", + pendingCount: candidates.count + ) + return nil + } + + guard !isMusicHUDDeferredAfterUnlock else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "waiting for lock screen unlock animation to finish", + pendingCount: candidates.count + ) + return nil + } + + guard coordinator.musicLiveActivityEnabled else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "music live activity disabled in settings", + pendingCount: candidates.count + ) + return nil + } + + ExtensionRoutingDiagnostics.shared.logDisplay(.music, payload: payload) + return payload + } + + private func resolvedExtensionStandalonePayload(excluding musicPayloadID: String?) -> ExtensionLiveActivityPayload? { + let baseCandidates = extensionLiveActivityManager.sortedActivities() + guard !baseCandidates.isEmpty else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "no active extension payloads", + pendingCount: 0 + ) + ExtensionRoutingDiagnostics.shared.reset(.standalone) + return nil + } + + let candidates = baseCandidates.filter { $0.id != musicPayloadID } + guard let payload = candidates.first else { + if let musicPayloadID { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "all pending payloads are paired with music (\(musicPayloadID))", + pendingCount: baseCandidates.count + ) + } else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "no standalone payloads after filtering", + pendingCount: baseCandidates.count + ) + ExtensionRoutingDiagnostics.shared.reset(.standalone) + } + return nil + } + + guard enableExtensionLiveActivities else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "feature toggle disabled", + pendingCount: candidates.count + ) + return nil + } + + guard vm.notchState == .closed else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "notch is \(vm.notchState)", + pendingCount: candidates.count + ) + return nil + } + + guard !vm.hideOnClosed else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "hideOnClosed engaged (fullscreen)", + pendingCount: candidates.count + ) + return nil + } + + guard !lockScreenManager.isLocked else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "lock screen currently active", + pendingCount: candidates.count + ) + return nil + } + + guard vm.effectiveClosedNotchHeight > 0 else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "effective notch height is \(vm.effectiveClosedNotchHeight)", + pendingCount: candidates.count + ) + return nil + } + + guard !isCurrentScreenExpansionVisible else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "expanding view \(String(describing: currentScreenExpansionType ?? coordinator.expandingView.type)) visible", + pendingCount: candidates.count + ) + return nil + } + + ExtensionRoutingDiagnostics.shared.logDisplay(.standalone, payload: payload) + return payload + } + + private func extensionStandaloneLayout(for payload: ExtensionLiveActivityPayload, notchHeight: CGFloat, isHovering: Bool) -> ExtensionStandaloneLayout { + let outerHeight = notchHeight + let contentHeight = max(0, notchHeight - (isHovering ? 0 : 12)) + let leadingWidth = max(contentHeight, 44) + let centerWidth: CGFloat = max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96) + let trailingWidth = ExtensionLayoutMetrics.trailingWidth( + for: payload, + baseWidth: leadingWidth, + maxWidth: leadingWidth + centerWidth * 0.6 + ) + let totalWidth = leadingWidth + centerWidth + trailingWidth + return ExtensionStandaloneLayout( + totalWidth: totalWidth, + outerHeight: outerHeight, + contentHeight: contentHeight, + leadingWidth: leadingWidth, + centerWidth: centerWidth, + trailingWidth: trailingWidth + ) } + @MainActor + private final class ExtensionRoutingDiagnostics { + static let shared = ExtensionRoutingDiagnostics() + + enum Channel: Hashable { + case music + case standalone + + var label: String { + switch self { + case .music: + return "music pairing" + case .standalone: + return "standalone notch" + } + } + } + + private var lastMessages: [Channel: String] = [:] + + func logSuppression(_ channel: Channel, reason: String, pendingCount: Int) { + log("Extension \(channel.label) suppressed: \(reason) (pending: \(pendingCount))", channel: channel) + } + + func logDisplay(_ channel: Channel, payload: ExtensionLiveActivityPayload) { + log("Extension \(channel.label) showing \(payload.descriptor.id) from \(payload.bundleIdentifier)", channel: channel) + } + + func reset(_ channel: Channel) { + lastMessages.removeValue(forKey: channel) + } + + private func log(_ message: String, channel: Channel) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + guard lastMessages[channel] != message else { return } + lastMessages[channel] = message + Logger.log(message, category: .extensions) + } + } + @ViewBuilder var dragDetector: some View { - if Defaults[.dynamicShelf] { + if lockScreenManager.isLocked { + EmptyView() + } else if Defaults[.dynamicShelf] && !Defaults[.enableMinimalisticUI] { Color.clear .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) @@ -396,16 +1834,17 @@ struct ContentView: View { .onChange(of: vm.anyDropZoneTargeting) { _, isTargeted in if isTargeted, vm.notchState == .closed { coordinator.currentView = .shelf - doOpen() + openNotch() } else if !isTargeted { - print("DROP EVENT", vm.dropEvent) if vm.dropEvent { vm.dropEvent = false return } vm.dropEvent = false - vm.close() + if !shouldPreventAutoClose() { + vm.close() + } } } } else { @@ -413,131 +1852,1066 @@ struct ContentView: View { } } - private func doOpen() { - // Switch to timer tab if timer is active - if timerManager.isTimerActive { - coordinator.currentView = .timer - } - + // MARK: - Private Methods + private func openNotch() { withAnimation(.bouncy.speed(1.2)) { vm.open() } } + private func shouldShowClosedMusicWaveformPlayPauseOverlay(for secondary: MusicSecondaryLiveActivity?) -> Bool { + guard secondary == nil else { return false } + return isClosedMusicGestureContext && !Defaults[.openNotchOnHover] + } + + private var isClosedMusicGestureContext: Bool { + vm.notchState == .closed + && coordinator.musicLiveActivityEnabled + && closedMusicContentEnabled + && !vm.hideOnClosed + && !lockScreenManager.isLocked + && !isMusicHUDDeferredAfterUnlock + && !isCurrentScreenExpansionVisible + && (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil) + && !coordinator.firstLaunch + } + + private func handleClosedMusicWaveformTapIfNeeded() -> Bool { + guard shouldShowClosedMusicWaveformPlayPauseOverlay(for: nil), + isHoveringClosedMusicWaveformControl else { + return false + } + + if Defaults[.enableHaptics] { + triggerHapticIfAllowed() + } + musicManager.playPause() + return true + } + + private func hiddenHoverActivationContainsMouse(_ location: NSPoint = NSEvent.mouseLocation) -> Bool { + guard let screen = NSScreen.screens.first(where: { $0.localizedName == currentScreenName }) else { + return false + } + + let horizontalPadding: CGFloat = 8 + let activationWidth = vm.closedNotchSize.width + horizontalPadding * 2 + let activationHeight = max(vm.closedNotchSize.height + zeroHeightHoverPadding, 14) + + let activationRect = CGRect( + x: screen.frame.midX - activationWidth / 2, + y: screen.frame.maxY - activationHeight, + width: activationWidth, + height: activationHeight + ) + + return activationRect.contains(location) + } + + private func startHiddenEdgeHoverPolling() { + guard hiddenEdgeHoverPollingTask == nil else { return } + + hiddenEdgeHoverPollingTask = Task { @MainActor in + while !Task.isCancelled { + if self.shouldUseHiddenEdgeHoverPolling { + let hovering = self.hiddenHoverActivationContainsMouse() + if hovering != self.isHovering { + self.handleHover(hovering) + } + } + + try? await Task.sleep(for: .milliseconds(50)) + } + + self.hiddenEdgeHoverPollingTask = nil + } + } + + private func stopHiddenEdgeHoverPolling() { + hiddenEdgeHoverPollingTask?.cancel() + hiddenEdgeHoverPollingTask = nil + } + + private func startHoverClickMonitor() { + guard Defaults[.openNotchOnHover] else { return } + guard hoverClickMonitor == nil else { return } + + let handleClick: @Sendable () -> Void = { [weak vm, weak lockScreenManager] in + Task { @MainActor in + guard let vm, let lockScreenManager else { return } + guard !lockScreenManager.isLocked else { return } + guard vm.notchState == .closed else { return } + guard !self.coordinator.isHoverOpenSuppressed else { return } + guard self.isHovering else { return } + guard !self.handleClosedMusicWaveformTapIfNeeded() else { return } + if Defaults[.enableHaptics] { + self.triggerHapticIfAllowed() + } + self.openNotch() + } + } + + // Global monitor catches clicks outside the app window (e.g. when + // the cursor is at the very top screen edge and the click goes to + // the system rather than our panel). + hoverClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { _ in + handleClick() + } + + // Local monitor catches clicks that DO hit our window — at the + // screen edge SwiftUI's .onTapGesture may not fire reliably, but + // the NSEvent local monitor will. + hoverClickLocalMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { event in + handleClick() + return event + } + } + + private func stopHoverClickMonitor() { + if let hoverClickMonitor { + NSEvent.removeMonitor(hoverClickMonitor) + self.hoverClickMonitor = nil + } + if let hoverClickLocalMonitor { + NSEvent.removeMonitor(hoverClickLocalMonitor) + self.hoverClickLocalMonitor = nil + } + } + + /// Installs the global outside-click monitor whenever the Terminal tab is open + /// (e.g. keyboard-opened terminal), regardless of sticky mode. + /// + /// Sticky mode only controls whether the terminal closes when the cursor leaves + /// the notch (see `shouldPreventAutoClose`). An outside click should always close + /// the terminal — this covers the case where the terminal is opened via the + /// shortcut and the cursor never enters the notch, so there's no hover-out event + /// to trigger the normal auto-close. + /// + /// While the cursor is hovering inside the notch, hover handling owns close + /// behavior, so the monitor is not installed; it is re-synced on hover-out. + private func syncStickyTerminalOutsideClickMonitor() { + guard vm.notchState == .open, coordinator.currentView == .terminal, !isHovering else { + removeStickyTerminalClickMonitor() + return + } + installStickyTerminalClickMonitor() + } + + private func installStickyTerminalClickMonitor() { + guard stickyTerminalClickMonitor == nil else { return } + stickyTerminalClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { [weak vm] _ in + Task { @MainActor in + guard let vm, vm.notchState == .open else { return } + let clickLocation = NSEvent.mouseLocation + if self.isPointInsideNotchWindow(clickLocation) { + return + } + vm.close() + } + } + } + + private func removeStickyTerminalClickMonitor() { + if let stickyTerminalClickMonitor { + NSEvent.removeMonitor(stickyTerminalClickMonitor) + self.stickyTerminalClickMonitor = nil + } + } + // MARK: - Hover Management /// Handle hover state changes with debouncing private func handleHover(_ hovering: Bool) { - // Don't process events if we're already transitioning - if isHoverStateChanging { return } - - // Cancel any pending tasks - hoverWorkItem?.cancel() - hoverWorkItem = nil - debounceWorkItem?.cancel() - debounceWorkItem = nil - + hoverTask?.cancel() + if hovering { - // Handle mouse enter - withAnimation(.bouncy.speed(1.2)) { - isHovering = true + startHoverClickMonitor() + removeStickyTerminalClickMonitor() + } else { + stopHoverClickMonitor() + if isHoveringClosedMusicWaveformControl { + withAnimation(.smooth(duration: 0.16)) { + isHoveringClosedMusicWaveformControl = false + } } - - // Only provide haptic feedback if notch is closed - if vm.notchState == .closed && Defaults[.enableHaptics] { - haptics.toggle() + } + + if hovering { + withAnimation(.bouncy.speed(1.2)) { + isHovering = true } - - // Don't open notch if there's a sneak peek showing - if coordinator.sneakPeek.show { - return + + if vm.notchState == .closed && Defaults[.enableHaptics] { + triggerHapticIfAllowed() } - - // Delay opening the notch - let task = DispatchWorkItem { - // ContentView is a struct, so we don't use weak self here - guard vm.notchState == .closed, isHovering else { return } - doOpen() + + let shouldFocusTimerTab = enableTimerFeature && timerDisplayMode == .tab && timerManager.isTimerActive && !enableMinimalisticUI + + guard vm.notchState == .closed, + !isSneakPeekVisibleOnCurrentScreen, + (Defaults[.openNotchOnHover] || shouldFocusTimerTab) else { return } + + hoverTask = Task { + try? await Task.sleep(for: .seconds(Defaults[.minimumHoverDuration])) + guard !Task.isCancelled else { return } + + await MainActor.run { + guard self.vm.notchState == .closed, + self.isHovering, + !self.isSneakPeekVisibleOnCurrentScreen, + !self.coordinator.isHoverOpenSuppressed else { return } + + if shouldFocusTimerTab { + withAnimation(.smooth) { + self.coordinator.currentView = .timer + } + } + self.openNotch() + } } - - hoverWorkItem = task - DispatchQueue.main.asyncAfter( - deadline: .now() + Defaults[.minimumHoverDuration], - execute: task - ) } else { - // Handle mouse exit with debounce to prevent flickering - let debounce = DispatchWorkItem { - // ContentView is a struct, so we don't use weak self here - - // Update visual state - withAnimation(.bouncy.speed(1.2)) { - isHovering = false - } - - // Close the notch if it's open and battery popover is not active - if vm.notchState == .open && !vm.isBatteryPopoverActive { - vm.close() + hoverTask = Task { + try? await Task.sleep(for: .milliseconds(100)) + guard !Task.isCancelled else { return } + + await MainActor.run { + withAnimation(.bouncy.speed(1.2)) { + self.isHovering = false + } + + if self.vm.notchState == .open && !self.shouldPreventAutoClose() { + self.vm.close() + } else if self.vm.notchState == .open + && Defaults[.terminalStickyMode] + && self.coordinator.currentView == .terminal { + // Re-sync monitor state through one code path to avoid + // monitor lifecycle races between hover and state updates. + self.syncStickyTerminalOutsideClickMonitor() + } } } - - debounceWorkItem = debounce - // Add a small delay to debounce rapid mouse movements - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: debounce) + } + } + + private func isPointInsideNotchWindow(_ point: CGPoint) -> Bool { + if let appDelegate = AppDelegate.shared { + if Defaults[.showOnAllDisplays] { + return appDelegate.windows.values.contains(where: { $0.frame.contains(point) }) + } + if let window = appDelegate.window { + return window.frame.contains(point) + } + } + + return NSApp.windows.contains(where: { $0.frame.contains(point) }) + } + + // Helper function to check if any popovers are active + private func hasAnyActivePopovers() -> Bool { + return vm.isBatteryPopoverActive || + vm.isClipboardPopoverActive || + vm.isColorPickerPopoverActive || + vm.isStatsPopoverActive || + vm.isTimerPopoverActive || + vm.isMediaOutputPopoverActive || + vm.isReminderPopoverActive + } + + private func shouldPreventAutoClose() -> Bool { + coordinator.firstLaunch || hasAnyActivePopovers() || vm.isAutoCloseSuppressed || SharingStateManager.shared.preventNotchClose || (Defaults[.terminalStickyMode] && coordinator.currentView == .terminal) + } + + // Helper to prevent rapid haptic feedback + private func triggerHapticIfAllowed() { + let now = Date() + if now.timeIntervalSince(lastHapticTime) > 0.3 { // Minimum 300ms between haptics + haptics.toggle() + lastHapticTime = now } } + // Helper to check if stats tab has 4+ graphs (needs expanded height) + private func enabledStatsGraphCount() -> Int { + var enabledCount = 0 + if showCpuGraph { enabledCount += 1 } + if showMemoryGraph { enabledCount += 1 } + if showGpuGraph { enabledCount += 1 } + if showNetworkGraph { enabledCount += 1 } + if showDiskGraph { enabledCount += 1 } + return enabledCount + } + + private func statsRowCount() -> Int { + let count = enabledStatsGraphCount() + if count == 0 { return 0 } + return count <= 3 ? 1 : 2 + } + + private func currentExtensionTabPayload() -> ExtensionNotchExperiencePayload? { + guard Defaults[.enableThirdPartyExtensions], + Defaults[.enableExtensionNotchExperiences], + Defaults[.enableExtensionNotchTabs] else { + return nil + } + if let selectedID = coordinator.selectedExtensionExperienceID, + let payload = extensionNotchExperienceManager.payload(experienceID: selectedID) { + return payload + } + return extensionNotchExperienceManager.highestPriorityTabPayload() + } + + private func extensionTabPreferredHeight(baseSize: CGSize) -> CGFloat? { + guard let preferred = currentExtensionTabPayload()?.descriptor.tab?.preferredHeight else { + return nil + } + let minHeight = baseSize.height + let maxHeight = baseSize.height + statsAdditionalRowHeight + return min(max(preferred, minHeight), maxHeight) + } + + // Estimate the height required for minimalistic overrides (notably web content) and clamp it to the notch bounds. + private func extensionMinimalisticPreferredHeight(baseSize: CGSize) -> CGFloat? { + guard let configuration = extensionNotchExperienceManager.minimalisticReplacementPayload()?.descriptor.minimalistic else { + return nil + } + + let minHeight = baseSize.height + let maxHeight = baseSize.height + statsAdditionalRowHeight + + var contentHeight: CGFloat = 0 + var blockCount = 0 + + if configuration.headline != nil { + contentHeight += 24 + blockCount += 1 + } + + if configuration.subtitle != nil { + contentHeight += 20 + blockCount += 1 + } + + if !configuration.sections.isEmpty { + let sectionEstimate: CGFloat = 98 + contentHeight += CGFloat(configuration.sections.count) * sectionEstimate + blockCount += configuration.sections.count + } + + if let webDescriptor = configuration.webContent { + contentHeight += webDescriptor.preferredHeight + blockCount += 1 + } + + guard blockCount > 0 else { return nil } + + let spacingAllowance = CGFloat(max(blockCount - 1, 0)) * 16 + let topPadding: CGFloat = 10 + let bottomPadding: CGFloat = configuration.webContent == nil ? 10 : 0 + let estimatedHeight = contentHeight + spacingAllowance + topPadding + bottomPadding + + let clampedHeight = min(max(estimatedHeight, minHeight), maxHeight) + return clampedHeight > minHeight ? clampedHeight : nil + } + // MARK: - Gesture Handling private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) { + handleScrollGesture(isDownward: true, translation: translation, phase: phase) + } + + private func handleUpGesture(translation: CGFloat, phase: NSEvent.Phase) { + handleScrollGesture(isDownward: false, translation: translation, phase: phase) + } + + private func handleScrollGesture(isDownward: Bool, translation: CGFloat, phase: NSEvent.Phase) { + let reverse = Defaults[.reverseScrollGestures] + let shouldOpen = isDownward ? !reverse : reverse + + if shouldOpen { + handleOpenScrollGesture(translation: translation, phase: phase) + } else { + guard Defaults[.closeGestureEnabled] else { return } + handleCloseScrollGesture(translation: translation, phase: phase) + } + } + + private func handleOpenScrollGesture(translation: CGFloat, phase: NSEvent.Phase) { guard vm.notchState == .closed else { return } - + withAnimation(.smooth) { gestureProgress = (translation / Defaults[.gestureSensitivity]) * 20 } - + if phase == .ended { withAnimation(.smooth) { gestureProgress = .zero } } - + if translation > Defaults[.gestureSensitivity] { if Defaults[.enableHaptics] { - haptics.toggle() + triggerHapticIfAllowed() } withAnimation(.smooth) { gestureProgress = .zero } - doOpen() + openNotch() } } - - private func handleUpGesture(translation: CGFloat, phase: NSEvent.Phase) { - if vm.notchState == .open && !vm.isHoveringCalendar { + + private func handleCloseScrollGesture(translation: CGFloat, phase: NSEvent.Phase) { + guard vm.notchState == .open, !vm.isHoveringCalendar, !vm.isScrollGestureActive else { return } + + withAnimation(.smooth) { + gestureProgress = (translation / Defaults[.gestureSensitivity]) * -20 + } + + if phase == .ended { withAnimation(.smooth) { - gestureProgress = (translation / Defaults[.gestureSensitivity]) * -20 + gestureProgress = .zero } - - if phase == .ended { - withAnimation(.smooth) { - gestureProgress = .zero + } + + if translation > Defaults[.gestureSensitivity] { + withAnimation(.smooth) { + gestureProgress = .zero + isHovering = false + } + vm.close() + + if Defaults[.enableHaptics] { + triggerHapticIfAllowed() + } + } + } + + private func handleSkipGesture(direction: MusicManager.SkipDirection, translation: CGFloat, phase: NSEvent.Phase) { + if phase == .ended { + skipGestureActiveDirection = nil + return + } + + guard canPerformSkipGesture() else { + skipGestureActiveDirection = nil + return + } + + if skipGestureActiveDirection == nil && translation > Defaults[.gestureSensitivity] { + let effectiveDirection: MusicManager.SkipDirection + if Defaults[.reverseSwipeGestures] { + effectiveDirection = direction == .forward ? .backward : .forward + } else { + effectiveDirection = direction + } + skipGestureActiveDirection = effectiveDirection + + if Defaults[.enableHaptics] { + triggerHapticIfAllowed() + } + + musicManager.handleSkipGesture(direction: effectiveDirection) + } + } + + private func canPerformSkipGesture() -> Bool { + let canSkipInOpenHome = vm.notchState == .open && coordinator.currentView == .home + let canSkipInClosedMusic = !Defaults[.openNotchOnHover] && isClosedMusicGestureContext + + return enableHorizontalMusicGestures + && (canSkipInOpenHome || canSkipInClosedMusic) + && (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil) + && !lockScreenManager.isLocked + && !hasAnyActivePopovers() + && !vm.isHoveringCalendar + && !vm.isScrollGestureActive + } + + private func handleMusicControlPlaybackChange(isPlaying: Bool) { + guard musicControlWindowEnabled else { return } + + if isPlaying { + clearMusicControlVisibilityDeadline() + requestMusicControlWindowSyncIfHidden() + } else { + extendMusicControlVisibilityAfterPause() + } + } + + private func handleMusicControlIdleChange(isIdle: Bool) { + guard musicControlWindowEnabled else { return } + + if isIdle { + if musicControlVisibilityDeadline == nil { + extendMusicControlVisibilityAfterPause() + } + } else if musicManager.isPlaying { + clearMusicControlVisibilityDeadline() + } + } + + private func handleStandardMediaControlsAvailabilityChange() { + guard musicControlWindowEnabled else { + hideMusicControlWindow() + return + } + + if standardMediaControlsActive { + if musicManager.isPlaying || !musicManager.isPlayerIdle { + clearMusicControlVisibilityDeadline() + } + enqueueMusicControlWindowSync(forceRefresh: true) + } else { + cancelMusicControlWindowSync() + hideMusicControlWindow() + clearMusicControlVisibilityDeadline() + hasPendingMusicControlSync = false + pendingMusicControlForceRefresh = false + } + } + + private func extendMusicControlVisibilityAfterPause() { + let deadline = Date().addingTimeInterval(musicControlPauseGrace) + musicControlVisibilityDeadline = deadline + scheduleMusicControlVisibilityCheck(deadline: deadline) + requestMusicControlWindowSyncIfHidden() + } + + private func clearMusicControlVisibilityDeadline() { + musicControlVisibilityDeadline = nil + cancelMusicControlVisibilityTimer() + } + + private func scheduleMusicControlVisibilityCheck(deadline: Date) { + cancelMusicControlVisibilityTimer() + + let interval = max(0, deadline.timeIntervalSinceNow) + + musicControlHideTask = Task.detached(priority: .background) { [interval] in + if interval > 0 { + let nanoseconds = UInt64(interval * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } + + guard !Task.isCancelled else { return } + + await MainActor.run { + if let currentDeadline = musicControlVisibilityDeadline, currentDeadline <= Date() { + musicControlVisibilityDeadline = nil } + + enqueueMusicControlWindowSync(forceRefresh: false) + + musicControlHideTask = nil } - - if translation > Defaults[.gestureSensitivity] { - withAnimation(.smooth) { - gestureProgress = .zero - isHovering = false + } + } + + private func cancelMusicControlVisibilityTimer() { + musicControlHideTask?.cancel() + musicControlHideTask = nil + } + + private func musicControlVisibilityIsActive() -> Bool { + if musicManager.isPlaying { + return true + } + + guard let deadline = musicControlVisibilityDeadline else { return false } + return Date() <= deadline + } + + private func suppressMusicControlWindowUpdates() { + isMusicControlWindowSuppressed = true + musicControlSuppressionTask?.cancel() + musicControlSuppressionTask = nil + } + + private func releaseMusicControlWindowUpdates(after delay: TimeInterval) { + musicControlSuppressionTask?.cancel() + musicControlSuppressionTask = Task { [delay] in + if delay > 0 { + let nanoseconds = UInt64(delay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } + + guard !Task.isCancelled else { return } + + await MainActor.run { + if vm.notchState == .closed && !lockScreenManager.isLocked && !isMusicHUDDeferredAfterUnlock { + isMusicControlWindowSuppressed = false + triggerPendingMusicControlSyncIfNeeded() + } else { + isMusicControlWindowSuppressed = true } - vm.close() - - if Defaults[.enableHaptics] { - haptics.toggle() + musicControlSuppressionTask = nil + } + } + } + + private func triggerPendingMusicControlSyncIfNeeded() { + guard hasPendingMusicControlSync else { return } + + let shouldForce = pendingMusicControlForceRefresh + hasPendingMusicControlSync = false + pendingMusicControlForceRefresh = false + + logMusicControlEvent("Flushing pending floating window sync (force: \(shouldForce))") + scheduleMusicControlWindowSync(forceRefresh: shouldForce, bypassSuppression: true) + } + + private func shouldDeferMusicControlSync() -> Bool { + vm.notchState != .closed + || lockScreenManager.isLocked + || isMusicHUDDeferredAfterUnlock + || isMusicControlWindowSuppressed + } + + private func enqueueMusicControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0) { + if shouldDeferMusicControlSync() { + hasPendingMusicControlSync = true + if forceRefresh { + pendingMusicControlForceRefresh = true + } + logMusicControlEvent("Queued floating window sync (force: \(forceRefresh)) while deferred") + return + } + + logMusicControlEvent("Scheduling floating window sync (force: \(forceRefresh), delay: \(delay))") + scheduleMusicControlWindowSync(forceRefresh: forceRefresh, delay: delay) + } + + private func shouldShowMusicControlWindow() -> Bool { + guard musicControlWindowEnabled, + coordinator.musicLiveActivityEnabled, + standardMediaControlsActive, + vm.notchState == .closed, + !vm.hideOnClosed, + !lockScreenManager.isLocked, + !isMusicHUDDeferredAfterUnlock, + !isMusicControlWindowSuppressed else { + return false + } + + return musicControlVisibilityIsActive() + } + + private func scheduleMusicControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0, bypassSuppression: Bool = false) { + #if os(macOS) + cancelMusicControlWindowSync() + + guard shouldShowMusicControlWindow() else { + hasPendingMusicControlSync = false + pendingMusicControlForceRefresh = false + hideMusicControlWindow() + return + } + + if !bypassSuppression && (isMusicControlWindowSuppressed || lockScreenManager.isLocked || isMusicHUDDeferredAfterUnlock) { + hasPendingMusicControlSync = true + if forceRefresh { + pendingMusicControlForceRefresh = true + } + return + } + + hasPendingMusicControlSync = false + pendingMusicControlForceRefresh = false + + let syncDelay = max(0, delay) + + pendingMusicControlTask = Task.detached(priority: .userInitiated) { [forceRefresh, syncDelay] in + if syncDelay > 0 { + let nanoseconds = UInt64(syncDelay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } + + guard !Task.isCancelled else { return } + + await MainActor.run { + if shouldShowMusicControlWindow() { + logMusicControlEvent("Running floating window sync (force: \(forceRefresh))") + syncMusicControlWindow(forceRefresh: forceRefresh) + } else { + logMusicControlEvent("Skipping floating window sync (conditions changed)") + hideMusicControlWindow() } + + pendingMusicControlTask = nil } } + #endif } + + private func cancelMusicControlWindowSync() { + pendingMusicControlTask?.cancel() + pendingMusicControlTask = nil + } + + #if os(macOS) + private func currentMusicControlWindowMetrics() -> MusicControlWindowMetrics { + MusicControlWindowMetrics( + notchHeight: max(vm.closedNotchSize.height, vm.effectiveClosedNotchHeight), + notchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0), + rightWingWidth: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), + cornerRadius: activeCornerRadiusInsets.closed.bottom, + spacing: 36 + ) + } + + private func syncMusicControlWindow(forceRefresh: Bool = false) { + let notchAvailable = vm.effectiveClosedNotchHeight > 0 && vm.closedNotchSize.width > 0 + let targetVisible = shouldShowMusicControlWindow() && notchAvailable + + if targetVisible { + let metrics = currentMusicControlWindowMetrics() + if !isMusicControlWindowVisible { + let didPresent = MusicControlWindowManager.shared.present(using: vm, metrics: metrics) + isMusicControlWindowVisible = didPresent + } else if forceRefresh { + let didRefresh = MusicControlWindowManager.shared.refresh(using: vm, metrics: metrics) + if !didRefresh { + MusicControlWindowManager.shared.hide() + isMusicControlWindowVisible = false + } + } + } else if isMusicControlWindowVisible { + MusicControlWindowManager.shared.hide() + isMusicControlWindowVisible = false + } + } + + private func hideMusicControlWindow() { + if isMusicControlWindowVisible { + MusicControlWindowManager.shared.hide() + isMusicControlWindowVisible = false + } + } + #else + private func syncMusicControlWindow(forceRefresh: Bool = false) {} + + private func hideMusicControlWindow() {} + #endif + + private func shouldFixSizeForSneakPeek() -> Bool { + guard isSneakPeekVisibleOnCurrentScreen else { return false } + let style = resolvedSneakPeekStyle() + + // Check for extension sneak peek + if case .extensionLiveActivity = coordinator.sneakPeek.type { + return vm.notchState == .closed && style == .standard + } + + // Original logic for other types + let isMusicSneak = coordinator.sneakPeek.type == .music && vm.notchState == .closed && !vm.hideOnClosed && style == .standard + let isTimerSneak = coordinator.sneakPeek.type == .timer && !vm.hideOnClosed && style == .standard + let isReminderSneak = coordinator.sneakPeek.type == .reminder && !vm.hideOnClosed && style == .standard + let isOtherSneak = coordinator.sneakPeek.type != .music && coordinator.sneakPeek.type != .timer && coordinator.sneakPeek.type != .reminder && vm.notchState == .closed + + return isMusicSneak || isTimerSneak || isReminderSneak || isOtherSneak + } + + private func resolvedSneakPeekStyle() -> SneakPeekStyle { + if case .extensionLiveActivity = coordinator.sneakPeek.type { + return .standard + } + return coordinator.sneakPeek.styleOverride ?? Defaults[.sneakPeekStyles] + } +} + +private enum MusicSecondaryLiveActivity: Equatable { + case timer + case reminder(ReminderLiveActivityManager.ReminderEntry) + case recording + case focus(FocusModeType) + case capsLock(showLabel: Bool) + case extensionPayload(ExtensionLiveActivityPayload) + case shelf(count: Int) + + var id: String { + switch self { + case .timer: + return "timer" + case .reminder(let entry): + return "reminder-\(entry.id)" + case .recording: + return "recording" + case .focus(let mode): + return "focus-\(mode.rawValue)" + case .capsLock(let showLabel): + return showLabel ? "caps-lock-label" : "caps-lock-icon" + case .extensionPayload(let payload): + return "extension-\(payload.id)" + case .shelf(let count): + return "shelf-\(count)" + } + } +} + +private struct MusicTimerSupplementView: View { + @ObservedObject var timerManager: TimerManager + let accentColor: Color + let showsCountdown: Bool + let showsProgress: Bool + let progressStyle: TimerProgressStyle + let notchHeight: CGFloat + + private var clampedProgress: Double { + min(max(timerManager.progress, 0), 1) + } + + private var showsRingProgress: Bool { + showsProgress && progressStyle == .ring + } + + private var showsBarProgress: Bool { + showsProgress && progressStyle == .bar + } + + private var countdownText: String { + timerManager.formattedRemainingTime() + } + + private var countdownTextWidth: CGFloat { + max(1, TimerSupplementMetrics.countdownTextWidth(for: countdownText)) + } + + private var countdownFrameWidth: CGFloat { + TimerSupplementMetrics.countdownFrameWidth(for: countdownText) + } + + private var timerNameFrameWidth: CGFloat { + TimerSupplementMetrics.timerNameFrameWidth(for: timerManager.timerName) + } + + private var ringDiameter: CGFloat { + max(min(notchHeight - 4, 26), 20) + } + + var body: some View { + HStack(spacing: showsRingProgress && showsCountdown ? 8 : 0) { + if showsRingProgress { + ringView + } + + if showsCountdown { + countdownStack + } else if showsBarProgress { + standaloneBarView + } else { + timerNameView + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + + private var countdownStack: some View { + VStack(alignment: .trailing, spacing: showsBarProgress ? 4 : 0) { + Text(countdownText) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(timerManager.isOvertime ? .red : .white) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: timerManager.remainingTime) + .frame(width: countdownFrameWidth, alignment: .trailing) + + if showsBarProgress { + barView(width: countdownTextWidth) + } + } + .padding(.trailing, 8) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + private var ringView: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.18), lineWidth: 3) + Circle() + .trim(from: 0, to: clampedProgress) + .stroke(accentColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: clampedProgress) + } + .frame(width: ringDiameter, height: ringDiameter) + .frame(width: max(ringDiameter + 4, 30), height: notchHeight, alignment: .center) + } + + private var standaloneBarView: some View { + barView(width: 68) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + private var timerNameView: some View { + Text(timerManager.timerName) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + .frame(width: timerNameFrameWidth, alignment: .trailing) + } + + private func barView(width: CGFloat) -> some View { + Capsule() + .fill(Color.white.opacity(0.15)) + .frame(width: width, height: 4) + .overlay(alignment: .leading) { + Capsule() + .fill(accentColor) + .frame(width: width * max(0, CGFloat(clampedProgress)), height: 4) + .animation(.smooth(duration: 0.25), value: clampedProgress) + } + } + +} + +private struct MusicReminderSupplementView: View { + let entry: ReminderLiveActivityManager.ReminderEntry + let now: Date + let style: ReminderPresentationStyle + let accent: Color + let notchHeight: CGFloat + + var body: some View { + Group { + switch style { + case .ringCountdown: + ringCountdownView + case .digital: + digitalCountdownView + case .minutes: + minutesCountdownView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + + private var ringCountdownView: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.15), lineWidth: 3) + Circle() + .trim(from: 0, to: progressValue) + .stroke(accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: progressValue) + } + .frame(width: ringDiameter, height: ringDiameter) + .frame(width: max(ringDiameter + 4, 26), height: notchHeight, alignment: .center) + } + + private var digitalCountdownView: some View { + Text(digitalCountdownText) + .font(.system(size: 15, weight: .semibold, design: .monospaced)) + .foregroundColor(accent) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: digitalCountdownText) + .frame(width: digitalFrameWidth, alignment: .trailing) + .frame(height: notchHeight, alignment: .center) + } + + private var minutesCountdownView: some View { + Text(minutesCountdownText) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(accent) + .frame(width: minutesFrameWidth, alignment: .trailing) + .frame(height: notchHeight, alignment: .center) + } + + private var progressValue: Double { + guard entry.leadTime > 0 else { return 1 } + let remaining = max(entry.event.start.timeIntervalSince(now), 0) + let elapsed = entry.leadTime - remaining + return min(max(elapsed / entry.leadTime, 0), 1) + } + + private var digitalCountdownText: String { + ReminderSupplementMetrics.digitalCountdownText(for: entry, now: now) + } + + private var minutesCountdownText: String { + ReminderSupplementMetrics.minutesCountdownText(for: entry, now: now) + } + + private var ringDiameter: CGFloat { + ReminderSupplementMetrics.ringDiameter(for: notchHeight) + } + + private var digitalFrameWidth: CGFloat { + ReminderSupplementMetrics.digitalFrameWidth(for: digitalCountdownText) + } + + private var minutesFrameWidth: CGFloat { + ReminderSupplementMetrics.minutesFrameWidth(for: minutesCountdownText) + } +} + +private struct MusicCapsLockLabelView: View { + let color: Color + + var body: some View { + Text("Caps Lock") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(color) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .trailing) + .contentTransition(.opacity) + } +} + +#if canImport(AppKit) +private typealias MusicSupplementFont = NSFont +#elseif canImport(UIKit) +private typealias MusicSupplementFont = UIFont +#endif + +private enum TimerSupplementMetrics { + static func countdownTextWidth(for text: String) -> CGFloat { + musicMeasureText(text, font: MusicSupplementFont.monospacedDigitSystemFont(ofSize: 13, weight: .semibold)) + } + + static func countdownFrameWidth(for text: String) -> CGFloat { + max(countdownTextWidth(for: text) + 16, 72) + } + + static func timerNameFrameWidth(for text: String) -> CGFloat { + guard !text.isEmpty else { return 64 } + let width = musicMeasureText(text, font: MusicSupplementFont.systemFont(ofSize: 12, weight: .medium)) + return max(width + 14, 64) + } +} + +private enum ReminderSupplementMetrics { + static func digitalCountdownText(for entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + let remaining = max(entry.event.start.timeIntervalSince(now), 0) + let totalSeconds = Int(remaining.rounded(.down)) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + static func minutesCountdownText(for entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + let remaining = max(entry.event.start.timeIntervalSince(now), 0) + let minutes = max(1, Int(ceil(remaining / 60))) + return minutes == 1 ? "in 1 min" : "in \(minutes) min" + } + + static func digitalFrameWidth(for text: String) -> CGFloat { + let width = musicMeasureText(text, font: MusicSupplementFont.monospacedDigitSystemFont(ofSize: 15, weight: .semibold)) + return max(width + 18, 76) + } + + static func minutesFrameWidth(for text: String) -> CGFloat { + let width = musicMeasureText(text, font: MusicSupplementFont.systemFont(ofSize: 13, weight: .semibold)) + return max(width + 18, 88) + } + + static func ringDiameter(for notchHeight: CGFloat) -> CGFloat { + max(min(notchHeight - 12, 22), 16) + } +} + +private func musicMeasureText(_ text: String, font: MusicSupplementFont) -> CGFloat { + guard !text.isEmpty else { return 0 } + let attributes: [NSAttributedString.Key: Any] = [.font: font] + return CGFloat(ceil(NSAttributedString(string: text, attributes: attributes).size().width)) } struct FullScreenDropDelegate: DropDelegate { diff --git a/DynamicIsland/DynamicIsland-Bridging-Header.h b/DynamicIsland/DynamicIsland-Bridging-Header.h new file mode 100644 index 00000000..9438e57b --- /dev/null +++ b/DynamicIsland/DynamicIsland-Bridging-Header.h @@ -0,0 +1,18 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Swift bridging header for Objective-C/C++ code. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +#ifndef DynamicIsland_Bridging_Header_h +#define DynamicIsland_Bridging_Header_h + +#import "audio/AudioBridge.h" + +#endif /* DynamicIsland_Bridging_Header_h */ diff --git a/DynamicIsland/DynamicIsland.entitlements b/DynamicIsland/DynamicIsland.entitlements index 6d10c937..8d7db4eb 100644 --- a/DynamicIsland/DynamicIsland.entitlements +++ b/DynamicIsland/DynamicIsland.entitlements @@ -2,29 +2,21 @@ - com.apple.security.temporary-exception.apple-events - - com.spotify.client - com.apple.Music - - com.apple.security.app-sandbox - - com.apple.security.automation.apple-events - - com.apple.security.device.camera - - com.apple.security.files.user-selected.read-write - - com.apple.security.network.client - - com.apple.security.network.server - - com.apple.security.personal-information.calendars - - com.apple.security.temporary-exception.mach-lookup.global-name - - $(PRODUCT_BUNDLE_IDENTIFIER)-spks - $(PRODUCT_BUNDLE_IDENTIFIER)-spki - + com.apple.security.mach-services + + com.ebullioscopic.Atoll.xpc + + com.apple.security.automation.apple-events + + com.apple.security.temporary-exception.apple-events + + com.spotify.client + com.apple.Music + + com.apple.security.temporary-exception.mach-lookup.global-name + + $(PRODUCT_BUNDLE_IDENTIFIER)-spks + $(PRODUCT_BUNDLE_IDENTIFIER)-spki + diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 40db3ca7..25a5e954 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -1,9 +1,20 @@ -// -// DynamicIslandApp.swift -// DynamicIslandApp -// -// Created by Harsh Vardhan Goswami on 02/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import AVFoundation import Combine @@ -11,6 +22,7 @@ import Defaults import KeyboardShortcuts import Sparkle import SwiftUI +import SkyLightWindow @main struct DynamicNotchApp: App { @@ -29,19 +41,13 @@ struct DynamicNotchApp: App { } var body: some Scene { - MenuBarExtra("dynamic.island", systemImage: "sparkle", isInserted: $showMenuBarIcon) { + MenuBarExtra("dynamic.island", systemImage: "mountain.2.fill", isInserted: $showMenuBarIcon) { Button("Settings") { SettingsWindowController.shared.showWindow() } - .keyboardShortcut(KeyEquivalent(","), modifiers: .command) - if false { - Button("Activate License") { - openWindow(id: "activation") - } - } CheckForUpdatesView(updater: updaterController.updater) Divider() - Button("Restart Dynamic Island") { + Button("Restart Atoll") { guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return } let workspace = NSWorkspace.shared @@ -55,19 +61,34 @@ struct DynamicNotchApp: App { workspace.openApplication(at: appURL, configuration: configuration) } - NSApplication.shared.terminate(nil) + NSApplication.shared.terminate(self) } Button("Quit", role: .destructive) { - NSApp.terminate(nil) + NSApplication.shared.terminate(self) } .keyboardShortcut(KeyEquivalent("Q"), modifiers: .command) } + } - Window("Activation", id: "activation") { - ActivationWindow() + @CommandsBuilder + var commands: some Commands { + CommandGroup(replacing: .appSettings) { + Button("Settings…") { + SettingsWindowController.shared.showWindow() + } } - .windowStyle(.hiddenTitleBar) - .windowResizability(.contentSize) + } +} + +final class FirstMouseHostingView: NSHostingView { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } +} + +extension AppDelegate { + static var shared: AppDelegate? { + NSApplication.shared.delegate as? AppDelegate } } @@ -80,30 +101,201 @@ class AppDelegate: NSObject, NSApplicationDelegate { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared var whatsNewWindow: NSWindow? var timer: Timer? - let calendarManager = CalendarManager() + let calendarManager = CalendarManager.shared let webcamManager = WebcamManager.shared + let dndManager = DoNotDisturbManager.shared // NEW: DND detection + let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection + let idleAnimationManager = IdleAnimationManager.shared // NEW: Custom idle animations + let downloadManager = DownloadManager.shared // NEW: Chromium downloads detection + let lockScreenPanelManager = LockScreenPanelManager.shared // NEW: Lock screen music panel + let mediaControlsStateCoordinator = MediaControlsStateCoordinator.shared + let systemTimerBridge = SystemTimerBridge.shared + let extensionXPCServiceHost = ExtensionXPCServiceHost.shared + let extensionRPCServer = ExtensionRPCServer.shared var closeNotchWorkItem: DispatchWorkItem? private var previousScreens: [NSScreen]? private var onboardingWindowController: NSWindowController? + private var cancellables = Set() + private var windowsHiddenForLock = false + private var optionalShortcutHandlersRegistered = false + private weak var focusWithoutDevToolsMenuItem: NSMenuItem? + private weak var focusUseDevToolsMenuItem: NSMenuItem? + + // Debouncing mechanism for window size updates + private var windowSizeUpdateWorkItem: DispatchWorkItem? +// let calendarManager = CalendarManager.shared +// let webcamManager = WebcamManager.shared +// var closeNotchWorkItem: DispatchWorkItem? +// private var previousScreens: [NSScreen]? +// private var onboardingWindowController: NSWindowController? +// private var cancellables = Set() +// +// // Debouncing mechanism for window size updates +// private var windowSizeUpdateWorkItem: DispatchWorkItem? + + private func debouncedUpdateWindowSize() { + // Cancel any existing work item + windowSizeUpdateWorkItem?.cancel() + + // Create new work item with delay + let workItem = DispatchWorkItem { [weak self] in + self?.updateWindowSizeIfNeeded() + } + + // Store reference and schedule + windowSizeUpdateWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: workItem) + } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false } + + func applicationDidBecomeActive(_ notification: Notification) { + installTopMenuItemsIfNeeded() + } + + func application(_ application: NSApplication, open urls: [URL]) { + _ = handleIncomingShelfURLs(urls) + } + + func application(_ sender: NSApplication, openFile filename: String) -> Bool { + handleIncomingShelfURLs([URL(fileURLWithPath: filename)]) + } + + private func handleIncomingShelfURLs(_ urls: [URL]) -> Bool { + let fileURLs = urls.filter(\.isFileURL) + guard !fileURLs.isEmpty else { return false } + + Task { @MainActor [weak self] in + let items = await ShelfDropService.items(from: fileURLs) + guard !items.isEmpty else { return } + + ShelfStateViewModel.shared.add(items) + self?.coordinator.currentView = .shelf + } + + return true + } + + /// Setup observers for music player state changes to restart AudioTap capture + private func setupAudioTapMusicObservers() { + // Listen for app launches to restart capture when music apps are opened + let targetBundleIDs = [ + "com.apple.Music", + "com.spotify.client", + "com.amazon.music", + "com.apple.Safari", + "com.tidal.desktop", + "tv.plex.plexamp", + "com.roon.Roon", + "com.audirvana.Audirvana-Studio", + "com.vox.vox", + "com.coppertino.Vox", + ] + + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, + object: nil, + queue: .main + ) { notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let bundleID = app.bundleIdentifier, + targetBundleIDs.contains(bundleID) else { return } + + // A target music app was launched, restart capture to include it + if Defaults[.enableRealTimeWaveform] { + print("🎵 [AudioTap] Music app launched: \(bundleID), restarting capture...") + // Give the app a moment to fully launch + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + AudioTap.shared.restartCapture() + } + } + } + + // Also observe app terminations to restart capture + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didTerminateApplicationNotification, + object: nil, + queue: .main + ) { notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let bundleID = app.bundleIdentifier, + targetBundleIDs.contains(bundleID) else { return } + + // A target music app was terminated, restart capture to update the list + if Defaults[.enableRealTimeWaveform] { + print("🎵 [AudioTap] Music app terminated: \(bundleID), restarting capture...") + AudioTap.shared.restartCapture() + } + } + } func applicationWillTerminate(_ notification: Notification) { + let userInfo: [String: Any] = [ + AtollDistributedNotifications.UserInfoKey.sourcePID: NSNumber(value: ProcessInfo.processInfo.processIdentifier) + ] + DistributedNotificationCenter.default().postNotificationName( + AtollDistributedNotifications.didBecomeIdle, + object: nil, + userInfo: userInfo, + deliverImmediately: true + ) + + // Cancel any pending window size updates + windowSizeUpdateWorkItem?.cancel() NotificationCenter.default.removeObserver(self) + extensionXPCServiceHost.stop() + extensionRPCServer.stop() + + // Stop AudioTap capture + AudioTap.shared.stopCapture() + + // Restore Lunar's native OSD if integration was active + LunarManager.shared.appWillTerminate() } @objc func onScreenLocked(_: Notification) { print("Screen locked") - cleanupWindows() + hideWindowsForLock() } - + @objc func onScreenUnlocked(_: Notification) { print("Screen unlocked") DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.cleanupWindows() - self?.adjustWindowPosition(changeAlpha: true) + guard let self = self else { return } + self.restoreWindowsAfterLock() + self.adjustWindowPosition(changeAlpha: true) + } + } + + private func hideWindowsForLock() { + guard !windowsHiddenForLock else { return } + windowsHiddenForLock = true + + if Defaults[.showOnAllDisplays] { + for window in windows.values { + window.alphaValue = 0 + window.orderOut(nil) + } + } else if let window = window { + window.alphaValue = 0 + window.orderOut(nil) + } + } + + private func restoreWindowsAfterLock() { + guard windowsHiddenForLock else { return } + windowsHiddenForLock = false + + if Defaults[.showOnAllDisplays] { + for window in windows.values { + window.orderFrontRegardless() + window.alphaValue = 1 + } + } else if let window = window { + window.orderFrontRegardless() + window.alphaValue = 1 } } @@ -125,22 +317,30 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func createDynamicIslandWindow(for screen: NSScreen, with viewModel: DynamicIslandViewModel) -> NSWindow { + // Use the current required size instead of always using openNotchSize + let baseSize = calculateRequiredNotchSize() + let requiredSize = adjustedSizeForScreen(baseSize, screen: screen) + let window = DynamicIslandWindow( contentRect: NSRect( - x: 0, y: 0, width: openNotchSize.width, height: openNotchSize.height), + x: 0, y: 0, width: requiredSize.width, height: requiredSize.height), styleMask: [.borderless, .nonactivatingPanel, .utilityWindow, .hudWindow], backing: .buffered, defer: false ) + + window.animationBehavior = .none - window.contentView = NSHostingView( + window.contentView = FirstMouseHostingView( rootView: ContentView() .environmentObject(viewModel) .environmentObject(webcamManager) + //.moveToSky() ) window.orderFrontRegardless() NotchSpaceManager.shared.notchSpace.windows.insert(window) + //SkyLightOperator.shared.delegateWindow(window) return window } @@ -150,21 +350,394 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.alphaValue = 0 } - DispatchQueue.main.async { [weak window] in - guard let window = window else { return } - let screenFrame = screen.frame - window.setFrameOrigin( - NSPoint( - x: screenFrame.origin.x + (screenFrame.width / 2) - window.frame.width / 2, - y: screenFrame.origin.y + screenFrame.height - window.frame.height - )) + // Use the same centering logic as updateWindowSizeIfNeeded() + let screenFrame = screen.frame + let centerX = screenFrame.origin.x + (screenFrame.width / 2) + let newX = centerX - (window.frame.width / 2) + let newY = screenFrame.origin.y + screenFrame.height - window.frame.height + + window.setFrame(NSRect( + x: newX, + y: newY, + width: window.frame.width, + height: window.frame.height + ), display: false) + + if changeAlpha { window.alphaValue = 1 } } + private func updateWindowSizeIfNeeded() { + // Calculate required size based on current state + let requiredSize = calculateRequiredNotchSize() + let animateResize = shouldAnimateResize(for: requiredSize) + resizeWindows(to: requiredSize, animated: animateResize, force: false) + } + + private func updateWindowSizeForTabSwitch() { + let requiredSize = calculateRequiredNotchSize() + resizeWindows(to: requiredSize, animated: false, force: true) + } + + private func calculateRequiredNotchSize() -> CGSize { + // Check if inline sneak peek is showing and notch is closed + let isInlineSneakPeekActive = vm.notchState == .closed && + coordinator.expandingView.show && + (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) && + Defaults[.enableSneakPeek] && + Defaults[.sneakPeekStyles] == .inline + + // If inline sneak peek is active, use a wider width to accommodate the expanded content + if isInlineSneakPeekActive { + // Calculate required width for inline sneak peek: + // Album art (~32) + Middle section (380) + Visualizer (~32) + horizontal padding (28) + clip shape margin (12) + let inlineSneakPeekWidth: CGFloat = 460 + return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight) + } + + // Check for battery HUD expansion + if vm.notchState == .closed && + coordinator.expandingView.show && + coordinator.expandingView.type == .battery && + Defaults[.showPowerStatusNotifications] { + + let batteryModel = BatteryStatusViewModel.shared + if let kind = batteryModel.activeTemporaryHUDKind { + let closedNotchHeight = vm.effectiveClosedNotchHeight + let closedNotchWidth = vm.closedNotchSize.width + + let style: BatteryNotificationStyle = { + switch kind { + case .charging: return .compact + case .lowBattery: return Defaults[.lowBatteryHUDStyle] + case .fullBattery: return Defaults[.fullBatteryHUDStyle] + } + }() + + var width = closedNotchWidth + var height = closedNotchHeight + + switch (kind, style) { + case (.charging, _), (.lowBattery, .compact), (.fullBattery, .compact): + width += 180 + case (.lowBattery, .standard): + width += 100 + height += 75 + case (.fullBattery, .standard): + width += 80 + height += 70 + } + + return addShadowPadding(to: CGSize(width: width, height: height), isMinimalistic: Defaults[.enableMinimalisticUI]) + } + } + + // Use minimalistic or normal size based on settings + var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize + + // Use a consistent height for different view types + if coordinator.currentView == .timer { + baseSize.height = 250 // Extra space for timer presets + } else if coordinator.currentView == .notes || coordinator.currentView == .clipboard { + let preferredHeight = coordinator.notesLayoutState.preferredHeight + baseSize.height = max(baseSize.height, preferredHeight) + } else if coordinator.currentView == .terminal { + let screenHeight = NSScreen.main?.visibleFrame.height ?? 800 + let maxFraction = Defaults[.terminalMaxHeightFraction] + baseSize.height = min(screenHeight * maxFraction, max(300, screenHeight * maxFraction)) + } + + let adjustedContentSize = statsAdjustedNotchSize( + from: baseSize, + isStatsTabActive: coordinator.currentView == .stats, + secondRowProgress: coordinator.statsSecondRowExpansion + ) + var result = addShadowPadding( + to: adjustedContentSize, + isMinimalistic: Defaults[.enableMinimalisticUI] + ) + + return result + } + + /// Adjusts a base notch size for a specific screen by adding Dynamic Island + /// shadow insets and top-offset only when the screen lacks a physical notch + /// and the user has chosen the Dynamic Island style. + private func adjustedSizeForScreen(_ baseSize: CGSize, screen: NSScreen) -> CGSize { + guard shouldUseDynamicIslandMode(for: screen.localizedName) else { + return baseSize + } + var adjusted = baseSize + adjusted.width += dynamicIslandShadowInset * 2 + adjusted.height += dynamicIslandTopOffset + return adjusted + } + + func ensureWindowSize(_ size: CGSize, animated: Bool, force: Bool = false) { + resizeWindows(to: size, animated: animated, force: force) + } + + private func resizeWindows(to size: CGSize, animated: Bool, force: Bool) { + guard size.width > 0, size.height > 0 else { return } + + if Defaults[.showOnAllDisplays] { + for (screen, window) in windows { + let screenSize = adjustedSizeForScreen(size, screen: screen) + if force || window.frame.size != screenSize { + resizeWindow(window, on: screen, to: screenSize, animated: animated) + } + } + } else if let window { + let screen = window.screen ?? NSScreen.screens.first { $0.frame.intersects(window.frame) } ?? NSScreen.main ?? NSScreen.screens.first + guard let screen else { return } + let screenSize = adjustedSizeForScreen(size, screen: screen) + if force || window.frame.size != screenSize { + resizeWindow(window, on: screen, to: screenSize, animated: animated) + } + } + } + + private func resizeWindow(_ window: NSWindow, on screen: NSScreen, to size: CGSize, animated: Bool) { + let screenFrame = screen.frame + // Clamp width to screen width so the notch never extends beyond screen edges on scaled displays + let clampedWidth = min(size.width, screenFrame.width) + let clampedHeight = min(size.height, screenFrame.height) + let centerX = screenFrame.midX + let newX = centerX - (clampedWidth / 2) + let newY = screenFrame.origin.y + screenFrame.height - clampedHeight + let targetFrame = NSRect(x: newX, y: newY, width: clampedWidth, height: clampedHeight) + + window.setFrame(targetFrame, display: true) + } + + private func shouldAnimateResize(for newSize: CGSize) -> Bool { + if Defaults[.enableMinimalisticUI] && !ReminderLiveActivityManager.shared.activeWindowReminders.isEmpty { + return false + } + return true + } + func applicationDidFinishLaunching(_ notification: Notification) { + let userInfo: [String: Any] = [ + AtollDistributedNotifications.UserInfoKey.sourcePID: NSNumber(value: ProcessInfo.processInfo.processIdentifier) + ] + DistributedNotificationCenter.default().postNotificationName( + AtollDistributedNotifications.didBecomeActive, + object: nil, + userInfo: userInfo, + deliverImmediately: true + ) + + LockScreenLiveActivityWindowManager.shared.configure(viewModel: vm) + LockScreenManager.shared.configure(viewModel: vm) + extensionXPCServiceHost.start() + extensionRPCServer.start() + + // Migrate legacy progress bar settings + Defaults.Keys.migrateProgressBarStyle() + Defaults.Keys.migrateMusicAuxControls() + Defaults.Keys.migrateMusicControlSlots() + Defaults.Keys.migrateCapsLockTintMode() + Defaults.Keys.migrateThirdPartyDDCIntegration() + + Defaults.publisher(.enableThirdPartyDDCIntegration, options: []) + .sink { _ in + Defaults.Keys.syncLegacyThirdPartyDDCKeys() + } + .store(in: &cancellables) + + Defaults.publisher(.thirdPartyDDCProvider, options: []) + .sink { _ in + Defaults.Keys.syncLegacyThirdPartyDDCKeys() + } + .store(in: &cancellables) + + // Initialize idle animations (load bundled + built-in face) + idleAnimationManager.initializeDefaultAnimations() + + applySelectedAppIcon() + installTopMenuItemsIfNeeded() + + Defaults.publisher(.focusMonitoringMode, options: []) + .sink { [weak self] _ in + self?.updateFocusMenuState() + } + .store(in: &cancellables) + + // Setup SystemHUD Manager + SystemHUDManager.shared.setup(coordinator: coordinator) - coordinator.setupWorkersNotificationObservers() + // Setup BetterDisplay integration + BetterDisplayManager.shared.configure(coordinator: coordinator) + + // Setup Lunar integration + LunarManager.shared.configure(coordinator: coordinator) + + // Setup ScreenRecording Manager + if Defaults[.enableScreenRecordingDetection] { + ScreenRecordingManager.shared.startMonitoring() + } + + // Setup Do Not Disturb Manager + if Defaults[.enableDoNotDisturbDetection] { + dndManager.startMonitoring() + } + + // Setup Privacy Indicator Manager (camera and microphone monitoring) + PrivacyIndicatorManager.shared.startMonitoring() + + // Setup Real-time Audio Waveform capture if enabled + if Defaults[.enableRealTimeWaveform] { + Task { + await AudioTap.shared.startCapture() + } + setupAudioTapMusicObservers() + } + + // Observe enableRealTimeWaveform changes + Defaults.publisher(.enableRealTimeWaveform, options: []) + .sink { [weak self] change in + if change.newValue { + Task { + await AudioTap.shared.startCapture() + } + self?.setupAudioTapMusicObservers() + } else { + AudioTap.shared.stopCapture() + } + } + .store(in: &cancellables) + + // Observe tab changes - use immediate resize to keep the notch pinned + // Deferred to next run loop tick because @Published fires on willSet, + // so coordinator.currentView still holds the OLD value at emission time. + coordinator.$currentView.sink { [weak self] _ in + DispatchQueue.main.async { + self?.updateWindowSizeForTabSwitch() + } + }.store(in: &cancellables) + + coordinator.$notesLayoutState + .removeDuplicates() + .sink { [weak self] _ in + self?.updateWindowSizeIfNeeded() + } + .store(in: &cancellables) + + // Observe stats settings changes - use debounced updates + Defaults.publisher(.enableStatsFeature, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + + Defaults.publisher(.showCpuGraph, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + + Defaults.publisher(.showMemoryGraph, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + + Defaults.publisher(.showGpuGraph, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + + Defaults.publisher(.showNetworkGraph, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + + Defaults.publisher(.showDiskGraph, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + + Defaults.publisher(.openNotchWidth, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + + // Observe terminal settings changes + Defaults.publisher(.enableTerminalFeature, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + + Defaults.publisher(.terminalMaxHeightFraction, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + + MemoryUsageMonitor.shared.startMonitoring() + + ReminderLiveActivityManager.shared.$activeWindowReminders + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + } + .store(in: &cancellables) + + TimerManager.shared.$activeSource + .combineLatest(TimerManager.shared.$isTimerActive) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + } + .store(in: &cancellables) + + Defaults.publisher(.enableShortcuts, options: []).sink { [weak self] change in + Task { @MainActor [weak self] in + guard let self else { return } + KeyboardShortcuts.isEnabled = change.newValue + self.updateFeatureShortcutAvailability() + } + }.store(in: &cancellables) + + Defaults.publisher(.enableTimerFeature, options: []).sink { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateFeatureShortcutAvailability() + } + }.store(in: &cancellables) + + Defaults.publisher(.enableClipboardManager, options: []).sink { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateFeatureShortcutAvailability() + } + }.store(in: &cancellables) + + Defaults.publisher(.enableColorPickerFeature, options: []).sink { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateFeatureShortcutAvailability() + } + }.store(in: &cancellables) + + Defaults.publisher(.enableScreenAssistant, options: []).sink { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateFeatureShortcutAvailability() + } + }.store(in: &cancellables) + + // Observe minimalistic UI setting changes - trigger window resize + Defaults.publisher(.enableMinimalisticUI, options: []).sink { [weak self] _ in + // Update window size IMMEDIATELY (no debouncing) to prevent position shift + self?.updateWindowSizeIfNeeded() + }.store(in: &cancellables) + + // Observe screen recording settings changes + Defaults.publisher(.enableScreenRecordingDetection, options: []).sink { _ in + if Defaults[.enableScreenRecordingDetection] { + ScreenRecordingManager.shared.startMonitoring() + } else { + ScreenRecordingManager.shared.stopMonitoring() + } + }.store(in: &cancellables) + + Defaults.publisher(.enableDoNotDisturbDetection, options: []).sink { [weak self] _ in + guard let self else { return } + + if Defaults[.enableDoNotDisturbDetection] { + self.dndManager.startMonitoring() + } else { + self.dndManager.stopMonitoring() + } + }.store(in: &cancellables) + + // Note: Polling setting removed - now uses event-driven private API detection only NotificationCenter.default.addObserver( self, @@ -189,8 +762,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { forName: Notification.Name.automaticallySwitchDisplayChanged, object: nil, queue: nil ) { [weak self] _ in guard let self = self, let window = self.window else { return } - window.alphaValue = - self.coordinator.selectedScreen == self.coordinator.preferredScreen ? 1 : 0 + DispatchQueue.main.async { + window.alphaValue = + self.coordinator.selectedScreen == self.coordinator.preferredScreen ? 1 : 0 + } } NotificationCenter.default.addObserver( @@ -219,6 +794,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { KeyboardShortcuts.onKeyDown(for: .toggleSneakPeek) { [weak self] in guard let self = self else { return } + guard Defaults[.enableShortcuts] else { return } + self.coordinator.toggleSneakPeek( status: !self.coordinator.sneakPeek.show, type: .music, @@ -228,9 +805,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in guard let self = self else { return } - + guard Defaults[.enableShortcuts] else { return } + let mouseLocation = NSEvent.mouseLocation - + var viewModel = self.vm if Defaults[.showOnAllDisplays] { @@ -243,31 +821,29 @@ 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 } - // Start a 5-minute demo timer - TimerManager.shared.startDemoTimer(duration: 300) - } - + + KeyboardShortcuts.isEnabled = Defaults[.enableShortcuts] + registerOptionalShortcutHandlers() + updateFeatureShortcutAvailability() + if !Defaults[.showOnAllDisplays] { let viewModel = self.vm let window = createDynamicIslandWindow( @@ -286,6 +862,282 @@ class AppDelegate: NSObject, NSApplicationDelegate { } previousScreens = NSScreen.screens + + if Defaults[.enableLockScreenWeatherWidget] { + LockScreenWeatherManager.shared.prepareLocationAccess() + Task { @MainActor in + await LockScreenWeatherManager.shared.refresh(force: true) + } + } + + // Warm up the lock screen timer widget manager so it can observe timer/default + // changes immediately instead of waiting for the first lock event. + let timerWidgetManager = LockScreenTimerWidgetManager.shared + timerWidgetManager.handleLockStateChange(isLocked: LockScreenManager.shared.currentLockStatus) + + } + + private func installTopMenuItemsIfNeeded() { + guard let mainMenu = NSApp.mainMenu else { return } + if mainMenu.items.contains(where: { $0.identifier?.rawValue == "Atoll.Focus.Menu" }) { + updateFocusMenuState() + return + } + + let insertionIndex = preferredMenuInsertionIndex(in: mainMenu) + + let focusMenuItem = NSMenuItem(title: "Focus", action: nil, keyEquivalent: "") + focusMenuItem.identifier = NSUserInterfaceItemIdentifier("Atoll.Focus.Menu") + let focusSubmenu = NSMenu(title: "Focus") + + let withoutDevTools = NSMenuItem( + title: "Use without DevTools", + action: #selector(selectFocusWithoutDevTools), + keyEquivalent: "" + ) + withoutDevTools.target = self + + let useDevTools = NSMenuItem( + title: "Use DevTools", + action: #selector(selectFocusUseDevTools), + keyEquivalent: "" + ) + useDevTools.target = self + + focusSubmenu.addItem(withoutDevTools) + focusSubmenu.addItem(useDevTools) + focusMenuItem.submenu = focusSubmenu + mainMenu.insertItem(focusMenuItem, at: insertionIndex) + + focusWithoutDevToolsMenuItem = withoutDevTools + focusUseDevToolsMenuItem = useDevTools + + let accessibilityMenuItem = NSMenuItem(title: "Accessibility", action: nil, keyEquivalent: "") + accessibilityMenuItem.identifier = NSUserInterfaceItemIdentifier("Atoll.Accessibility.Menu") + let accessibilitySubmenu = NSMenu(title: "Accessibility") + + let requestAccessibility = NSMenuItem( + title: "Request Accessibility Access", + action: #selector(requestAccessibilityAccess), + keyEquivalent: "" + ) + requestAccessibility.target = self + + let openAccessibility = NSMenuItem( + title: "Open Accessibility Settings", + action: #selector(openAccessibilitySettings), + keyEquivalent: "" + ) + openAccessibility.target = self + + accessibilitySubmenu.addItem(requestAccessibility) + accessibilitySubmenu.addItem(openAccessibility) + accessibilityMenuItem.submenu = accessibilitySubmenu + mainMenu.insertItem(accessibilityMenuItem, at: insertionIndex + 1) + + let permissionsMenuItem = NSMenuItem(title: "Permissions", action: nil, keyEquivalent: "") + permissionsMenuItem.identifier = NSUserInterfaceItemIdentifier("Atoll.Permissions.Menu") + let permissionsSubmenu = NSMenu(title: "Permissions") + + let requestFullDisk = NSMenuItem( + title: "Request Full Disk Access", + action: #selector(requestFullDiskAccess), + keyEquivalent: "" + ) + requestFullDisk.target = self + + let openFullDisk = NSMenuItem( + title: "Open Full Disk Access Settings", + action: #selector(openFullDiskAccessSettings), + keyEquivalent: "" + ) + openFullDisk.target = self + + let openDevTools = NSMenuItem( + title: "Open Developer Tools Settings", + action: #selector(openDeveloperToolsSettingsFromMenu), + keyEquivalent: "" + ) + openDevTools.target = self + + permissionsSubmenu.addItem(requestFullDisk) + permissionsSubmenu.addItem(openFullDisk) + permissionsSubmenu.addItem(NSMenuItem.separator()) + permissionsSubmenu.addItem(openDevTools) + permissionsMenuItem.submenu = permissionsSubmenu + mainMenu.insertItem(permissionsMenuItem, at: insertionIndex + 2) + + updateFocusMenuState() + } + + private func preferredMenuInsertionIndex(in mainMenu: NSMenu) -> Int { + if let index = mainMenu.items.firstIndex(where: { $0.title == "Window" }) { + return index + } + if let index = mainMenu.items.firstIndex(where: { $0.title == "Help" }) { + return index + } + return max(mainMenu.numberOfItems, 0) + } + + private func updateFocusMenuState() { + let mode = Defaults[.focusMonitoringMode] + focusWithoutDevToolsMenuItem?.state = mode == .withoutDevTools ? .on : .off + focusUseDevToolsMenuItem?.state = mode == .useDevTools ? .on : .off + } + + @objc private func selectFocusWithoutDevTools() { + Defaults[.focusMonitoringMode] = .withoutDevTools + updateFocusMenuState() + } + + @objc private func selectFocusUseDevTools() { + Defaults[.focusMonitoringMode] = .useDevTools + updateFocusMenuState() + } + + @objc private func requestAccessibilityAccess() { + AccessibilityPermissionStore.shared.requestAuthorizationPrompt() + } + + @objc private func openAccessibilitySettings() { + AccessibilityPermissionStore.shared.openSystemSettings() + } + + @objc private func requestFullDiskAccess() { + FullDiskAccessPermissionStore.shared.requestAccessPrompt() + } + + @objc private func openFullDiskAccessSettings() { + FullDiskAccessPermissionStore.shared.openSystemSettings() + } + + @objc private func openDeveloperToolsSettingsFromMenu() { + let urls = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_DevTools", + "x-apple.systempreferences:com.apple.preference.security" + ] + + for candidate in urls { + guard let url = URL(string: candidate) else { continue } + if NSWorkspace.shared.open(url) { + return + } + } + } + + private func registerOptionalShortcutHandlers() { + guard !optionalShortcutHandlersRegistered else { return } + optionalShortcutHandlersRegistered = true + + KeyboardShortcuts.onKeyDown(for: .startDemoTimer) { + guard Defaults[.enableShortcuts], Defaults[.enableTimerFeature] else { return } + TimerManager.shared.startDemoTimer(duration: 300) + } + + KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in + guard let self else { return } + guard Defaults[.enableShortcuts], Defaults[.enableClipboardManager] else { return } + + if !ClipboardManager.shared.isMonitoring { + ClipboardManager.shared.startMonitoring() + } + + switch Defaults[.clipboardDisplayMode] { + case .panel: + ClipboardPanelManager.shared.toggleClipboardPanel() + case .popover: + if vm.notchState == .closed { + vm.open() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil) + } + } else { + NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil) + } + case .separateTab: + if vm.notchState == .closed { + vm.open() + coordinator.currentView = .notes + } else { + if coordinator.currentView == .notes { + vm.close() + } else { + coordinator.currentView = .notes + } + } + } + } + + KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) { + guard Defaults[.enableShortcuts], Defaults[.enableColorPickerFeature] else { return } + ColorPickerPanelManager.shared.toggleColorPickerPanel() + } + + KeyboardShortcuts.onKeyDown(for: .toggleTerminalTab) { [weak self] in + guard let self else { return } + guard Defaults[.enableShortcuts], Defaults[.enableTerminalFeature] else { return } + + if vm.notchState == .closed { + closeNotchWorkItem?.cancel() + closeNotchWorkItem = nil + vm.open() + coordinator.currentView = .terminal + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() + TerminalManager.shared.focusTerminalIfPossible() + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() + } else { + if coordinator.currentView == .terminal { + coordinator.suppressHoverOpen() + TerminalManager.shared.resignTerminalFirstResponderIfNeeded() + vm.close() + } else { + closeNotchWorkItem?.cancel() + closeNotchWorkItem = nil + coordinator.currentView = .terminal + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() + TerminalManager.shared.focusTerminalIfPossible() + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() + } + } + } + + KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in + guard let self else { return } + guard Defaults[.enableShortcuts], Defaults[.enableScreenAssistant] else { return } + + switch Defaults[.screenAssistantDisplayMode] { + case .panel: + ScreenAssistantPanelManager.shared.toggleScreenAssistantPanel() + case .popover: + if vm.notchState == .closed { + vm.open() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil) + } + } else { + NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil) + } + } + } + } + + @MainActor + private func updateFeatureShortcutAvailability() { + updateShortcut(.startDemoTimer, isEnabled: Defaults[.enableShortcuts] && Defaults[.enableTimerFeature]) + updateShortcut(.clipboardHistoryPanel, isEnabled: Defaults[.enableShortcuts] && Defaults[.enableClipboardManager]) + updateShortcut(.colorPickerPanel, isEnabled: Defaults[.enableShortcuts] && Defaults[.enableColorPickerFeature]) + updateShortcut(.screenAssistantPanel, isEnabled: Defaults[.enableShortcuts] && Defaults[.enableScreenAssistant]) + updateShortcut(.toggleTerminalTab, isEnabled: Defaults[.enableShortcuts] && Defaults[.enableTerminalFeature]) + } + + @MainActor + private func updateShortcut(_ name: KeyboardShortcuts.Name, isEnabled: Bool) { + if isEnabled { + KeyboardShortcuts.enable(name) + } else { + KeyboardShortcuts.disable(name) + } } func playWelcomeSound() { @@ -404,6 +1256,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSApplication.shared.terminate(nil) } + + private func showOnboardingWindow() { if onboardingWindowController == nil { let window = NSWindow( @@ -431,6 +1285,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.isRestorable = false window.identifier = NSUserInterfaceItemIdentifier("OnboardingWindow") + ScreenCaptureVisibilityManager.shared.register(window, scope: .panelsOnly) + onboardingWindowController = NSWindowController(window: window) } @@ -460,3 +1316,98 @@ extension CGRect: @retroactive Hashable { return lhs.origin == rhs.origin && lhs.size == rhs.size } } + +@MainActor +final class MediaControlsStateCoordinator { + static let shared = MediaControlsStateCoordinator() + + private var cancellables = Set() + + private init() { + let masterPublisher = Defaults.publisher(.showStandardMediaControls) + let minimalisticPublisher = Defaults.publisher(.enableMinimalisticUI) + + Publishers.CombineLatest(masterPublisher, minimalisticPublisher) + .receive(on: RunLoop.main) + .sink { [weak self] masterChange, minimalisticChange in + self?.handleStateChange( + showStandard: masterChange.newValue, + minimalistic: minimalisticChange.newValue + ) + } + .store(in: &cancellables) + } + + private func handleStateChange(showStandard: Bool, minimalistic: Bool) { + if !showStandard && !minimalistic { + cacheAndDisableMusicLiveActivity() + } else { + restoreMusicLiveActivity(clearCache: showStandard) + } + + if showStandard { + restoreLockScreenPanelIfNeeded() + restoreMusicControlWindowIfNeeded() + } else { + cacheAndDisableLockScreenPanel() + cacheAndDisableMusicControlWindow() + } + } + + private func cacheAndDisableMusicLiveActivity() { + if Defaults[.cachedMusicLiveActivityPreference] == nil { + Defaults[.cachedMusicLiveActivityPreference] = DynamicIslandViewCoordinator.shared.musicLiveActivityEnabled + } + + if DynamicIslandViewCoordinator.shared.musicLiveActivityEnabled { + DynamicIslandViewCoordinator.shared.musicLiveActivityEnabled = false + } + } + + private func restoreMusicLiveActivity(clearCache: Bool) { + guard let cached = Defaults[.cachedMusicLiveActivityPreference] else { return } + + if DynamicIslandViewCoordinator.shared.musicLiveActivityEnabled != cached { + DynamicIslandViewCoordinator.shared.musicLiveActivityEnabled = cached + } + + if clearCache { + Defaults[.cachedMusicLiveActivityPreference] = nil + } + } + + private func cacheAndDisableLockScreenPanel() { + if Defaults[.cachedLockScreenMediaWidgetPreference] == nil { + Defaults[.cachedLockScreenMediaWidgetPreference] = Defaults[.enableLockScreenMediaWidget] + } + + if Defaults[.enableLockScreenMediaWidget] { + Defaults[.enableLockScreenMediaWidget] = false + LockScreenPanelManager.shared.hidePanel() + } + } + + private func restoreLockScreenPanelIfNeeded() { + guard let cached = Defaults[.cachedLockScreenMediaWidgetPreference] else { return } + Defaults[.enableLockScreenMediaWidget] = cached + Defaults[.cachedLockScreenMediaWidgetPreference] = nil + } + + private func cacheAndDisableMusicControlWindow() { + if Defaults[.cachedMusicControlWindowPreference] == nil { + Defaults[.cachedMusicControlWindowPreference] = Defaults[.musicControlWindowEnabled] + } + + if Defaults[.musicControlWindowEnabled] { + Defaults[.musicControlWindowEnabled] = false + } + + MusicControlWindowManager.shared.hide() + } + + private func restoreMusicControlWindowIfNeeded() { + guard let cached = Defaults[.cachedMusicControlWindowPreference] else { return } + Defaults[.musicControlWindowEnabled] = cached + Defaults[.cachedMusicControlWindowPreference] = nil + } +} diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index 420eedcc..c6ebb3a1 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -1,16 +1,26 @@ -// -// DynamicIslandViewCoordinator.swift -// DynamicIsland -// -// Created by Alexander on 2024-11-20. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Combine import Defaults import SwiftUI -import TheBoringWorkerNotifier -enum SneakContentType { +enum SneakContentType: Equatable { case brightness case volume case backlight @@ -19,6 +29,50 @@ enum SneakContentType { case battery case download case timer + case reminder + case recording + case doNotDisturb + case bluetoothAudio + case privacy + case lockScreen + case capsLock + case extensionLiveActivity(bundleID: String, activityID: String) +} + +extension SneakContentType { + static func == (lhs: SneakContentType, rhs: SneakContentType) -> Bool { + switch (lhs, rhs) { + case (.brightness, .brightness), + (.volume, .volume), + (.backlight, .backlight), + (.music, .music), + (.mic, .mic), + (.battery, .battery), + (.download, .download), + (.timer, .timer), + (.reminder, .reminder), + (.recording, .recording), + (.doNotDisturb, .doNotDisturb), + (.bluetoothAudio, .bluetoothAudio), + (.privacy, .privacy), + (.lockScreen, .lockScreen), + (.capsLock, .capsLock): + return true + case let (.extensionLiveActivity(lb, la), .extensionLiveActivity(rb, ra)): + return lb == rb && la == ra + default: + return false + } + } +} + +extension SneakContentType { + var isExtensionPayload: Bool { + if case .extensionLiveActivity = self { + return true + } + return false + } } struct sneakPeek { @@ -26,13 +80,11 @@ struct sneakPeek { var type: SneakContentType = .music var value: CGFloat = 0 var icon: String = "" -} - -struct SharedSneakPeek: Codable { - var show: Bool - var type: String - var value: String - var icon: String + var title: String = "" + var subtitle: String = "" + var accentColor: Color? + var styleOverride: SneakPeekStyle? = nil + var targetScreenName: String? = nil } enum BrowserType { @@ -45,22 +97,45 @@ struct ExpandedItem { var type: SneakContentType = .battery var value: CGFloat = 0 var browser: BrowserType = .chromium + var autoHideDuration: TimeInterval? = nil } class DynamicIslandViewCoordinator: ObservableObject { static let shared = DynamicIslandViewCoordinator() - var notifier: TheBoringWorkerNotifier = .init() + private var cancellables = Set() + private var hoverOpenSuppressedUntil: Date = .distantPast + + private static let tabOrder: [NotchViews] = [.home, .shelf, .timer, .stats, .colorPicker, .notes, .clipboard, .terminal, .extensionExperience] + + /// Direction of the most recent tab switch (true = forward/right, false = backward/left) + @Published var tabSwitchForward: Bool = true + + @Published var currentView: NotchViews = .home { + didSet { + if Defaults[.enableMinimalisticUI] && currentView != .home { + currentView = .home + return + } + // Track direction before SwiftUI re-renders + let oldIdx = Self.tabOrder.firstIndex(of: oldValue) ?? 0 + let newIdx = Self.tabOrder.firstIndex(of: currentView) ?? 0 + tabSwitchForward = newIdx >= oldIdx + handleStatsTabTransition(from: oldValue, to: currentView) + } + } - @Published var currentView: NotchViews = .home - private var sneakPeekDispatch: DispatchWorkItem? - private var expandingViewDispatch: DispatchWorkItem? + @Published var statsSecondRowExpansion: CGFloat = 1 + @Published var notesLayoutState: NotesLayoutState = .list + @Published var selectedExtensionExperienceID: String? @AppStorage("firstLaunch") var firstLaunch: Bool = true @AppStorage("showWhatsNew") var showWhatsNew: Bool = true @AppStorage("musicLiveActivityEnabled") var musicLiveActivityEnabled: Bool = true @AppStorage("timerLiveActivityEnabled") var timerLiveActivityEnabled: Bool = true - @AppStorage("currentMicStatus") var currentMicStatus: Bool = true + + @Default(.enableTimerFeature) private var enableTimerFeature + @Default(.timerDisplayMode) private var timerDisplayMode @AppStorage("alwaysShowTabs") var alwaysShowTabs: Bool = true { didSet { @@ -81,11 +156,7 @@ class DynamicIslandViewCoordinator: ObservableObject { } } - @AppStorage("hudReplacement") var hudReplacement: Bool = true { - didSet { - notifier.postNotification(name: notifier.toggleHudReplacementNotification.name, userInfo: nil) - } - } + @AppStorage("hudReplacement") var hudReplacement: Bool = true @AppStorage("preferred_screen_name") var preferredScreen = NSScreen.main?.localizedName ?? "Unknown" { didSet { @@ -97,120 +168,297 @@ class DynamicIslandViewCoordinator: ObservableObject { @Published var selectedScreen: String = NSScreen.main?.localizedName ?? "Unknown" @Published var optionKeyPressed: Bool = true + private let extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared private init() { selectedScreen = preferredScreen - notifier = TheBoringWorkerNotifier() + Defaults.publisher(.timerDisplayMode) + .receive(on: DispatchQueue.main) + .sink { [weak self] change in + self?.handleTimerDisplayModeChange(change.newValue) + } + .store(in: &cancellables) + + Defaults.publisher(.enableTimerFeature) + .receive(on: DispatchQueue.main) + .sink { [weak self] change in + self?.handleTimerFeatureToggle(change.newValue) + } + .store(in: &cancellables) + + Defaults.publisher(.enableMinimalisticUI) + .receive(on: DispatchQueue.main) + .sink { [weak self] change in + self?.handleMinimalisticModeChange(change.newValue) + } + .store(in: &cancellables) + + extensionNotchExperienceManager.$activeExperiences + .receive(on: DispatchQueue.main) + .sink { [weak self] experiences in + self?.handleExtensionExperienceSnapshot(experiences) + } + .store(in: &cancellables) + + Defaults.publisher(.enableThirdPartyExtensions) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleExtensionFeatureToggle() + } + .store(in: &cancellables) + + Defaults.publisher(.enableExtensionNotchExperiences) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleExtensionFeatureToggle() + } + .store(in: &cancellables) + + Defaults.publisher(.enableExtensionNotchTabs) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleExtensionFeatureToggle() + } + .store(in: &cancellables) + + handleExtensionExperienceSnapshot(extensionNotchExperienceManager.activeExperiences) + + // Observe all tab-affecting settings to enforce minimum notch width + Publishers.MergeMany( + Defaults.publisher(.showStandardMediaControls).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.showCalendar).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.showMirror).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.dynamicShelf).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.enableTimerFeature).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.timerDisplayMode).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.enableStatsFeature).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.enableNotes).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.enableClipboardManager).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.clipboardDisplayMode).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.enableTerminalFeature).map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.enableMinimalisticUI).map { _ in () }.eraseToAnyPublisher() + ) + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) + .sink { _ in + enforceMinimumNotchWidth() + } + .store(in: &cancellables) + + // Enforce minimum width on launch for existing configurations + enforceMinimumNotchWidth() } - - func setupWorkersNotificationObservers() { - notifier.setupObserver(notification: notifier.micStatusNotification, handler: initialMicStatus) - notifier.setupObserver(notification: notifier.sneakPeakNotification, handler: sneakPeekEvent) + + var isHoverOpenSuppressed: Bool { + Date() < hoverOpenSuppressedUntil + } + + func suppressHoverOpen(for duration: TimeInterval = 0.35) { + hoverOpenSuppressedUntil = Date().addingTimeInterval(max(0, duration)) + } + + private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) { + guard oldValue != newValue else { return } + if newValue == .stats && Defaults[.enableStatsFeature] { + statsSecondRowExpansion = 1 } - - @objc func sneakPeekEvent(_ notification: Notification) { - let decoder = JSONDecoder() - if let decodedData = try? decoder.decode(SharedSneakPeek.self, from: notification.userInfo?.first?.value as! Data) { - let contentType = decodedData.type == "brightness" ? SneakContentType.brightness : decodedData.type == "volume" ? SneakContentType.volume : decodedData.type == "backlight" ? SneakContentType.backlight : decodedData.type == "mic" ? SneakContentType.mic : decodedData.type == "timer" ? SneakContentType.timer : SneakContentType.brightness + } - let value = CGFloat((NumberFormatter().number(from: decodedData.value) ?? 0.0).floatValue) - let icon = decodedData.icon - - print(decodedData) - - toggleSneakPeek(status: decodedData.show, type: contentType, value: value, icon: icon) - - } else { - print("Failed to decode JSON data") + private func handleTimerDisplayModeChange(_ mode: TimerDisplayMode) { + guard mode == .popover, currentView == .timer else { return } + withAnimation(.smooth) { + currentView = .home + } + } + + private func handleTimerFeatureToggle(_ isEnabled: Bool) { + guard !isEnabled, currentView == .timer else { return } + withAnimation(.smooth) { + currentView = .home + } + } + + private func handleMinimalisticModeChange(_ isEnabled: Bool) { + guard isEnabled else { return } + if currentView != .home { + withAnimation(.smooth) { + currentView = .home } } + } + + private func handleExtensionExperienceSnapshot(_ experiences: [ExtensionNotchExperiencePayload]) { + guard extensionTabsAllowed else { + selectedExtensionExperienceID = nil + resetExtensionViewIfNeeded() + return + } + + let tabCapablePayloads = experiences.filter { $0.descriptor.tab != nil } + guard !tabCapablePayloads.isEmpty else { + selectedExtensionExperienceID = nil + resetExtensionViewIfNeeded() + return + } + + if let currentID = selectedExtensionExperienceID, + tabCapablePayloads.contains(where: { $0.descriptor.id == currentID }) { + return + } + + selectedExtensionExperienceID = tabCapablePayloads.first?.descriptor.id + } + + private func handleExtensionFeatureToggle() { + handleExtensionExperienceSnapshot(extensionNotchExperienceManager.activeExperiences) + } + + private func resetExtensionViewIfNeeded() { + guard currentView == .extensionExperience else { return } + withAnimation(.smooth) { + currentView = .home + } + } + + private var extensionTabsAllowed: Bool { + Defaults[.enableThirdPartyExtensions] + && Defaults[.enableExtensionNotchExperiences] + && Defaults[.enableExtensionNotchTabs] + } - func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") { - sneakPeekDuration = duration - if type != .music && type != .timer { - // close() - if !hudReplacement { - return - } + func toggleSneakPeek( + status: Bool, + type: SneakContentType, + duration: TimeInterval = 1.5, + value: CGFloat = 0, + icon: String = "", + title: String = "", + subtitle: String = "", + accentColor: Color? = nil, + styleOverride: SneakPeekStyle? = nil, + onScreen targetScreen: NSScreen? = nil + ) { + let resolvedDuration: TimeInterval + switch type { + case .timer: + resolvedDuration = 10 + case .reminder: + resolvedDuration = Defaults[.reminderSneakPeekDuration] + case .extensionLiveActivity: + resolvedDuration = duration + default: + resolvedDuration = duration + } + sneakPeekDuration = resolvedDuration + let bypassedTypes: [SneakContentType] = [.music, .timer, .reminder, .bluetoothAudio] + + // Check if it's an extension type + let isExtensionType: Bool + if case .extensionLiveActivity = type { + isExtensionType = true + } else { + isExtensionType = false + } + + if !isExtensionType && !bypassedTypes.contains(type) && !Defaults[.enableSystemHUD] { + return } DispatchQueue.main.async { - withAnimation(.smooth) { + withAnimation(.smooth(duration: 0.3)) { self.sneakPeek.show = status self.sneakPeek.type = type self.sneakPeek.value = value self.sneakPeek.icon = icon + self.sneakPeek.title = title + self.sneakPeek.subtitle = subtitle + self.sneakPeek.accentColor = accentColor + self.sneakPeek.styleOverride = styleOverride + self.sneakPeek.targetScreenName = targetScreen?.localizedName } } - - if type == .mic { - currentMicStatus = value == 1 - } } private var sneakPeekDuration: TimeInterval = 1.5 - - @Published var sneakPeek: sneakPeek = .init() { - didSet { - if sneakPeek.show { - sneakPeekDispatch?.cancel() + private var sneakPeekTask: Task? - sneakPeekDispatch = DispatchWorkItem { [weak self] in - guard let self = self else { return } - withAnimation { - self.toggleSneakPeek(status: false, type: SneakContentType.music) - self.sneakPeekDuration = 1.5 - } + // Helper function to manage sneakPeek timer using Swift Concurrency + private func scheduleSneakPeekHide(after duration: TimeInterval) { + sneakPeekTask?.cancel() + + // Don't schedule auto-hide if duration is infinite (for persistent indicators like Caps Lock) + guard duration.isFinite else { return } + + sneakPeekTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(duration)) + guard let self = self, !Task.isCancelled else { return } + await MainActor.run { + withAnimation { + // Hide the sneak peek with the correct type that was showing + self.toggleSneakPeek(status: false, type: self.sneakPeek.type) + self.sneakPeekDuration = 1.5 } - DispatchQueue.main - .asyncAfter( - deadline: .now() + self.sneakPeekDuration, - execute: sneakPeekDispatch! - ) } } } - func toggleExpandingView(status: Bool, type: SneakContentType, value: CGFloat = 0, browser: BrowserType = .chromium) { - if expandingView.show { - withAnimation(.smooth) { - self.expandingView.show = false + @Published var sneakPeek: sneakPeek = .init() { + didSet { + if sneakPeek.show { + scheduleSneakPeekHide(after: sneakPeekDuration) + } else { + sneakPeekTask?.cancel() } } - DispatchQueue.main.async { + } + + func toggleExpandingView( + status: Bool, + type: SneakContentType, + value: CGFloat = 0, + browser: BrowserType = .chromium, + autoHideDuration: TimeInterval? = nil + ) { + Task { @MainActor in withAnimation(.smooth) { self.expandingView.show = status self.expandingView.type = type self.expandingView.value = value self.expandingView.browser = browser + self.expandingView.autoHideDuration = autoHideDuration } } } + + private var expandingViewTask: Task? @Published var expandingView: ExpandedItem = .init() { didSet { if expandingView.show { - expandingViewDispatch?.cancel() - - expandingViewDispatch = DispatchWorkItem { [weak self] in - guard let self = self else { return } - self.toggleExpandingView(status: false, type: SneakContentType.battery) + expandingViewTask?.cancel() + // Only auto-hide for battery, not for downloads (DownloadManager handles that) + if expandingView.type != .download { + let duration = expandingView.autoHideDuration ?? 3 + expandingViewTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(duration)) + guard let self = self, !Task.isCancelled else { return } + self.toggleExpandingView(status: false, type: .battery) + } } - - DispatchQueue.main.asyncAfter(deadline: .now() + (expandingView.type == .download ? 2 : 3), execute: expandingViewDispatch!) + } else { + expandingViewTask?.cancel() } } } - @objc func initialMicStatus(_ notification: Notification) { - currentMicStatus = notification.userInfo?.first?.value as! Bool + func showEmpty() { + currentView = .home } - func toggleMic() { - notifier.postNotification(name: notifier.toggleMicNotification.name, userInfo: nil) - } + // MARK: - Clipboard Management + @Published var shouldToggleClipboardPopover: Bool = false - func showEmpty() { - currentView = .home + func toggleClipboardPopover() { + // Toggle the published property to trigger UI updates + shouldToggleClipboardPopover.toggle() } } diff --git a/DynamicIsland/Info.plist b/DynamicIsland/Info.plist index d07ae610..181d8a5e 100644 --- a/DynamicIsland/Info.plist +++ b/DynamicIsland/Info.plist @@ -7,18 +7,20 @@ NSAllowsArbitraryLoads - NSAppleEventsUsageDescription - Dynamic Island uses AppleScripts to control Spotify and Apple Music - NSCalendarsFullAccessUsageDescription - Show calender events inside the notch + NSAppleMusicUsageDescription + Atoll uses Apple Music to fetch animated artwork for the lock screen wallpaper. + NSAudioCaptureUsageDescription + Atoll needs audio capture permission to display the real-time music waveform visualizer. + NSScreenCaptureUsageDescription + Atoll needs screen recording permission to capture screenshots for the AI assistant feature. SUEnableDownloaderService SUEnableInstallerLauncherService SUFeedURL - https://TheBoredTeam.github.io/boring.notch/appcast.xml + https://raw.githubusercontent.com/Ebullioscopic/Atoll/main/Updates/appcast.xml SUPublicEDKey - B1Y47t8C/v8ImurYA+9arEsuCrpxwJSviekiflMElbI= + q2YQaJ1umGkaIJWMGN9Isj5fx/YlUtxnzHEBqFtfZcg= UTImportedTypeDeclarations diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 19358cf2..24072c87 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -2,985 +2,52564 @@ "sourceLanguage" : "en", "strings" : { "" : { - + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + } + } }, " – %lld" : { - + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : " – %lld" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : " – %lld" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : " – %lld" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : " – %lld" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : " – %lld" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : " – %lld" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : " – %lld" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : " – %lld" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : " – %lld" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "- %lld" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : " – %lld" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : " – %lld" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : " – %lld" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "– %lld" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : " – %lld" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : " – %lld" + } + } + } + }, + " %@" : { + "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, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : " %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : " %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : " %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : " %@" + } + } + } + }, + "-%@" : { + "comment" : "A label displaying the duration of the current song. The argument is the duration of the song in seconds.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "-%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "-%@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "-%@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "-%@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "-%@" + } + } + } + }, + "—" : { + "comment" : "A placeholder text used when GPU usage is not available.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "—" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "—" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "—" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "—" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "—" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "—" + } + } + } + }, + ":" : { + "comment" : "A colon used in the timer interface.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : ":" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : ":" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : ":" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : ":" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : ":" + } + } + } }, "'Now Playing' was the only option on previous versions and works with all media apps." : { - + "comment" : "merge:ko:alt='이전 버전에서는 ‘지금 재생 중’만 선택할 수 있었으며, 모든 미디어 앱과 호환됩니다.'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'Now Playing' was the only option on previous versions and works with all media apps." + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "\"Nyní hraje\" byla jediná možnost na předchozích verzích a funguje se všemi mediálními aplikacemi." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"Jetzt läuft\" war die einzige Option auf vorherigen Versionen und funktioniert mit allen Medien-Apps." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'Now Playing' was the only option on previous versions and works with all media apps." + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "'Now Playing' was the only option on previous versions and works with all media apps." + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'En reproducción' era la única opción en versiones anteriores y funciona con todas las aplicaciones multimedia." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "“À l’écoute” était la seule option dans les précédentes versions et fonctionne avec toutes les applications multimédias." + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'Now Playing' was the only option on previous versions and works with all media apps." + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'Now Playing' era l'unica opzione sulle versioni precedenti e funziona con tutte le app multimediali." + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이전 버전에서는 ‘현재 재생 중' 만 선택할 수 있었으며, 모든 미디어 애플리케이션과 호환됩니다." + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'Teraz odtwarzane' było jedyną opcją w poprzednich wersjach i działa ze wszystkimi multimediami." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "'Tocando agora' era a única opção em versões anteriores e funciona em todos os aplicativos de mídia." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "'Сейчас воспроизводится' был единственным вариантом в предыдущих версиях, он работает со всеми медиа приложениями." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "'Şimdi Oynatılıyor' önceki sürümlerdeki tek seçenekti ve tüm medya uygulamalarıyla çalışır." + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'Зараз грає' був єдиним варіантом на попередніх версіях і працює з усіма медіа застосунками." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "“当前播放”是以前版本中唯一的选项,并适用于所有媒体应用。" + } + } + } + }, + "(%@)" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + } + } + }, + "%.0f seconds" : { + "comment" : "merge:ko:alt='%.0f초'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.0f seconds" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.0f sekund" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f Sekunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f seconds" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f seconds" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.0f segundos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f secondes" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.0f seconds" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.0f secondi" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.0f 초" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.0f sekundy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f segundos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f секунд" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f saniye" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.0f секунд" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.0f 秒" + } + } + } + }, + "%.1fs" : { + "comment" : "merge:ko:alt='%.1f초'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.1fs" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.1fs" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.1fs" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.1fs" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.1fs" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.1fs" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.1fs" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.1fs" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.1fs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.1fs" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.1fs" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.1fs" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.1fс" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.1f'ler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%.1fs" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.1f秒" + } + } + } + }, + "%@" : { + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@" + } + } + } + }, + "%@ · %@" : { + "comment" : "A secondary line of text that provides additional information about the air quality index, including its scale label and category name.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ · %2$@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@" + } + } + } + }, + "%@ left" : { + "comment" : "A string indicating how much time there is left of an event.", + "extractionState" : "manual" + }, + "%@ Models" : { + "comment" : "merge:ko:alt='%@ 모델'", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ modèles" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@모델" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Modelos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@: Модели" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Modelleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 模型" + } + } + } + }, + "%@ Offset" : { + "comment" : "A control within the lock screen settings that allows the user to adjust the offset for a specific dimension (e.g. width or height). The argument is the name of the dimension, and the second argument is", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 오프셋" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Deslocamento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@: Смещение" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Ofset" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 偏移" + } + } + } + }, + "%@ pt" : { + "comment" : "A text view displaying the current offset value for a specific lock screen control. The argument is the name of the control.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ pt" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ pt" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ пт" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ nokta" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ pt" + } + } + } + }, + "%@:" : { + "comment" : "A row of text with a label and its corresponding value. The argument is the label text, and the second is the value text.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@:" + } + } + } + }, + "%@. Focus active: %@" : { + "comment" : "A row that combines a calendar event with a focus indicator. The first argument is the text of the calendar event. The second argument is the name of the focus.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@. Focus active: %2$@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@. %2$@ 집중 모드 켜짐" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@. Активный фокус: %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@. Etkin odaklama: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@。专注模式已激活:%@" + } + } + } + }, + "%@°" : { + "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, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@°" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@°" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@°" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@°" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@°" + } + } + } + }, + "%0.1f" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%0.1f" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%0.1f" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%0.1f" + } + } + } + }, + "%d%%" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d%%" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d%%" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d%%" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d%%" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d%%" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d%%" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d%%" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d%%" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d%%" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d%%" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d%%" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d%%" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d%%" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d%%" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d%%" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d%%" + } + } + } + }, + "%lld" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + } + }, + "shouldTranslate" : false + }, + "%lld %@" : { + "comment" : "A text label showing the number of apps with which the current app has granted permissions. The argument is the count of such apps.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld %@" + } + } + }, + "shouldTranslate" : false + }, + "%lld animations" : { + "comment" : "A label displaying the number of custom animations available. The argument is the count of custom animations. | A label displaying the number of custom animations available. The argument is the count of custom animations. | merge:ko:alt='%lld 애니메이션'", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld animations" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 애니메이션" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld애니메이션" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld animações" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld анимаций" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld animasyonları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个动画" + } + } + } + }, + "%lld chars" : { + "comment" : "A small, rounded badge displaying the number of characters in the note.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld글자" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld caracteres" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld симв." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld karakterleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 字符" + } + } + } + }, + "%lld colors" : { + "comment" : "merge:ko:alt='%lld 색상'", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld couleurs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld색상" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld цв." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld renkleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 种颜色" + } + } + } + }, + "%lld day%@ ago" : { + "comment" : "Text that appears in the \"Time\" column of the \"Account History\" view, indicating how long ago a particular event occurred. The number in the parentheses is replaced with the actual number of days. | Text that appears in the \"Time\" column of the \"Account History\" view, indicating how long ago a particular event occurred. The number in the parentheses is replaced with the actual number of days. | merge:ko:alt='%1$lld일%2$@ 전'", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld day%2$@ ago" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld 일%2$@ 전" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld дней%2$@ назад" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld gün%@ önce" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 天%@前" + } + } + } + }, + "%lld hour%@ ago" : { + "comment" : "Text for a time string, showing how many hours have passed since a given date. | Text for a time string, showing how many hours have passed since a given date. | merge:ko:alt='%1$lld시간%2$@ 전'", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld hour%2$@ ago" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld시간%2$@전" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld часов%2$@ назад" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld saat%@ önce" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 小时%@前" + } + } + } + }, + "%lld min" : { + "comment" : "A text label displaying the current value of the \"Notify before\" slider. The argument is the current value of the slider, expressed in minutes.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld мин" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dk." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 分钟" + } + } + }, + "shouldTranslate" : false + }, + "%lld minute%@ ago" : { + "comment" : "A string describing how long ago a particular event occurred, based on the number of minutes that have passed. | A string describing how long ago a particular event occurred, based on the number of minutes that have passed. | merge:ko:alt='%1$lld분%2$@ 전'", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld minute%2$@ ago" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld분%2$@전" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld минут%2$@ назад" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dakika%@ önce" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 分钟%@前" + } + } + } + }, + "%lld pt" : { + "comment" : "A label displaying the current font size of the terminal. The value is shown in point size.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld pt" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld пт" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nk" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld pt" + } + } + } + }, + "%lld px" : { + "comment" : "A label displaying the current expanded notch width value. The value is shown in pixels.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld пикс." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld piksel" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 像素" + } + } + }, + "shouldTranslate" : false + }, + "%lld s" : { + "comment" : "A text view displaying the duration of the \"sneak peek\" feature in the reminder live activity section of the calendar settings view. The text is followed by a unit of measurement (\"s\").", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld с" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld s" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 秒" + } + } + }, + "shouldTranslate" : false + }, + "%lld tab%@ enabled · min %lld px" : { + "comment" : "A label displaying the number of enabled tabs, the recommended minimum notch width, and a button to reset the notch width to its recommended minimum value. The first argument is the count of enabled tabs. The second argument is the recommended minimum notch width as a formatted pixel count. | A label displaying the number of enabled tabs, the recommended minimum notch width, and a button to reset the notch width to its recommended minimum value. The first argument is the count of enabled tabs. The second argument is the recommended minimum notch width as a formatted pixel count. | merge:ko:alt='탭 %1$lld 개 활성화됨⋅최소 %3$lld px'", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld tab%2$@ enabled · min %3$lld px" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "탭 %1lld 개 활성화됨⋅최소 %3$lld px" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld вкладок%2$@ включено · мин %3$lld пкс" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld sekmesi%@ etkin · minimum %lld piksel" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已启用 %lld 个标签页%@ · 最小 %lld 像素" + } + } + } + }, + "%lld%%" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld%%" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld%%" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld%%" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld%%" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld%%" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld%%" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld%%" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld%%" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld%%" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld%%" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld%%" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld%%" + } + } + }, + "shouldTranslate" : false + }, + "%lldd ago" : { + "comment" : "A string describing how many days have passed since a given date. The argument is the number of days.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld일 전" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldд. назад" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldd önce" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld天前" + } + } + } + }, + "%lldh" : { + "comment" : "Text that describes a time interval, such as \"1 hour ago\".", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld시간" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld ч" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld小时" + } + } + } + }, + "%lldh ago" : { + "comment" : "A string describing how long ago a given date was, in terms of hours.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld시간 전" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldч. назад" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldh önce" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld小时前" + } + } + } + }, + "%lldm" : { + "comment" : "A string that describes how long ago a given date was, in a human-readable form.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld분" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldм" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld分钟" + } + } + } + }, + "%lldm ago" : { + "comment" : "A string describing how long ago a given date was, in terms of minutes. The argument is the number of minutes.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld분 전" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldм. назад" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldm önce" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld分钟前" + } + } + } + }, + "%llds" : { + + }, + "%lldx" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld fois" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldм назад" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldx" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldx" + } + } + }, + "shouldTranslate" : false + }, + "•" : { + "comment" : "A period used to separate two related items.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "•" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "•" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "•" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "•" + } + } + }, + "shouldTranslate" : false + }, + "• Bug fixes" : { + "comment" : "merge:ko:alt='• 버그 수정'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Bug fixes" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Opravy chyb" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Fehlerbehebungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Bug fixes" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Bug fixes" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Corrección de errores" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Corrections de bugs" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Bug fixes" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Correzione bug" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "수정된 버그들" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Poprawki błędów" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correções de bugs" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Исправления ошибок" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Hata düzeltmeleri" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Виправлення помилок" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Bug 修复" + } + } + } + }, + "• Frosted Glass: Translucent blur effect" : { + "comment" : "A bullet point in the \"Appearance\" section of the Custom OSD settings, describing a glass material option. | A bullet point in the \"Appearance\" section of the Custom OSD settings, describing a glass material option. | merge:ko:alt='• Frosted Glass: 반투명 블러 효과'", + "isCommentAutoGenerated" : true, + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "• 프로스티드 글래스: 반투명 블러 효과" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "• 프로스티드 글래스: 반투명 블러 효과" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Vidro Fosco: Efeito de desfoque translúcido" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Матовое стекло: Эффект полупрозрачного размытия" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Buzlu Cam: Yarı saydam bulanıklık efekti" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "• 磨砂玻璃:半透明模糊效果" + } + } + } + }, + "• Improved performance" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Improved performance" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Zlepšený výkon" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Verbesserte Leistung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Improved performance" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Improved performance" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Rendimiento mejorado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Amélioration des performances" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Improved performance" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Prestazioni migliorate" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• 향상된 성능" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Poprawiona wydajność" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Desempenho melhorado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Улучшена производительность" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Geliştirilmiş performans" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Покращено продуктивність" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• 提升性能" + } + } + } + }, + "• Liquid Glass: Modern glass effect (macOS 26+)" : { + "comment" : "A description of the \"Liquid Glass\" option in the Custom OSD settings. | A description of the \"Liquid Glass\" option in the Custom OSD settings. | merge:ko:alt='• Liquid Glass: 현대적인 유리 효과 (macOS 26+)'", + "isCommentAutoGenerated" : true, + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "• 리퀴드 글래스: 현대적인 유리 효과 (macOS 26+)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "• 리퀴드 글래스: 현대적인 유리 효과 (macOS 26+)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Liquid Glass:: Efeito de Vidro Moderno (macOS 26+)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Liquid Glass: Современный эффект стекла (macOS 26+)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Sıvı Cam: Modern cam efekti (macOS 26+)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "• 液态玻璃:现代玻璃效果(macOS 26+)" + } + } + } + }, + "• New feature 1" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• New feature 1" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Nová funkce 1" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Neue Funktion 1" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "• New feature 1" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "• New feature 1" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Nueva funcionalidad 1" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nouvelle fonctionnalité 1" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• New feature 1" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Nuova funzionalità 1" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• 새 기능 1" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nowa funkcja 1" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Novo recurso 1" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Новая функция 1" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Yeni özellik 1" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Нова функція 1" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• 新功能1" + } + } + } + }, + "• Solid Dark/Light/Auto: Opaque backgrounds" : { + "comment" : "A bullet point in the \"Appearance\" section of the Custom OSD settings, describing the appearance of the OSD when using solid colors.", + "isCommentAutoGenerated" : true, + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "• 다크/라이트/자동 단색: 불투명 배경" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "• 다크/라이트/자동 단색: 불투명 배경" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "•Escuro/Claro/Auto: Backgrounds Opacos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Сплошной Темный/Светлый/Авто: Непрозрачный фон" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Düz Koyu/Açık/Otomatik: Opak arka planlar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "• 纯色深色/浅色/自动:不透明背景" + } + } + } + }, + "• Thinking" : { + "comment" : "merge:ko:alt='• 생각 중'", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Réflexion" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "• 생각중" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Pensando" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Размышление" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Düşünme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "• 思考中" + } + } + } + }, + "• Updated %@" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "• Mis à jour %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "• %@ 업데이트됨" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "• Обновлено %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "• %@ güncellendi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "• 更新于 %@" + } + } + } + }, + "••••••••" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "••••••••" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "••••••••" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "••••••••" + } + } + }, + "shouldTranslate" : false + }, + "+%lld more" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "+%lld en plus" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "+%lld 더보기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "+%lld mais" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "••••••••" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "+%lld daha" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "+%lld 更多" + } + } + } + }, + "±10 Seconds" : { + "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "±10 초" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "±10 Секунд" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "±10 Saniye" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "±10 秒" + } + } + } + }, + "0" : { + "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + } + }, + "shouldTranslate" : false + }, + "00" : { + "comment" : "A placeholder text for the minutes in the timer.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "00" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "00" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "00" + } + } + }, + "shouldTranslate" : false + }, + "00:05:00" : { + "comment" : "A time displayed in the timer panel on a lock screen.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "00:05:00" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "00:05:00" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "00:05:00" + } + } + }, + "shouldTranslate" : false + }, + "1 hour" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "1시간" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 час" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 saat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 小时" + } + } + } + }, + "1 min" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "1분" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 мин" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 dakika" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 分钟" + } + } + } + }, + "1. Open Spotify in a browser and log in" : { + "comment" : "A step in the process of getting the Spotify cookie.", + "isCommentAutoGenerated" : true + }, + "1m" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "1분" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1м" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1m" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "1分钟" + } + } + } + }, + "1x" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1x" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "1x" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 мин" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1x" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "1x" + } + } + } + }, + "2 hours" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "2시간" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "2 часов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "2 saat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "2 小时" + } + } + } + }, + "2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com" : { + "comment" : "A step to copy the Spotify cookie from the developer tools.", + "isCommentAutoGenerated" : true + }, + "3 items" : { + "comment" : "merge:ko:alt='항목 3개'", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 éléments" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 항목" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 Itens" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "2 часа" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 ürün" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 个项目" + } + } + } + }, + "3. Copy the value of `sp_dc` and paste it here" : { + "comment" : "A step in the process of getting the Spotify cookie.", + "isCommentAutoGenerated" : true + }, + "5 colors" : { + "comment" : "merge:ko:alt='5가지 색상'", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 couleurs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "5개" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 Cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 цв." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 renk" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 种颜色" + } + } + } + }, + "5 items" : { + "comment" : "merge:ko:alt='항목 5개'", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 éléments" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 항목" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 Itens" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 шт." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 ürün" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 个项目" + } + } + } + }, + "5 mins" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "5분" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 мин" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 dakika" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 分钟" + } + } + } + }, + "5m" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "5분" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "5м" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "5m" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5分钟" + } + } + } + }, + "7 items" : { + "comment" : "merge:ko:alt='항목 7개'", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 éléments" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 항목" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 Itens" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 мин" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 ürün" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "7 个项目" + } + } + } + }, + "10 colors" : { + "comment" : "merge:ko:alt='10가지 색상'", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 couleurs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "10개" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 Cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 цв." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 renk" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 种颜色" + } + } + } + }, + "10 items" : { + "comment" : "merge:ko:alt='항목 10개'", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 éléments" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 항목" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 Itens" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 шт." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 ürün" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 个项目" + } + } + } + }, + "10 mins" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "10분" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 мин" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 dakika" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 分钟" + } + } + } + }, + "15 colors" : { + "comment" : "merge:ko:alt='15가지 색상'", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 couleurs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "15개" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 Cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 мин" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 renk" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 种颜色" + } + } + } + }, + "15 mins" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "15분" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 мин" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 dakika" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 分钟" + } + } + } + }, + "15m" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "15분" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "15м" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "15m" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "15分钟" + } + } + } + }, + "20" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "20" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 мин" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "20" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "20" + } + } + }, + "shouldTranslate" : false + }, + "20 colors" : { + "comment" : "merge:ko:alt='20가지 색상'", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "20 couleurs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "20개" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "20 Cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "20 цв." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "20 renk" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "20 种颜色" + } + } + } + }, + "30" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "30" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "30" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "30" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "30" + } + } + }, + "shouldTranslate" : false + }, + "30 mins" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "30분" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 мин" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 dakika" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 分钟" + } + } + } + }, + "45 mins" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "45분" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "45 мин" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "45 dakika" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "45 分钟" + } + } + } + }, + "100" : { + "comment" : "The text displayed below a 100% battery level in the lock screen.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "100" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "100" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "100" + } + } + }, + "shouldTranslate" : false + }, + "100%" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "100%" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "100" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "100%" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "100%" + } + } + }, + "shouldTranslate" : false + }, + "About" : { + "comment" : "merge:ko:alt='정보'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "About" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "O aplikaci" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "About" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Acerca de" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "À propos" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "About" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Info" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "설명" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "O programie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sobre" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "О программе" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hakkında" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Про додаток" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "关于" + } + } + } + }, + "Accent" : { + "comment" : "Display name for the caps lock indicator tint mode.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "강조 색상" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Акцент" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aksan" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "强调色" + } + } + } + }, + "Accent color" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Accent color" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Barva odstínu" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Akzentfarbe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accent color" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accent colour" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Color de énfasis" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couleur d'accentuation" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Accent color" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Colore in rilievo" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "강조 색상" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kolor akcentu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Cor de destaque" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выделяющий цвет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vurgu rengi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Колір відтінку" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "首选颜色" + } + } + } + }, + "Accent colour" : { + "comment" : "A label describing the colour picker for the timer preset.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "강조 색상" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cor de destaque" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет акцента" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vurgu rengi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "强调色" + } + } + } + }, + "Access Denied" : { + "comment" : "merge:ko:alt='접근 거부됨'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Access Denied" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Přístup zamítnut" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zugriff verweigert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Access Denied" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Access Denied" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Acceso denegado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accès non autorisé" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Access Denied" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Accesso Negato" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "권한 거부됨" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Odmowa dostępu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acesso Negado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступ запрещён" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Erişim Reddedildi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Доступ заборонено" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "被拒绝访问" + } + } + } + }, + "Accessibility" : { + "comment" : "merge:ko:alt='손쉬운 사용'", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "접근성" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acessibilidade" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Универсальный доступ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erişilebilirlik" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "辅助功能" + } + } + } + }, + "Actions" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actions" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "동작" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ações" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Действия" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eylemler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "操作" + } + } + } + }, + "Activate" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активировать" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "激活" + } + } + } + }, + "Activate your license" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer votre license" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "라이선스 활성화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativar sua licença" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активируйте вашу лицензию" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisansınızı etkinleştirin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "激活您的许可证" + } + } + } + }, + "Activation" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activation" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активация" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivasyon" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "激活" + } + } + } + }, + "Active" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiv" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activité" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활동중" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активно" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活跃" + } + } + } + }, + "Active - No Focus" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiv - kein Fokus" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "집중 모드 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativo - Sem Foco" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активно — без фокусирования" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif - Odaklanma Yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活跃 - 无专注模式" + } + } + } + }, + "Active - No Recording" : { + "comment" : "A description of the recording detection feature, indicating that recording is not currently active.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actif - Aucun enregistrement" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "녹화 활동 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativo - Sem Gravação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активно — без записи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkin - Kayıt Yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活跃 - 未录制" + } + } + } + }, + "Active GPU" : { + "comment" : "Label for the active GPU field in the GPU usage dashboard.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용 중인 GPU" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активный GPU" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif GPU" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活跃 GPU" + } + } + } + }, + "Adaptive gradient" : { + "comment" : "Display name for the timer icon color mode: adaptive gradient.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "적응형 그라디언트" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Адаптивный градиент" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uyarlanabilir degrade" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自适应渐变" + } + } + } + }, + "Add" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Přidat" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Añadir" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Aggiungi" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "추가" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Dodaj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Додати" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "添加" + } + } + } + }, + "Add Animation from URL" : { + "comment" : "A title for the sheet that allows users to add animations from a URL. | A title for the sheet that allows users to add animations from a URL. | merge:ko:alt='URL에서 애니메이션 추가'", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter une animation depuis une URL" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL로 부터 애니메이션 추가" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar Animação a partir da URL" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить анимацию по URL" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL'den Animasyon Ekle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从 URL 添加动画" + } + } + } + }, + "Add files" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datei hinzufügen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter des fichiers" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일 추가" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar arquivos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить файлы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosya ekle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加文件" + } + } + } + }, + "Add from URL..." : { + "comment" : "A button label that prompts the user to add a Lottie animation from a URL. | A button label that prompts the user to add a Lottie animation from a URL. | merge:ko:alt='URL에서 추가…'", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter depuis une URL…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL로 부터 추가…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar a partir da URL" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить по URL..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL'den ekle..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从 URL 添加..." + } + } + } + }, + "Add icon" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아이콘 추가" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить иконку" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Simge ekle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加图标" + } + } + } + }, + "Add manually" : { + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add manually" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Přidat manuálně" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuell hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add manually" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add manually" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Añadir manualmente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter manuellement" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add manually" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Aggiungi manualmente" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "수동 추가" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Dodaj ręcznie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Adicionar manualmente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить вручную" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Manuel ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Додати вручну" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "手动添加" + } + } + } + }, + "Add New" : { + "comment" : "A label displayed below the button to add a new idle animation.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새로운 항목 추가" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar Novo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить новый" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yeni Ekle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新增" + } + } + } + }, + "Add new visualizer" : { + "comment" : "merge:ko:alt='새 비주얼라이저 추가'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add new visualizer" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Přidat nový vizualizér" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neuen Visualisierer hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add new visualizer" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add new visualiser" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Añadir nuevo visualizador" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter un nouveau visualiseur" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Add new visualizer" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Aggiungi nuovo visualizzatore" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "새 시각화 도구 추가" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Dodaj nowy wizualizer" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar novo visualizador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить новую визуализацию" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Yeni görselleştirici ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Додати новий візуалізатор" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "添加新的可视化器" + } + } + } + }, + "Add Preset" : { + "comment" : "A button label that adds a new timer preset.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프리셋 추가" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar Preset" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить пресет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ön Ayar Ekle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加预设" + } + } + } + }, + "Additional features" : { + "comment" : "merge:ko:alt='추가 기능'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Additional features" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Další funkce" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zusätzliche Funktionen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Additional features" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Additional features" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Funcionalidades adicionales" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fonctionnalités supplémentaires" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Additional features" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Funzionalità aggiuntive" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "추가 기능들" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Dodatkowe funkcje" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recursos adicionais" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дополнительные функции" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ek özellikler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Додаткові можливості" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "其他功能" + } + } + } + }, + "Adds a Guake-style dropdown terminal tab. The terminal session persists across notch open/close cycles." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guake 스타일의 드롭다운 터미널 탭을 추가합니다. 터미널 세션은 노치를 여닫아도 끊기지 않고 유지됩니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавляет выпадающее окно терминала в стиле Guake. Сессия сохраняется." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guake tarzı açılır terminal sekmesi ekler. Terminal oturumu çentik açma/kapama döngüleri boyunca devam eder." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加 Guake 风格的下拉终端标签页。终端会话在刘海开关周期间保持。" + } + } + } + }, + "Adds a notch HUD when Caps Lock is enabled, with optional label and tint controls." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caps Lock이 활성화되면 노치 HUD를 추가합니다. 레이블 및 색조 제어 옵션을 제공합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adiciona um HUD no notch quando o Caps Lock está ativado, com controles opcionais de etiqueta e tonalidade." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить выпадающий терминал в стиле Guake. Сессия терминала сохраняется при сворачивании/разворачивании выреза." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caps Lock etkinleştirildiğinde isteğe bağlı etiket ve renk tonu kontrolleriyle çentikte bir HUD gösterir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用 Caps Lock 时添加刘海 HUD,可选标签和着色控制。" + } + } + } + }, + "Adds the AirPlay/route picker button back to the customizable controls palette." : { + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirPlay/경로 선택기 버튼을 사용자 지정 제어기 팔레트에 다시 추가합니다." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirPlay/경로 선택기 버튼을 사용자 지정 제어기 팔레트에 다시 추가합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adiciona o botão AirPlay/seletor de rota de volta à paleta de controles personalizáveis." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавляет кнопку AirPlay/выбора маршрута обратно в палитру." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirPlay/rota seçici düğmesini özelleştirilebilir kontrol paletine geri ekler." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将隔空播放/路由选择按钮添加回可自定义的控制面板。" + } + } + } + }, + "Adjust settings above to see changes in real-time. The actual OSD appears at the bottom center of your screen." : { + "comment" : "A footer in the \"Preview\" section of the Custom OSD settings, explaining how to use the live preview feature.", + "isCommentAutoGenerated" : true, + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "위의 설정을 조정하여 변경 사항을 실시간으로 확인하세요. 실제 OSD는 화면 중앙 하단에 나타납니다." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "위의 설정을 조정하여 변경 사항을 실시간으로 확인하세요. 실제 OSD는 화면 중앙 하단에 나타납니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajuste as configurações acima para ver as alterações em tempo real. O OSD real aparece na parte inferior central da tela." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройте параметры выше. OSD появится внизу по центру." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Değişiklikleri gerçek zamanlı olarak görmek için yukarıdaki ayarları ayarlayın. Gerçek OSD, ekranınızın alt ortasında görünür." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "调整上方设置即可实时查看变化。实际 OSD 显示在屏幕底部中央。" + } + } + } + }, + "Adjusts the lock screen timer widget width without affecting button sizing." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "버튼 크기에 영향을 주지 않고 잠금 화면 타이머 위젯 너비를 조절합니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düğme boyutunu etkilemeden kilit ekranı zamanlayıcı widget'ının genişliğini ayarlar." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "调整锁屏计时器小组件宽度,不影响按钮大小。" + } + } + } + }, + "AI Assistant" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assistant IA" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 어시스턴트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assistente de IA" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ИИ-помощник" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yapay Zeka Asistanı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 助手" + } + } + } + }, + "AI is thinking..." : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L’IA rélfléchis" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI가 생각하는 중..." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "IA está pensando…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ИИ думает..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yapay zeka düşünüyor..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 正在思考..." + } + } + } + }, + "AI Model Selection" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sélection du modèle d’IA" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 모델 선택" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleção de Modelo de IA" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбор ИИ-модели" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yapay Zeka Modeli Seçimi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 模型选择" + } + } + } + }, + "AI Provider" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fournisseur des modèles" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 공급자" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provedor de IA" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Провайдер ИИ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yapay Zeka Sağlayıcı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 服务商" + } + } + } + }, + "AI-powered assistant that can analyze files, images, and provide conversational help. Use Cmd+Shift+A to quickly access the assistant." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KI-gestützter Assistent, der Dateien und Bilder analysieren und Ihnen im Dialog weiterhelfen kann. Mit Cmd+Shift+A können Sie schnell auf den Assistenten zugreifen." + } + }, + "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." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일과 이미지를 분석하고 대화형 도움말을 제공하는 AI 지원 어시스턴트입니다. Cmd+Shift+A를 눌러 어시스턴트에 빠르게 접근할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assistente alimentado por IA que pode analisar arquivos, imagens e fornecer ajuda de conversação. Use Cmd+Shift+A para acessar rapidamente o assistente." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ИИ-помощник для анализа файлов, изображений и ведения диалога. Нажмите Cmd+Shift+A для быстрого доступа." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosyaları, görüntüleri analiz edebilen ve konuşma yardımı sağlayabilen yapay zeka destekli asistan. Asistana hızlı bir şekilde erişmek için Cmd+Shift+A tuşlarını kullanın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 驱动的助手,可分析文件和图像并提供对话式帮助。使用 Cmd+Shift+A 快速访问。" + } + } + } + }, + "Air quality index %d, %@" : { + "comment" : "Air quality accessibility label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Air quality index %1$d, %2$@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대기 질 지수 %1$d, %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nível de qualidade de ar %1$d, %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Индекс качества воздуха %1$d, %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hava kalitesi endeksi %d, %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空气质量指数 %1$d,%2$@" + } + } + } + }, + "Air quality requires the Open Meteo provider." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대기 질 정보를 표시하려면 Open Meteo 제공업체 설정이 필요합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A qualidade do ar requer o provedor Open Meteo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для данных о качестве воздуха требуется провайдер Open Meteo." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hava kalitesi Open Meteo sağlayıcısını gerektirir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空气质量需要 Open Meteo 数据源。" + } + } + } + }, + "Air quality scale" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공기질 척도" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escala da qualidade do ar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Шкала качества воздуха" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hava kalitesi ölçeği" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空气质量量表" + } + } + } + }, + "AirDrop" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop" + } + } + }, + "shouldTranslate" : false + }, + "AirDrop service not available" : { + "comment" : "merge:ko:alt='AirDrop 서비스를 사용할 수 없음'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop service not available" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Služba AirDrop není dostupná" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop Funktionen sind nicht verfügbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop service not available" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop service not available" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Servicio AirDrop no disponible" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Service AirDrop indisponible" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop service not available" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Servizio AirDrop non disponibile" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop 서비스 불가" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Usługa AirDrop nie jest dostępna" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop não disponível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Служба AirDrop недоступна" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop hizmeti kullanılamıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Служба AirDrop недоступна" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AirDrop 服务不可用" + } + } + } + }, + "AirPlay" : { + "comment" : "A label for the AirPlay button.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirPlay" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirPlay" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirPlay" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隔空播放" + } + } + } + }, + "Alignment" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "정렬" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выравнивание" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hizalama" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "对齐" + } + } + } + }, + "All" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tudo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выравнивание" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hepsi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全部" + } + } + } + }, + "All-day" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "All-day" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Celý den" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ganztägig" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All-day" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "All-day" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Todo el día" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toute la journée" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "All-day" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tutto Il Giorno" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "하루 종일" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Całodniowy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dia inteiro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Весь день" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gün boyu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Увесь день" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "全天" + } + } + } + }, + "All-Day Events" : { + "comment" : "A section header that allows the user to hide all-day events from the calendar view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "종일 이벤트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventos do Dia Todo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "События на весь день" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tüm Gün Etkinlikleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全天事件" + } + } + } + }, + "Allow Access" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Allow Access" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Povolit přístup" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zugriff erlauben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow Access" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow Access" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Permitir acceso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autoriser l'accès" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Allow Access" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Consenti Accesso" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "권한 허용" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zezwól na dostęp" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permitir acesso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить доступ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Erişime izin ver" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Надати доступ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "允许访问" + } + } + } + }, + "Allow extension live activities" : { + "comment" : "Toggle to allow extension live activities. | Toggle to allow extension live activities. | merge:ko:alt='실시간 현황 확장 허용'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확장 실시간 현황 허용" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить Live Activities расширений" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzantı canlı etkinliklerine izin ver" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "允许扩展实时活动" + } + } + } + }, + "Allow extension lock screen widgets" : { + "comment" : "Text displayed in a toggle that allows users to enable or disable the display of lock screen widgets in Atoll.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 위젯 확장 허용" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить виджеты экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzantı kilit ekranı widget'larına izin ver" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "允许扩展锁屏小组件" + } + } + } + }, + "Allow extension notch experiences" : { + "comment" : "Text displayed in a toggle that allows users to enable or disable the display of notch-specific features in an extension.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치 경험 확장 허용" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить функции выреза расширений" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzantı çentik deneyimlerine izin ver" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "允许扩展刘海体验" + } + } + } + }, + "Allow interactive web content" : { + "comment" : "Toggle label for allowing interactive web content in notch experiences.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대화형 웹 콘텐츠 허용" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить интерактивный веб-контент" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkileşimli web içeriğine izin ver" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "允许交互式网页内容" + } + } + } + }, + "Allow minimalistic overrides" : { + "comment" : "Label for a toggle that allows minimalistic overrides in notch experiences.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미니멀 레이아웃 덮어쓰기 허용" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить упрощенный интерфейс" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimalist geçersiz kılmalara izin ver" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "允许简约模式覆盖" + } + } + } + }, + "Allow mouse reporting" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마우스 보고 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить отслеживание мыши" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fare raporlamasına izin ver" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "允许鼠标报告" + } + } + } + }, + "Allowed Features" : { + "comment" : "A label describing the allowed features for an app extension.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "허용된 기능" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recursos Permitidos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить отправку сообщений о действиях мыши" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İzin Verilen Özellikler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "允许的功能" + } + } + } + }, + "ALPHA" : { + "comment" : "A label indicating a feature that is still in development.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "АЛЬФА" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALFA" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "测试版" + } + } + }, + "shouldTranslate" : false + }, + "Always hide in fullscreen" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Always hide in fullscreen" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vždy skrýt v režimu celé obrazovky" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Immer verstecken im Vollbildmodus" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Always hide in fullscreen" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Always hide in full-screen" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ocultar siempre en pantalla completa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toujours cacher en plein écran" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Always hide in fullscreen" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nascondi sempre a schermo intero" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "전체 화면에서 항상 숨기기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zawsze ukrywaj podczas pełnego ekranu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sempre esconder em tela cheia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всегда скрывать в полноэкранном режиме" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tam ekranda daima gizle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Завжди приховувати в повноекранному режимі" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "总是隐藏在全屏" + } + } + } + }, + "Always show tabs" : { + "comment" : "merge:ko:alt='항상 탭 표시'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Always show tabs" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vždy zobrazovat panely" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Immer Tabs anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Always show tabs" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Always show tabs" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mostrar siempre las pestañas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toujours afficher les onglets" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Always show tabs" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mostra sempre schede" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "항상 탭 보이기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zawsze pokazuj zakładki" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permitir mostrar guias" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всегда показывать вкладки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sekmeleri daima göster" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Завжди показувати вкладки" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "总是显示标签页" + } + } + } + }, + "Amazon Music" : { + "extractionState" : "stale", + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amazon Music" + } + } + } + }, + "Animation" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animation" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "애니메이션" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Анимация" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animasyon" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "动画" + } + } + } + }, + "Animation Name" : { + "comment" : "A label for the name input field in the URL import sheet.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom de l'animation" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "애니메이션 이름" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome da Animaçãp" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Название анимации" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animasyon Adı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "动画名称" + } + } + } + }, + "API Configuration" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration de l'API" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "API 구성" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuração da API" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка API" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Yapılandırması" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "API 配置" + } + } + } + }, + "API Key Required" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clé d’API nécessaire" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "API 키 필요" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chave de API Necessária " + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется ключ API" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Anahtarı Gerekli" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "需要 API 密钥" + } + } + } + }, + "App" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앱" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приложение" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uygulama" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用" + } + } + } + }, + "App icon" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "App icon" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ikona aplikace" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Icon" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "App icon" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "App icon" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Icono de la aplicación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icône d'application" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "App icon" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Icona app" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "앱 아이콘" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ikona aplikacji" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ícone do App" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Значок приложения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Uygulama simgesi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Значок додатка" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "应用图标" + } + } + } + }, + "App Permissions" : { + "comment" : "A section header in the settings view that lists the permissions of installed apps.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앱 권한" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permissões do App" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешения приложений" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uygulama İzinleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用权限" + } + } + } + }, + "Appearance" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Appearance" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vzhled" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Darstellung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appearance" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appearance" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Apariencia" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apparence" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Appearance" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Aspetto" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "외관" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wygląd" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aparência" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внешний вид" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Belirme" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Вигляд" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "外观" + } + } + } + }, + "Apple Music" : { + "comment" : "Media controller type for Apple Music", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Music" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Music" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Music" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Music" + } + } + } + }, + "Applies the notch-style parallax effect to the lock screen media widget album art." : { + "comment" : "merge:ko:alt='잠금 화면 미디어 위젯의 앨범 아트에 노치 스타일의 시차 효과를 적용합니다.'", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 미디어 위젯의 앨범 아트에 노치 스타일의 패럴랙스 효과를 적용합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplica o efeito de paralaxe ao notch à arte do álbum do widget de mídia da tela de bloqueio." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Music" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı medya widget'ının albüm kapağına çentik stili paralaks efektini uygular." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将刘海风格的视差效果应用于锁屏媒体小组件的专辑封面。" + } + } + } + }, + "Apps using AtollExtensionKit will appear here once they request permission" : { + "comment" : "A description below the placeholder text in the \"Search extensions...\" text field, explaining that apps using AtollExtensionKit will be listed here once they grant permission to use the app.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AtollExtensionKit을 사용하는 앱이 권한을 요청하면 여기에 표시됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicativos usando AtollExtensionKit aparecerão aqui assim que solicitarem permissão" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приложения с AtollExtensionKit появятся здесь после запроса разрешения." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AtollExtensionKit kullanan uygulamalar izin istediklerinde burada görünür" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用 AtollExtensionKit 的应用在请求权限后将显示在此处" + } + } + } + }, + "AQI" : { + "comment" : "Label for the US AQI scale in the lock screen weather view.", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AQI" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "AQI" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AQI" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空气质量指数" + } + } + } + }, + "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 \"%@\" ?" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\"을(를) 삭제하시겠습니까?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\"을(를) 삭제하시겠습니까?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tem certeza de que deseja excluir “%@”?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "AQI" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\"yi silmek istediğinizden emin misiniz?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "您确定要删除「%@」吗?" + } + } + } + }, + "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 ?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 색상을 기록에서 삭제하시겠습니까?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tem certeza de que deseja excluir essa cor do seu histórico?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить этот цвет из истории?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu rengi geçmişinizden silmek istediğinizden emin misiniz?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "您确定要从历史记录中删除此颜色吗?" + } + } + } + }, + "Ask me anything..." : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demandez-moi ce que vous voulez…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "무엇이든 물어보세요..." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Me pergunte qualquer coisa…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Спросите меня о чем-либо..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bana bir şey sor..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "随便问我..." + } + } + } + }, + "Atoll" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll" + } + } + }, + "shouldTranslate" : false + }, + "Atoll always listens to selected-provider brightness events, and listens to provider volume events only when external volume listener is enabled." : { + "comment" : "A footer text that explains the behavior of the third-party DDC integration settings. | A footer text that explains the behavior of the third-party DDC integration settings. | merge:ko:alt='Atoll은 항상 선택한 제공자의 밝기 이벤트를 수신하며, 외부 음량 감지가 활성화된 경우에만 제공자의 음량 이벤트를 수신합니다.'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll은 항상 선택된 제공업체의 밝기 이벤트를 감지하며, 외부 음량 감지 기능이 활성화된 경우에만 제공업체의 음량 이벤트를 감지합니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll отслеживает яркость от выбранного провайдера, а громкость — только при внешнем контроле." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll, seçili sağlayıcının parlaklık olaylarını her zaman dinler; sağlayıcı ses düzeyi olaylarını ise yalnızca harici ses düzeyi dinleyicisi etkinken dinler." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll 始终监听所选提供商的亮度事件,仅在启用外部音量监听器时监听提供商的音量事件。" + } + } + } + }, + "Atoll can show all your upcoming events in one place. Access to your calendar is needed to display your schedule." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll은 예정된 모든 일정을 한곳에 모아 보여줍니다. 일정을 표시하려면 캘린더 접근 권한이 필요합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O Atoll pode mostrar todos os seus próximos eventos em um só lugar. O acesso ao seu calendário é necessário para exibir sua agenda." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для отображения расписания нужен доступ к календарю." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll, yaklaşan tüm etkinliklerinizi tek bir yerde gösterebilir. Programınızı görüntülemek için takviminize erişim gerekir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll 可以在一处显示您所有即将到来的事件。需要访问您的日历才能显示您的日程。" + } + } + } + }, + "Atoll includes a mirror feature that lets you quickly check your appearance using your camera, right from the notch. Camera access is required only to show this live preview. You can turn the mirror feature on or off at any time in the app." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll에는 노치에서 바로 카메라를 통해 외모를 빠르게 확인할 수 있는 거울 기능이 포함되어 있습니다. 카메라 접근 권한은 이 실시간 미리보기를 표시하는 용도로만 사용됩니다. 거울 기능은 앱 설정에서 언제든지 켜거나 끌 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O Atoll possui um recurso de espelho que permite verificar rapidamente sua aparência usando sua câmera, direto do notch. O acesso à câmera é necessário apenas para mostrar esta visualização ao vivo. Você pode ligar ou desligar o recurso de espelho a qualquer momento no aplicativo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешение к камере нужно для включения функции зеркала в вырезе." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll, kameranızı kullanarak görünümünüzü doğrudan çentikten hızlıca kontrol etmenizi sağlayan bir ayna özelliği içerir. Kamera erişimi yalnızca bu canlı önizlemeyi göstermek için gereklidir. Ayna özelliğini uygulamada istediğiniz zaman açıp kapatabilirsiniz." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll 包含镜像功能,让您可以直接从刘海处使用相机快速检查外观。相机访问仅用于显示实时预览。您可以随时在应用中开启或关闭镜像功能。" + } + } + } + }, + "Atoll keeps native volume key interception. External provider volume payloads are ignored while this is off." : { + "comment" : "A description of the behavior of Atoll's volume key interception when the external volume control listener is disabled.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll은 시스템 기본 음량 키 제어 기능을 유지합니다. 이 기능을 끄면 외부 제공업체의 음량 데이터가 무시됩니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внешние данные громкости игнорируются, пока включен системный перехват." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll yerel ses tuşu yakalamayı korur. Bu kapalıyken harici sağlayıcı ses düzeyi verileri yok sayılır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll 保持原生音量键拦截。关闭此项时将忽略外部提供商的音量数据。" + } + } + } + }, + "Atoll needs Full Disk Access to read the system notification database. If Atoll does not appear in the list, use 'Reveal App in Finder' and drag it into the settings window." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll이 시스템 알림 데이터베이스를 읽으려면 '전체 디스크 접근 권한'이 필요합니다. 목록에 Atoll이 표시되지 않으면 'Finder에서 앱 보기'를 사용하여 앱을 설정 창으로 드래그하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O Atoll precisa de Acesso Completo ao Disco para ler o banco de dados de notificações do sistema. Se o Atoll não aparecer na lista, use ‘Mostrar aplicativo no Finder' e arraste-o para a janela de configurações." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для чтения уведомлений нужен полный доступ к диску. Добавьте Atoll в настройках вручную, если его нет." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll'un sistem bildirim veritabanını okuyabilmesi için Tam Disk Erişimi gerekir. Atoll listede görünmüyorsa 'Finder'da Uygulamayı Göster' seçeneğini kullanın ve uygulamayı ayarlar penceresine sürükleyin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll 需要完全磁盘访问权限来读取系统通知数据库。如果 Atoll 未出现在列表中,请使用「在 Finder 中显示应用」并将其拖入设置窗口。" + } + } + } + }, + "Atoll uses the local `sp_dc` cookie only to request Spotify's internal web-player token and fetch the matching Canvas for the current track." : { + "comment" : "A description of how the Spotify cookie is used.", + "isCommentAutoGenerated" : true + }, + "Atoll's built-in volume key interception is disabled while external volume listening is on. Volume HUD/OSD will follow %@ payloads." : { + "comment" : "A description below the toggle that explains the effect of enabling or disabling the feature.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "외부 음량 감지 기능이 켜져 있으면 Atoll의 내장 음량 키 제어 기능이 비활성화됩니다. 음량 HUD/OSD는 %@ 데이터를 기준으로 표시됩니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Встроенный перехват отключен. HUD/OSD следует данным от %@." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Harici ses dinleme açıkken Atoll'un yerleşik ses tuşu yakalaması devre dışı bırakılır. Ses HUD/OSD gösterimi %@ verilerini izler." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用外部音量监听时,Atoll 的内置音量键拦截已禁用。音量 HUD/OSD 将跟随 %@ 的数据。" + } + } + } + }, + "Attached Files" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fichiers joints" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "첨부된 파일" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arquivos Anexados" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вложенные файлы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekli Dosyalar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "附件" + } + } + } + }, + "Audio feedback" : { + "comment" : "A section header that lists audio feedback settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오디오 피드백" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feedback de Áudio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Звуковой отклик" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sesli geri bildirim" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "音频反馈" + } + } + } + }, + "Authorize" : { + "comment" : "A button that authorizes an app extension.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인증" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorizar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Авторизовать" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yetkilendir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "授权" + } + } + } + }, + "Authorized" : { + "comment" : "A label indicating that an app has been granted permission to use AtollExtensionKit.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인증됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorizado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Авторизовано" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yetkili" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已授权" + } + } + } + }, + "Auto-hide inactive notch media player" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비활성 노치 미디어 플레이어 자동 숨기기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрывать неактивный плеер в вырезе" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkin olmayan çentikli medya oynatıcısını otomatik gizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动隐藏不活跃的刘海媒体播放器" + } + } + } + }, + "Auto-scroll to next event" : { + "comment" : "A toggle that enables or disables the automatic scrolling to the next event in the calendar view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다음 이벤트까지 자동 스크롤" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rolar automaticamente para o próximo evento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переходить к следующему событию" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonraki etkinliğe otomatik kaydırma" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动滚动到下一个事件" + } + } + } + }, + "Automatically check for updates" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically check for updates" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automaticky vyhledávat aktualizace" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisch nach Updates suchen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatically check for updates" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatically check for updates" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Comprobar actualizaciones automáticamente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérifier automatiquement les mises à jour" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically check for updates" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Controlla automaticamente gli aggiornamenti" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "자동으로 업데이트 확인" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatycznie sprawdzaj aktualizacje" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Procurar por atualizações automaticamente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматически проверять обновления" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Güncellemeleri otomatik kontrol et" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Автоматично перевіряти наявність оновлень" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "自动检查更新" + } + } + } + }, + "Automatically download updates" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically download updates" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automaticky stahovat aktualizace" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisch nach Updates suchen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatically download updates" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatically download updates" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Descargar actualizaciones automáticamente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Télécharger automatiquement les mises à jour" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatically download updates" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Scarica automaticamente gli aggiornamenti" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "자동으로 업데이트 다운로드" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Automatycznie pobieraj aktualizacje" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baixar atualizações automaticamente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматически загружать уведомления" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Güncellemeleri otomatik indir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Автоматично завантажувати оновлення" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "自动下载更新" + } + } + } + }, + "Automatically switch displays" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디스플레이 자동 전환" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматически переключать дисплеи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekranları otomatik olarak değiştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动切换显示器" + } + } + } + }, + "Average" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "평균" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Среднее" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ortalama" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "平均" + } + } + } + }, + "Averages" : { + "comment" : "A label displayed above the average usage and last update information for a GPU.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moyennes" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "평균" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Médias" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Средние" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ortalamalar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "平均值" + } + } + } + }, + "Background" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배경" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фон" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arkaplan" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "背景" + } + } + } + }, + "Backlight" : { + "comment" : "A control for adjusting the keyboard backlight intensity.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "백라이트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luz de Fundi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фон" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arka ışık" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "背光" + } + } + } + }, + "Bar" : { + "comment" : "Name of the timer progress style that shows a horizontal bar.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "막대" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Полоса" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "条形" + } + } + } + }, + "Battery" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Battery" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Baterie" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batterie" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Batería" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batterie" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Battery" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Batteria" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "배터리" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Bateria" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bateria" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Батарея" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Акумулятор" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "电池" + } + } + } + }, + "Battery at %d percent" : { + "comment" : "Battery percentage", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 %d 퍼센트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bateria em %d porcento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заряд батареи: %d%%" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pil yüzde %d seviyesinde" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "电池电量 %d%%" + } + } + } + }, + "Battery at %d percent, charging on hold" : { + "comment" : "Plugged in but charging is paused by the system" + }, + "Battery charging" : { + "comment" : "Battery charging without estimate", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 충전중" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carregando Bateria" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Батарея заряжается" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pil şarjı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "电池充电中" + } + } + } + }, + "Battery charging, %@ remaining" : { + "comment" : "Charging time remaining", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 충전중, %@ 남음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carregando Bateria, %@ restante" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зарядка батареи, осталось %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pil şarj oluyor, kalan %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "电池充电中,剩余 %@" + } + } + } + }, + "Battery fully charged" : { + "comment" : "Battery is full", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 충전 완료" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bateria totalmente carregada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Батарея полностью заряжена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pil tamamen şarj edildi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "电池已充满" + } + } + } + }, + "Battery HUDs" : { + + }, + "Battery Indicator Styling" : { + "comment" : "A section header in the Settings app that lists options for customizing the appearance of the battery indicator.", + "isCommentAutoGenerated" : true, + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 표시기 스타일" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 표시기 스타일" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estilo do indicador de bateria" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль индикатора батареи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pil Göstergesi Şekillendirme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "电池指示器样式" + } + } + } + }, + "Battery Information" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Battery Information" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Informace o baterii" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batterie Informationen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery Information" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery Information" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Información de batería" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informations de la batterie" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Battery Information" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Informazioni Batteria" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "배터리 정보" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Informacje o baterii" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informações da Bateria" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Информация о батарее" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pil bilgisi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Інформація про акумулятор" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "电池信息" + } + } + } + }, + "Battery Settings" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Battery Settings" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nastavení baterie" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batterie Einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery Settings" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery Settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Configuración de batería" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paramètres de la batterie" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Battery Settings" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Impostazioni Batteria" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "배터리 설정" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ustawienia baterii" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurações da Bateria" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки батареи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pil ayarları" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Налаштування акумулятора" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "电池设置" + } + } + } + }, + "Battery settings and informations are only available on MacBooks" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 설정 및 정보는 MacBook에서만 사용할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "As configurações e informações da bateria estão disponíveis apenas em MacBooks" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки батареи доступны только на MacBook" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pil ayarları ve bilgileri yalnızca MacBook'lerde mevcuttur" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "电池设置和信息仅在 MacBook 上可用" + } + } + } + }, + "Battery Status" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Battery Status" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Stav baterie" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batterie Status" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery Status" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery Status" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Estado de batería" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statut de la batterie" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Battery Status" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Stato Batteria" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "배터리 상태" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Stan baterii" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status da bateria" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус батареи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pil durumu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Стан заряду акумулятора" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "电池状态" + } + } + } + }, + "Battery Widget" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 위젯" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget da Bateria" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виджет батареи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pil Widget'ı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "电池小组件" + } + } + } + }, + "Behavior & Style" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "동작 및 스타일" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comportamente e Estilo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поведение и стиль" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Davranış ve Stil" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "行为与样式" + } + } + } + }, + "BETA" : { + "comment" : "A label indicating a beta version.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "BETA" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "BETA" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "测试版" + } + } + }, + "shouldTranslate" : false + }, + "Blue dashed box shows actual size in Atoll" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La boîte bleue en pointillés affiche la taille actuelle dans la Atoll" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파란색 점선 상자는 Atoll에서의 실제 크기를 나타냅니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A caixa tracelhada azul mostra o tamanho real no Atol" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экранные индикаторы BetterDisplay будут отображаться в активном стиле панели Atoll. Убедитесь, что интеграция индикатора включена в BetterDisplay: Настройки › Приложение › Интеграция." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mavi kesikli kutu Atoll içindeki gerçek boyutu gösterir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "蓝色虚线框显示 Atoll 中的实际大小" + } + } + } + }, + "Blue outline matches the notch canvas" : { + "comment" : "A tooltip explaining that the blue outline in the animation preview matches the notch on a device.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파란색 윤곽선이 노치 캔버스 영역에 맞춰 표시됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O contorno azul combina com a tela do entale" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Синий контур ограничивает размер выреза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mavi çerçeve çentikli tuvalle eşleşiyor" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "蓝色轮廓匹配刘海画布" + } + } + } + }, + "Bluetooth Audio Devices" : { + "comment" : "A section header that lists Bluetooth audio devices.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appareils audios Bluetooth" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "블루투스 오디오 기기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivos de Áudio Bluetooth" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Аудиоустройства Bluetooth" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Ses Cihazları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "蓝牙音频设备" + } + } + } + }, + "Bluetooth device %@ at %d percent" : { + "comment" : "Bluetooth device battery", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bluetooth device %1$@ at %2$d percent" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth 기기 %1$@: %2$d 퍼센트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo Bluetooth %1$@ em %2$d por cento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth-устройство %1$@ на %2$d %" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth cihazı %@ yüzde %d seviyesinde" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "蓝牙设备 %1$@ 电量 %2$d%%" + } + } + } + }, + "Bold text as bright colors" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "볼드체를 밝은 색으로 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bold text as bright цв." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parlak renkler olarak kalın metin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "粗体文本显示为亮色" + } + } + } + }, + "Boost your productivity with Clipboard Manager" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Boost your productivity with Clipboard Manager" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zvyšte svou produktivitu pomocí správce schránky" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steigere deine Produktivität mit Zwischenablage Manager" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boost your productivity with Clipboard Manager" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boost your productivity with Clipboard Manager" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Aumenta tu productividad con Clipboard Manager" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boostez votre productivité avec le gestionnaire de presse-papiers" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Boost your productivity with Clipboard Manager" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Incrementa la tua produttività con il gestore appunti" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "클립보드 매니저로 업무 효율을 높이세요" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zwiększ swoją produktywność dzięki menedżerowi schowka" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aumente sua produtividade com o Clipboard Manager" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повысьте свою продуктивность с помощью менеджера буфера обмена." + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pano Yöneticisiyle üretkenliğinizi arttırın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Підвищіть продуктивність за допомогою менеджера буфера обміну" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "使用剪贴板管理器提升你的效率" + } + } + } + }, + "Both" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Both" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les deux" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모두" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ambos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оба" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Her ikisi de" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "两者" + } + } + } + }, + "Both Active" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Both Active" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İkisi de etkin" + } + } + } + }, + "Break" : { + "comment" : "Name of a default timer preset that lasts for 5 minutes.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "휴식" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перерыв" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mola" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "休息" + } + } + } + }, + "Breakdown" : { + "comment" : "A label displayed above the breakdown of memory usage.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Répartition" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상세 내역" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Breakdown" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перерыв" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arıza" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "详细信息" + } + } + } + }, + "Brightness" : { + "comment" : "A control that adjusts the brightness of the device.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "밝기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brilho" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Яркость" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parlaklık" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "亮度" + } + } + } + }, + "Brightness fine step" : { + + }, + "Brightness HUD" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luminosité de l’ATH" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "밝기 HUD" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD de Brilho" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD яркости" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parlaklık HUD" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "亮度 HUD" + } + } + } + }, + "Brightness OSD" : { + "comment" : "A label for a toggle that controls whether the brightness OSD is enabled", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "밝기 OSD" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "OSD de Brilho" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "OSD яркости" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parlaklık OSD" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "亮度 OSD" + } + } + } + }, + "Brightness step" : { + + }, + "Built-in" : { + "comment" : "A label indicating that an animation is a built-in one.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intégré" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내장" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Built-in" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Встроенный" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yerleşik" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内置" + } + } + } + }, + "Built-in System Monitoring" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Système Intégré de Suivi" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내장 시스템 모니터링" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Встроенный системный мониторинг" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dahili Sistem İzleme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内置系统监控" + } + } + } + }, + "Built-in system monitoring detects volume, brightness, and keyboard backlight changes directly without requiring external apps." : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die integrierte Systemüberwachung erkennt Änderungen der Lautstärke, Helligkeit und Tastaturbeleuchtung direkt, ohne dass externe Apps erforderlich sind." + } + }, + "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." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내장 시스템 모니터링은 외부 앱 없이도 음량, 밝기 및 키보드 백라이트 변경 사항을 직접 감지합니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Встроенный мониторинг отслеживает громкость и яркость напрямую." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yerleşik sistem izleme, harici uygulamalar gerektirmeden ses düzeyi, parlaklık ve klavye arka aydınlatması değişikliklerini doğrudan algılar." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内置系统监控直接检测音量、亮度和键盘背光变化,无需外部应用。" + } + } + } + }, + "Buy us a coffee" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kauf uns einen Kaffee" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Achetez-nous un café" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발자 후원하기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Купить нам кофе" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bize bir kahve ısmarla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请我们喝杯咖啡" + } + } + } + }, + "Cached" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캐시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В кэше" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önbelleğe alındı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已缓存" + } + } + } + }, + "Calendar" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Calendar" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kalendář" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kalender" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendar" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendar" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Calendario" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendrier" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Calendar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Calendario" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "캘린더" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kalendarz" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendário" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Календарь" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Takvim" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Календар" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "日历" + } + } + } + }, + "Calendar access is denied. Please enable it in System Settings." : { + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Calendar access is denied. Please enable it in System Settings." + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Přístup do kalendáře byl odepřen. Prosím povolte jej v nastavení systému." + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kalender Zugriff verweigert. Bitte aktiviere es in den Systemeinstellungen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendar access is denied. Please enable it in System Settings." + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendar access is denied. Please enable it in System Settings." + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Acceso al calendario denegado. Por favor, habilítelo en Configuración del sistema." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'accès au calendrier est refusé. Veuillez l’autoriser dans les réglages." + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Calendar access is denied. Please enable it in System Settings." + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "L'accesso al calendario è negato. Si prega di abilitarlo nelle impostazioni di sistema." + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "캘린더 권한 거부됨. 시스템 설정에서 허용해주세요.." + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Dostęp do kalendarza został zablokowany. Proszę odblokować dostęp w Ustawieniach Systemowych." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Acesso ao Calendário negado. Por favor, autorize nas Configurações do Sistema." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступ к календарю запрещен. Пожалуйста, включите его в настройках системы." + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Takvim erişimi reddedildi. Lütfen Sistem Ayarlarından aktifleştiriniz." + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Доступ до календаря заборонений. Будь ласка, увімкніть його в Налаштуваннях Системи." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "日历访问被拒绝。请在系统设置中启用它。" + } + } + } + }, + "Calendar App" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캘린더 앱" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takvim Uygulaması" + } + } + } + }, + "Calendar or Reminder access is denied. Please enable it in System Settings." : { + "comment" : "A message displayed when a user has denied access to the calendar or reminders on their device. It instructs the user to open System Settings to grant access.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Zugriff auf Kalender oder Erinnerungen ist verweigert. Bitte aktivieren Sie ihn in den Systemeinstellungen." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캘린더 또는 미리 알림 접근 권한이 거부되었습니다. 시스템 설정에서 접근 권한을 활성화 해주세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O acesso ao Calendário ou Lembrete foi negado. Ative-o nas Configurações do Sistema." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступ к календарю или напоминаниям запрещен. Включите его в настройках." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takvim veya Anımsatıcı erişimi reddedildi. Lütfen Sistem Ayarları'nda etkinleştirin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日历或提醒事项访问被拒绝。请在系统设置中启用。" + } + } + } + }, + "Calendar Widget" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캘린더 위젯" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виджет календаря" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takvim Widget'ı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日历小组件" + } + } + } + }, + "Calendars" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kalender" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendriers" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캘린더" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendários" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виджет календаря" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takvimler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日历" + } + } + } + }, + "Camera" : { + "comment" : "A label for the camera privacy indicator.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caméra" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "카메라" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Камера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "相机" + } + } + } + }, + "Camera Active" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera Aktiv" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caméra Active" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "카메라 활동" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Câmera Ativa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Камера активна" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera Aktif" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "相机已激活" + } + } + } + }, + "Camera Only" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera Only" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yalnızca kamera" + } + } + } + }, + "Camera Status" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera Status" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status de la Caméra" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "카메라 상태" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status da Câmera" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Состояние камеры" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera Durumu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "相机状态" + } + } + } + }, + "Cancel" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Cancel" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zrušit" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Cancelar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Cancel" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Annulla" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "취소" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Anuluj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "İptal et" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Скасувати" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "取消" + } + } + } + }, + "Caps Lock" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caps Lock" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Büyük Harf Kilidi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caps Lock" + } + } + }, + "shouldTranslate" : false + }, + "Caps Lock color" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caps Lock 색상" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cor do Caps Lock" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caps Lock" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Büyük Harf Kilidi rengi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caps Lock 颜色" + } + } + } + }, + "Caps Lock Indicator" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caps Lock 표시기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indicador de Caps Lock" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Индикатор Caps Lock" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Büyük Harf Kilidi Göstergesi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caps Lock 指示器" + } + } + } + }, + "Center" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mitte" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Centrer" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "중앙" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Centro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По центру" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Merkez" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "居中" + } + } + } + }, + "Change" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changer" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "변경" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mudar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Değiştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更改" + } + } + } + }, + "Change Media Output" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 출력 변경" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya Çıkışını Değiştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更改媒体输出" + } + } + } + }, + "Charging" : { + "comment" : "Charging fallback label when no estimate is available", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Charging" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nabíjení" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lädt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charging" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charging" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Cargando" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En charge" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Charging" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Caricamento" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "충전 중" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ładowanie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carregando" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зарядка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Şarj oluyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Заряджається" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "充电中" + } + } + } + }, + "Charging battery" : { + "comment" : "Text displayed in the status text field when the device is charging.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 충전중" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зарядка батареи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilin şarj edilmesi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "电池充电中" + } + } + } + }, + "Charging duration" : { + + }, + "Charging HUD" : { + + }, + "Charging on hold" : { + "comment" : "Plugged in but charging is paused by the system" + }, + "Charging on Hold: Desktop Mode" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Charging on Hold: Desktop Mode" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nabíjení zadrženo: Režim plochy" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ladevorgang unterbrochen: Desktop-Modus" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charging on Hold: Desktop Mode" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charging on Hold: Desktop Mode" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Carga en Espera: Modo Escritorio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge suspendue : mode bureau" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Charging on Hold: Desktop Mode" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Caricamento in attesa: Modalità Desktop" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "충전 대기 중: 데스크탑 모드" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ładowanie wstrzymane: Tryb stacjonarny" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carregando em espera: Modo Desktop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зарядка приостановлена: Настольный режим" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Şarj beklemede: Masaüstü Modu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Заряджання в Режимі очікування: Режим робочого столу" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暂停充电:桌面模式" + } + } + } + }, + "Check for Updates…" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Check for Updates…" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zkontrolovat aktualizace…" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suche nach Updates…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check for Updates…" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check for Updates…" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Buscar actualizaciones…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérifier les mises à jour…" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Check for Updates…" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Controlla aggiornamenti…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "업데이트 확인…" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sprawdź aktualizacje…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verificar atualizações…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверить обновления..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Güncellemeleri Kontrol et…" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Перевірка наявності оновлень…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "检查更新…" + } + } + } + }, + "Chip color" : { + "comment" : "A label for the color of the chip in the lock screen reminder widget.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "칩 색상" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Cor do Chip" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет плашки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çip rengi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "芯片颜色" + } + } + } + }, + "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." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머가 종료될 때 재생할 사용자 지정 사운드 파일을 선택하세요. 지원 형식: MP3, M4A, WAV, AIFF." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머가 종료될 때 재생할 사용자 지정 사운드 파일을 선택하세요. 지원 형식: MP3, M4A, WAV, AIFF." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha um arquivo de som personalizado que será reproduzido quando o cronômetro for concluído. Formatos suportados: MP3, M4A, WAV, AIFF." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите звуковой файл для окончания таймера. Поддерживаются форматы: MP3, M4A, WAV, AIFF." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı tamamlandığında çalacak özel bir ses dosyası seçin. Desteklenen formatlar: MP3, M4A, WAV, AIFF." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择计时器完成时播放的自定义声音文件。支持格式:MP3、M4A、WAV、AIFF。" + } + } + } + }, + "Choose a Music Source" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Choose a Music Source" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vyberte zdroj hudby" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle eine Musikquelle" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a Music Source" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a Music Source" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Elige una Fuente de Música" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir une source de musique" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Choose a Music Source" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Scegli una fonte musicale" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음악 소스 선택" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wybierz źródło muzyki" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha fonte da música" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите источник музыки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Müzik kaynağı seç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Оберіть джерело музики" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "选择音乐来源" + } + } + } + }, + "Choose AI model" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir le modèle d’IA" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 모델 선택" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolher o modelo de IA" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать модель ИИ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yapay zeka modelini seçin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择 AI 模型" + } + } + } + }, + "Choose animation to display when Atoll is idle. Tap to select, hold to delete custom animations." : { + "comment" : "A footer label under the section that lists their idle animations.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wählen Sie die Animation aus, die angezeigt werden soll, wenn Atoll im Leerlauf ist. Tippen Sie zum Auswählen und halten Sie zum Löschen benutzerdefinierter Animationen gedrückt." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir une animation à afficher lorsque la Atoll est inactive. Touchez pour sélectionner, maintenez pour supprimer." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll이 유휴 상태일 때 표시할 애니메이션을 선택하세요. 탭하여 선택하고, 길게 눌러 사용자 지정 애니메이션을 삭제할 수 있습니다." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll이 유휴 상태일 때 표시할 애니메이션을 선택하세요. 탭하여 선택하고, 길게 눌러 사용자 지정 애니메이션을 삭제할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha a animação para exibir quando o Atol estiver ocioso. Toque para selecionar, segure para excluir animações personalizadas." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите анимацию для режима ожидания. Нажмите для выбора, удерживайте для удаления." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll boştayken gösterilecek animasyonu seçin. Seçmek için dokunun, özel animasyonları silmek için basılı tutun." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择 Atoll 空闲时显示的动画。点击选择,长按删除自定义动画。" + } + } + } + }, + "Choose directly on which side of the screen the vertical bar appears." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수직 막대가 나타날 화면 방향을 직접 선택하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha diretamente em qual lado da tela a barra vertical aparece." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите сторону экрана для вертикальной панели." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dikey çubuğun ekranın hangi tarafında görüneceğini doğrudan seçin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直接选择垂直条在屏幕的哪一侧显示。" + } + } + } + }, + "Choose File" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir un fichier" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일 선택" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolher Arquivo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать файл" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosya Seç" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择文件" + } + } + } + }, + "Choose the global material mode for lock screen widgets. Custom Liquid unlocks per-widget variant sliders while Standard sticks to the classic frosted/liquid options." : { + "comment" : "merge:ko:alt='잠금 화면 위젯의 전체적인 Material 모드를 선택하세요. '사용자 지정 Liquid’를 선택하면 위젯별 스타일 슬라이더를 사용할 수 있으며, '표준'을 선택하면 기존의 Frosted/Liquid 옵션이 유지됩니다.'", + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 위젯의 전체적인 Material 모드를 선택하세요. '사용자 지정 리퀴드'를 선택하면 위젯별 스타일 슬라이더를 사용할 수 있으며, '표준'을 선택하면 기존의 프로스티드/리퀴드 옵션이 유지됩니다." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 위젯의 전체적인 Material 모드를 선택하세요. '사용자 지정 리퀴드'를 선택하면 위젯별 스타일 슬라이더를 사용할 수 있으며, '표준'을 선택하면 기존의 프로스티드/리퀴드 옵션이 유지됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha o modo de material global para widgets de tela de bloqueio. O Custom Liquid desbloqueia controles deslizantes de variante por widget, enquanto o Padrão adere às opções clássicas de fosco/líquido." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите режим материала для виджетов экрана блокировки." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı widget'ları için genel malzeme modunu seçin. Özel Sıvı, widget başına değişken kaydırıcıların kilidini açarken Standart, klasik buzlu/sıvı seçeneklerine sadık kalır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择锁屏小组件的全局材质模式。自定义液态解锁每个小组件的变体滑块,标准则使用经典磨砂/液态选项。" + } + } + } + }, + "Choose which service to use when sharing files from the shelf. Drag files onto the shelf or click the shelf button to pick files." : { + "comment" : "A footer description under the Quick Share section of the Shelf settings. | A footer description under the Quick Share section of the Shelf settings. | merge:ko:alt='서랍에서 파일을 공유할 때 사용할 서비스를 선택하세요. 파일을 서랍으로 드래그하거나 셸프 버튼을 클릭하여 파일을 선택할 수 있습니다.'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서랍에서 파일을 공유할 때 사용할 서비스를 선택하세요. 파일을 셸프로 드래그하거나 셸프 버튼을 클릭하여 파일을 선택할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha qual serviço usar ao compartilhar arquivos da prateleira. Arraste os arquivos para a prateleira ou clique no botão da prateleira para escolher os arquivos." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите сервис для файлов с полки. Перетащите файлы на полку или нажмите кнопку." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raftan dosya paylaşırken hangi hizmetin kullanılacağını seçin. Dosyaları rafa sürükleyin veya dosyaları seçmek için raf düğmesini tıklayın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择从架子共享文件时使用的服务。将文件拖到架子上或点击架子按钮选择文件。" + } + } + } + }, + "Choose which system controls should display custom OSD windows." : { + "comment" : "A footer description for the \"Controls\" section of the Custom OSD settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 OSD 창을 표시할 시스템 제어기를 선택하세요." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 OSD 창을 표시할 시스템 제어기를 선택하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha quais controles de sistema devem exibir janelas OSD personalizadas." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите элементы управления для показа всплывающих окон OSD." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hangi sistem denetimlerinin özel OSD pencerelerini göstermesi gerektiğini seçin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择哪些系统控制应显示自定义 OSD 窗口。" + } + } + } + }, + "Choose which system controls should display HUD notifications." : { + "comment" : "A footer for the \"Controls\" section in the HUD settings, explaining that users can choose which system controls should display HUD notifications.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD 알림을 표시할 시스템 제어기를 선택하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha quais controles do sistema devem exibir notificações do HUD." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите элементы управления для показа уведомлений HUD." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hangi sistem kontrollerinin HUD bildirimlerini görüntülemesi gerektiğini seçin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择哪些系统控制应显示 HUD 通知。" + } + } + } + }, + "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" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선호하는 AI 모델 및 구성을 선택하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha o seu modelo e configuração de IA preferidos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите предпочитаемую модель ИИ и её параметры" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tercih ettiğiniz yapay zeka modelini ve yapılandırmasını seçin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择您偏好的 AI 模型和配置" + } + } + } + }, + "Choose Your Profile" : { + "comment" : "A title displayed above the list of profile selection options.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez votre profil" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로필 선택" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha seu Perfil" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите свой профиль" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profilinizi Seçin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择您的配置文件" + } + } + } + }, + "Circle" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Circle" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kruh" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kreis" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Circle" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Circle" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Círculo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cercle" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Circle" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Cerchio" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "원형의" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Okręg" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Círculo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Круг" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "daire" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Коло" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "圆形" + } + } + } + }, + "Circular" : { + "comment" : "Label for the \"Circular\" tab in the HUD and OSD settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원형" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Круглый" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dairesel" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "圆形" + } + } + } + }, + "Classic" : { + "comment" : "Name of the timer surface mode in the lock screen that looks like a traditional stopwatch.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클래식" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Классический" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klasik" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "经典" + } + } + } + }, + "Classic mode keeps the original translucent black background." : { + "comment" : "A description of the \"Classic mode\" option in the Lock Screen Integration settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클래식 모드에서는 기존의 반투명 검은색 배경이 유지됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O modo clássico mantém o fundo preto translúcido original." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Классический" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klasik mod orijinal yarı saydam siyah arka planı korur." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "经典模式保持原始半透明黑色背景。" + } + } + } + }, + "Classic notch shape that blends into the top screen edge" : { + "comment" : "Description of a notch shape that blends into the top screen edge.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "화면 상단 가장자리와 자연스럽게 어우러지는 클래식 노치 디자인" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Классическая форма выреза, сливающаяся с верхним краем экрана" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Üst ekranın kenarına uyum sağlayan klasik çentik şekli" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "与屏幕顶部边缘融合的经典刘海形状" + } + } + } + }, + "Clear" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지우기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Классическая форма выреза, плавно переходящая в верхний край экрана" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除" + } + } + } + }, + "Clear All Files" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer tous les fichiers" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 파일 지우기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpar Todos os Arquivos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить все файлы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tüm Dosyaları Temizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除所有文件" + } + } + } + }, + "Clear all files removes all attached files and audio recordings. This action is permanent." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Dateien löschen entfernt alle angehängten Dateien und Audioaufnahmen. Diese Aktion ist unwiderruflich." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer tous les fichiers supprime tous les fichiers et enregistrements joints. Cette action est définitive." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'모든 파일 지우기'를 선택하면 첨부된 모든 파일과 오디오 녹음이 삭제됩니다. 이 작업은 영구적입니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpa todos os arquivos e remove todos os arquivos anexados e gravações de áudio. Esta ação é permanente." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистка удалит все прикрепленные файлы и аудиозаписи. Это действие необратимо." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tüm dosyaları temizle, ekli tüm dosyaları ve ses kayıtlarını kaldırır. Bu eylem kalıcıdır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除所有文件将移除所有附件和音频录制。此操作不可撤销。" + } + } + } + }, + "Clear Clipboard History" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer l’Historique du Presse-papiers" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드 기록 지우기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpar o Histórico da Área de Transferência" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить историю буфера обмена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pano Geçmişini Temizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除剪贴板历史" + } + } + } + }, + "Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durch „Zwischenablage löschen“ werden die letzten Kopien entfernt. Durch „Angeheftete Elemente löschen“ werden Ihre Favoriten entfernt. Beide Aktionen sind dauerhaft." + } + }, + "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." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'클립보드 기록 지우기'를 선택하면 최근 복사한 항목이 삭제됩니다. '고정된 항목 지우기'를 선택하면 즐겨찾기가 삭제됩니다. 두 작업 모두 영구적입니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpar histórico da área de transferência remove cópias recentes. Limpar itens fixados remove seus favoritos. Ambas as ações são permanentes." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистка истории или закрепленных элементов необратима." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pano geçmişini temizle, son kopyaları kaldırır. Sabitlenmiş öğeleri temizle favorilerinizi kaldırır. Her iki eylem de kalıcıdır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除剪贴板历史将移除最近的复制内容。清除固定项将移除您的收藏。两项操作均不可撤销。" + } + } + } + }, + "Clear Clipboard History?" : { + "comment" : "An alert title that asks the user to confirm clearing their clipboard history.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드 기록을 지우시겠습니까?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpar a Área de Transferência?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить историю буфера обмена?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pano Geçmişi Temizlensin mi?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除剪贴板历史?" + } + } + } + }, + "Clear Color History" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer l’Historique de Couleurs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 기록 지우기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpar o Histórico de Cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить историю цветов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Geçmişini Temizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除颜色历史" + } + } + } + }, + "Clear color history removes all picked colors. Start color picking begins screen color capture mode." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durch das Löschen des Farbverlaufs werden alle ausgewählten Farben entfernt. Durch das Starten der Farbauswahl wird der Bildschirmfarbenerfassungsmodus gestartet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer l’historique de couleurs supprime toutes les couleurs prélevées." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'색상 기록 지우기'를 선택하면 추출한 모든 색상이 삭제됩니다. '색상 선택 시작'을 선택하면 화면 색상 캡처 모드가 시작됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpar histórico de cores remove todas as cores escolhidas. Iniciar a seleção de cores inicia o modo de captura de cores da tela." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистка удаляет все цвета. Запуск выбора включает режим захвата цвета." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk geçmişini temizle, seçilen tüm renkleri kaldırır. Renk seçmeyi başlat ekran rengi yakalama modunu başlatır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除颜色历史将移除所有取色记录。开始取色将进入屏幕取色模式。" + } + } + } + }, + "Clear conversation and attachments" : { + "comment" : "A help text for the \"Reset Context\" button in the chat messages view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대화 및 첨부 항목 지우기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить диалог и вложения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konuşmayı ve ekleri temizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除对话和附件" + } + } + } + }, + "Clear Data" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer les données" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "데이터 지우기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpar Dados" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить данные" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verileri Temizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除数据" + } + } + } + }, + "Clear History" : { + "comment" : "The text on a button that clears the clipboard history.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록 지우기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpar Histórico" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить историю" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçmişi Temizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除历史" + } + } + } + }, + "Clear Pinned Items" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer les Éléments épinglés" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "고정된 항목 지우기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpar os Itens Fixados" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить закрепленные элементы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sabitlenmiş Öğeleri Temizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除固定项" + } + } + } + }, + "Clear search" : { + "comment" : "An accessibility label for the button that clears the search field in the sidebar.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검색 지우기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpar a pesquisa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить поиск" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aramayı temizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除搜索" + } + } + } + }, + "Clear slot" : { + "comment" : "A label for a button that clears a music control slot.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "슬롯 지우기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpar slot" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить слот" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yuvayı temizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除插槽" + } + } + } + }, + "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" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'색상 선택기'를 클릭하거나 Cmd+Shift+P를 눌러 색상 선택을 시작하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clique em 'Escolher Cor' ou use Cmd+Shift+P para começar a escolher cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Click 'Pick Color' or use Cmd+Shift+P to start picking цв." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renkleri seçmeye başlamak için 'Renk Seç'i tıklayın veya Cmd+Shift+P'yi kullanın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "点击「取色」或使用 Cmd+Shift+P 开始取色" + } + } + } + }, + "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, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cliquez pour en savoir plus" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "눌려서 자세히 보기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clique para exibir detalhes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нажмите для подробностей" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayrıntılar için tıklayın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "点击查看详情" + } + } + } + }, + "Clipboard" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presse-papiers" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Área de Transferência" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Буфер обмена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pano" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剪贴板" + } + } + } + }, + "Clipboard feature is disabled" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La fonctionnalité de presse-papiers est désactivée" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드 기능 비활성화됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Funcionalidade da Área de Transferência está desabilitada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Функция буфера обмена отключена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pano özelliği devre dışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剪贴板功能已禁用" + } + } + } + }, + "Clipboard History" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historique du presse-papiers" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드 기록" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Histórico da Área de Transferência" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История буфера обмена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pano Geçmişi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剪贴板历史" + } + } + } + }, + "Clipboard History:" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historique du presse-papiers:" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드 기록:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Histórico da Área de Transferência:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История буфера обмена:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pano Geçmişi:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剪贴板历史:" + } + } + } + }, + "Clipboard Manager" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestionnaire de presse-papiers" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드 매니저" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gerenciador da Área de Transferência" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Менеджер буфера обмена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pano Yöneticisi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剪贴板管理器" + } + } + } + }, + "Close" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "قُفْل\n" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zavřít" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Cerrar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Close" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Chiudi" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "닫기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zamknij" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fechar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Закрити" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "关闭" + } + } + } + }, + "Close assistant" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer l’assistant" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "어시스턴트 닫기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fechar Assistente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть помощника" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asistanı kapat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭助手" + } + } + } + }, + "Close gesture" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "닫기 제스쳐" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Жест закрытия" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hareketi kapat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭手势" + } + } + } + }, + "Code and debug with color picker, stats monitoring, and screen assistant." : { + "comment" : "Description of a user profile when the name is \"Developer\".", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 선택기, 상태 모니터링 및 화면 어시스턴트로 코딩하고 디버깅하세요." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Программируйте и отлаживайте с пипеткой цветов, мониторингом системы и экранным помощником." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk seçici, istatistik izleme ve ekran asistanıyla kodlayın ve hata ayıklayın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用取色器、系统监控和屏幕助手进行编码和调试。" + } + } + } + }, + "Collect the latest crash report to share with the developer when reporting lock screen or overlay issues." : { + "comment" : "A footer for the \"Diagnostics\" section of the lock screen settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 또는 오버레이 문제를 보고할 때 개발자에게 공유할 최신 충돌 보고서를 수집합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Colete o relatório de falhas mais recente para compartilhar com o desenvolvedor ao relatar problemas de tela de bloqueio ou sobreposição." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разработка и отладка: цветовая пипетка, мониторинг статистики и экранный помощник." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı veya katman sorunlarını bildirirken geliştiriciyle paylaşmak üzere en son kilitlenme raporunu toplayın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收集最新崩溃报告以便向开发者报告锁屏或覆盖层问题时分享。" + } + } + } + }, + "Color Details" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Détails des couleurs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 세부 정보" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalhes das Cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Детали цвета" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Detayları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "颜色详情" + } + } + } + }, + "Color Formats" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Formats de couleurs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 포맷" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Formato de Cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Форматы цвета" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Formatları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "颜色格式" + } + } + } + }, + "Color History" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historique des couleurs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 기록" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Histórico de Cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История цветов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Geçmişi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "颜色历史" + } + } + } + }, + "Color options control the icon and progress bar appearance. Auto adapts to system theme." : { + "comment" : "A description under the \"Appearance\" section of the Custom OSD settings, explaining that the color options control the icon and progress bar appearance, and that \"Auto\" automatically adapts to the system theme.", + "isCommentAutoGenerated" : true, + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 옵션으로 아이콘 및 진행 표시줄의 외관을 제어합니다. '자동' 설정 시 시스템 테마에 맞게 조정됩니다." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 옵션으로 아이콘 및 진행 표시줄의 외관을 제어합니다. '자동' 설정 시 시스템 테마에 맞게 조정됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "As opções de cores controlam a aparência do ícone e da barra de progresso. Adapta-se automaticamente ao tema do sistema." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки цвета влияют на иконку и индикатор. Автоматически адаптируется под тему." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk seçenekleri simgenin ve ilerleme çubuğunun görünümünü kontrol eder. Otomatik olarak sistem temasına uyum sağlar." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "颜色选项控制图标和进度条外观。自动适应系统主题。" + } + } + } + }, + "Color Picked!" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couleur prélevée!" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 선택됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cor Selecionada!" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет выбран!" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Seçildi!" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已取色!" + } + } + } + }, + "Color Picker" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pipette de couleurs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 선택기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seletor de Cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пипетка цветов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Seçici" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取色器" + } + } + } + }, + "Color Picker feature is disabled" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La fonctionnalité de pipette est désactivée" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 선택기 기능 비활성화됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Funcionalidade do Seletor de Cores está desabilitado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Функция пипетки отключена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Seçici özelliği devre dışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取色器功能已禁用" + } + } + } + }, + "Color Picker Panel:" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panneau de la pipette de couleurs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 선택기 패널:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Painel do Seletor de Cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Панель пипетки:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Seçici Paneli:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取色器面板:" + } + } + } + }, + "Color-coded battery display" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상별 배터리 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цветное отображение батареи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk kodlu pil ekranı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "彩色电池显示" + } + } + } + }, + "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, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'세그먼트' 모드에서는 색상별 채우기 및 부드러운 그라데이션을 사용할 수 없습니다. 이 옵션을 조정하려면 '계층형' 또는 '그라데이션'으로 전환하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enchimentos codificados por cores e gradientes suaves não estão disponíveis no modo Segmentado. Mude para Hierárquico ou Gradiente para ajustar essas opções." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цветная заливка и градиенты недоступны в режиме Segmented." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk kodlu dolgular ve pürüzsüz gradyanlar Bölümlenmiş modda kullanılamaz. Bu seçenekleri ayarlamak için Hiyerarşik veya Gradyan'a geçin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分段模式下不支持彩色填充和平滑渐变。切换到层级或渐变模式来调整这些选项。" + } + } + } + }, + "Color-coded fills are unavailable in Segmented mode. Switch to Hierarchical or Gradient inside Controls › Dynamic Island to adjust advanced options." : { + "comment" : "A message explaining that color-coded battery indicators are unavailable in segmented progress bar style. It also includes a suggestion to switch to a more advanced progress bar style to adjust advanced options.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'세그먼트' 모드에서는 색상별 채우기를 사용할 수 없습니다. 고급 옵션을 조정하려면 '제어기 › Dynamic Island'에서 '계층형' 또는 '그라데이션'으로 전환하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Os preenchimentos codificados por cores não estão disponíveis no modo Segmentado. Mude para Hierárquico ou Gradiente em Controles ›Dynamic Island para ajustar opções avançadas." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цветная заливка недоступна в режиме Segmented." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk kodlu dolgular Bölümlere ayrılmış modda kullanılamaz. Gelişmiş seçenekleri ayarlamak için Hiyerarşik veya Gradyan İç Kontrollere geçin › Dynamic Island." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分段模式下不支持彩色填充。切换到控制 › 灵动岛中的层级或渐变模式来调整高级选项。" + } + } + } + }, + "Color-coded Volume" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상별 음량" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цветная громкость" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Kodlu Hacim" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "彩色音量" + } + } + } + }, + "Color-coded volume display" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상별 음량 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цветное отображение громкости" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk kodlu ses seviyesi ekranı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "彩色音量显示" + } + } + } + }, + "ColorPicker Disabled" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La pipette de couleur est désactivée" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 선택기 비활성화됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seletor de Cores Desabilitado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пипетка отключена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Seçici Devre Dışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取色器已禁用" + } + } + } + }, + "Colors" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвета" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renkler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "颜色" + } + } + } + }, + "Coming soon" : { + "comment" : "merge:ko:alt='출시 예정'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Již brzy" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demnächst" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coming soon" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coming soon" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Próximamente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bientôt disponible" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Coming soon" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "In arrivo" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "곧 출시" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Już wkrótce" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Em breve" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скоро будет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Çok yakında geliyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Незабаром" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "即将上线" + } + } + } + }, + "Compact keeps the alert inline. Standard uses the taller full-charge HUD with the charging animation." : { + + }, + "Compact matches the charging HUD. Standard uses the expanded DynamicNotch-style card." : { + + }, + "Compressed" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "압축됨" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сжато" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sıkıştırılmış" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已压缩" + } + } + } + }, + "Compute" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "계산" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вычисления" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hesapla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计算" + } + } + } + }, + "Configuration" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "구성" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuração" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Конфигурация" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yapılandırma" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "配置" + } + } + } + }, + "Configure countdown style and lock screen widgets in the Calendar tab." : { + "comment" : "A footer for the \"Enable reminder live activity\" toggle in the Live Activities settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캘린더 탭에서 카운트다운 스타일과 잠금 화면 위젯을 설정하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configure o estilo de contagem regressiva e os widgets da tela de bloqueio na guia Calendário." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройте стиль обратного отсчета и виджеты на вкладке Calendar." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takvim sekmesinde geri sayım stilini ve kilit ekranı widget'larını yapılandırın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在日历标签页中配置倒计时样式和锁屏小组件。" + } + } + } + }, + "Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치가 닫혔을 때의 타이머 모양을 설정하세요. 진행 상황은 아이콘 주변의 고리 또는 가로 막대 형태로 표시될 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configure como o temporizador fica dentro do notch fechado. O progresso pode ser renderizado como um anel ao redor do ícone ou como barras horizontais." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройте внешний вид таймера в закрытом вырезе." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcının kapalı çentik içinde nasıl görüneceğini yapılandırın. İlerleme, simgenin etrafında bir halka veya yatay çubuklar şeklinde oluşturulabilir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "配置计时器在关闭的刘海中的外观。进度可以显示为图标周围的圆环或水平条。" + } + } + } + }, + "Configure lock screen materials from the Appearance tab. Custom Liquid unlocks variant sliders for both widgets whenever Liquid Glass is selected." : { + "comment" : "merge:ko:alt='외관 탭에서 잠금 화면 Material을 설정하세요. 'Liquid Glass'를 선택하면 두 위젯 모두에서 '사용자 지정 Liquid’ 스타일 슬라이더를 사용할 수 있습니다.'", + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "화면 표시 탭에서 잠금 화면 Material을 설정하세요. '리퀴드 글래스'를 선택하면 두 위젯 모두에서 '사용자 지정 리퀴드' 스타일 슬라이더를 사용할 수 있습니다." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "화면 표시 탭에서 잠금 화면 Material을 설정하세요. '리퀴드 글래스'를 선택하면 두 위젯 모두에서 '사용자 지정 리퀴드' 스타일 슬라이더를 사용할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configure os materiais da tela de bloqueio na aba Aparência. O Custom Liquid desbloqueia controles deslizantes variantes para ambos os widgets sempre que o Liquid Glass é selecionado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройте материалы экрана блокировки на вкладке Appearance." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Görünüm sekmesinden kilit ekranı malzemelerini yapılandırın. Custom Liquid, Liquid Glass seçildiğinde her iki widget için de değişken kaydırıcıların kilidini açar." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在外观标签页中配置锁屏材质。选择液态玻璃时,自定义液态将解锁两个小组件的变体滑块。" + } + } + } + }, + "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, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정에서 프리셋 구성" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurar predefinições em Configurações" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настроить пресеты в Настройках" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayarlar'da ön ayarları yapılandırın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在设置中配置预设" + } + } + } + }, + "Configure presets in Settings to see them here." : { + "comment" : "A message displayed when there are no timer presets configured. It instructs the user to configure presets in the app settings to see them here.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여기에 표시하려면 설정에서 프리셋을 구성하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configure as predefinições em Configurações para vê-las aqui." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройте пресеты в Настройках, чтобы увидеть их здесь." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ön ayarları burada görmek için Ayarlar'da yapılandırın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在设置中配置预设即可在此查看。" + } + } + } + }, + "Connect Spotify" : { + "comment" : "A label displayed above the Spotify login sheet.", + "isCommentAutoGenerated" : true + }, + "Connected" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecté" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결됨" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключено" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bağlı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已连接" + } + } + } + }, + "Connects directly to the Apple Music app." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Music 앱과 직접 연결됩니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Напрямую подключается к приложению Apple Music." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doğrudan Apple Music uygulamasına bağlanır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直接连接 Apple Music 应用。" + } + } + } + }, + "Connects directly to the Spotify app." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spotify 앱과 직접 연결됩니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Напрямую подключается к приложению Spotify." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doğrudan Spotify uygulamasına bağlanır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直接连接 Spotify 应用。" + } + } + } + }, + "Continue" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Continue" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pokračovat" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortfahren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Continuar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continuer" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Continue" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Continua" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "계속" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kontynuuj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continuar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devam et" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Далі" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "继续" + } + } + } + }, + "Control Palette" : { + "comment" : "A section header in the music slot configuration view, describing the available controls.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "조작 팔레트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paleta de Controle" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Палитра элементов управления" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontrol Paleti" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制面板" + } + } + } + }, + "Control timer availability, live activity behaviour, and whether the app mirrors timers started from the macOS Clock app." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 활성화 여부, 실시간 현황 동작, 그리고 macOS '시계' 앱에서 시작된 타이머의 연동 여부를 제어합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controle a disponibilidade do cronômetro, o comportamento da atividade ao vivo e se o aplicativo espelha os temporizadores iniciados no aplicativo Relógio do MacOS." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управляет доступностью таймера, Live Activities и тем, дублирует ли приложение таймеры из системного приложения Часы." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı kullanılabilirliğini, canlı etkinlik davranışını ve uygulamanın macOS Clock uygulamasından başlatılan zamanlayıcıları yansıtıp yansıtmadığını kontrol edin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制计时器可用性、实时活动行为,以及应用是否同步 macOS 时钟应用启动的计时器。" + } + } + } + }, + "Controls" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contrôles" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제어 항목" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controles" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управление" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontroller" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制" + } + } + } + }, + "Controls how often system metrics refresh while monitoring is active." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모니터링이 활성화된 동안 시스템 측정값이 새로고침되는 빈도를 조절합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controla com que frequência as métricas do sistema são atualizadas enquanto o monitoramento está ativo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управляет частотой обновления системных метрик при активном мониторинге." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İzleme etkinken sistem ölçümlerinin ne sıklıkta yenilendiğini kontrol eder." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制监控激活时系统指标的刷新频率。" + } + } + } + }, + "Controls the lock screen reminder chip and its positioning." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 리마인더 칩과 그 위치를 제어합니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управляет плашкой напоминаний на экране блокировки и ее позицией." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı hatırlatma çipini ve konumunu kontrol eder." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制锁屏提醒标签及其位置。" + } + } + } + }, + "Controls the optional timer widget that floats above the media panel, including its classic, frosted, or liquid glass surface independent of the global material setting." : { + "comment" : "merge:ko:alt='미디어 패널 위에 떠 있는 선택적 타이머 위젯을 제어합니다. 여기에는 전체 Material 설정과는 별개로 적용되는 클래식, Frosted 또는 Liquid Glass 스타일이 포함됩니다.'", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 패널 위에 떠 있는 선택적 타이머 위젯을 제어합니다. 여기에는 전체 Material 설정과는 별개로 적용되는 클래식, 프로스티드 또는 리퀴드 글래스 스타일이 포함됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controla o widget de temporizador opcional que flutua acima do painel de mídia, incluindo sua superfície de vidro clássico, fosco ou líquido, independentemente da configuração global do material." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управляет областью напоминаний на экране блокировки и её расположением." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genel malzeme ayarından bağımsız olarak klasik, buzlu veya sıvı cam yüzeyi de dahil olmak üzere medya panelinin üzerinde yüzen isteğe bağlı zamanlayıcı widget'ını kontrol eder." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制浮动在媒体面板上方的可选计时器小组件,包括其经典、磨砂或液态玻璃表面(独立于全局材质设置)。" + } + } + } + }, + "Controls whether Dynamic Island mirrors lock/unlock events with its own live activity and audible chimes." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island가 자체 실시간 현황 및 효과음과 함께 잠금/잠금 해제 이벤트를 연동할지 여부를 제어합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controla se a Dynamic Island espelha eventos de bloqueio/desbloqueio com sua própria atividade ao vivo e sinos audíveis." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управляет тем, дублирует ли Dynamic Island системные события блокировки/разблокировки своими Live Activity и звуками." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island'ın kendi canlı etkinliği ve sesli çanları ile kilitleme/kilit açma olaylarını yansıtıp yansıtmadığını kontrol eder." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制灵动岛是否通过自身的实时活动和提示音来响应锁屏/解锁事件。" + } + } + } + }, + "Copy event link" : { + "comment" : "A tooltip for a button that copies the link of a reminder event to the clipboard.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이벤트 링크 복사" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiar link do evento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать ссылку на событие" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinlik bağlantısını kopyala" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制事件链接" + } + } + } + }, + "Copy items on drag" : { + "comment" : "A toggle that allows users to copy items to the clipboard when dragging them.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "드래그 시 항목 복사" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiar itens ao arrastar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать элементы при перетаскивании" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öğeleri sürüklerken kopyala" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拖拽时复制项目" + } + } + } + }, + "Copy Latest Crash Report" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copier le dernier rapport de crash" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최근 충돌 보고 복사" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiar o último relatório de falhas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировать последний отчет об ошибке" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En Son Kilitlenme Raporunu Kopyala" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制最新崩溃报告" + } + } + } + }, + "Copy something to get started" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiez quelque-chose pour commencer" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시작하려면 항목을 복사하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copie algo para começar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопируйте что-нибудь для начала" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Başlamak için bir şeyi kopyalayın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制内容即可开始" + } + } + } + }, + "Copy something to start" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copier quelque-chose pour commencer" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "항목을 복사하여 시작하기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copie algo para começar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопируйте что-нибудь для начала" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Başlamak için bir şeyi kopyalayın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制内容即可开始" + } + } + } + }, + "Copy text to see it here" : { + "comment" : "A description below the \"No copies yet\" text, instructing the user to copy text to see it in the clipboard.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "텍스트를 복사하면 여기에 표시됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copie o texto para vê-lo aqui" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопируйте текст, чтобы он здесь появился" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Burada görmek için metni kopyalayın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制文本后将在此显示" + } + } + } + }, + "Core %lld" : { + "comment" : "A label showing the ID of a CPU core. The argument is the ID of the CPU core.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cœur %lld" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ядро %lld" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Core %lld" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "核心 %lld" + } + } + }, + "shouldTranslate" : false + }, + "Core Clock" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코어 클럭" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Частота ядра" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çekirdek Saat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "核心时钟" + } + } + } + }, + "Cores" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코어" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ядра" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çekirdekler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "核心数" + } + } + } + }, + "Corner radius scaling" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모서리 곡률 조정" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Масштабирование радиуса углов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Köşe yarıçapı ölçeklendirme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "圆角缩放" + } + } + } + }, + "Countdown style" : { + "comment" : "A label for the style of countdown to use when reminders are live.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "카운트다운 스타일" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estilo de contagem regressiva" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль обратного отсчета" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geri sayım stili" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "倒计时样式" + } + } + } + }, + "CPU" : { + "comment" : "Title for the CPU usage graph.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU" + } + } + } + }, + "CPU Overview" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU 개요" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обзор CPU" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU Genel Bakış" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU 概览" + } + } + } + }, + "CPU Usage" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilisation CPU" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU 사용량" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uso de CPU" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ЦП" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU Kullanımı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU 使用率" + } + } + } + }, + "Create and design with color picker, mirror, and visual effects." : { + "comment" : "Description of a user profile when the name is \"Designer\".", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 선택기, 거울 및 시각 효과를 사용하여 제작하고 디자인해 보세요." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Творите и дизайните с пипеткой, зеркалом и визуальными эффектами." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk seçici, ayna ve görsel efektlerle yaratın ve tasarlayın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用取色器、镜像和视觉效果进行创作和设计。" + } + } + } + }, + "Create from Clipboard" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드로 부터 생성" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Criar a partir da Área de Transferência" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создание и дизайн: пипетка, зеркало, визуальные эффекты" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panodan Oluştur" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从剪贴板创建" + } + } + } + }, + "Critical" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "심각" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Критическое" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritik" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "严重" + } + } + } + }, + "Current custom timer:" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minuteur personnalisé actuel :" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 사용자 지정 타이머:" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 사용자 지정 타이머:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текущий таймер:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mevcut özel zamanlayıcı:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前自定义计时器:" + } + } + } + }, + "Current default:" : { + "comment" : "A label displayed alongside the currently set default timer duration.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 기본값:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Padrão atual:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбран по умолчанию:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mevcut varsayılan:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前默认:" + } + } + } + }, + "Current History" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historique actuel :" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 기록" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Histórico Atual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текущая история" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Güncel Tarih" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前历史" + } + } + } + }, + "Current Items" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Éléments actuels" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 항목" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Itens Atuais" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текущие элементы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mevcut Öğeler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前项目" + } + } + } + }, + "Current Transform" : { + "comment" : "A label displayed above the current transform values in the preview stats view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 변형" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transformação Atual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текущее преобразование" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akım Dönüşümü" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前变换" + } + } + } + }, + "Currently active" : { + "comment" : "A text indicating whether the camera is \"Currently active\" or \"Not active\".", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activée" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 활동" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atualmente ativo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В данный момент активен" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şu anda aktif" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前活跃" + } + } + } + }, + "Currently selected: %@" : { + "comment" : "A label showing the currently selected quick share service. The argument is the ID of the currently selected quick share service.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 선택된 항목: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecionado Atualmente: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрано: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şu anda seçili: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前选择:%@" + } + } + } + }, + "Currently: %@" : { + "comment" : "A label inside the quick share settings view, describing the currently selected quick share service.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şu anda: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前:%@" + } + } + } + }, + "Cursor" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "커서" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Курсор" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İmleç" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "光标" + } + } + } + }, + "Cursor style" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "커서 스타일" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль курсора" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İmleç stili" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "光标样式" + } + } + } + }, + "Custom" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personnalisé" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль указателя" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义" + } + } + } + }, + "Custom color" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom color" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couleur personnalisée" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 색상" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 색상" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пользовательский цвет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel renk" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义颜色" + } + } + } + }, + "Custom Focus metadata" : { + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 집중 모드 메타데이터" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 집중 모드 메타데이터" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel Odak meta verileri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义专注模式元数据" + } + } + } + }, + "Custom height" : { + "comment" : "merge:hu:alt='사용자 지정 높이'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Custom height" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vlastní výška" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benutzerdefinierte Höhe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom height" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom height" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Altura personalizada" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hauteur personnalisée" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Custom height" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Altezza personalizzata" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용자 정의 높이" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Niestandardowa wysokość" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altura Customizada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пользовательская высота" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel yükseklik" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Користувацька висота" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "自定义高度" + } + } + } + }, + "Custom Liquid" : { + "comment" : "merge:ko:alt='사용자 지정 Liquid'", + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "커스텀 리퀴드" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "커스텀 리퀴드" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пользовательский Liquid" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel Sıvı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义液态玻璃" + } + } + } + }, + "Custom liquid glass already renders with Apple's liquid material, so this option is managed automatically." : { + "comment" : "A description of the custom liquid blur option in the media settings. | A description of the custom liquid blur option in the media settings. | merge:ko:alt='사용자 지정 Liquid Glass는 이미 Apple의 Liquid Material로 렌더링되므로, 이 옵션은 자동으로 관리됩니다.'", + "isCommentAutoGenerated" : true, + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "커스텀 리퀴드 글래스는 이미 Apple의 리퀴드 마테리얼로 렌더링되므로, 이 옵션은 자동으로 관리됩니다." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 리퀴드 글래스는 이미 Apple의 리퀴드 마테리얼로 렌더링되므로, 이 옵션은 자동으로 관리됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O Liquid Glass personalizado já renderiza com o Liquid Glass da Apple, então essa opção é gerenciada automaticamente." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настраиваемая жидкость" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel sıvı cam zaten Apple'ın sıvı malzemesiyle işleniyor, dolayısıyla bu seçenek otomatik olarak yönetiliyor." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义液态玻璃已使用 Apple 的液态材质渲染,因此此选项将自动管理。" + } + } + } + }, + "Custom Liquid is available on macOS 26 or later." : { + "comment" : "merge:ko:alt='사용자 지정 Liquid는 macOS 26 이상 버전이 필요합니다.'", + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "커스텀 리퀴드는 macOS 26 이상 버전이 필요합니다." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "커스텀 리퀴드는 macOS 26 이상 버전이 필요합니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пользовательский Liquid доступен на macOS 26 и новее." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom Liquid, macOS 26 veya üzeri sürümlerde mevcuttur." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义液态玻璃仅在 macOS 26 或更高版本可用。" + } + } + } + }, + "Custom Liquid settings require the Liquid Glass material." : { + "comment" : "merge:ko:alt='사용자 지정 Liquid Glass을 사용하려면 'Liquid Glass' Material이 필요합니다.'", + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "커스텀 리퀴드 설정을 사용하려면 '리퀴드 글래스' 머티리얼이 필요합니다." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 리퀴드 설정을 사용하려면 '리퀴드 글래스' 머티리얼이 필요합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "As configurações do Liquid Glass personalizadas requerem o material de Liquid Glass." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настраиваемая жидкость доступна на macOS 26+" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel Liquid ayarları Liquid Glass malzemesini gerektirir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义液态玻璃设置需要液态玻璃材质。" + } + } + } + }, + "Custom liquid variant" : { + "comment" : "merge:ko:alt='사용자 지정 Liquid 유형'", + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "커스텀 리퀴드 유형" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "커스텀 리퀴드 유형" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вариант пользовательского Liquid" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel sıvı çeşidi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义液态变体" + } + } + } + }, + "Custom music live activity animation" : { + "comment" : "merge:hu:alt='사용자 정의 음악 재생 애니메이션'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Custom music live activity animation" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vlastní animace živé aktivity hudby" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benutzerdefinierte Animation für Musik-Live-Aktivitäten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom music live activity animation" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom music live activity animation" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Animación de actividad de música en vivo personalizada" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animation d'activité musicale personnalisée" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Custom music live activity animation" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Animazione attività musicale personalizzata" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용자 정의 음악 재생 애니메이션" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Niestandardowa animacja aktywności muzyki" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animação de atividade ao vivo de música personalizada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пользовательская анимация эфира активности музыки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel müzik canlı aktivite animasyonu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Власна анімація музики в реальному часі" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "自定义音乐实时动画" + } + } + } + }, + "Custom notch size - %.0f" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Custom notch size - %.0f" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vlastní velikost notche - %.0f" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benutzerdefinierte Notchgröße - %.0f" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom notch size - %.0f" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom notch size - %.0f" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tamaño de notch personalizado - %.0f" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taille de l'encoche personnalisée - %.0f" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Custom notch size - %.0f" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Dimensione notch personalizzata - %.0f" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용자 정의 노치 사이즈 - %.0f" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Niestandardowy rozmiar notcha - %.0f" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamanho do notch personalizado - %.0f" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пользовательский размер выреза - %.0f" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel çentik boyutu - %.0f" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Користувацький розмір брові - %.0f" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "自定义Notch大小 - %.0f" + } + } + } + }, + "Custom OSD" : { + "comment" : "The title of the settings screen for customizing the OSD.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 OSD" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "OSD personalizado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Собственный OSD" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel OSD" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义 OSD" + } + } + } + }, + "Custom OSD feature requires macOS 15 or later." : { + "comment" : "A description of the Custom OSD feature, visible only on macOS 15 or later.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 OSD 기능은 macOS 15 또는 최신 버전을 필요로 합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O recurso OSD personalizado requer o macOS 15 ou posterior." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Функция Собственный OSD требует macOS 15 и новее." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel OSD özelliği, macOS 15 veya üzerini gerektirir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义 OSD 功能需要 macOS 15 或更高版本。" + } + } + } + }, + "Custom Timer" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minuteur personnalisé" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 타이머" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temporizador Personalizado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Свой таймер" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel Zamanlayıcı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义计时器" + } + } + } + }, + "Custom Timer Duration" : { + "comment" : "merge:ko:alt='사용자 지정 타이머 시간'", + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durée du minuteur personnalisée" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 타이머 지속시간" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Длительность пользовательского таймера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel Zamanlayıcı Süresi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义计时器时长" + } + } + } + }, + "Custom vizualizers (Lottie)" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Custom vizualizers (Lottie)" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vlastní vizualizátory (Lottie)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eigene Audiovisualisierung (via Lottie)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom vizualizers (Lottie)" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom visualisers (Lottie)" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Visualizadores personalizados (Lottie)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visualiseurs personnalisés (Lottie)" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Custom vizualizers (Lottie)" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vizualizers personalizzati (Lottie)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용자 지정 시각화 도구(Lottie)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Niestandardowe wizualizatory (Lottie)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vizualizadores personalizados (Lottie)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пользовательская визуализация (Lottie)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel görselleştiriciler (Lottie)" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Користувацькі візуалізатори (Лотті)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "自定义可视化器(Lottie)" + } + } + } + }, + "Custom: %@" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Personnalisé: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Customizado: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Свой: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义:%@" + } + } + } + }, + "Customize Animation" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personnaliser l’animation" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 애니메이션" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Customizar Animações" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настроить анимацию" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animasyonu Özelleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义动画" + } + } + } + }, + "Customize how you organize and create notes. Enabling color filtering and search helps manage large lists." : { + "comment" : "A footer text that describes the purpose of the \"Customize how you organize and create notes\" section in the settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모를 정리하고 작성하는 방법을 사용자 지정하세요. 색상 필터링과 검색 기능을 활성화하면 방대한 목록을 관리하는 데 도움이 됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personalize a forma como você organiza e cria notas. Habilitar a filtragem de cores e a pesquisa ajuda a gerenciar listas grandes." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройте организацию и создание заметок. Включение фильтра по цвету и поиска поможет управлять большими списками." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notları düzenleme ve oluşturma şeklinizi özelleştirin. Renk filtrelemeyi ve aramayı etkinleştirmek büyük listeleri yönetmenize yardımcı olur." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义笔记的组织和创建方式。启用颜色过滤和搜索有助于管理大量列表。" + } + } + } + }, + "Customize in Settings" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Customize in Settings" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Přizpůsobit v nastavení" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In den Einstellungen anpassen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Customize in Settings" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Customise in Settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Personalizar en ajustes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personnaliser dans les paramètres" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Customize in Settings" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Personalizza nelle impostazioni" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정에서 사용자 지정" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Dostosuj w ustawieniach" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Customizar em Configurações" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Персонализировать в Настройках" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayarlar'da özelleştirin" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Змінити в Налаштуваннях" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在设置中自定义" + } + } + } + }, + "Customize the appearance of the LocalSend device selection popup that appears when you drop files." : { + "comment" : "A description of the LocalSend device picker.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일을 드롭할 때 나타나는 LocalSend 기기 선택 팝업의 모양을 맞춤 설정합니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosyaları bıraktığınızda görünen LocalSend aygıt seçimi açılır penceresinin görünümünü özelleştirin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义拖放文件时出现的 LocalSend 设备选择弹出窗口的外观。" + } + } + } + }, + "Dash outline equals the exact notch bounds." : { + "comment" : "A description under the live preview section of the animation editor.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "점선 외곽선은 실제 노치 영역과 일치합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O contorno do traço é igual aos limites exatos do entaque." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пунктирный контур соответствует точным границам выреза." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çizgi taslağı tam çentik sınırlarına eşittir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "虚线轮廓等于确切的刘海边界。" + } + } + } + }, + "Deep Work" : { + "comment" : "Name of a default timer preset for deep work.", + "isCommentAutoGenerated" : true, + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "딥 워크" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "딥 워크" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Глубокая работа" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Derin Çalışma" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "深度工作" + } + } + } + }, + "Default" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Default" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Výchozí" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Default" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Default" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Por defecto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Par défaut" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Default" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Predefinito" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "기본" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Domyślny" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Padrão" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По умолчанию" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsayılan" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Типово" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "默认" + } + } + } + }, + "Default Custom Timer" : { + "comment" : "A label displayed above the section that lets users configure a custom timer.", + "isCommentAutoGenerated" : true, + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본 사용자 지정 타이머" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본 사용자 지정 타이머" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temporizador Personalizado Padrão" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стандартный" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsayılan Özel Zamanlayıcı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "默认自定义计时器" + } + } + } + }, + "Default View" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본 보기" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsayılan Görünüm" + } + } + } + }, + "Default: dynamic.m4a" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Par défaut : dynamique.m4a" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본: dynamic.m4a" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Padrão: dynamic.m4a" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По умолчанию: dynamic.m4a" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsayılan: dinamik.m4a" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "默认:dynamic.m4a" + } + } + } + }, + "Delete" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deletar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sil" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除" + } + } + } + }, + "Delete All Notes?" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 메모를 삭제하시겠습니까?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deletar Todas as Notas?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все заметки?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tüm Notlar Silinsin mi?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除所有笔记?" + } + } + } + }, + "Delete Animation" : { + "comment" : "The title of an alert that appears when trying to delete a custom animation.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer l’Animation" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "애니메이션 삭제" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deletar Animação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить анимацию" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animasyonu Sil" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除动画" + } + } + } + }, + "Delete Color" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer la Couleur" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 삭제" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deletar Cor" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить цвет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rengi Sil" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除颜色" + } + } + } + }, + "Denied" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "거부됨" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reddedildi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已拒绝" + } + } + } + }, + "Denied/Revoked" : { + "comment" : "A label describing an extension with denied or revoked permissions.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "거부됨/취소됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recusado/Revogado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запрещено/Отозвано" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reddedildi/İptal Edildi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已拒绝/已撤销" + } + } + } + }, + "Deny" : { + "comment" : "A button that denies an app extension's access.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "거부" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recusar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запретить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reddet" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拒绝" + } + } + } + }, + "Description" : { + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Description" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Popis" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beschreibung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Description" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Description" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Descripción" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Description" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Description" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Descrizione" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "설명" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Opis" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Descrição" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Описание" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Açıklama" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Опис" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "描述" + } + } + } + }, + "Deselect All" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모두 선택해제" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отменить выбор" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tümünün Seçimini Kaldır" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消全选" + } + } + } + }, + "Designer" : { + "comment" : "Name of a user profile for a designer.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디자이너" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дизайнер" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tasarımcı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设计师" + } + } + } + }, + "Detection Status" : { + "comment" : "The header for a section that displays whether the screen recording detection feature is currently active or inactive.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statut de la détéction" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "감지 상태" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status de Detecção" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дизайнер" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algılama Durumu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "检测状态" + } + } + } + }, + "Developer" : { + "comment" : "Name of a user profile.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발자" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разработчик" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geliştirici" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开发者" + } + } + } + }, + "Device Picker Style" : { + "comment" : "A dropdown menu that lets you choose how the LocalSend device picker is displayed.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기 선택 스타일" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cihaz Seçici Stili" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设备选择样式" + } + } + } + }, + "Devices" : { + "comment" : "The title of the \"Devices\" section in the settings menu.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разработчик" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cihazlar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设备" + } + } + } + }, + "Diagnostics" : { + "comment" : "A section header in the Lock Screen Settings view, describing diagnostics-related functionality.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진단" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diagnósticos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Диагностика" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teşhis" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "诊断" + } + } + } + }, + "Digital" : { + "comment" : "The display name for the \"Digital\" reminder presentation style.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디지털" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цифровой" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dijital" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "数字" + } + } + } + }, + "Dimensions" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "치수" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dimensões" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цифровой" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boyutlar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尺寸" + } + } + } + }, + "Disable" : { + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Disable" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vypnout" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktivieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disable" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disable" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Deshabilitar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactiver" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Disable" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Disabilita" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비활성화" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wyłącz" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Disable" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devre dışı bırak" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Вимкнути" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "禁用" + } + } + } + }, + "Disable Minimalistic UI to configure the standard notch media controls." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "표준 노치 미디어 제어기를 설정하려면 '미니멀리즘 UI'를 비활성화하세요." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключите минималистичный интерфейс, чтобы настроить стандартный медиаплеер выреза." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standart çentikli medya kontrollerini yapılandırmak için Minimalist Kullanıcı Arayüzünü devre dışı bırakın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "禁用简约界面以配置标准刘海媒体控制。" + } + } + } + }, + "Disabled" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Disabled" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vypnuto" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktiviert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disabled" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disabled" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Deshabilitado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactivé" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Disabled" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Disabilitato" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비활성화됨" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wyłączone" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disabilitado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключено" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devre dışı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Вимкнено" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已禁用" + } + } + } + }, + "Disabled while external display brightness integration is active." : { + + }, + "Disabled while external display integration is active — brightness keys are handled by the external app." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "외부 디스플레이 연동이 활성화된 동안에는 비활성화됩니다. 밝기 키는 외부 앱에서 제어합니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключено при активной интеграции внешнего дисплея — клавиши яркости обрабатываются внешним приложением." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Harici ekran entegrasyonu etkinken devre dışı bırakılır; parlaklık tuşları harici uygulama tarafından yönetilir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "外部显示器集成激活时已禁用——亮度键由外部应用处理。" + } + } + } + }, + "Disabled while external display volume integration is active." : { + + }, + "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, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "단계적 전환은 초록색(0~60%), 노란색(60~85%), 빨간색(85~100%) 구간에 맞춰 색상이 즉시 전환됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transições discretas se encaixam entre Verde (0–60%), Amarelo (60–85%) e Vermelho (85–100%)." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключено при активной интеграции BetterDisplay — приложение использует Cmd+Клавиши яркости для своего управления." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayrık geçişler Yeşil (%0–60), Sarı (%60–85) ve Kırmızı (%85–100) arasında geçiş yapar." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "离散过渡在绿色(0-60%)、黄色(60-85%)和红色(85-100%)之间切换。" + } + } + } + }, + "Disk" : { + "comment" : "Title for the \"Disk\" section in the stats view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디스크" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Диск" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "disk" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "磁盘" + } + } + } + }, + "Disk I/O" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디스크 I/O" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ввод/вывод диска" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disk G/Ç" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "磁盘 I/O" + } + } + } + }, + "Disk Overview" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디스크 개요" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обзор диска" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diske Genel Bakış" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "磁盘概览" + } + } + } + }, + "Disk Read" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lecture du disque" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디스크 읽기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leitura em Disco" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Диск" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disk Okuma" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "磁盘读取" + } + } + } + }, + "Disk Write" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Écriture du disque" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디스크 쓰기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escrita em Disco" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запись на диск" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diske Yazma" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "磁盘写入" + } + } + } + }, + "Display Mode" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mode d’affichage" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디스플레이 모드" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo de exibição" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим отображения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran Modu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示模式" + } + } + } + }, + "Display Options" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Options d’affichage" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디스플레이 옵션" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры отображения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran Seçenekleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示选项" + } + } + } + }, + "Display Style" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "디스플레이 스타일" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль отображения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran Stili" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示样式" + } + } + } + }, + "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, + "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." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth 오디오 기기(헤드폰, AirPods, 스피커)가 연결되면 기기 이름과 배터리 잔량을 보여주는 HUD 알림을 표시합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exibe uma notificação HUD quando os dispositivos de áudio Bluetooth (fones de ouvido, AirPods, alto-falantes) se conectam, mostrando o nome do dispositivo e o nível da bateria." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль дисплея" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth ses aygıtları (kulaklık, AirPods, hoparlörler) bağlandığında aygıt adını ve pil seviyesini gösteren bir HUD bildirimi görüntüler." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "蓝牙音频设备(耳机、AirPods、扬声器)连接时显示 HUD 通知,包含设备名称和电池电量。" + } + } + } + }, + "Displays play/pause and skip buttons beside the notch while music is active. Disabled by default." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "음악이 재생 중일 때 노치 옆에 재생/일시 정지 및 건너뛰기 버튼을 표시합니다. 이 기능은 기본적으로 비활성화되어 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exibe os botões de reprodução/pausa e pular ao lado do notch enquanto a música está ativa. Desativado por padrão." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отображает кнопки \"Воспроизведение/Пауза\" и \"Вперед\" рядом с вырезом при активной музыке. Отключено по умолчанию." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Müzik etkinken çentiğin yanında oynat/duraklat ve atla düğmelerini görüntüler. Varsayılan olarak devre dışı." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "音乐播放时在刘海旁边显示播放/暂停和跳过按钮。默认禁用。" + } + } + } + }, + "Displays the current Focus state above the weather capsule whenever Focus detection is enabled." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "집중 모드 감지가 활성화된 경우 날씨 캡슐 위에 현재 집중 모드 상태를 표시합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Exibe o estado atual do Foco acima da cápsula climática sempre que a detecção de Foco está ativada." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывает текущее Фокусирование над виджетом погоды, если включено отслеживание Фокусирования." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odak algılama etkinleştirildiğinde hava durumu kapsülünün üzerinde mevcut Odak durumunu görüntüler." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用专注检测时,在天气胶囊上方显示当前专注状态。" + } + } + } + }, + "Displays your next upcoming calendar event above or below the weather capsule. Calendar selection here is independent from the Dynamic Island calendar filter." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "날씨 캡슐의 위 또는 아래에 예정된 다음 일정을 표시합니다. 여기서 선택한 캘린더는 Dynamic Island의 캘린더 필터와 별개로 적용됩니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывает следующее событие календаря над или под виджетом погоды. Выбор календаря не зависит от фильтров Dynamic Island." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir sonraki yaklaşan takvim etkinliğinizi hava durumu kapsülünün üstünde veya altında görüntüler. Buradaki takvim seçimi Dynamic Island takvim filtresinden bağımsızdır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在天气胶囊上方或下方显示下一个即将到来的日历事件。此处的日历选择独立于灵动岛日历过滤器。" + } + } + } + }, + "Do Not Disturb" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "방해금지 모드" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não Perturbe" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отображает ближайшее предстоящее событие календаря над или под виджетом погоды. Выбор календаря здесь не зависит от фильтра календаря в динамическом острове." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rahatsız Etmeyin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "勿扰模式" + } + } + } + }, + "Donate" : { + "comment" : "A button label that says \"Donate\".", + "isCommentAutoGenerated" : true, + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "후원" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "후원" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поддержать проект" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bağış yap" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "捐赠" + } + } + } + }, + "Done" : { + "comment" : "A button label that indicates that the user has finished editing a note.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feito" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поддержать" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitti" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成" + } + } + } + }, + "Down" : { + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вниз" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aşağı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载" + } + } + } + }, + "Download" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Download" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Stáhnout" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Herunterladen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Descargar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Téléchargements" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Download" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Scarica" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "다운로드" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pobierz" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Download" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "İndir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Завантажити" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "下载" + } + } + }, + "shouldTranslate" : false + }, + "Download Detection" : { + "comment" : "A section header in the Downloads settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 감지" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 감지" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detecção de Download" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отслеживание загрузок" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algılamayı İndir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载检测" + } + } + } + }, + "Download icon style" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download icon style" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Télécharger le style d’icône" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 아이콘 스타일" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estilo do ícone de download" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль значка загрузки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Simge stilini indir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载图标样式" + } + } + } + }, + "Download indicator style" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download indicator style" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Télécharger le style de l’indicateur" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 표시기 스타일" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estilo do indicador de download" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль индикатора загрузки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gösterge stilini indirin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载指示器样式" + } + } + } + }, + "Download indicators" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Télécharger les indicateurs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 표시기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Индикаторы загрузки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Göstergeleri indir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载指示器" + } + } + } + }, + "Downloaded" : { + "localizations" : { + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드됨" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드됨" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загружено" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İndirildi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已下载" + } + } + } + }, + "Downloads" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Downloads" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Stahování" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Descargas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Téléchargements" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Downloads" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Scaricati" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "다운로드" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pobrane" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Downloads" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "İndirilenler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Завантаження" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "下载" + } + } + }, + "shouldTranslate" : false + }, + "Drag a control onto a slot or tap to place it in the first empty slot." : { + "comment" : "A description below the control palette that explains how users can interact with it.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제어기를 슬롯으로 드래그하거나, 탭하여 첫 번째 빈 슬롯에 배치하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arraste um controle para um slot ou toque para colocá-lo no primeiro slot vazio." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перетащите элемент в слот или нажмите, чтобы добавить его в первый свободный слот." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir kontrolü bir yuvaya sürükleyin veya ilk boş yuvaya yerleştirmek için dokunun." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将控件拖到插槽上或点击将其放在第一个空插槽中。" + } + } + } + }, + "Drag items between slots or drop from the palette to remap controls." : { + "comment" : "A description of how to interact with the music slot configuration view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "슬롯 간에 항목을 드래그하거나 팔레트에서 드롭하여 제어기를 재배치하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arraste itens entre slots ou solte da paleta para remapear os controles." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перетаскивайте элементы между слотами или сбрасывайте их с палитры для изменения управления." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontrolleri yeniden eşlemek için öğeleri yuvalar arasında sürükleyin veya paletten bırakın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在插槽之间拖动项目或从面板中放置以重新映射控件。" + } + } + } + }, + "Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Use the width sliders below to narrow the media and timer widgets without exceeding their default size. Changes apply instantly while the widgets are visible." : { + "comment" : "A footer text describing the functionality of the lock screen positioning controls.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미리보기를 드래그하여 세로 위치를 조정하세요. 양수 값은 패널을 올리고, 음수 값은 내립니다. 아래의 너비 슬라이더를 사용하여 미디어 및 타이머 위젯의 너비를 기본 크기 내에서 좁게 조절할 수 있습니다. 위젯이 표시되는 동안 변경 사항이 즉시 적용됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arraste as visualizações para ajustar o posicionamento vertical. Valores positivos levantam o painel; valores negativos o abaixam. Use os controles deslizantes de largura abaixo para restringir os widgets de mídia e temporizador sem exceder seu tamanho padrão. As alterações se aplicam instantaneamente enquanto os widgets estão visíveis." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перетаскивайте превью по вертикали и используйте ползунки ширины (не превышая исходный размер). Изменения применяются мгновенно." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dikey yerleşimi ayarlamak için önizlemeleri sürükleyin. Pozitif değerler paneli kaldırır; Negatif değerler onu düşürür. Medya ve zamanlayıcı widget'larını varsayılan boyutlarını aşmadan daraltmak için aşağıdaki genişlik kaydırıcılarını kullanın. Widget'lar görünür durumdayken değişiklikler anında uygulanır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拖动预览调整垂直位置。正值上移面板,负值下移。使用下方宽度滑块缩小媒体和计时器小组件而不超过默认大小。更改在小组件可见时立即生效。" + } + } + } + }, + "Drop a PNG, JPEG, TIFF, or ICNS file to add it to your icon library." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "PNG, JPEG, TIFF 또는 ICNS 파일을 드롭하여 아이콘 라이브러리에 추가하세요." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Бросьте файл PNG, JPEG, TIFF или ICNS, чтобы добавить его в библиотеку иконок." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Simge kitaplığınıza eklemek için bir PNG, JPEG, TIFF veya ICNS dosyasını bırakın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拖放 PNG、JPEG、TIFF 或 ICNS 文件以添加到图标库。" + } + } + } + }, + "Drop files here" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Drop files here" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Přetáhněte soubory sem" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dateien hier ablegen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Drop files here" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Drop files here" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Soltar archivos aquí" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déposer les fichiers ici" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Drop files here" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Trascina i file qui" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "여기로 파일을 드래그하세요" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Upuść pliki tutaj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solte os arquivos aqui" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перетащите файлы сюда" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Doslayarı buraya bırak" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Перетягніть файл сюди" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "拖放文件到这里" + } + } + } + }, + "Dynamic Island" : { + "comment" : "Description of the dynamic island style.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "灵动岛" + } + } + } + }, + "Dynamic Island Progress Bars" : { + "comment" : "A section header that lists the options for customizing the appearance of the progress bars displayed by Dynamic Island.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island 진행 표시줄" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Barra de Progresso da Dynamic Island" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Динамический остров" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island İlerleme Çubukları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "灵动岛进度条" + } + } + } + }, + "Dynamic Island Visibility" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Видимость Dynamic Island" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island Görünürlük" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "灵动岛可见性" + } + } + } + }, + "dynamic.island" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dynamic.island" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Видимость динамического острова" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dynamic.island" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "灵动岛" + } + } + }, + "shouldTranslate" : false + }, + "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." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "각 그래프는 개별적으로 활성화하거나 비활성화할 수 있습니다. 네트워크 활동은 다운로드/업로드 속도를, 디스크 I/O는 읽기/쓰기 속도를 보여줍니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cada gráfico pode ser ativado ou desativado individualmente. A atividade de rede mostra velocidades de download/upload, e a E/S do disco mostra velocidades de leitura/gravação." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каждый график можно включить или выключить отдельно. Сетевая активность показывает скорость интернета, а ввода-вывода — скорость диска." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Her grafik ayrı ayrı etkinleştirilebilir veya devre dışı bırakılabilir. Ağ etkinliği indirme/yükleme hızlarını gösterir ve disk G/Ç okuma/yazma hızlarını gösterir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每个图表可以单独启用或禁用。网络活动显示下载/上传速度,磁盘 I/O 显示读写速度。" + } + } + } + }, + "EAQI" : { + "comment" : "The label to use for the compact version of the weather air quality scale in the lock screen.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "EAQI" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ЕИВ (EAQI)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "EAQI" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "欧洲空气质量指数" + } + } + } + }, + "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Snadno kopírujte, ukládejte a spravujte svůj nejpoužívanejší obsah. Upgradujte nyní pro pokročilé funkce, jako je vícenásobné ulužiště a rychlý přístup!" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kopieren, speichern und verwalten Sie ganz einfach Ihre am häufigsten verwendeten Inhalte. Aktualisieren Sie jetzt, um erweiterte Funktionen wie die Speicherung mehrerer Elemente und schnellen Zugriff zu erhalten!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Copia, almacena y gestiona fácilmente tu contenido más usado. ¡Actualiza ahora para funciones avanzadas como almacenamiento de múltiples elementos y acceso rápido!" + } + }, + "fr" : { + "stringUnit" : { + "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 !" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Copia facilmente, memorizza e gestisci i tuoi contenuti più usati. Aggiorna ora per funzioni avanzate come storage multi-item e accesso rapido!" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "자주 사용하는 파일들을 쉽게 복사, 저장, 관리하세요. 업그레이드 해서 다중 저장 기능과 빠른 접근 기능을 활성화하세요!" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Łatwo kopiuj, przechowuj i zarządzaj najczęściej używaną treścią. Uaktualnij teraz, aby uzyskać zaawansowane funkcje, takie jak przechowywanie wielu produktów i szybki dostęp!" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copie, armazene e gerencie facilmente seu conteúdo mais usado. Atualize agora para recursos avançados, como armazenamento de vários itens e acesso rápido!" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Легко копируйте, храните и управляйте наиболее часто используемым контентом. Обновите подписку прямо сейчас, чтобы получить доступ к расширенным функциям, таким как мульи-элементное хранение и быстрый доступ!" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En çok kullanılan içeriğinizi kolayca kopyalayın, saklayın ve yönetin. Çok öğeli depolama ve hızlı erişim gibi gelişmiş özellikler için şimdi yükseltin!" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "轻松复制、存储和管理您最常用的内容。升级后可解锁高级功能,如多项目存储和快速访问!" + } + } + } + }, + "Edit" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düzenle" + } + } + } + }, + "Edit Animation" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier l’Animation" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "애니메이션 수정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar Animação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить анимацию" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animasyonu Düzenle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑动画" + } + } + } + }, + "Edit layout" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Edit layout" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Upravit rozložení" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Layout bearbeiten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit layout" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit layout" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Editar diseño" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier la mise en page" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Edit layout" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Modifica layout" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "레이아웃 수정" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Edytuj układ" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar Layout" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редактировать макет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Yerleşimi düzenle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Редагувати розташування" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "编辑布局" + } + } + } + }, + "Email address" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adresse e-mail" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이메일 주소" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Электронная почта" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "E-posta adresi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "电子邮件地址" + } + } + } + }, + "Empty Slot" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "빈 슬롯" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boş Yuva" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空插槽" + } + } + } + }, + "Enable \"Idle Animation\" to customize animations" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable \"Idle Animation\" to customize animations" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추천 번역 ⭐️: 애니메이션을 사용자 지정하려면 '유휴 상태 애니메이션'을 활성화하세요." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите \"Анимация бездействия\" для персональных анимаций." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animasyonları özelleştirmek için \"Boşta animasyonu\"nu etkinleştirin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用「空闲动画」以自定义动画" + } + } + } + }, + "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.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "애니메이션을 사용자 지정하려면 '유휴 상태 시 멋진 얼굴 애니메이션 표시'를 활성화하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ative \"Mostrar animação de rosto legal durante a inatividade\" para personalizar as animações" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите \"Отображать лицо при бездействии\", чтобы настроить анимации." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animasyonları özelleştirmek için \"Hareketsizlik sırasında havalı yüz animasyonunu göster\"i etkinleştirin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用「空闲时显示有趣的表情动画」以自定义动画" + } + } + } + }, + "Enable album art parallax" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앨범 아트 시차 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить параллакс обложки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Albüm kapağı paralaksını etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用专辑封面视差效果" + } + } + } + }, + "Enable album art parallax effect" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앨범 아트 시차 효과 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить параллакс-эффект" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Albüm kapağı paralaks efektini etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用专辑封面视差效果" + } + } + } + }, + "Enable and style the media controls that appear above the system clock when the screen is locked." : { + "comment" : "A description of the media panel settings in the lock screen settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "화면이 잠겨 있을 때 시스템 시계 위에 나타나는 미디어 제어기를 활성화하고 스타일을 지정합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ative e estilize os controles de mídia que aparecem acima do relógio do sistema quando a tela está bloqueada." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включение и настройка плера над системными часами при заблокированном экране." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran kilitliyken sistem saatinin üzerinde görünen medya denetimlerini etkinleştirin ve şekillendirin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用并设置屏幕锁定时系统时钟上方显示的媒体控制。" + } + } + } + }, + "Enable blur effect behind album art" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앨범 아트 배경 블러 효과 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Размытие фона обложки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Albüm resminin arkasında bulanıklaştırma efektini etkinleştirin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用专辑封面后的模糊效果" + } + } + } + }, + "Enable Built-in System HUD" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer le système d’ATH intégré" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내장 시스템 HUD 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить системный HUD" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yerleşik Sistemi Etkinleştir HUD" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用内置系统 HUD" + } + } + } + }, + "Enable built-in system monitoring to replace macOS HUD notifications with Atoll displays." : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activez le système de suivi intégré pour remplacer les notifications macOS avec celles de Atoll." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내장 시스템 모니터링을 활성화하여 macOS HUD 알림을 Atoll 디스플레이로 대체할 수 있습니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заменяет стандартные HUD-уведомления macOS (яркость/громкость) красивыми элементами Atoll." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS HUD bildirimlerini Atoll ekranlarıyla değiştirmek için yerleşik sistem izlemeyi etkinleştirin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用内置系统监控,用 Atoll 显示替代 macOS HUD 通知。" + } + } + } + }, + "Enable Calendar Access" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캘린더 접근 허용" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilitar O Acesso Ao Calendário" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить доступ к календарю" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takvim Erişimini Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用日历访问" + } + } + } + }, + "Enable Camera Access" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "카메라 접근 허용" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilitar Acesso à Câmera" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить использование камеры" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera Erişimini Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用相机访问" + } + } + } + }, + "Enable Camera Detection" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "카메라 감지 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить обнаружение камеры" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera Algılamayı Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用相机检测" + } + } + } + }, + "Enable Clipboard Manager" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드 매니저 확설화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить Менеджер буфера обмена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pano Yöneticisini Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用剪贴板管理器" + } + } + } + }, + "Enable Color Filtering" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 필터링 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить фильтрацию по цвету" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Filtrelemeyi Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用颜色过滤" + } + } + } + }, + "Enable Color Picker" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 선택기 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить пипетку" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Seçiciyi Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用取色器" + } + } + } + }, + "Enable colored spectograms" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 스펙트로그램 활성화" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renkli spektogramları etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用彩色频谱图" + } + } + } + }, + "Enable colored spectrograms" : { + "comment" : "merge:ko:alt='색상 스펙트로그램 활성화'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable colored spectrograms" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable colored spectrograms" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Farbe für Audiovisualisierung aktivieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable colored spectrograms" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable coloured spectrograms" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable colored spectrograms" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Activer les spectrogrammes de couleurs" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable colored spectrograms" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable colored spectrograms" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable colored spectrograms" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable colored spectrograms" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable colored spectrograms" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить цветную спектрограмму" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renkli spektrogramları etkinleştir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable colored spectrograms" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "启用彩色谱图" + } + } + } + }, + "Enable ColorPicker in Settings → ColorPicker" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activez la pipette de couleur dans Réglages → Pipette de couleur" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'설정 → 색상 선택기'에서 색상 선택기를 활성화하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ative o ColorPicker em Configurações → ColorPicker" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите Пипетку: Настройки → Пипетка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayarlar → ColorPicker'da ColorPicker'ı etkinleştirin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在设置 → 取色器中启用取色器" + } + } + } + }, + "Enable Create from Clipboard" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드로 부터 생성 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создавать из буфера обмена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panodan Oluşturmayı Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用从剪贴板创建" + } + } + } + }, + "Enable customizable controls" : { + "comment" : "A toggle that allows users to enable or disable customizable controls for media playback.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 정의 조작 활성화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilitar controles personalizáveis" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Кастомизация управления" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özelleştirilebilir kontrolleri etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用自定义控制" + } + } + } + }, + "Enable download detection" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 감지 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отслеживать загрузки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İndirme algılamayı etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用下载检测" + } + } + } + }, + "Enable Dynamic Island media controls to manage the lock screen panel." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island 미디어 제어기를 활성화하여 잠금 화면 패널을 관리합니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите управление Dynamic Island для контроля панели экрана блокировки." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı panelini yönetmek için Dynamic Island medya kontrollerini etkinleştirin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用灵动岛媒体控制以管理锁屏面板。" + } + } + } + }, + "Enable Dynamic mirror" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다이내믹 미러 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить Динамическое Зеркало" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dinamik yansıtmayı etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用动态镜像" + } + } + } + }, + "Enable extension diagnostics logging" : { + "comment" : "A toggle that enables or disables logging of diagnostic information related to extensions.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진단 기록 확장 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Логирование диагностики расширений" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzantı tanılama günlüğünü etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用扩展诊断日志" + } + } + } + }, + "Enable extensions to allow third-party apps to display live activities and lock screen widgets in Atoll." : { + "comment" : "A footer text that explains the purpose of enabling extensions. | A footer text that explains the purpose of enabling extensions. | merge:ko:alt='타사 앱이 Atoll에서 실시간 현황 및 잠금 화면 위젯을 표시할 수 있도록 확장을 활성화합니다.'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll에서 서드파티 앱의 실시간 현황 및 잠금 화면 위젯을 표시할 수 있도록 확장 프로그램을 활성화하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ative extensões para permitir que aplicativos de terceiros exibam atividades ao vivo e widgets de tela de bloqueio no Atoll." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить журнал диагностики для расширений" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Üçüncü taraf uygulamaların Atoll içinde canlı etkinlikler ve kilit ekranı widget'ları göstermesine izin vermek için uzantıları etkinleştirin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用扩展以允许第三方应用在 Atoll 中显示实时活动和锁屏小组件。" + } + } + } + }, + "Enable external volume control listener" : { + "comment" : "A toggle that enables external volume control listener.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추가 볼륨 조작 리스너 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внешний датчик контроля громкости" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Harici ses kontrolü dinleyicisini etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用外部音量控制监听" + } + } + } + }, + "Enable Focus Detection" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "집중 모드 감지 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить отслеживание Фокусирования" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odak Algılamayı Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用专注模式检测" + } + } + } + }, + "Enable gestures" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제스쳐 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить жесты" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hareketleri etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用手势" + } + } + } + }, + "Enable global keyboard shortcuts" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역 키보드 간축키 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить глобальные сочетания клавиш" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genel klavye kısayollarını etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用全局键盘快捷键" + } + } + } + }, + "Enable global keyboard shortcuts above to customize your shortcuts." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "위의 글로벌 키보드 단축키를 활성화하여 단축키를 사용자 지정하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ative os atalhos de teclado globais acima para personalizar seus atalhos." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите глобальные сочетания клавиш выше, чтобы настроить их." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kısayollarınızı özelleştirmek için yukarıdaki genel klavye kısayollarını etkinleştirin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用上方的全局键盘快捷键以自定义您的快捷键。" + } + } + } + }, + "Enable glowing effect" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "글로우 효과 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить эффект свечения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parlama efektini etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用发光效果" + } + } + } + }, + "Enable graph visibility in Settings → Stats to view performance data." : { + "comment" : "A message displayed when no graphs are enabled in the Stats tab.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "성능 데이터를 확인하려면 '설정 → 상태'에서 그래프 표시를 활성화하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ative a visibilidade do gráfico em Configurações → Estatísticas para visualizar os dados de desempenho." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите отображение графиков в Settings → Stats." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Performans verilerini görüntülemek için Ayarlar → İstatistikler bölümünde grafik görünürlüğünü etkinleştirin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在设置 → 统计中启用图表可见性以查看性能数据。" + } + } + } + }, + "Enable haptics" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable haptics" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable haptics" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Haptisches Feedback aktivieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable haptics" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable haptics" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable haptics" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Activer les retours haptiques" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable haptics" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable haptics" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "햅틱 켜기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable haptics" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable haptics" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить тактильную обратную связь" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dokunsal özelliği etkinleştir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Увімкнути тактильний відгук" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "启用触觉" + } + } + } + }, + "Enable HUD replacement" : { + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable HUD replacement" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Povolit nahrazení HUD" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD Ersatz aktivieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable HUD replacement" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable HUD replacement" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Habilitar reemplazo HUD" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer le mode de remplacement ATH" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable HUD replacement" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Abilita sostituzione HUD" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUD 대체 활성화" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Włącz zastępowanie HUD" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable HUD replacement" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить замену системной панели" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD değiştirmeyi etkinleştir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable HUD replacement" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "启用 HUD 替换" + } + } + } + }, + "Enable it in Settings → Terminal" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'설정 → 터미널'에서 활성화하세요." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите в Settings → Terminal" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayarlar → Terminal'de etkinleştirin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在设置 → 终端中启用" + } + } + } + }, + "Enable lock screen live activity" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 실시간 현황 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить Live Activity на экране блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı canlı etkinliğini etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用锁屏实时活动" + } + } + } + }, + "Enable lyrics" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "가사 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить тексты песен" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şarkı sözlerini etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用歌词" + } + } + } + }, + "Enable media panel blur" : { + "comment" : "A toggle switch that enables or disables the blur effect on the media panel.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 패널 블러 활성화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativar desfoque do painel de mídia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите в Настройках → Терминал" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya paneli bulanıklığını etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用媒体面板模糊" + } + } + } + }, + "Enable Microphone Detection" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마이크 감지 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отслеживать использование микрофона" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mikrofon Algılamayı Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用麦克风检测" + } + } + } + }, + "Enable Minimalistic UI" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "간결한 UI 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минималистичный интерфейс" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimalist Kullanıcı Arayüzünü Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用简约界面" + } + } + } + }, + "Enable music live activity" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable music live activity" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Povolist živou aktivitu hudby" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Musik-Live-Aktivität aktivieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable music live activity" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable music live activity" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Habilitar actividad de música en vivo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer l'activité musicale en direct" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable music live activity" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Abilita attività musicale live" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음악 실시간 현황 켜기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Włącz aktywność muzyki na żywo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativar atividade de música ao vivo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить эфир активности для музыки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Müzik canlı etkinliğini etkinleştir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enable music live activity" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "启用音乐实时动态" + } + } + } + }, + "Enable Note Pinning" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모 고정 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить закрепление заметок" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Sabitlemeyi Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用笔记固定" + } + } + } + }, + "Enable Note Search" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모 검색 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск по заметкам" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Aramayı Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用笔记搜索" + } + } + } + }, + "Enable Notes" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить Заметки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notları Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用笔记" + } + } + } + }, + "Enable real-time waveform" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실시간 파형 활성화" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gerçek zamanlı dalga biçimini etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用实时波形" + } + } + } + }, + "Enable reminder live activity" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미리 알림 실시간 현황 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить Live Activity напоминаний" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hatırlatıcı canlı etkinliğini etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用提醒实时活动" + } + } + } + }, + "Enable Screen Assistant" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스크린 어시스턴트 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать Помощника экрана" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Screen Assistant'ı etkinleştirin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用屏幕助手" + } + } + } + }, + "Enable screen color picking functionality. Use Cmd+Shift+P to quickly access the color picker." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "화면 색상 선택 기능을 활성화하세요. Cmd+Shift+P를 사용하여 색상 선택기를 빠르게 실행할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ative a funcionalidade de seleção de cores da tela. Use Cmd+Shift+P para acessar rapidamente o seletor de cores." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите пипетку цветов. Cmd+Shift+P для быстрого доступа." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran rengi toplama işlevini etkinleştirin. Renk seçiciye hızlı bir şekilde erişmek için Cmd+Shift+P tuşlarını kullanın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用屏幕取色功能。使用 Cmd+Shift+P 快速访问取色器。" + } + } + } + }, + "Enable Screen Recording Detection" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "화면 녹화 감지 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отслеживать запись экрана" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran Kaydı Algılamayı Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用屏幕录制检测" + } + } + } + }, + "Enable shelf" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서랍 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить полку" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rafı etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用架子" + } + } + } + }, + "Enable sneak peek" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer Sneak Peek" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "살짝 보기 활성화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilitar a pré-vizualização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить быстрый просмотр" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gizli bakışı etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用快速预览" + } + } + } + }, + "Enable stats monitoring in Settings to view system performance data." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시스템 성능 데이터를 확인하려면 설정에서 상태 모니터링을 활성화하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ative o monitoramento de estatísticas em Configurações para visualizar os dados de desempenho do sistema." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите мониторинг системы в настройках." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistem performans verilerini görüntülemek için Ayarlar'da istatistik izlemeyi etkinleştirin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在设置中启用统计监控以查看系统性能数据。" + } + } + } + }, + "Enable system stats monitoring" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시스템 상태 모니터링 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить мониторинг системы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistem istatistikleri izlemeyi etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用系统统计监控" + } + } + } + }, + "Enable terminal" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "터미널 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить терминал" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminali etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用终端" + } + } + } + }, + "Enable the battery capsule and configure its layout." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 캡슐을 활성화하고 레이아웃을 설정하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ative a cápsula da bateria e configure seu layout." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить капсулу батареи и настроить её." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pil kapsülünü etkinleştirin ve düzenini yapılandırın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用电池胶囊并配置其布局。" + } + } + } + }, + "Enable the timer feature in Settings to access this tab." : { + "comment" : "A message explaining that the timer feature must be enabled in settings to access this tab.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 탭을 사용하려면 설정에서 타이머 기능을 활성화하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ative o recurso de temporizador em Configurações para acessar esta guia." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите функцию таймера в настройках, чтобы получить доступ." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu sekmeye erişmek için Ayarlar'da zamanlayıcı özelliğini etkinleştirin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在设置中启用计时器功能以访问此标签页。" + } + } + } + }, + "Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators." : { + "comment" : "A footer for the \"Weather Widget\" section in the Lock Screen Settings view, describing its purpose.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "날씨 캡슐을 활성화하고 레이아웃, 제공처, 단위 및 선택 사항인 배터리/AQI 표시등을 설정하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ative a cápsula meteorológica e configure seu layout, provedor, unidades e indicadores opcionais de bateria/AQI." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить капсулу погоды и настроить её." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hava kapsülünü etkinleştirin ve düzenini, sağlayıcısını, birimlerini ve isteğe bağlı pil/AQI göstergelerini yapılandırın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用天气胶囊并配置其布局、数据源、单位和可选的电池/AQI 指示器。" + } + } + } + }, + "Enable Thinking Mode" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사고 모드 활성화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativar Modo de Pensamento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим обдумывания" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düşünme Modunu Etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用思考模式" + } + } + } + }, + "Enable third-party calendar app launch" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타사 캘린더 앱 실행 활성화" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Üçüncü taraf takvim uygulamasının başlatılmasını etkinleştirin" + } + } + } + }, + "Enable third-party DDC app integration" : { + "comment" : "A toggle switch that enables or disables integration with a third-party Dynamic Display Control (DDC) app. | A toggle switch that enables or disables integration with a third-party Dynamic Display Control (DDC) app. | merge:ko:alt='타사 DDC 앱 통합 활성화'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제 3자 DDC 앱 통합 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить интеграцию со сторонними DDC приложениями" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Üçüncü taraf DDC uygulama entegrasyonunu etkinleştirin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用第三方 DDC 应用集成" + } + } + } + }, + "Enable third-party extensions" : { + "comment" : "Toggle to enable or disable third-party extensions. | Toggle to enable or disable third-party extensions. | merge:ko:alt='타사 확장 활성화'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제 3자 확장 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить сторонние расширения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Üçüncü taraf uzantıları etkinleştirin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用第三方扩展" + } + } + } + }, + "Enable timer feature" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 기능 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить функцию таймера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı özelliğini etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用计时器功能" + } + } + } + }, + "Enable timer live activity" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 실시간 현황 활성화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilitar a atividade ao vivo do temporizador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить сторонние расширения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı canlı etkinliğini etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用计时器实时活动" + } + } + } + }, + "Enable to route BetterDisplay or Lunar display adjustments through Atoll's active HUD style." : { + "comment" : "A description of the feature that allows third-party display integrations to be enabled. | A description of the feature that allows third-party display integrations to be enabled. | merge:ko:alt='BetterDisplay 또는 Lunar의 디스플레이 조절 사항이 Atoll의 활성 HUD 스타일을 통해 표시되도록 활성화합니다.'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "BetterDisplay 또는 Lunar의 디스플레이 조정을 Atoll의 활성 HUD 스타일로 연결하려면 활성화하세요." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Маршрутизировать изменения дисплея через Atoll." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "BetterDisplay veya Lunar ekran ayarlarını Atoll'un etkin HUD stili üzerinden yönlendirmek için etkinleştirin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用后通过 Atoll 的活跃 HUD 样式传递 BetterDisplay 或 Lunar 的显示调整。" + } + } + } + }, + "Enable when scaling beyond 30x20px so the Atoll opens gracefully." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "30x20px 이상으로 확대할 때 Atoll이 자연스럽게 열리도록 활성화합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ative ao escalar além de 30x20 px para que o Atol abra graciosamente." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите, чтобы отображать изменения яркости и громкости BetterDisplay через панель Atoll вместо системных экранных индикаторов." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll'un sorunsuz açılması için 30x20 pikselin üzerine ölçeklendirirken etkinleştirin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当缩放超过 30x20 像素时启用以使 Atoll 优雅打开。" + } + } + } + }, + "Enable window shadow" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "창 그림자 활성화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить тень окна" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pencere gölgesini etkinleştir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用窗口阴影" + } + } + } + }, + "Engine Utilization" : { + "comment" : "A label describing the utilization of the GPU's engine components.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilisation" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "엔진 사용률" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Utilização do motor" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использование движка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Motor Kullanımı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "引擎利用率" + } + } + } + }, + "Enhance your experience with HUDs" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enhance your experience with HUDs" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vylepšete své zkušenosti s HUDy" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbessere deine Erfahrung mit HUDs" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhance your experience with HUDs" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhance your experience with HUDs" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mejora tu experiencia con HUDs" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Améliorez votre expérience avec les HUD" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enhance your experience with HUDs" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Migliora la tua esperienza con gli HUD" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUD로 경험을 향상 하세요" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ulepsz swoje doświadczenia z HUD" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Melhore sua experiência com HUDs" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повысьте удобство работы с панелями" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD'lerle deneyiminizi geliştirin" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enhance your experience with HUDs" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "用 HUD 提升您的体验" + } + } + } + }, + "Enjoy your free time!" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enjoy your free time!" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Užijte si svůj volný čas!" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genieße deine Freizeit!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enjoy your free time!" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enjoy your free time!" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "¡Disfruta de tu tiempo libre!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profitez de votre temps libre !" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Enjoy your free time!" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Goditi il tuo tempo libero!" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "즐거운 시간 되세요!" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ciesz się swoim wolnym czasem!" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aproveite o seu tempo livre!" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Наслаждайтесь свободным временем!" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kıymetli vaktinizin keyfini çıkarın!" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Насолоджуйтесь своїм вільним часом!" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "享受您的闲暇时光!" + } + } + } + }, + "Enter your Gemini API Key" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrez votre clé Gemini API" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gemini API 키 입력" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insira sua chave de API do Gemini" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите Gemini API Key" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gemini API Anahtarınızı girin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入您的 Gemini API 密钥" + } + } + } + }, + "Error" : { + "comment" : "merge:ko:alt='오류'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Error" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Chyba" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Error" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erreur" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Error" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Errore" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "에러" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Błąd" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hata" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Помилка" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "错误" + } + } + } + }, + "Event color" : { + "comment" : "Name of the chip style that shows the event color.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이벤트 색상" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет события" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinlik rengi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "事件颜色" + } + } + } + }, + "Event List" : { + "comment" : "A section header in the calendar settings view, describing the list of calendar events.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이벤트 목록" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lista de eventos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет события" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinlik Listesi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "事件列表" + } + } + } + }, + "Exclude apps" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exclude apps" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exclure des applications" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제외할 앱" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Исключить приложения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uygulamaları hariç tut" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "排除应用" + } + } + } + }, + "Exit" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Exit" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ukončit" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verlassen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exit" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exit" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Salir" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitter" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Exit" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Esci" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "종료" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wyjdź" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sair" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выход" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Çıkış yap" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Вихід" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "退出" + } + } + } + }, + "Expand notch to follow animation width" : { + "comment" : "merge:ko:alt='애니메이션 너비에 맞춰 노치 확장하기'", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "애니메이션 너비에 맞춰 노치 확장" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expanda o notch para seguir a largura da animação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расширять вырез под ширину анимации" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animasyon genişliğini takip etmek için çentiği genişletin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "展开刘海以跟随动画宽度" + } + } + } + }, + "Expanded drag detection area" : { + "comment" : "A toggle that lets users enable or disable expanded drag detection.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "드래그 감지 구역 확장" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Área de detecção quando expandido" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расширенная зона перетаскивания" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genişletilmiş sürükleme algılama alanı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "扩展的拖拽检测区域" + } + } + } + }, + "Expanded notch width" : { + "comment" : "A label describing the expanded notch width setting.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치 너비 확장" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Largura do Notch expandido" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Увеличенная ширина выреза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genişletilmiş çentik genişliği" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "展开的刘海宽度" + } + } + } + }, + "Extend hover area" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "호버 영역 확장" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расширить зону наведения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fareyle üzerine gelme alanını genişlet" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "扩展悬停区域" + } + } + } + }, + "Extension tab unavailable" : { + "comment" : "A message displayed when the extension tab is not available. | A message displayed when the extension tab is not available. | merge:ko:alt='확장 탭을 사용할 수 없음'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확장 프로그램 탭을 사용할 수 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aba de extensões indisponível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вкладка расширения недоступна" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzantı sekmesi kullanılamıyor" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "扩展标签页不可用" + } + } + } + }, + "Extensions" : { + "comment" : "merge:ko:alt='확장'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Extensions" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rozšíření" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erweiterungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensions" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensions" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Extensiones" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensions" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Extensions" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Estensioni" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "확장기능들" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rozszerzenia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensões" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расширения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Genişletmeler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Розширення" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "扩展" + } + } + } + }, + "External" : { + "comment" : "A label indicating an external storage device.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Externe" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "외부" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Externo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внешний" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Harici" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "外置" + } + } + } + }, + "External display style" : { + "comment" : "A picker that lets the user choose the style of the external display.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "외부 디스플레이 스타일" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль внешнего дисплея" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Harici ekran stili" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "外接显示器样式" + } + } + } + }, + "Fan Speed" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "팬 속도" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость кулера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fan Hızı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "风扇转速" + } + } + } + }, + "Favorites" : { + "comment" : "Localized name of the \"Favorites\" tab in the clipboard.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Избранное" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoriler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏" + } + } + } + }, + "File" : { + "comment" : "Display name for a \"File\" clipboard item type.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файл" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosya" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件" + } + } + } + }, + "Files dropped here will be shared via this service" : { + "comment" : "A description of what happens when files are dropped into the AirDrop view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여기에 드롭한 파일은 이 서비스를 통해 공유됩니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buraya bırakılan dosyalar bu hizmet aracılığıyla paylaşılacak" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拖放到此处的文件将通过此服务共享" + } + } + } + }, + "Files dropped on the shelf will be shared via this service" : { + "comment" : "A description below the currently selected quick share provider, explaining that files dropped on the shelf will be shared via that provider.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서랍에 드롭한 파일은 이 서비스를 통해 공유됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Os arquivos deixados na prateleira serão compartilhados através deste serviço" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файл" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rafa düşen dosyalar bu hizmet aracılığıyla paylaşılacak" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拖放到架子上的文件将通过此服务共享" + } + } + } + }, + "Files shared from the shelf will use this service" : { + "comment" : "A description of how files shared from the shelf will be shared.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서랍에서 공유된 파일은 이 서비스를 사용합니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raftan paylaşılan dosyalar bu hizmeti kullanacak" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从架子共享的文件将使用此服务" + } + } + } + }, + "Fill Notch" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치 채우기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preencher Notch" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заполнить вырез" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentiği Doldur" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "填充刘海" + } + } + } + }, + "Finish" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Finish" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Dokončit" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beenden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finish" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finish" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Finalizar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminer" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Finish" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Fine" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "완료" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zakończ" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finalizar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitiş" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Завершити" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "完成" + } + } + } + }, + "Fit to Notch" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치에 맞추기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajuste para o Notch" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вписать в вырез" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentiklere Sığdır" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "适应刘海" + } + } + } + }, + "Floating window panel skip behaviour" : { + "comment" : "A section header that describes the floating window panel's skip behaviour settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "플로팅 윈도우 패널 건너뛰기 동작" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comportamento de pular do painel da janela flutuante" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поведение пропуска плавающего окна" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kayan pencere paneli atlama davranışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "浮动窗口面板跳过行为" + } + } + } + }, + "Focus" : { + "comment" : "Name of a default timer preset.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "집중" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фокус" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odaklanma" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "专注" + } + } + } + }, + "Focus active: %@" : { + "comment" : "An accessibility label for the focus widget, describing the currently active focus mode.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "집중 모드 켜짐: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foc ativo: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фокус" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkin odaklama: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "专注模式已激活:%@" + } + } + } + }, + "Focus detection mode" : { + "comment" : "The title of a confirmation dialog that lets users choose between using the focus detection mode with or without the use of the SwiftUI Inspector (DevTools).", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "집중 모드 감지 모드" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим обнаружения фокуса" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odak algılama modu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "专注检测模式" + } + } + } + }, + "Focus Status" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "집중 상태" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status do Foco" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим обнаружения фокуса" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odaklanma Durumu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "专注状态" + } + } + } + }, + "Focus Widget" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "집중 위젯" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget de Foco" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виджет Фокуса" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odak Widget'ı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "专注小组件" + } + } + } + }, + "Font family" : { + "comment" : "merge:ko:alt='서체'", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "폰트" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Шрифт" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yazı tipi ailesi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "字体系列" + } + } + } + }, + "Font size" : { + "comment" : "A label displayed above a slider that controls the font size of the terminal | A label displayed above a slider that controls the font size of the terminal | merge:ko:alt='서체 크기'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "폰트 사이즈" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Размер шрифта" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yazı tipi boyutu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "字体大小" + } + } + } + }, + "Foreground" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "글자 색" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Передний план" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ön plan" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "前景" + } + } + } + }, + "Forward 10s" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "10초 앞으로 가기" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "10'lu ileri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "快进 10 秒" + } + } + } + }, + "Free" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여유" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Свободно" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ücretsiz" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "免费" + } + } + } + }, + "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, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Libre · %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여유 공간 · %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Livre · %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текст" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ücretsiz · %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "可用 · %@" + } + } + } + }, + "Frequency" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주파수" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Частота" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frekans" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "频率" + } + } + } + }, + "Frosted Glass" : { + "comment" : "The name of the frosted glass style. | The name of the frosted glass style. | merge:ko:alt='Frosted Glass'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로스티드 글래스" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Матовое стекло" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buzlu Cam" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "磨砂玻璃" + } + } + } + }, + "Full Access" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 접근 가능" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tam Erişim" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完全访问" + } + } + } + }, + "Full Battery" : { + + }, + "Full battery duration" : { + + }, + "Full battery style" : { + + }, + "Full charge" : { + "comment" : "Text displayed in the status text when the battery is fully charged.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완전 충전" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Полный заряд" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tam şarj" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已充满" + } + } + } + }, + "Full charge threshold" : { + + }, + "Full Disk Access required" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 디스크 접근 권한 필요" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acesso completo ao disco necessário" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заряжено" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tam Disk Erişimi gerekli" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "需要完全磁盘访问权限" + } + } + } + }, + "Full Disk Access unlocks custom Focus icons, colors, and labels. Standard Focus detection still works without it—grant access only if you need personalized indicators." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 디스크 접근 권한을 허용하면 사용자 지정 집중 모드 아이콘, 색상 및 레이블을 사용할 수 있습니다. 권한이 없어도 표준 집중 모드 감지는 정상적으로 작동하므로, 개인화된 표시기가 필요한 경우에만 권한을 허용하십시오." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tam Disk Erişimi, özel Odak simgelerinin, renklerinin ve etiketlerinin kilidini açar. Standart Odak tespiti bu özellik olmadan da çalışır; yalnızca kişiselleştirilmiş göstergelere ihtiyacınız varsa erişim izni verin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完全磁盘访问解锁自定义专注模式图标、颜色和标签。标准专注检测无需此权限即可工作——仅在需要个性化指示器时授予访问权限。" + } + } + } + }, + "Fullscreen artwork on right-click" : { + + }, + "Fully charged" : { + "comment" : "Charging fallback label when already charged", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "충전 완료" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totalmente carregado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Полностью заряжена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamamen şarj edildi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已充满" + } + } + } + }, + "Fully charged HUD" : { + + }, + "Gemini API Key" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clé Gemini API" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gemini API 키" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chave de API do Gemini" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ключ Gemini API" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gemini API Anahtarı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gemini API 密钥" + } + } + } + }, + "General" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "General" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Obecné" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allgemein" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "General" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "General" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "General" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Général" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "General" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Generali" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "일반" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ogólne" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geral" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Общие" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genel" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "General" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "通用" + } + } + } + }, + "Gesture control" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gesture control" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kontrola gest" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestensteuerung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesture control" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesture control" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Control de gestos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contrôle gestuel" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gesture control" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Controllo gesti" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "제스처 컨트롤" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ustawienia gestów" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controle do gesto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управление жестами" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hareket kontrolü" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Керування жестами" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "手势控制" + } + } + } + }, + "Gesture sensitivity" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gesture sensitivity" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Citlivost gesta" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesten Empfindlichkeit" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesture sensitivity" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesture sensitivity" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sensibilidad del gesto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensibilité de la gestuelle" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gesture sensitivity" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sensibilità gesti" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "제스처 민감도" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Czułość gestów" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensibilidade do gesto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чувствительность жестов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hareket hassasiyeti" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Чутливість жестів" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "手势灵敏度" + } + } + } + }, + "Gesture skip behavior" : { + "comment" : "A picker that lets the user choose how a music player skips tracks using horizontal gestures.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제스처 건너뛰기 동작" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comportamento do gesto de pular" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поведение при пропуске жестом" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hareketle atlama davranışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手势跳过行为" + } + } + } + }, + "Get started" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Get started" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Začněte" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erste Schritte" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Get started" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Get started" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Comenzar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commencer" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Get started" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Inizia" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "시작하기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rozpocznij" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Começar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приступить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Başlayın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Get started" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "开始使用" + } + } + } + }, + "Get the cookie manually" : { + "comment" : "A section that provides instructions on how to get the Spotify cookie manually.", + "isCommentAutoGenerated" : true + }, + "Get your free API key from Google AI Studio" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtenez votre clé API gratuitement depuis Google AI Studio" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google AI Studio에서 무료 API 키를 받으세요" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtenha sua chave de API gratuita do Google AI Studio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получите бесплатный API ключ в Google AI Studio" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google AI Studio'den ücretsiz API anahtarınızı alın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从 Google AI Studio 获取免费 API 密钥" + } + } + } + }, + "GitHub" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "GitHub" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "GitHub" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "GitHub" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "GitHub" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "GitHub" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "GitHub" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "GitHub" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "GitHub" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "GitHub" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "GitHub" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub" + } + } + }, + "shouldTranslate" : false + }, + "Glass" : { + "comment" : "Name of the timer surface mode that uses a glass effect. | Name of the timer surface mode that uses a glass effect. | merge:ko:alt='Glass'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "글래스" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стекло" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cam" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "玻璃" + } + } + } + }, + "Glass mode" : { + "comment" : "merge:ko:alt='Glass 모드'", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "글래스 모드" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo Vidro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стекло" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cam modu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "玻璃模式" + } + } + } + }, + "Global Settings" : { + "comment" : "The header for the section that controls global settings related to extensions.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역 설정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurações Globais" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Глобальные настройки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genel Ayarlar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全局设置" + } + } + } + }, + "Got it!" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Got it!" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mám to!" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verstanden!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Got it!" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Got it!" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "¡Entendido!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compris !" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Got it!" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Capito!" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "알겠습니다!" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rozumiem!" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entendi!" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Есть!" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anladım!" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Got it!" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "明白了!" + } + } + } + }, + "GPU" : { + "comment" : "Title for the GPU usage graph.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" + } + } + } + }, + "GPU Overview" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU 개요" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обзор GPU" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU Genel Bakış" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU 概览" + } + } + } + }, + "GPU Usage" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilisation GPU" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU 사용량" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uso de GPU" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ГП" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU Kullanımı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU 使用率" + } + } + } + }, + "Gradient" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gradient" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Přechod" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Farbverlauf" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gradient" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gradient" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gradiente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dégradé" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gradient" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sfumatura" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그라디언트" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gradient" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gradiente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Градиент" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gradyan" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Градієнт" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "渐变" + } + } + } + }, + "Graph Visibility" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visibilité du graphique" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그래프 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visibilidade do Gráfico" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отображение графиков" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grafik Görünürlüğü" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图表可见性" + } + } + } + }, + "Green" : { + "comment" : "Display name for the green caps lock indicator tint mode.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "초록" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зеленый" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yeşil" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "绿色" + } + } + } + }, + "Height:" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hauteur :" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "높이:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altura:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зелёный" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yükseklik:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高度:" + } + } + } + }, + "Height: %lldpx" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "높이: %lldpx" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altura: %lld px" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высота: %lld px" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yükseklik: %lldpx" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高度:%lld像素" + } + } + } + }, + "Hello, glass!" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hello, glass!" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Merhaba, cam!" + } + } + } + }, + "HH" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시간" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "HH" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "SS" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "时" + } + } + } + }, + "Hide" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cacher" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "숨기기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏" + } + } + } + }, + "Hide active event and show next upcoming event" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성 이벤트를 숨기고 다음 예정된 이벤트 표시" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkin etkinliği gizle ve bir sonraki yaklaşan etkinliği göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏当前事件并显示下一个即将到来的事件" + } + } + } + }, + "Hide all-day events" : { + "comment" : "A toggle to hide all-day events in the calendar.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "종일 이벤트 숨기기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar eventos que duram todo o dia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть события на весь день" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tüm gün süren etkinlikleri gizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏全天事件" + } + } + } + }, + "Hide completed reminders" : { + "comment" : "A toggle that lets the user hide completed reminders", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료된 미리 알림 숨기기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar lembretes concluídos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть завершенные напоминания" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamamlanan hatırlatıcıları gizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏已完成的提醒" + } + } + } + }, + "Hide Dynamic Island during screenshots & recordings" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스크린샷 및 화면 기록 시 Dynamic Island 숨기기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрывать Dynamic Island на снимках и записях экрана" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran görüntüleri ve kayıtlar sırasında Dynamic Island'yi gizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "截图和录制时隐藏灵动岛" + } + } + } + }, + "Hide DynamicIsland Options" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cacher les options de DynamicIsland" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다이나믹 아일랜드 옵션 숨기기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar Opções do DynamicIsland" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть настройки Dynamic Island" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "DynamicIsland Seçeneklerini Gizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏灵动岛选项" + } + } + } + }, + "Hide lock screen preview" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 미리보기 숨기기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть предпросмотр экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı önizlemesini gizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏锁屏预览" + } + } + } + }, + "Hide only when NowPlaying app is in fullscreen" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hide only when NowPlaying app is in fullscreen" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Skrýt pouze když je Právě hrající aplikace v režimu celé obrazovky" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur ausblenden, wenn die Läuft gerade App im Vollbildmodus ist" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide only when NowPlaying app is in fullscreen" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide only when NowPlaying app is in full-screen" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hide only when NowPlaying app is in fullscreen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masquer seulement quand lorsque l'app \"À l’écoute\" est en plein écran" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hide only when NowPlaying app is in fullscreen" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nascondi solo quando l'app NowPlaying è a schermo intero" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "현재 재생 중인 앱이 전체 화면일 때만 숨기기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ukryj tylko wtedy, gdy aplikacja NowPlaying jest w trybie pełnoekranowym" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar apenas quando o aplicativo NowPlaying estiver em tela cheia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрывать только во время полноэкранного режима медиа приложения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yalnızca NowPlaying uygulaması tam ekrandayken gizle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hide only when NowPlaying app is in fullscreen" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "仅在“当前播放”全屏时隐藏" + } + } + } + }, + "Hide until hovered on non-notch displays" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치가 없는 디스플레이 에서는 포인터를 올릴 때까지 숨기기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрывать до наведения на дисплеях без выреза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentiksiz ekranlarda fareyle üzerine gelinene kadar gizle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在无刘海显示器上隐藏直到悬停" + } + } + } + }, + "Hierarchical" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hierarchical" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hierarchické" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hierarchisch" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hierarchical" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hierarchical" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hierarchical" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiérarchique" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hierarchical" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gerarchico" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "계층적" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hierarchiczny" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hierárquico" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ступенчатый" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiyerarşik" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hierarchical" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "分级" + } + } + } + }, + "High" : { + "comment" : "High battery level label", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "High" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vysoká" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoch" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "High" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "High" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Alto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haut" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "High" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Alta" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "높음" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wysoki" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высокий" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yüksek" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Високий" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "高" + } + } + } + }, + "High-frequency updates without a timeout can increase battery usage." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시간 제한 없는 빈번한 업데이트는 배터리 사용량을 증가시킬 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atualizações de alta frequência sem tempo limite podem aumentar o uso da bateria." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Частые обновления без таймаута могут увеличить расход батареи." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zaman aşımı olmadan yüksek frekanslı güncellemeler pil kullanımını artırabilir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无超时的高频更新可能会增加电池消耗。" + } + } + } + }, + "History" : { + "comment" : "Localized name of each tab in the clipboard view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarih" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "历史" + } + } + } + }, + "History Size" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taille de l’historique" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록 크기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamanho do Histórico" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçmiş Boyutu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "历史大小" + } + } + } + }, + "Horizontal:" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Horizontal :" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По горизонтали:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yatay:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "水平:" + } + } + }, + "shouldTranslate" : false + }, + "Hours" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시간" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saatler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "小时" + } + } + } + }, + "https://example.com/animation.json" : { + "comment" : "A placeholder text for a Lottie JSON URL input field.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://exemple.com/animation.json" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://example.com/animation.json" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://example.com/animation.json" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://example.com/animation.json" + } + } + }, + "shouldTranslate" : false + }, + "https://github.com/th-ch/youtube-music" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "https://github.com/th-ch/youtube-music" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://github.com/th-ch/youtube-music" + } + } + }, + "shouldTranslate" : false + }, + "HUD Duration" : { + + }, + "HUD icon style" : { + "comment" : "A label describing the style of the 3D or symbol-based Bluetooth HUD icon.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD 아이콘 스타일" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль иконки HUD" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD simge stili" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD 图标样式" + } + } + } + }, + "HUD Position" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD 위치" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posição do HUD" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль значка в панели" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD Konumu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD 位置" + } + } + } + }, + "HUD style" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUD style" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Styl HUD" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD Stil" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD style" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD style" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUD style" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Style de l’ATH" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUD style" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Stile HUD" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUD 스타일" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Styl HUD" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estilo do HUD" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль панели" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD tarzı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUD style" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUD 样式" + } + } + } + }, + "HUD Tests" : { + + }, + "HUDs" : { + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUDs" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUDy" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUDs" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUDs" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUDs" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUDs" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ATH" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUDs" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUDs" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUD들" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Interfejsy HUD" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUDs" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Панели" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD'ler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUDs" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "HUD" + } + } + }, + "shouldTranslate" : false + }, + "Icon & Progress Color" : { + "comment" : "A picker that lets users choose the color scheme for the icon and progress bar in the OSD.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아이콘 & 진행도 색상" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ícone & Cor do Progresso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет иконки и индикатора" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Simge ve İlerleme Rengi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图标和进度颜色" + } + } + } + }, + "Idle" : { + "comment" : "A label displayed alongside the status of a GPU device.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En veille" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "유휴" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocioso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ожидание" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boşta" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空闲" + } + } + } + }, + "Idle Animation" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "유휴 애니메이션" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Анимация ожидания" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boşta Animasyon" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空闲动画" + } + } + } + }, + "Idle Animation Style" : { + "comment" : "The header label for the section that lets users choose the style of idle animation.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Style de l’animation de veille" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "유휴 애니메이션 스타일" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estilo de Animação Ocioso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль анимации ожидания" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boşta Animasyon Stili" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空闲动画样式" + } + } + } + }, + "Image" : { + "comment" : "Name of the \"Image\" clipboard item type.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미지" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изображение" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resim" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图片" + } + } + } + }, + "Import" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importer" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "가져오기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изображение" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İthalat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入" + } + } + } + }, + "Import Error" : { + "comment" : "The title of an alert that appears when there is an error importing a Lottie animation. | The title of an alert that appears when there is an error importing a Lottie animation. | merge:ko:alt='가져오기 오류'", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erreur d’importation" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "가져오기 에러" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importar Erro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка импорта" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İçe Aktarma Hatası" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入错误" + } + } + } + }, + "Import File..." : { + "comment" : "A menu option to import a Lottie animation from a file.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importer un fichier" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일 가져오기..." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importar Arquivo..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Импортировать файл..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosyayı İçe Aktar..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入文件..." + } + } + } + }, + "in %@" : { + "comment" : "\"In x hours\" as showed on the lock screen calendar widget.", + "extractionState" : "manual" + }, + "in %lld" : { + "comment" : "A label used to indicate an event happening in a set amount of time.", + "extractionState" : "manual" + }, + "In progress" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "In progress" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Probíhá" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In Bearbeitung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "In progress" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "In progress" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "En curso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En cours" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "In progress" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "In corso" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "진행 중" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "W toku" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Em progresso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В процессе" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devam ediyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "В процесі" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "进行中" + } + } + } + }, + "Inactive" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Inactif" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비활성" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inativo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неактивно" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkin değil" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不活跃" + } + } + } + }, + "Inline" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Inline" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Inline" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inline" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inline" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inline" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Inline" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Inline" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Inline" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "In linea" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "인라인" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wbudowany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Inline" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Строкой" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Satır içi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Inline" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "内嵌" + } + } + } + }, + "Inline snapshot preview" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인라인 스냅샷 미리보기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Inline snapshot preview" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Встроенный предпросмотр снимка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Satır içi anlık görüntü önizlemesi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内联快照预览" + } + } + } + }, + "Input" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "입력" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ввод" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giriş" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入" + } + } + } + }, + "Install updates automatically" : { + "comment" : "merge:ko:alt='자동으로 업데이트 설치'", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Install updates automatically" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installer les mises à jour automatiquement" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업데이트 자동 설치" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматически устанавливать обновления" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Güncellemeleri otomatik olarak yükle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动安装更新" + } + } + } + }, + "Installed extensions" : { + "comment" : "merge:ko:alt='설치된 확장 프로그램'", + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Installed extensions" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nainstalovaná rozšíření" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installierte Erweiterungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installed extensions" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installed extensions" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Installed extensions" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensions installées" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Installed extensions" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Estensioni installate" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "설치된 확장기능들" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zainstalowane rozszerzenia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensões Instaladas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установленные расширения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yüklü uzantılar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Встановлені розширення" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已安装扩展" + } + } + } + }, + "Integrations" : { + "comment" : "Section header for settings related to third-party integrations.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "통합" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интеграции" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entegrasyonlar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "集成" + } + } + } + }, + "Interactive (Drag to Change)" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "반응형 (드래그해서 변경)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interativo (Arraste para Mudar)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интерактивный (Перетащите для изменения)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkileşimli (Değiştirmek için Sürükleyin)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "交互式(拖拽更改)" + } + } + } + }, + "Interfaces" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인터페이스" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интерфейсы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arayüzler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "网络接口" + } + } + } + }, + "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, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "IPv4 · %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "IPv4 · %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "IPv4 · %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "IPv4 · %@" + } + } + }, + "shouldTranslate" : false + }, + "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, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "IPv6 · %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "IPv6 · %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "IPv6 · %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "IPv6 · %@" + } + } + }, + "shouldTranslate" : false + }, + "Join the meeting" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미팅 참여" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrar na reunião" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Присоединиться к встрече" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toplantıya katılın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加入会议" + } + } + } + }, + "just now" : { + "comment" : "Text describing a moment in time that is just now.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "방금" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "только что" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "şimdi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "刚刚" + } + } + } + }, + "Just now" : { + "comment" : "A string indicating that an action was performed just recently.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "방금" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только что" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Az önce" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "刚刚" + } + } + } + }, + "Keep album art visible during fullscreen artwork" : { + + }, + "Keep terminal open until clicked outside" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "외부를 클릭할 때까지 터미널 유지" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Держать терминал открытым до клика за его пределами" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dışarıdan tıklanana kadar terminali açık tutun" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保持终端打开直到点击外部" + } + } + } + }, + "Keyboard Backlight HUD" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ATH de l’éclairage du clavier" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "키보드 백라이트 HUD" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD de Brilho do Teclado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только что" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klavye Arka Işığı HUD" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "键盘背光 HUD" + } + } + } + }, + "Keyboard Backlight OSD" : { + "comment" : "A label displayed above the toggle for the keyboard backlight OSD.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "키보드 백라이트 OSD" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "OSD de Brilho do Teclado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "OSD подсветки клавиатуры" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klavye Arka Işığı OSD" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "键盘背光 OSD" + } + } + } + }, + "Keyboard shortcuts are disabled" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les raccourcis claviers sont désactivés" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "키보드 단축키 비활성화됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atalhos de teclado estão desativados" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сочетания клавиш отключены" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klavye kısayolları devre dışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "键盘快捷键已禁用" + } + } + } + }, + "Labels are forced to compact on/off text while brief toast mode is enabled." : { + "comment" : "A help text explaining why the \"Show Focus Label\" toggle is disabled when the \"Show Focus as brief toast\" toggle is enabled.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'간소화된 토스트 모드'가 활성화되어 있는 동안에는 레이블이 '켬/끔' 텍스트로 축소되어 표시됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Os rótulos são forçados a compactar o texto ligado/desligado enquanto o modo brief toast está ativado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текст уплотнён до вкл/выкл, пока работает режим кратких уведомлений." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kısa kızartma modu etkinleştirildiğinde, etiketler açık/kapalı metni sıkıştırmaya zorlanır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用简短提示模式时,标签将强制为紧凑开/关文本。" + } + } + } + }, + "Last Denied Reason:" : { + "comment" : "A label for the \"Last Denied Reason\" section in the extensions settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마지막 거부 사유:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Último Motivo negado:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последняя причина отказа:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son Reddedilme Sebebi:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上次拒绝原因:" + } + } + } + }, + "Last Update" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마지막 업데이트" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son Güncelleme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上次更新" + } + } + } + }, + "Last Updated" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dernière mise à jour" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마지막 업데이트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atualizado pelo Última vez" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последнее обновление" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son Güncelleme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上次更新" + } + } + } + }, + "Later" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Later" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Později" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Später" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Later" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Later" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Later" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus tard" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Later" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Più tardi" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "나중에" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Później" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Depois" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позже" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daha sonra" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Пізніше" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "稍后再说" + } + } + } + }, + "Launch at login" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로그인 시 실행" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запускать при входе" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giriş sırasında başlat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录时启动" + } + } + } + }, + "Layout" : { + "comment" : "A label for the segment control that lets the user choose the layout of the weather widget.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Макет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düzen" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "布局" + } + } + }, + "shouldTranslate" : false + }, + "Layout Preview" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "레이아웃 미리보기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pré-vizualização do Layout" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Макет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düzen Önizlemesi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "布局预览" + } + } + } + }, + "Left" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "왼쪽" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esquerda" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Слева" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sol" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "左" + } + } + } + }, + "License key" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clé de license" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "라이선스 키" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лицензионный ключ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisans anahtarı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "许可证密钥" + } + } + } + }, + "Light Use" : { + "comment" : "Name of the \"Light Use\" user profile.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일반 사용자" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Легкая нагрузка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hafif Kullanım" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "轻度使用" + } + } + } + }, + "Line Width: %lldpx" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선 너비: %lldpx" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Largura da Linha: %lld px" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лёгкий режим" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Satır Genişliği: %lldpx" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "线宽:%lld像素" + } + } + } + }, + "LinkedIn" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "LinkedIn" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "LinkedIn" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "LinkedIn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "LinkedIn" + } + } + }, + "shouldTranslate" : false + }, + "Liquid Glass" : { + "comment" : "Name of the \"Liquid Glass\" lock screen glass style. | Name of the \"Liquid Glass\" lock screen glass style. | merge:ko:alt='Liquid Glass'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리퀴드 글래스" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liquid Glass" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sıvı Cam" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "液态玻璃" + } + } + } + }, + "Liquid Glass requires macOS 26 or later." : { + "comment" : "merge:ko:alt='Liquid Glass는 macOS 26 이상 버전이 필요합니다.'", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liquid Glass nécessite macOS 26 ou plus récent." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리퀴드 글래스는 macOS 26 이상 버전이 필요합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O Liquid Glass requer MacOS 26 ou posterior." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Жидкое стекло" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liquid Glass, macOS 26 veya üzerini gerektirir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "液态玻璃需要 macOS 26 或更高版本。" + } + } + } + }, + "Liquid Glass Variant" : { + "comment" : "A label for selecting the variant of the Liquid Glass effect.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liquid Glass 변형" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sıvı Cam Varyantı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "液态玻璃变体" + } + } + } + }, + "Listens for Focus session changes via distributed notifications" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "분산 알림을 통해 집중 모드 세션 변경 사항을 감지합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escuta as alterações da sessão de foco por meio de notificações distribuídas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отслеживает изменения сеансов Фокуса через распределенные уведомления" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dağıtılmış bildirimler aracılığıyla Odaklanma oturumu değişikliklerini dinler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过分布式通知监听专注模式会话变化" + } + } + } + }, + "LIVE" : { + "comment" : "A label indicating that the music is live.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "AO VIVO" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIVE" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CANLI" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直播" + } + } + } + }, + "Live Activities" : { + "comment" : "The title of the \"Live Activities\" screen.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실시간 현황" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atividade em Tempo Real" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live Activities" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canlı Etkinlikler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实时活动" + } + } + } + }, + "Live Activity & Feedback" : { + "comment" : "The header for the \"Live Activity & Feedback\" section in the lock screen settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실시간 현황 & 피드백" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atividade ao vivo e feedback" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live Activity и Отклик" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canlı Etkinlik ve Geri Bildirim" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实时活动与反馈" + } + } + } + }, + "Live Monitoring" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivi en directe" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실시간 모니터링" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Мониторинг в реальном времени" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canlı İzleme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实时监控" + } + } + } + }, + "Live Performance Data" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Données de performance en directe" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실시간 성능 데이터" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dados de desempenho ao vivo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные производительности" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canlı Performans Verileri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实时性能数据" + } + } + } + }, + "Live Preview" : { + "comment" : "A section header that indicates the live preview of custom OSD settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실시간 미리보기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visualização ao vivo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предпросмотр в реальном времени" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canlı Önizleme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实时预览" + } + } + } + }, + "Live stream indicator" : { + "comment" : "A visual indicator that appears when a music stream is live.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실시간 스트림 표시기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indicador de transmissão ao vivo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Индикатор прямого эфира" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canlı yayın göstergesi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直播指示器" + } + } + } + }, + "Load 1m" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "1분 부하" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Load 1м" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1m yük" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "1分钟负载" + } + } + } + }, + "Load 5m" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "5분 부하" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Load 5м" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yük 5m" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5分钟负载" + } + } + } + }, + "Load 15m" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "15분 부하" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Load 15м" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yük 15 m" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "15分钟负载" + } + } + } + }, + "Load Average" : { + "comment" : "A label displayed above the load average values.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moyenne de la charge" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "부하 평균" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Média de Carga" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Средняя нагрузка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yük Ortalaması" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "负载平均值" + } + } + } + }, + "Loading..." : { + "comment" : "A placeholder text displayed while loading the wallpaper.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "불러오는 중..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yükleniyor..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加载中..." + } + } + } + }, + "LocalSend Device Picker" : { + "comment" : "A section header for the LocalSend device picker settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "LocalSend 기기 선택기" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "LocalSend Cihaz Seçici" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "LocalSend 设备选择器" + } + } + } + }, + "Lock Screen" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Écran verrouillé" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tela de Bloqueio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit Ekranı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锁屏" + } + } + } + }, + "Lock Screen Glass" : { + "comment" : "merge:ko:alt='잠금 화면 Glass'", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 글래스" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vidro na Tela de Bloqueio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стекло экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit Ekranı Camı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锁屏玻璃" + } + } + } + }, + "Lock screen glass mode" : { + "comment" : "merge:ko:alt='잠금 화면 Glass 모드'", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 글래스 모드" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo vidro na Tela de Bloqueio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим стекла экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı cam modu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锁屏玻璃模式" + } + } + } + }, + "Lock Screen Integration" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 통합" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integração da Tela de Bloqueio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интеграция с экраном блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit Ekranı Entegrasyonu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锁屏集成" + } + } + } + }, + "Lock Screen Liquid Glass" : { + "comment" : "The title of the music panel on the lock screen. | The title of the music panel on the lock screen. | merge:ko:alt='잠금 화면 Liquid Glass'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 리퀴드 글래스" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liquid Glass na Tela de Bloqueio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liquid Glass экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit Ekranı Sıvı Cam" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锁屏液态玻璃" + } + } + } + }, + "Lock Screen Media" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 미디어" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mídia na tela de bloqueio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Медиа на экране блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit Ekranı Medyası" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锁屏媒体" + } + } + } + }, + "Lock screen panel preview" : { + "comment" : "A description of the content displayed in the lock screen panel.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 패널 미리보기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visualização do painel de tela de bloqueio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предпросмотр панели экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı paneli önizlemesi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锁屏面板预览" + } + } + } + }, + "Lock Screen Positioning" : { + "comment" : "The header text for the \"Lock Screen Positioning\" section in the settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 배치" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posicionamento da tela de bloqueio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позиционирование на экране блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit Ekranı Konumlandırma" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锁屏定位" + } + } + } + }, + "Lock Screen Reminder Widget" : { + "comment" : "A section header for the lock screen reminder widget settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 미리 알림 위젯" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget de Lembrete da Tela de Bloqueio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виджет напоминаний экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit Ekranı Hatırlatma Widget'ı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锁屏提醒小组件" + } + } + } + }, + "Lock Screen Widgets" : { + "comment" : "A toggle that enables or disables the use of lock screen widgets by the extension.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 위젯" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widgets da Tela de Bloqueio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виджеты экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit Ekranı Widget'ları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锁屏小组件" + } + } + } + }, + "Logical cores" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "논리 프로세서" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Логические ядра" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mantıksal çekirdekler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "逻辑核心" + } + } + } + }, + "Loop Mode" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "반복 모드" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo de Loop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим зацикливания" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Döngü Modu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "循环模式" + } + } + } + }, + "Lottie JSON URL" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Lottie JSON URL" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Lottie JSON URL" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lottie JSON URL" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lottie JSON URL" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lottie JSON URL" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Lottie JSON URL" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lien URL Lottie JSON" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Lottie JSON URL" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "URL Lottie JSON" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Lottie JSON URL" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Lottie JSON URL" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Lottie JSON URL" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ссылка на JSON Lottie" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lottie JSON URL" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Lottie JSON URL" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Lottie JSON 链接" + } + } + } + }, + "Low" : { + "comment" : "Low battery level label", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Low" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nízká" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niedrig" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Low" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Low" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Bajo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bas" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Low" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Bassa" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "낮음" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Niski" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baixa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Низкий" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düşük" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Low" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "低" + } + } + } + }, + "Low battery" : { + "comment" : "Text displayed when the battery level drops below a certain threshold.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 부족" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Низкий заряд батареи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düşük pil" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "电量低" + } + } + } + }, + "Low Battery" : { + + }, + "Low battery duration" : { + + }, + "Low battery HUD" : { + + }, + "Low battery style" : { + + }, + "Low battery threshold" : { + + }, + "Low Power Mode" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Low Power Mode" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Režim nízké spotřeby" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Energiesparmodus" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Low Power Mode" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Low Power Mode" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Modo de bajo consumo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mode économie d'énergie" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Low Power Mode" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Modalità Bassa Potenza" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "저전력 모드" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tryb niskiego zużycia energii" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo Pouca Energia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим энергосбережения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Düşük Güç Modu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Режим економії енергії" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "低电量模式" + } + } + } + }, + "Low Power: %@" : { + "comment" : "Text displayed in a status label at the top of the screen indicating whether low power mode is on or off.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 부족: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Энергосбережение: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düşük Güç: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "低电量:%@" + } + } + } + }, + "Lyrics" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "가사" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şarkı sözleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "歌词" + } + } + } + }, + "Mac battery at %d percent" : { + "comment" : "Mac battery gauge accessibility label", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "맥 배터리 %d 퍼센트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bateria do Mac em %d porcento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим энергосбережения: %d" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac pili yüzde %d seviyesinde" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac 电池电量 %d%%" + } + } + } + }, + "macOS 15 or later required" : { + "comment" : "A message displayed when the Custom OSD feature is unavailable on this version of macOS.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 15 또는 최신 버전 필요" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "MacOS 15 ou posterior necessário" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется macOS 15 или новее" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 15 veya üzeri gereklidir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "需要 macOS 15 或更高版本" + } + } + } + }, + "Made with ❤️ by Ebullioscopic" : { + "comment" : "A footer text that credits the developer of the app.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ebullioscopic이 ❤️으로 제작함" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feito com ❤️ por Ebullioscopic" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сделано с ❤️ Ebullioscopic" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ebullioskopik tarafından ❤️ ile yapılmıştır" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "由 Ebullioscopic 用 ❤️ 制作" + } + } + } + }, + "Main screen style" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메인 화면 스타일" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль главного экрана" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ana ekran stili" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主屏幕样式" + } + } + } + }, + "Make sure LocalSend is running on other devices" : { + "comment" : "A description of the error.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다른 기기에서 LocalSend가 실행 중인지 확인하십시오." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "LocalSend'nin diğer cihazlarda çalıştığından emin olun" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请确保 LocalSend 在其他设备上运行" + } + } + } + }, + "Manage indicator preferences from Settings → Privacy. No manual microphone toggle is required." : { + "comment" : "A description of how to manage the microphone privacy indicator preferences.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정 → 개인정보 보호'에서 표시기 환경설정을 관리하세요. 마이크 토글을 수동으로 조작할 필요가 없습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gerencie as preferências do indicador em Configurações → Privacidade. Não é necessário alternar manualmente o microfone." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль главного экрана" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gösterge tercihlerini Ayarlar → Gizlilik bölümünden yönetin. Manuel mikrofon değiştirmeye gerek yoktur." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在设置 → 隐私中管理指示器偏好。无需手动切换麦克风。" + } + } + } + }, + "Mark as complete" : { + "comment" : "An accessibility label for a button that marks an item as complete.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료로 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marcar com completo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отметить как выполненное" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamamlandı olarak işaretle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标记为完成" + } + } + } + }, + "Mark as incomplete" : { + "comment" : "An accessibility label for a button that marks an item as incomplete.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미완료로 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marcar como incompleto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отметить как невыполненное" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksik olarak işaretle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标记为未完成" + } + } + } + }, + "Match menubar height" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Match menubar height" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Match menubar height" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Höhe der Menüleiste" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Match menubar height" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Match menubar height" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Match menubar height" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correspondre à la hauteur de la barre de menu" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Match menubar height" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Adatta altezza menubar" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "메뉴바 높이에 맞추기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Dopasuj do wysokości paska menu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corresponder à altura da barra de menu" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Соответствовать высоте строки меню" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Menubar yüksekliğini eşle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Match menubar height" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "匹配菜单栏高度" + } + } + } + }, + "Match real notch size" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Match real notch size" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Match real notch size" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Größe der echten Notch" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Match real notch size" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Match real notch size" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Match real notch size" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correspondre à la vraie hauteur de l'encoche" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Match real notch size" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Adatta altezza reale menubar" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "실제 노치 크기에 맞추기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Dopasuj do rzeczywistego rozmiaru notcha" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Combine o tamanho real do notch" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Соответствовать реальным размерам выреза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gerçek çentik boyutunu eşle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Match real notch size" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "匹配Notch真实大小" + } + } + } + }, + "Material" : { + "comment" : "A picker that lets the user select the material of the lock screen glass.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Материал" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malzeme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "材质" + } + } + }, + "shouldTranslate" : false + }, + "Material Options:" : { + "comment" : "A label describing the material options for the custom OSD.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Material 옵션" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opções de Material:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Материал" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malzeme Seçenekleri:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "材质选项:" + } + } + } + }, + "Max Capacity: %lld%%" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Max Capacity: %lld%%" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Max Capacity: %lld%%" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Max. Kapazität: %lld%%" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Max Capacity: %lld%%" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Max Capacity: %lld%%" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Max Capacity: %lld%%" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capacité maximale : %lld%%" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Max Capacity: %lld%%" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Capacità Massima: %lld%%" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "최대 용량: %lld%%" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Maksymalna pojemność: %lld%%" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capacidade Máxima: %lld%%" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимальная ёмкость: %lld%%" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimum Kapasite: %lld%%" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Max Capacity: %lld%%" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "最大容量:%lld%%" + } + } + } + }, + "Maximum height" : { + "comment" : "A label for the terminal's maximum height setting.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최대 높이" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимальная высота" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimum yükseklik" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最大高度" + } + } + } + }, + "Media" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Média" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "미디어" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Multimedia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mídia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Медиа" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Medya" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "媒体" + } + } + } + }, + "Media & Display" : { + "comment" : "Display title for the section header for settings related to media and display.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 및 디스플레이" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Медиа и Дисплей" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya ve Görüntülü Reklam Ağı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "媒体与显示" + } + } + } + }, + "Media change with horizontal gestures" : { + "comment" : "A toggle that lets the user enable or disable the feature of changing the currently playing media using horizontal gestures.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media change with horizontal gestures" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media change with horizontal gestures" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium Änderung mit Horizontalen Gesten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media change with horizontal gestures" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media change with horizontal gestures" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media change with horizontal gestures" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changement de média avec les gestuelles horizontales" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media change with horizontal gestures" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Cambia media con gesti orizzontali" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "가로 방향 제스처로 미디어 변경" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zmiana multimediów z poziomymi gestami" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media change with horizontal gestures" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Средний" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yatay hareketlerle medya değişimi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media change with horizontal gestures" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "横向手势改变媒体" + } + } + } + }, + "Media controls" : { + "comment" : "merge:ko:alt='미디어 제어기'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media controls" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media controls" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium Steuerung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media controls" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media controls" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media controls" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contrôles des médias" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media controls" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Controlli multimediali" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "미디어 컨트롤" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ustawienia multimediów" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controles de Mídia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Элементы управления медиа" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya kontrolleri" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media controls" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "媒体控制" + } + } + } + }, + "Media inactivity timeout" : { + "comment" : "merge:ko:alt='미디어 비활성화 시간 제한'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media inactivity timeout" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Časový limit nečinnosti médií" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium Inaktivität Zeitüberschreitung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media inactivity timeout" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media inactivity timeout" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media inactivity timeout" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Délai d'inactivité du média" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media inactivity timeout" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Timeout inattività media" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "미디어 비활성화 시간 초과" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Limit czasu braku aktywności multimediów" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo limite de inatividade de mídia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Таймаут отсутствия активности медиа" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya hareketsizliği zaman aşımı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media inactivity timeout" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "媒体非活动超时" + } + } + } + }, + "Media Live Activity" : { + "comment" : "A section header that lists the media live activity settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 실시간 현황" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atividade de mídia ao vivo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Медиа Live Activity" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya Canlı Etkinliği" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "媒体实时活动" + } + } + } + }, + "Media output" : { + "comment" : "Accessibility label describing the control that opens the media output selector.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 출력" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saída de Mídia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вывод аудио" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya çıkışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "媒体输出" + } + } + } + }, + "Media Panel" : { + "comment" : "A section header in the lock screen settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 패널" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Painel de Mídia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Медиа-панель" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya Paneli" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "媒体面板" + } + } + } + }, + "Media Panel Width" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 패널 넓이" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya Paneli Genişliği" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "媒体面板宽度" + } + } + } + }, + "Media playback live activity" : { + "comment" : "merge:ko:alt='미디어 재생 실시간 현황'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media playback live activity" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Živá aktivita přehrávání médií" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium Wiedergabe live Aktivität" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media playback live activity" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media playback live activity" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media playback live activity" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activité de lecture de média en direct" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media playback live activity" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Riproduzione media attività live" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "미디어 재생 실시간 활동" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Odtwarzanie multimediów na żywo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Atividade ao vivo da reprodução de mídia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эфир активности воспроизведения медиа" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya oynatma canlı etkinliği" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media playback live activity" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "媒体播放实时动态" + } + } + } + }, + "Media Source" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media Source" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zdroj médií" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium Quelle" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media Source" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media Source" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media Source" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Source du Média" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media Source" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sorgente multimediale" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "미디어 소스" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Źródło multimediów" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fonte de mídia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Источник медиа" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Medya kaynağı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media Source" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "媒体来源" + } + } + } + }, + "Medium" : { + "comment" : "Medium battery level label", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Medium" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Střední" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mittel" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mediano" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moyen" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Medium" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Media" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "중간" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Średni" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Médio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Medium" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Orta" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Medium" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "中" + } + } + } + }, + "Meeting starts at %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@에 회의 시작" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A reunião começa às %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Встреча начинается в %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toplantı %@'de başlıyor" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会议将在 %@ 开始" + } + } + } + }, + "Memory" : { + "comment" : "Title of the memory usage single value graph.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Память" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bellek" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内存" + } + } + } + }, + "Memory Clock" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리 클럭" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Частота памяти" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bellek Saati" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内存时钟" + } + } + } + }, + "Memory Overview" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리 개요" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обзор памяти" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Belleğe Genel Bakış" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内存概览" + } + } + } + }, + "Memory pressure is critical; the system may purge aggressively." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리 압력이 심각함. 시스템이 메모리를 강제로 정리할 수 있음." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нагрузка на память критическая; система может начать агрессивную очистку." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bellek baskısı kritik öneme sahiptir; sistem agresif bir şekilde temizlenebilir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内存压力严重;系统可能会激进地清理内存。" + } + } + } + }, + "Memory pressure is elevated. Closing apps may help." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리 압력이 높음. 앱을 종료하면 도움이 될 수 있음." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нагрузка на память повышена. Закройте приложения." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bellek basıncı arttı. Uygulamaları kapatmak yardımcı olabilir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内存压力升高。关闭应用可能有助于缓解。" + } + } + } + }, + "Memory Usage" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilisation de la mémoire" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리 사용량" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uso de Memória" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Память" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bellek Kullanımı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内存使用情况" + } + } + } + }, + "Menubar icon" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Menubar icon" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Menubar icon" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Icon in Menüleiste anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menubar icon" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menubar icon" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Menubar icon" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Icône dans la barre des menus" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Menubar icon" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Menubar icon" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "메뉴바 아이콘" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Menubar icon" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Menubar icon" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Значок в строке меню" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menü çubuğu simgesi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Menubar icon" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "菜单栏图标" + } + } + } + }, + "Messages" : { + "comment" : "The title of the \"Messages\" settings page.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메세지" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensagens" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mesajlar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "消息" + } + } + } + }, + "Method source" : { + "comment" : "A link to the source code of Spotify's method for retrieving a user's Spotify Canvas.", + "isCommentAutoGenerated" : true + }, + "Mic" : { + "comment" : "Name of a sneak action type that controls the microphone.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마이크" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Микрофон" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mikrofon" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "麦克风" + } + } + } + }, + "Mic %@" : { + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mic %@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mic %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mikrofon %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mic %@" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mic %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mic %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microphone %@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mic %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mic %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "마이크 %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mikrofon %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mic %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Микро %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mikrofon %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mic %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "麦克风 %@" + } + } + } + }, + "Mic muted" : { + "comment" : "A description of the state when the microphone is muted.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마이크 음소거" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microfone mudo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Микрофон выключен" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mikrofonun sesi kapatıldı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "麦克风已静音" + } + } + } + }, + "Mic unmuted" : { + "comment" : "A description of the state when the microphone is unmuted.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마이크 음소거 해제" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Microfone fora do mudo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Микрофон включен" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mikrofonun sesi açıldı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "麦克风已取消静音" + } + } + } + }, + "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" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마이크" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microfone" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Микрофон" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mikrofon" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "麦克风" + } + } + } + }, + "Microphone Active" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microphone actif" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마이크 작동" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microfone Ativo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Микрофон активен" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mikrofon Aktif" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "麦克风已激活" + } + } + } + }, + "Microphone Only" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microphone Only" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yalnızca mikrofon" + } + } + } + }, + "Microphone privacy indicator runs automatically whenever apps access audio." : { + "comment" : "A description of the microphone privacy indicator feature.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@에 회의 시작" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O indicador de privacidade do microfone é executado automaticamente sempre que os aplicativos acessam o áudio." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Индикатор конфиденциальности микрофона включается автоматически, когда приложения используют аудио." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mikrofon gizlilik göstergesi, uygulamalar sese her eriştiğinde otomatik olarak çalışır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "麦克风隐私指示器在应用访问音频时自动运行。" + } + } + } + }, + "Microphone Status" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statut du microphone" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마이크 상태" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status do Microfone" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус микрофона" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mikrofon Durumu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "麦克风状态" + } + } + } + }, + "min" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "min" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "분" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "мин" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dk." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分钟" + } + } + } + }, + "min plural" : { + "comment" : "Shortened plural form of minutes. Should be kept short as it is used for the calendar reminder HUD.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "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." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미니멀 모드는 미디어 제어기와 시스템 HUD에 집중하며, 깔끔하고 몰입감 있는 경험을 위해 모든 추가 기능을 숨깁니다. 활성화 시 애니메이션도 자동으로 간소화됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O modo minimalista se concentra em controles de mídia e HUDs de sistema, ocultando todos os recursos extras para uma experiência limpa e focada. Ativa automaticamente animações mais simples." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минималистичный режим фокусируется на медиа и системных HUD, скрывая все лишние функции. Автоматически включает простые анимации." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimalist mod, temiz, odaklanmış bir deneyim için tüm ekstra özellikleri gizleyerek medya kontrollerine ve sistem HUD'lerine odaklanır. Daha basit animasyonları otomatik olarak etkinleştirir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "简约模式专注于媒体控制和系统 HUD,隐藏所有额外功能以提供简洁、专注的体验。自动启用更简单的动画。" + } + } + } + }, + "Minimum hover duration" : { + "comment" : "merge:ko:alt='최소 호버 시간'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Minimum hover duration" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Minimum hover duration" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimale Schwebedauer" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimum hover duration" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimum hover duration" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Minimum hover duration" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durée minimale du survol" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Minimum hover duration" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Durata minima di hover" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "가져다 대기 최소 시간" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Minimalny czas najechania kursorem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duração mínima do foco" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минимальная длительность наведения курсора" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Minimum asılı kalma süresi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Minimum hover duration" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "悬停最短时长" + } + } + } + }, + "Minutes" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minutes" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "분" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минуты" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dakika" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分钟" + } + } + } + }, + "Mirror" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mirror" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mirror" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spiegel" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mirror" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mirror" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mirror" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Miroir" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mirror" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Specchio" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "거울" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Lustro" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Espelhar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зеркало" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ayna" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mirror" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "镜子" + } + } + } + }, + "Mirror Camera" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayna Kamerası" + } + } + } + }, + "Mirror macOS Clock timers" : { + "comment" : "A toggle that enables or disables the app's ability to mirror macOS Clock timers.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 시계 타이머와 연동" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Espelhar os temporizadores do relógio do MacOS" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторять таймеры macOS" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayna macOS Saat zamanlayıcıları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "同步 macOS 时钟计时器" + } + } + } + }, + "Mirror shape" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mirror shape" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mirror shape" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spiegelform" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mirror shape" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mirror shape" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mirror shape" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forme du miroir" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mirror shape" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Forma dello specchio" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "거울 모양" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kształt lustra" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Espelhar o formato" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Форма зеркала" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ayna şekli" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mirror shape" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "镜子大小" + } + } + } + }, + "Mirrors the toggle found under Lock Screen settings so timer-specific workflows can enable or disable the widget without switching tabs." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'잠금 화면' 설정에 있는 토글과 연동되어, 탭을 전환하지 않고도 타이머 전용 워크플로에서 위젯을 활성화하거나 비활성화할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Espelha a alternância encontrada nas configurações da tela de bloqueio para que os fluxos de trabalho específicos do temporizador possam ativar ou desativar o widget sem alternar as guias." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дублирует переключатель настроек экрана блокировки для управления виджетом из этой вкладки." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit Ekranı ayarları altında bulunan geçişi yansıtır, böylece zamanlayıcıya özel iş akışları sekmeleri değiştirmeden widget'ı etkinleştirebilir veya devre dışı bırakabilir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "镜像锁屏设置中的开关,以便计时器工作流可以无需切换标签页即可启用或禁用小组件。" + } + } + } + }, + "MM" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "분" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "MM" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "DD" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分" + } + } + } + }, + "Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드 변경 사항을 모니터링하고 최근 복사 기록을 유지합니다. Cmd+Shift+V를 눌러 클립보드 기록에 빠르게 접근할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monitore as alterações na área de transferência e mantenha um histórico de cópias recentes. Use Cmd+Shift+V para acessar rapidamente o histórico da área de transferência." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отслеживайте изменения буфера обмена и ведите историю копирования. Нажмите Cmd+Shift+V для быстрого доступа." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pano değişikliklerini izleyin ve son kopyaların geçmişini saklayın. Pano geçmişine hızlı bir şekilde erişmek için Cmd+Shift+V tuşlarını kullanın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "监控剪贴板变化并保留最近复制的历史记录。使用 Cmd+Shift+V 快速访问剪贴板历史。" + } + } + } + }, + "Monitor your Downloads folder for Chromium-style downloads (.crdownload files) and show a live activity in the Dynamic Island while downloads are in progress." : { + "comment" : "A footer description for the \"Downloads\" section of the settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 폴더에서 크로미움 스타일의 다운로드(.crdownload 파일)를 감지하고, 다운로드가 진행되는 동안 Dynamic Island에 실시간 현황을 표시합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monitore sua pasta Downloads para downloads no estilo Chromium (arquivos .crdownload) e mostre uma atividade ao vivo na Dynamic Island enquanto os downloads estão em andamento." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отслеживает папку загрузок на наличие файлов Chromium (.crdownload) и показывает процесс в Dynamic Island." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chromium tarzı indirmeler (.crdownload dosyaları) için İndirilenler klasörünüzü izleyin ve indirmeler devam ederken Dynamic Island'de canlı bir etkinlik gösterin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "监控下载文件夹中 Chromium 风格的下载(.crdownload 文件),并在下载进行时在灵动岛中显示实时活动。" + } + } + } + }, + "Monitoring Behavior" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모니터링 행동" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comportamento do Monitoramento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим мониторинга" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Davranışı İzleme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "监控行为" + } + } + } + }, + "Monitoring Status" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statut du suivi" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모니터링 상태" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status de Monitoramente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус мониторинга" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İzleme Durumu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "监控状态" + } + } + } + }, + "Monitoring Stopped" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le suivi a pris fin" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모니터링 중지됨" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Мониторинг остановлен" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İzleme Durduruldu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "监控已停止" + } + } + } + }, + "More" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "More" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "More" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mehr" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "More" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "More" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Más" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "More" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Altro" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "더" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Więcej" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mais" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Больше" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daha Fazla" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "More" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "更多" + } + } + } + }, + "Move Down" : { + "comment" : "A button label that says \"Move Down\".", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아래로 이동" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mover para Baixo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переместить вниз" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aşağı Taşı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下移" + } + } + } + }, + "Move Up" : { + "comment" : "A button label that says \"Move Up\".", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "위로 이동" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mover para Cima" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переместить вверх" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yukarı Taşı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上移" + } + } + } + }, + "Music" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "음악" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Müzik" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "音乐" + } + } + } + }, + "Music panel variant" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "음악 패널 유형" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variante do painel de música" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вариант музыкальной панели" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Müzik paneli çeşidi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "音乐面板变体" + } + } + } + }, + "Music Source" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Music Source" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Music Source" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Musik Quelle" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Music Source" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Music Source" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Fuente de música" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Source de la musique" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Music Source" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sorgente Musica" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음악 소스" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Źródło muzyki" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fonte da Música" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Источник музыки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Müzik kaynağı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Music Source" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "音乐源" + } + } + } + }, + "Music Visualizer" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "음악 비주얼라이저" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Müzik Görselleştirici" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "音乐可视化" + } + } + } + }, + "muted" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "muted" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "muted" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stumm" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "muted" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "muted" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "silenciado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "muet" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "muted" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mutato" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음소거됨" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wyciszony" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sem Som" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "без звука" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "susturuldu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "muted" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已静音" + } + } + } + }, + "My Animation" : { + "comment" : "A default name for a new animation added via a URL.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mon Animation" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "나의 애니메이션" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minha Animação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Моя анимация" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animasyonum" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "我的动画" + } + } + } + }, + "Name" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Name" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Name" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nombre" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Name" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nome" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이름" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nazwa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Название" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "İsim" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Name" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "名称" + } + } + } + }, + "Navigation" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navigation" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "탐색" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navegação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Навигация" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navigasyon" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导航" + } + } + } + }, + "Need Restart" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Need Restart" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Need Restart" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Braucht einen Neustart" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Need Restart" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Need Restart" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Need Restart" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redémarrage nécessaire" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Need Restart" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Necessario Riavvio" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "재시작 필요" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Potrzebne ponowne uruchomienie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Necessário Reiniciar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется перезапуск" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yeniden Başlatma Gerekiyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Need Restart" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "需要重启" + } + } + } + }, + "Network" : { + "comment" : "Title for the \"Network\" section in the stats view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "네트워크" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сеть" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ağ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "网络" + } + } + } + }, + "Network Activity" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "네트워크 활동" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сетевая активность" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ağ Etkinliği" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "网络活动" + } + } + } + }, + "Network Download" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Téléchargement réseau" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "네트워크 다운로드" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download de Rede" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сеть" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ağ İndirme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "网络下载" + } + } + } + }, + "Network Overview" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "네트워크 개요" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обзор сети" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ağa Genel Bakış" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "网络概览" + } + } + } + }, + "Network Upload" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envoi réseau" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "네트워크 업로드" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upload de Rede" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ağ Yükleme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "网络上传" + } + } + } + }, + "Never hide" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Never hide" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Never hide" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niemals verstecken" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Never hide" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Never hide" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Never hide" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ne jamais masquer" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Never hide" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Non nascondere mai" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "숨기지 않기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nigdy nie ukrywaj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nunca esconder" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Никогда не скрывать" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Asla gizleme" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Never hide" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "永不隐藏" + } + } + } + }, + "Next Track" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다음 트랙" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonraki Parça" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一首" + } + } + } + }, + "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" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 감지된 활성 인터페이스가 없습니다" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhuma interface ativa detectada ainda." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активных интерфейсов пока не обнаружено." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Henüz etkin arayüz algılanmadı." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未检测到活跃网络接口。" + } + } + } + }, + "No AirPlay devices found" : { + "comment" : "A message displayed when no AirPlay devices are found.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirPlay 기기를 찾을 수 없음" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройства AirPlay не найдены" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirPlay cihazı bulunamadı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到隔空播放设备" + } + } + } + }, + "No AirPlay outputs available" : { + "comment" : "A message displayed when there are no available AirPlay outputs.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용 가능한 AirPlay 출력 장치 없음" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет выходов AirPlay" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirPlay çıkışı yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无可用隔空播放输出" + } + } + } + }, + "No audio outputs available" : { + "comment" : "Displayed when no audio output devices can be listed in the media output selector.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "유효한 오디오 출력 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhuma saída de áudio disponível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет аудиовыходов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ses çıkışı mevcut değil" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无可用音频输出" + } + } + } + }, + "No clipboard history" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L’historique du presse-papiers est vide" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드 기록 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sem histórico de área de transferência" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет истории буфера обмена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pano geçmişi yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无剪贴板历史" + } + } + } + }, + "No Colors Found" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune couleur n’a été trouvé" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 발견 안됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhuma Cor Encontrada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвета не найдены" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Bulunamadı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到颜色" + } + } + } + }, + "No colors match '%@'" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune couleur ne correspond à \"%@\"" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@'와(과) 일치하는 색상이 없습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhuma correspondência de cores ‘%@’" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет цветов, соответствующих «%@»" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' ile eşleşen renk yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有颜色匹配 '%@'" + } + } + } + }, + "No Colors Yet" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhuma cor ainda" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цветов еще нет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Henüz Renk Yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无颜色" + } + } + } + }, + "No copies yet" : { + "comment" : "A message displayed when the clipboard is empty, indicating that there are no copied items.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "복사된 항목 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhuma cópia ainda" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копий еще нет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Henüz kopya yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无复制记录" + } + } + } + }, + "No custom animation available" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No custom animation available" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No custom animation available" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Benutzerdefinierten Animationen verfügbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No custom animation available" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "No custom animation available" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No custom animation available" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune animation personnalisée disponible" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No custom animation available" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nessuna animazione personalizzata disponibile" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용자 정의 애니메이션 없음" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Brak dostępnych niestandardowych animacji" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhuma animação customizada disponível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет пользовательских анимаций" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel animasyon yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No custom animation available" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "没有可用的自定义动画" + } + } + } + }, + "No custom visualizer" : { + "comment" : "merge:ko:alt='사용자 지정 비주얼라이저 없음'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No custom visualizer" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No custom visualizer" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine benutzerdefinierte Audiovisualisierung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No custom visualizer" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "No custom visualiser" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No custom visualizer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun visualiseur personnalisé" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No custom visualizer" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nessun visualizzatore personalizzato" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용자 지정 시각화 도구 없음" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Brak niestandardowych wizualizerów" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhum visualizador customizado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет пользовательских визуализаций" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel görselleştirici yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No custom visualizer" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "没有自定义可视化器" + } + } + } + }, + "No devices found" : { + "comment" : "A message displayed when there are no local send devices available.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기를 찾을 수 없음" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiçbir cihaz bulunamadı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到设备" + } + } + } + }, + "No events" : { + "comment" : "A description displayed when there are no events in the calendar.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이벤트 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhum evento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет событий" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinlik yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无事件" + } + } + } + }, + "No events today" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No events today" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No events today" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Ereignisse heute" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No events today" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "No events today" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No events today" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun événement aujourd'hui" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No events today" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Non ci sono eventi" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "오늘 일정 없음" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Brak wydarzeń na dzisiaj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhum evento para hoje" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сегодня нет событий" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bugün etkinlik yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No events today" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "今天无日程" + } + } + } + }, + "No excludes" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No excludes" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제외된 항목 없음" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Исключений нет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hariç tutulan yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无排除项" + } + } + } + }, + "No extension installed" : { + "comment" : "merge:ko:alt='설치된 확장 프로그램 없음'", + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No extension installed" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No extension installed" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Erweiterungen installiert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No extension installed" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "No extension installed" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No extension installed" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune extension installée" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No extension installed" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nessuna estensione installata" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "설치된 확장기능 없음" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Brak zainstalowanych rozszerzeń" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhuma extensão instalada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расширения не установлены" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzantı yüklü değil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No extension installed" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "未安装扩展" + } + } + } + }, + "No extensions yet" : { + "comment" : "A message displayed when there are no extensions that have requested permission.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 확장 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ainda sem nenhuma extensão" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расширений еще нет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Henüz uzantı yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无扩展" + } + } + } + }, + "No favorites" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun favoris" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sem favoritos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет избранного" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favori yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无收藏" + } + } + } + }, + "No Graphs Enabled" : { + "comment" : "A label displayed when no graphs are enabled in the Stats tab.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성화된 그래프가 없습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhum Gráfico Habilitado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет включенных графиков" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grafik Etkin Değil" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未启用图表" + } + } + } + }, + "No notes yet" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 메모 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhuma nota ainda" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заметок еще нет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Henüz not yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无笔记" + } + } + } + }, + "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, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정된 프리셋이 없습니다. 타이머 팝오버에 표시되도록 프리셋을 추가해 보세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhuma predefinição configurada. Adicione uma predefinição para que ela apareça no pop-over do temporizador." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет настроенных пресетов. Добавьте пресет для его появления в окне таймера." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yapılandırılmış ön ayar yok. Zamanlayıcı açılır penceresinde görünmesi için bir ön ayar ekleyin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未配置预设。添加预设使其出现在计时器弹出窗口中。" + } + } + } + }, + "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, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 프로세스 데이터가 없습니다. 잠시 동안 '상태' 창을 열어두세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhum dado de processo ainda. Mantenha as estatísticas abertas por um momento." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данных о процессах пока нет. Оставьте меню открытым ненадолго." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Henüz işlem verisi yok. İstatistikleri bir süre açık tutun." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚无进程数据。请保持统计打开片刻。" + } + } + } + }, + "No results" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun résultats" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "결과 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhum resultado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет результатов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonuç yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无结果" + } + } + } + }, + "No results found" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun résultats" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "결과 발견 안됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhum resultado encontrado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Результаты не найдены" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonuç bulunamadı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到结果" + } + } + } + }, + "No storage devices detected yet." : { + "comment" : "A message indicating that no storage devices have been detected yet.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 감지된 저장 장치가 없습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhum dispositivo de armazenamento detectado ainda." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройства хранения пока не обнаружены." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Henüz hiçbir depolama cihazı algılanmadı." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未检测到存储设备。" + } + } + } + }, + "Non-notch display height" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Non-notch display height" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Non-notch display height" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Displayhöhe ohne Noch" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non-notch display height" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non-notch display height" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Non-notch display height" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hauteur de l'affichage hors-Notch" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Non-notch display height" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Altezza display senza-notch" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "노치 확장 전 표시 높이" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wysokość wyświetlacza bez notcha" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altura da tela sem o notch" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высота для дисплеев без выреза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentiksiz ekran yüksekliği" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Non-notch display height" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "非Notch显示高度" + } + } + } + }, + "Normal" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보통" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обычный" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "normal" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正常" + } + } + } + }, + "Not active" : { + "comment" : "A description of whether the camera is currently active.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non active" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성화되지 않음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inativo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неактивно" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif değil" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不活跃" + } + } + } + }, + "Not charging" : { + "comment" : "Text displayed in the status text when the battery is not charging.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "충전 중 아님" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не заряжается" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şarj olmuyor" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未充电" + } + } + } + }, + "Not Determined" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "결정 안 됨" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Belirlenmedi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未确定" + } + } + } + }, + "Not Now" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Not Now" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Not Now" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht jetzt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Now" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Now" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ahora no" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas pour l'instant" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Not Now" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Non Ora" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "나중에" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nie teraz" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agora Não" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не сейчас" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Şimdi Değil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Not Now" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "不是现在" + } + } + } + }, + "Not Recording" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Recording" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kayıt yok" + } + } + } + }, + "Not Set" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정 안 됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não Definido" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не задано" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayarlanmadı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未设置" + } + } + } + }, + "Notch behavior" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch behavior" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch behavior" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verhalten der Notch" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notch behavior" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notch behavior" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Comportamiento de Notch" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comportement du Notch" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch behavior" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Comportamento notch" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "노치 동작" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zachowanie notcha" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comportamento do notch" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поведение выреза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentik davranışı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch behavior" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch行为" + } + } + } + }, + "Notch display height" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch display height" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch display height" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anzeigehöhe der Notch" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notch display height" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notch display height" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch display height" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hauteur d'affichage du Notch" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch display height" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Altezza display con notch" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "노치 확장 후 표시 높이" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wysokość wyświetlania notcha" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altura da tela do notch" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высота выреза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentik ekran yüksekliği" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch display height" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch显示高度" + } + } + } + }, + "Notch Experiences" : { + "comment" : "A toggle that enables or disables the \"Notch Experiences\" feature for a specific app.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Функции выреза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentik Deneyimleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "刘海体验" + } + } + }, + "shouldTranslate" : false + }, + "Notch Height" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch Height" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch Height" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Größe der Notch" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notch Height" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notch Height" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch Height" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hauteur du Notch" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch Height" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Altezza Notch" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "노치 높이" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wysokość notcha" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altura do Notch" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высота выреза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Çentik yüksekliği" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch Height" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Notch高度" + } + } + } + }, + "Notch Width" : { + "comment" : "A section header for adjusting the expanded notch width.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치 너비" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Largura do Notch" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ширина выреза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentik Genişliği" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "刘海宽度" + } + } + } + }, + "Notes" : { + "comment" : "The title of the Notes settings page.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заметки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notlar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "笔记" + } + } + } + }, + "Nothing Playing" : { + "comment" : "A label displayed when there is nothing playing in the music player.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "재생중인 항목 없음" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ничего не воспроизводится" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiçbir şey oynatılmıyor" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无正在播放" + } + } + } + }, + "Notify before" : { + "comment" : "A label describing the slider that allows the user to set a reminder lead time.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미리 알림" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificar antes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ничего не воспроизводится" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daha önce bildir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "提前通知" + } + } + } + }, + "now" : { + "comment" : "A string indicating that an event is starting at the current time.", + "extractionState" : "manual" + }, + "Now Playing" : { + "comment" : "Name of the media controller type for the \"Now Playing\" option.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지금 재쟁 중" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сейчас воспроизводится" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şimdi Oynanıyor" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在播放" + } + } + } + }, + "Number of lines kept in the scrollback buffer. Higher values use more memory." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스크롤백 버퍼에 보관할 줄 수입니다. 값이 클수록 메모리 사용량이 늘어납니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Количество строк в буфере прокрутки. Высокие значения используют больше памяти." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geri kaydırma arabelleğinde tutulan satır sayısı. Daha yüksek değerler daha fazla bellek kullanır." + } + } + } + }, + "Off" : { + "comment" : "Text displayed in a status label at the top of the screen indicating whether low power mode is on or off.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "끔" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выкл" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapalı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关" + } + } + } + }, + "OK" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "OK" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "OK" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ok" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ok" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "OK" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "OK" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "네" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "OK" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "OK" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОК" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tamam" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "OK" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "好的" + } + } + }, + "shouldTranslate" : false + }, + "On" : { + "comment" : "Status text that indicates whether low power mode is on or off.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "켬" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вкл" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Açık" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开" + } + } + } + }, + "On hold" : { + "comment" : "Plugged in but charging is paused by the system" + }, + "One or more files failed to load" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "One or more files failed to load" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "One or more files failed to load" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eine oder mehrere Dateien konnten nicht geladen werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "One or more files failed to load" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "One or more files failed to load" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "One or more files failed to load" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de charger un ou plusieurs fichiers" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "One or more files failed to load" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Impossibile caricare uno o più file" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "한 개 이상의 파일 로드 실패" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nie udało się załadować jednego lub więcej plików" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Um ou mais arquivos falharam ao carregar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось загрузить один или несколько файлов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir veya daha fazla dosya yüklenemedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "One or more files failed to load" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "一个或多个文件加载失败" + } + } + } + }, + "Only app icon" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Only app icon" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앱 아이콘만" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только значок приложения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yalnızca uygulama simgesi" + } + } + } + }, + "Only applies when Material is set to Frosted Glass." : { + "comment" : "A description of the media panel blur setting, visible only when Material is set to Frosted Glass. | A description of the media panel blur setting, visible only when Material is set to Frosted Glass. | merge:ko:alt='‘Material’이 ‘Frosted Glass’로 설정된 경우에만 적용됩니다.'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "‘Material’이 '프로스티드 글래스'로 설정된 경우에만 적용됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Só se aplica quando o Material é definido para Vidro Fosco." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Применяется только если выбран материал «Матовое стекло»." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yalnızca Malzeme Buzlu Cam olarak ayarlandığında geçerlidir." + } + } + } + }, + "Only available when Material is set to Frosted Glass." : { + "comment" : "A description of a feature that is only available when a specific setting is enabled. | A description of a feature that is only available when a specific setting is enabled. | merge:ko:alt='Material이 'Frosted Glass'로 설정된 경우에만 사용할 수 있습니다.'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Material이 '프로스티드 글래스'로 설정된 경우에만 사용할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disponível somente quando o Material estiver definido para Vidro Fosco." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступно только если выбран материал «Матовое стекло»." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yalnızca Malzeme Buzlu Cam olarak ayarlandığında kullanılabilir." + } + } + } + }, + "Only download icon" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Only download icon" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 아이콘만" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только значок загрузки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yalnızca indir simgesi" + } + } + } + }, + "Open Google AI Studio" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google AI Studio 열기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir no Google AI Studio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть Google AI Studio" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google AI Studio'yi açın" + } + } + } + }, + "Open in Browser" : { + "comment" : "A button that opens Spotify in the user's browser.", + "isCommentAutoGenerated" : true + }, + "Open in Calendar" : { + "comment" : "A button label that opens a calendar with the event details.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캘린더에서 열기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir no Calendário" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть в приложении Календарь" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takvim'de aç" + } + } + } + }, + "Open Model Settings" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델 설정 열기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir as Configurações do Modelo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть настройки модели" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model Ayarlarını Aç" + } + } + } + }, + "Open notch on hover" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "포인터 올리기 시 노치 열기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открывать вырез при наведении" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fareyle üzerine gelindiğinde çentiği açın" + } + } + } + }, + "Open Privacy & Security" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개인정보 보호 및 보안 열기" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gizlilik ve Güvenlik'i açın" + } + } + } + }, + "Open shelf tab by default if items added" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "항목이 추가되면 기본적으로 서랍 탭 열기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открывать вкладку полки по умолчанию при добавлении файлов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öğeler eklenirse raf sekmesini varsayılan olarak aç" + } + } + } + }, + "Open Spotify Web Player" : { + "comment" : "A link to open Spotify.", + "isCommentAutoGenerated" : true + }, + "Open System Settings" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시스템 설정 열기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir as Configurações do Sistema" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть Системные Настройки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistem Ayarlarını Aç" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开系统设置" + } + } + } + }, + "Opens a transparent preview window with mock data that mirrors the current lock screen widget configuration." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 잠금 화면 위젯 설정이 반영된 가상 데이터와 함께 투명한 미리보기 창을 엽니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открывает прозрачное окно предпросмотра, которое дублирует конфигурацию виджетов." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçerli kilit ekranı widget yapılandırmasını yansıtan sahte verileri içeren şeffaf bir önizleme penceresi açar." + } + } + } + }, + "Opens the AI assistant panel for file analysis and conversation. Default is Cmd+Shift+A. Only works when screen assistant feature is enabled." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일 분석 및 대화를 위한 AI 어시스턴트 패널을 엽니다. 기본 단축키는 Cmd+Shift+A이며, 화면 어시스턴트 기능이 활성화된 경우에만 작동합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre o painel do assistente de IA para análise e conversação de arquivos. O padrão é Cmd+Shift+A. Só funciona quando o recurso de assistente de tela está ativado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открывает прозрачное окно предварительного просмотра с имитаций данных, отражающих текущую конфигурацию виджета экрана блокировки." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosya analizi ve konuşma için AI asistan panelini açar. Varsayılan Cmd+Shift+A'dır. Yalnızca ekran yardımcısı özelliği etkinleştirildiğinde çalışır." + } + } + } + }, + "Opens the clipboard history panel. Default is Cmd+Shift+V (similar to Windows+V on PC). Only works when clipboard feature is enabled." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드 기록 패널을 엽니다. 기본 단축키는 Cmd+Shift+V(Windows의 Win+V와 유사)이며," + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre o painel de histórico da área de transferência. O padrão é Cmd+Shift+V (semelhante ao Windows+V no PC). Só funciona quando o recurso da área de transferência está ativado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открывает панель буфера обмена. По умолчанию Cmd+Shift+V." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pano geçmişi panelini açar. Varsayılan Cmd+Shift+V'dir (PC'deki Windows+V'ye benzer). Yalnızca pano özelliği etkinleştirildiğinde çalışır." + } + } + } + }, + "Opens the color picker panel for screen color capture. Default is Cmd+Shift+P. Only works when color picker feature is enabled." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "화면 색상 캡처를 위한 색상 선택기 패널을 엽니다. 기본 단축키는 Cmd+Shift+P이며, 색상 선택기 기능이 활성화된 경우에만 작동합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre o painel do seletor de cores para captura de cores da tela. O padrão é Cmd+Shift+P. Só funciona quando o recurso de seleção de cores está ativado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открывает панель пипетки цветов. По умолчанию Cmd+Shift+P." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran rengi yakalama için renk seçici panelini açar. Varsayılan Cmd+Shift+P'dir. Yalnızca renk seçici özelliği etkinleştirildiğinde çalışır." + } + } + } + }, + "Opens the terminal tab in the notch. Default is Ctrl+`. Only works when terminal feature is enabled." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치에서 터미널 탭을 엽니다. 기본 단축키는 Ctrl+`이며, 터미널 기능이 활성화된 경우에만 작동합니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открывает вкладку терминала в вырезе. По умолчанию Ctrl+`." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentikteki terminal sekmesini açar. Varsayılan Ctrl+`'dir. Yalnızca terminal özelliği etkinleştirildiğinde çalışır." + } + } + } + }, + "Option as Meta key" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Option을 Meta 키로 사용" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Клавиша Option как Meta" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meta anahtarı olarak seçenek" + } + } + } + }, + "Option as Meta sends Esc+key instead of macOS special characters. Mouse reporting forwards mouse events to terminal applications like vim or tmux." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'Option을 Meta 키로 사용'을 선택하면 macOS 특수 문자 대신 Esc+키를 전송합니다. '마우스 리포팅'은 마우스 이벤트를 vim이나 tmux 같은 터미널 애플리케이션으로 전달합니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Option как Meta отправляет Esc+клавишу вместо спецсимволов." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meta seçeneği, macOS özel karakterleri yerine Esc+tuşunu gönderir. Fare raporlaması, fare olaylarını vim veya tmux gibi terminal uygulamalarına iletir." + } + } + } + }, + "Other" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기타" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Другое" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diğer" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "其他" + } + } + } + }, + "Output devices" : { + "comment" : "Section title for the list of available audio output devices in the media output selector.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "출력 기기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo de Saída" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использование Option в качестве Meta клавиши отправляет Esc+клавишу вместо специальных символов macOS. Отправка сообщений о действиях мыши передаёт события мыши приложениям терминала, например vim или tmux." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çıkış cihazları" + } + } + } + }, + "Output volume" : { + "comment" : "Label describing the system volume control inside the media output selector.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "출력 볼륨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volume de Saída" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Громкость звука" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çıkış hacmi" + } + } + } + }, + "Overtime" : { + "comment" : "A label indicating that the timer is running overtime.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시간 초과" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hora extra" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сверхурочное время" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fazla mesai" + } + } + } + }, + "Panel" : { + "comment" : "Display name for the clipboard display mode \"Panel\".", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패널" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Панель" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paneli" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "面板" + } + } + } + }, + "Panel mode shows clipboard in a floating window near the notch." : { + "comment" : "A description of the \"Panel\" clipboard display mode.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패널 모드는 노치 근처의 플로팅 윈도우에 클립보드를 표시합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O modo painel mostra a área de transferência em uma janela flutuante perto do notch." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Панель" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panel modu, panoyu çentiğin yakınında kayan bir pencerede gösterir." + } + } + } + }, + "Panel mode shows color picker in a floating window. Popover mode shows color picker as a dropdown attached to the color picker button." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패널 모드는 플로팅 윈도우에 색상 선택기를 표시합니다. 팝오버 모드는 색상 선택기 버튼에 연결된 드롭다운 형식으로 색상 선택기를 표시합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O modo painel mostra o seletor de cores em uma janela flutuante. O modo popover mostra o seletor de cores como um menu suspenso anexado ao botão do seletor de cores." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим \"Панель\" показывает пипетку в отдельном окне. Всплывающее окно – возле кнопки." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panel modu, yüzen bir pencerede renk seçiciyi gösterir. Açılır pencere modu, renk seçici düğmesine bağlı bir açılır menü olarak renk seçiciyi gösterir." + } + } + } + }, + "Panel mode shows the assistant in a floating window near the notch. Popover mode shows the assistant as a dropdown attached to the AI button." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패널 모드는 노치 근처의 플로팅 윈도우에 어시스턴트를 표시합니다. 팝오버 모드는 AI 버튼에 연결된 드롭다운 형식으로 어시스턴트를 표시합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O modo painel mostra o assistente em uma janela flutuante perto do notch. O modo Popover mostra o assistente como um menu suspenso conectado ao botão AI." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инструмент ИИ отображается в отдельном окне или контекстном меню кнопки." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panel modu, asistanı çentiğin yakınındaki yüzen bir pencerede gösterir. Açılır pencere modu, asistanı AI düğmesine bağlı bir açılır menü olarak gösterir." + } + } + } + }, + "Parallax Effect Intensity" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시차 효과 강도" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paralaks Etkisi Yoğunluğu" + } + } + } + }, + "Paste from Clipboard" : { + "comment" : "A button that copies the Spotify cookie from the clipboard.", + "isCommentAutoGenerated" : true + }, + "Pause" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пауза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duraklat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂停" + } + } + }, + "shouldTranslate" : false + }, + "Paused" : { + "comment" : "Text displayed in the timer view when the timer is paused.", + "isCommentAutoGenerated" : true + }, + "Peak" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최고" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пик" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zirve" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "峰值" + } + } + } + }, + "Pending" : { + "comment" : "A label indicating that a user's permission to access a feature is being checked.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대기 중" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pendente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В ожидании" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beklemede" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "待处理" + } + } + } + }, + "Per-Core Usage" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코어별 사용량" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка на ядро" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çekirdek Başına Kullanım" + } + } + } + }, + "Percent change per key press. Fine step applies when holding Shift+Option." : { + + }, + "Percentage" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Percentage" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "퍼센트" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Процент" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yüzde" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "百分比" + } + } + } + }, + "Permission States:" : { + "comment" : "A label describing the different permission states displayed in the footer of the extensions settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "권한 상태:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estados das Permissões" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статусы разрешений:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İzin Durumları:" + } + } + } + }, + "Permissions" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "권한" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permissões" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İzinler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "权限" + } + } + } + }, + "Pick Color" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 선택" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecione a Cor" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать цвет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Seç" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取色" + } + } + } + }, + "Pick per-widget liquid-glass variants below. Changes mirror the Lock Screen tab." : { + "comment" : "merge:ko:alt='아래에서 위젯별Liquid Glass 스타일을 선택하세요. 변경 사항은 '잠금 화면' 탭에 동일하게 반영됩니다.'", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아래에서 위젯별 리퀴드 글래스 스타일을 선택하세요. 변경 사항은 '잠금 화면' 탭에 동일하게 반영됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha variantes do Liquid Glass por widget abaixo. As alterações espelham a aba Tela de Bloqueio." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройте варианты liquid-glass для каждого виджета. Изменения дублируют вкладку экрана блокировки." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aşağıda widget başına sıvı cam çeşitlerini seçin. Değişiklikler Kilit Ekranı sekmesini yansıtır." + } + } + } + }, + "Picked %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 선택됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecionado %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбран %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ seçildi" + } + } + } + }, + "Picked Color" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택된 색상" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cor Selecionada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбранный цвет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seçilen Renk" + } + } + } + }, + "Picking Status" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택 상태" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status do Seletor" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус захвата" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toplama Durumu" + } + } + } + }, + "Pill-shaped island with rounded corners, similar to iPhone's Dynamic Island" : { + "comment" : "Description of the Dynamic Island style for external displays.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhone의 Dynamic Island와 유사한, 모서리가 둥근 알약 모양의 아일랜드" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вырез в форме таблетки с закругленными углами, как в iPhone." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhone'un Dynamic Island modeline benzer, köşeleri yuvarlatılmış hap şeklindeki ada" + } + } + } + }, + "Pin items from history to save them here" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록에서 항목을 고정하여 이곳에 저장하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fixe itens do histórico para salvá-los aqui" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остров с закруглёнными углами, похожий на динамический остров iPhone" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçmişteki öğeleri buraya kaydetmek için sabitleyin" + } + } + } + }, + "Pin items to add favorites" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "항목을 고정하여 즐겨찾기에 추가하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fixar itens para adicionar favoritos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрепите элементы для добавления в избранное" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorilere eklemek için öğeleri sabitleyin" + } + } + } + }, + "Pin items to add them to favorites" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "항목을 고정하여 즐겨찾기에 추가하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fixar itens para adicioná-los aos favoritos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрепите элементы для добавления в избранное" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorilere eklemek için öğeleri sabitleyin" + } + } + } + }, + "Pinned Items" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "고정된 항목" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Itens fixados" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрепленные элементы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sabitlenmiş Öğeler" + } + } + } + }, + "Play / Pause" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "재생 / 일시정지" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oynat / Duraklat" + } + } + } + }, + "Play feedback when volume is changed" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "음량 조절 시 피드백 재생" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проигрывать отклик при изменении громкости" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ses düzeyi değiştirildiğinde geri bildirim çal" + } + } + } + }, + "Play lock/unlock sounds" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 및 잠금 해제 사운드 재생" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проигрывать звуки при открытии/закрытии" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilitleme/kilit açma seslerini çal" + } + } + } + }, + "Play low battery alert sound" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 부족 경고음 재생" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Воспроизводить звук при низком заряде батареи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düşük pil uyarı sesini çal" + } + } + } + }, + "Plays the supplied feedback clip whenever you press the hardware volume keys." : { + "comment" : "A help text describing the audio feedback feature.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "하드웨어 음량 버튼을 누를 때마다 제공된 피드백 사운드를 재생합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reproduz o clipe de feedback fornecido sempre que você pressiona as teclas de volume do hardware." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Воспроизводит звуковой отклик при нажатии аппаратных клавиш громкости." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Donanım ses seviyesi tuşlarına her bastığınızda, birlikte verilen geri bildirim klibini oynatır." + } + } + } + }, + "Please configure your API key for the selected AI provider in model settings." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델 설정에서 선택한 AI 제공업체의 API 키를 설정해 주세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configure sua chave de API para o provedor de IA selecionado nas configurações do modelo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройте ваш API ключ для выбранного провайдера ИИ в настройках." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lütfen model ayarlarında seçilen AI sağlayıcısı için API anahtarınızı yapılandırın." + } + } + } + }, + "Plugged In" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Plugged In" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Plugged In" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angeschlossen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plugged In" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plugged In" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Plugged In" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Branché" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Plugged In" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Collegato" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "충전 중" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Podłączony" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключено" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takılı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Plugged In" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已插入" + } + } + } + }, + "Popover" : { + "comment" : "Description of a timer display mode when the controls are displayed as a popover.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "팝오버" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всплывающее окно" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Popover" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "弹出窗口" + } + } + } + }, + "Popover mode shows clipboard as a dropdown attached to the clipboard button." : { + "comment" : "A description of the \"Popover mode\" in the Clipboard Settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "팝오버 모드는 클립보드 버튼에 연결된 드롭다운 형식으로 클립보드를 표시합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O modo popover mostra a área de transferência como um menu suspenso anexado ao botão da área de transferência." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всплывающее окно" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Açılır pencere modu, panoyu pano düğmesine eklenmiş bir açılır menü olarak gösterir." + } + } + } + }, + "Popover mode shows color picker as a dropdown attached to the color picker button. Panel mode shows color picker in a floating window." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "팝오버 모드는 색상 선택기 버튼에 연결된 드롭다운 형식으로 색상 선택기를 표시합니다. 패널 모드는 플로팅 윈도우에 색상 선택기를 표시합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O modo popover mostra o seletor de cores como um menu suspenso anexado ao botão do seletor de cores. O modo painel mostra o seletor de cores em uma janela flutuante." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поведение отображения пипетки." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Açılır pencere modu, renk seçici düğmesine bağlı bir açılır menü olarak renk seçiciyi gösterir. Panel modu, yüzen bir pencerede renk seçiciyi gösterir." + } + } + } + }, + "Popover mode shows the assistant as a dropdown attached to the AI button. Panel mode shows the assistant in a floating window near the notch." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "팝오버 모드는 AI 버튼에 연결된 드롭다운 형식으로 어시스턴트를 표시합니다. 패널 모드는 노치 근처의 플로팅 윈도우에 어시스턴트를 표시합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O modo Popover mostra o assistente como um menu suspenso conectado ao botão AI. O modo painel mostra o assistente em uma janela flutuante perto do notch." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поведение отображения ИИ помощника." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Açılır pencere modu, asistanı AI düğmesine bağlı bir açılır menü olarak gösterir. Panel modu, asistanı çentiğin yakınındaki yüzen bir pencerede gösterir." + } + } + } + }, + "Position" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Position" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "위치" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posição" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позиция" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pozisyon" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "位置" + } + } + } + }, + "Preset name" : { + "comment" : "A label for the name of a timer preset.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프리셋 이름" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome da predefinições" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имя пресета" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ön ayar adı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预设名称" + } + } + } + }, + "Presets" : { + "comment" : "A header label for the list of timer presets.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프리셋" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predefinições" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пресеты" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ön ayarlar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预设" + } + } + } + }, + "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, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프리셋은 설정된 이름, 시간 및 강조 색상과 함께 타이머 팝오버 안에 표시됩니다. 표시 순서를 바꾸려면 항목을 재정렬하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "As predefinições aparecem dentro do pop-over do temporizador com o nome configurado, a duração e a cor de destaque. Reordene-os para alterar a ordem de exibição." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пресеты отображаются в окне таймера. Меняйте их порядок для изменения отображения." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ön ayarlar, yapılandırılmış ad, süre ve vurgu rengiyle birlikte zamanlayıcı açılır penceresinin içinde görünür. Görüntüleme sırasını değiştirmek için bunları yeniden sıralayın." + } + } + } + }, + "Pressure" : { + "comment" : "A label describing the memory pressure status.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pression" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "압력" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pressão" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Давление" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basınç" + } + } + } + }, + "Pressure & Swap" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "압력 및 스왑" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нагрузка и Файлы подкачки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basınç ve Değişim" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内存压力与交换" + } + } + } + }, + "Prevents the terminal from closing when the cursor accidentally leaves the notch area." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "커서가 실수로 노치 영역을 벗어날 때 터미널이 닫히지 않도록 방지합니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предотвращает закрытие терминала при случайном отводе курсора от зоны выреза." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İmleç yanlışlıkla çentik alanından çıktığında terminalin kapanmasını önler." + } + } + } + }, + "Preview" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aperçu" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미리보기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pré-Vizualização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предпросмотр" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önizleme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预览" + } + } + } + }, + "Preview lock screen widgets" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 위젯 미리보기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предпросмотр виджетов экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı widget'larını önizleyin" + } + } + } + }, + "Preview Zoom" : { + "comment" : "A label describing the zoom control for the animation preview.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미리보기 확대" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zoom de visualização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предпросмотр виджетов экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yakınlaştırmayı Önizle" + } + } + } + }, + "Previous Track" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이전 트랙" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önceki Parça" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一首" + } + } + } + }, + "Privacy Indicators" : { + "comment" : "A heading for the section that lists privacy indicators.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개인정보 보호 표시기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indicadores de Privacidade" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Индикаторы конфиденциальности" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gizlilik Göstergeleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐私指示器" + } + } + } + }, + "Privacy Policy" : { + "comment" : "A link to the privacy policy.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개인정보 보호 정책" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Política de Privacidade" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Политика конфиденциальности" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gizlilik Politikası" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐私政策" + } + } + } + }, + "PRO" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRO" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRO" + } + } + } + }, + "Productivity" : { + "comment" : "Title of a settings tab group related to productivity features.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "생산성" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продуктивность" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verimlilik" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "效率" + } + } + } + }, + "Progress" : { + "comment" : "Name of the download indicator style that shows a progress bar.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행도" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прогресс" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlerleme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "进度" + } + } + } + }, + "Progress bar" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Progress bar" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 표시줄" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Индикатор прогресса" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlerleme çubuğu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "进度条" + } + } + } + }, + "Progress style" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행도 스타일" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estilo do progresso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль индикатора" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlerleme stili" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "进度样式" + } + } + } + }, + "Progressbar style" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Progressbar style" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Progressbar style" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ladebalken Stil" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Progressbar style" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Progress bar style" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Progressbar style" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Style de la barre de progression" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Progressbar style" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Stile barra di avanzamento" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 표시줄 스타일" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Styl paska postępu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estilo da barra de progresso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль индикатора прогресса" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlerleme çubuğu stili" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Progressbar style" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "进度条样式" + } + } + } + }, + "Provider" : { + "comment" : "A label for selecting a third-party display integration provider.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공급자" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Провайдер" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "sağlayıcı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "提供商" + } + } + } + }, + "Quick Actions" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actions rapides" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "빠른 동작" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ações Rápidas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Быстрые действия" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hızlı Eylemler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "快捷操作" + } + } + } + }, + "Quick Share" : { + "comment" : "A section header that says \"Quick Share\".", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Быстрая отправка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hızlı Paylaşım" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "快速共享" + } + } + }, + "shouldTranslate" : false + }, + "Quick Share Service" : { + "comment" : "A picker that lets the user select a quick share service to use when sharing files from the shelf.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quick Share 서비스" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serviço de Quick Share" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Быстрая отправка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hızlı Paylaşım Hizmeti" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "快速共享服务" + } + } + } + }, + "Quick Share settings" : { + "comment" : "A help text for the \"Quick Share settings\" button in the AirDrop view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quick Share 설정" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hızlı Paylaşım ayarları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "快速共享设置" + } + } + } + }, + "Quit" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Quit" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Quit" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verlassen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quit" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quit" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Quit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitter" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Quit" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Chiudi" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "종료" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wyjdź" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fechar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Çık" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Quit" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "退出" + } + } + } + }, + "Quit app" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Quit app" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Quit app" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App verlassen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quit app" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quit app" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Cerrar app" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitter l'application" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Quit app" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Chiudi app" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "앱 종료" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wyjdź z aplikacji" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fechar Aplicativo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть приложение" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Uygulamadan Çık" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Quit app" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "退出应用" + } + } + } + }, + "Quit boring.notch" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quit boring.notch" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitter boring.notch" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "boring.notch 종료" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть boring.notch" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "boring.notch'tan çık" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "退出 boring.notch" + } + } + } + }, + "R " : { + "comment" : "Label for the read data in the disk usage graph.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "R" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "R " + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "O " + } + } + } + }, + "Re-authorize" : { + "comment" : "A button that re-authorizes an app when pressed.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "재인증" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorizar Novamente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ч " + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yeniden yetkilendir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新授权" + } + } + } + }, + "Read" : { + "comment" : "A heading displayed for the user's disk read speed.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lecture" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "읽기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ler" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чтение" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oku" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "读取" + } + } + } + }, + "Ready" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prêt" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "준비" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pronto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Готово" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hazır" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "就绪" + } + } + } + }, + "Reasoning Mode" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mode de raisonnemen" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추론 모드" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo de raciocínio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим размышления" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muhakeme Modu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "推理模式" + } + } + } + }, + "Recent Activity (last 5 minutes)" : { + "comment" : "A label describing the recent activity of a particular extension.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최근 활동 (최근 5분간)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atividade recente (últimos 5 minutos)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Недавняя активность (последние 5 мин)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son Etkinlik (son 5 dakika)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最近活动(最近 5 分钟)" + } + } + } + }, + "Recent Colors" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couleurs récentes" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최근 색상" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cores Recentes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Недавние цвета" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son Renkler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最近颜色" + } + } + } + }, + "Recommended minimum width adjusts automatically based on the number of enabled tabs." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성화된 탭 수에 따라 권장 최소 너비가 자동으로 조절됩니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önerilen minimum genişlik, etkin sekme sayısına göre otomatik olarak ayarlanır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "推荐最小宽度根据启用的标签页数量自动调整。" + } + } + } + }, + "Recording" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrement" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "녹화중" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gravando" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запись" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kayıt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "录制中" + } + } + } + }, + "Recording Active" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recording Active" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kayıt etkin" + } + } + } + }, + "Recording Detected" : { + "comment" : "A text description of a red dot displayed in the Atoll header when screen recording is active.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrement détécté" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "녹화 감지" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gravação Detectada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обнаружена запись" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kayıt Algılandı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "检测到录制" + } + } + } + }, + "Recording Status" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statut de l’enregistrement" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "녹화 상태" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status de Gravação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус записи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kayıt Durumu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "录制状态" + } + } + } + }, + "Refresh detection" : { + "comment" : "A button label that refreshes the detection status of BetterDisplay.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새로고침 감지" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление обнаружения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yenileme tespiti" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "刷新检测" + } + } + } + }, + "Refresh devices" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기 새로 고침" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cihazları yenile" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "刷新设备" + } + } + } + }, + "Rejected" : { + "comment" : "A label displayed when a device is rejected.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "거절됨" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reddedildi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已拒绝" + } + } + } + }, + "Release name" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Release name" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Release name" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veröffentlichungsname" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Release name" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Release name" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Release name" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom de la version" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Release name" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nome del rilascio" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "릴리즈 이름" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nazwa wydania" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome do lançamento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Название релиза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sürüm adı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Release name" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "版本名称" + } + } + } + }, + "Remember last tab" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Remember last tab" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Remember last tab" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Merke den letzten Tab" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remember last tab" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remember last tab" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Remember last tab" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se souvenir du dernier onglet" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Remember last tab" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ricorda l'ultima scheda" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "마지막 탭 기억하기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zapamiętaj ostatnią kartę" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lembre-se da última aba" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запомнить последнюю вкладку" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son sekmeyi hatırla" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Remember last tab" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "记住上一个标签页" + } + } + } + }, + "Reminder Live Activity" : { + "comment" : "A section header that lists the options related to live activity notifications for reminders.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미리 알림 실시간 현황" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lembrete de Atividade ao Vivo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live Activity напоминаний" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hatırlatma Canlı Etkinliği" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "提醒实时活动" + } + } + } + }, + "Reminder Widget" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미리 알림 위젯" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виджет напоминаний" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hatırlatma Widget'ı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "提醒小组件" + } + } + } + }, + "Reminders" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rappels" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미리 알림" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lembretes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виджет напоминаний" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hatırlatıcılar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "提醒事项" + } + } + } + }, + "Remove" : { + "comment" : "A button that removes an extension from the authorized list.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제거" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remover" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kaldır" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除" + } + } + } + }, + "Remove %@ from the authorized extensions list? This will dismiss all active live activities, lock screen widgets, and notch experiences from this app." : { + "comment" : "A message presented when a user confirms they want to remove an extension from the authorized list. The argument is the name of the extension. | A message presented when a user confirms they want to remove an extension from the authorized list. The argument is the name of the extension. | merge:ko:alt='%@을(를) 승인된 확장을 목록에서 제거하시겠습니까? 제거하면 이 앱의 모든 활성화된 실시간 현황, 잠금 화면 위젯 및 노치 경험이 종료됩니다.'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@을(를) 승인된 확장 프로그램 목록에서 제거하시겠습니까? 제거하면 이 앱의 모든 활성화된 실시간 현황, 잠금 화면 위젯 및 노치 경험이 종료됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remover %@ da lista de extensões autorizadas? Isso descartará todas as atividades ativas ao vivo, widgets de tela de bloqueio e experiências do Notch deste aplicativo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить %@ из списка авторизованных расширений?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ yetkili dahili numaralar listesinden kaldırılsın mı? Bu, bu uygulamadaki tüm aktif canlı etkinlikleri, kilit ekranı widget'larını ve çentik deneyimlerini devre dışı bırakacaktır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从授权扩展列表中移除 %@?这将关闭此应用的所有活跃实时活动、锁屏小组件和刘海体验。" + } + } + } + }, + "Remove Extension" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확장 제거" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remover Extensão" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить расширение" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzantıyı Kaldır" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除扩展" + } + } + } + }, + "Remove from shelf after dragging" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "드래그한 후 서랍에서 제거" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remova da prateleira depois de arrastar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалять с полки после перетаскивания" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sürükledikten sonra raftan çıkarın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拖拽后从架子上移除" + } + } + } + }, + "Remove selected" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택된 항목 제거" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить выбранное" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seçileni kaldır" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除所选" + } + } + } + }, + "Render" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "렌더" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рендер" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İşleme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "渲染" + } + } + } + }, + "Renderer" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "렌더러" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рендерер" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oluşturucu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "渲染器" + } + } + } + }, + "Repeat" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "반복" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tekrarla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重复播放" + } + } + } + }, + "Replaces the artwork tile with the live canvas when the current app provides one, and reuses that moving canvas for the surrounding lighting effect." : { + + }, + "Request Access" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demander l’accès" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "접근 권한 요청" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requer Acesso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить выбранное" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erişim Talebi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请求访问" + } + } + } + }, + "Request Full Disk Access" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 디스크 접근 권한 요청" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tam Disk Erişimi İste" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请求完全磁盘访问" + } + } + } + }, + "Requires a third-party client with API plugin enabled." : { + "comment" : "Description of a music controller type when it requires a third-party client with an API plugin enabled. | Description of a music controller type when it requires a third-party client with an API plugin enabled. | merge:ko:alt='API 플러그인이 활성화된 타사 클라이언트가 필요합니다.'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "API 플러그인이 활성화된 서드파티 클라이언트가 필요합니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется сторонний клиент с включенным API плагином." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "API eklentisinin etkin olduğu bir üçüncü taraf istemci gerektirir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "需要启用 API 插件的第三方客户端。" + } + } + } + }, + "Requires Accessibility permission so Dynamic Island can intercept the hardware volume keys." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island가 하드웨어 음량 키를 제어할 수 있도록 접근성 권한이 필요합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requer permissão de Acessibilidade para que a Dynamic Island possa interceptar as teclas de volume do hardware." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется сторонний клиент с включенным плагином API." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island'nin donanım ses düzeyi anahtarlarını ele geçirebilmesi için Erişilebilirlik izni gerektirir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "需要辅助功能权限以便灵动岛拦截硬件音量键。" + } + } + } + }, + "Reset" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "초기화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置" + } + } + } + }, + "Reset All" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tout réinitialiser" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 초기화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir Tudo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить все" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tümünü Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全部重置" + } + } + } + }, + "Reset Context" : { + "comment" : "A button label that resets the conversation context.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "컨텍스트 초기화" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить контекст" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İçeriği Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置上下文" + } + } + } + }, + "Reset Live Activities" : { + "comment" : "A menu item that resets live activities for a particular app.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실시간 현황 초기화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir Atividades Ao Vivo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить Live Activities" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canlı Etkinlikleri Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置实时活动" + } + } + } + }, + "Reset Lock Screen Widgets" : { + "comment" : "A menu item that resets all lock screen widgets for a particular app.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 위젯 초기화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir Widgets da Tela de Bloqueio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить виджеты экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit Ekranı Widget'larını Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置锁屏小组件" + } + } + } + }, + "Reset Media Width" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 너비 초기화" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya Genişliğini Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置媒体宽度" + } + } + } + }, + "Reset Music" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "음악 초기화" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Müziği Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置音乐" + } + } + } + }, + "Reset Notch Experiences" : { + "comment" : "A menu item that resets all notch experiences for a particular app.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치 경험 초기화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir experiências do Notch" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить UI выреза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentik Deneyimlerini Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置刘海体验" + } + } + } + }, + "Reset Rate Limits" : { + "comment" : "A button that resets the rate limits for a particular extension.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "속도 제한 재설정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir Limites de Taxa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить лимиты API" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hız Sınırlarını Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置速率限制" + } + } + } + }, + "Reset Session" : { + "comment" : "A button that clears the Spotify session.", + "isCommentAutoGenerated" : true + }, + "Reset Timer" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 초기화" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcıyı Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置计时器" + } + } + } + }, + "Reset Timer Width" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 너비 초기화" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı Genişliğini Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置计时器宽度" + } + } + } + }, + "Reset to Default" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser aux paramètres d’origine " + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본값으로 재설정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir para o Padrão" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По умолчанию" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsayılana Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复默认" + } + } + } + }, + "Reset to defaults" : { + "comment" : "A button that resets the music slot configuration to its default values.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본값으로 재설정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir para os padrões" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По умолчанию" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsayılanlara sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复默认" + } + } + } + }, + "Reset Weather" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "날씨 초기화" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hava Durumunu Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置天气" + } + } + } + }, + "Reset Width" : { + "comment" : "A button to reset the expanded notch width to its default value.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "너비 초기화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir Larguta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить ширину" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genişliği Sıfırla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置宽度" + } + } + } + }, + "Restart" : { + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Restart" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Restart" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neustarten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restart" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restart" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Restart" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redémarrer" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Restart" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Riavvia" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "재시작" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Uruchom ponownie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Restart" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезапустить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Yeniden başlat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Restart" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "重启" + } + } + } + }, + "Restart Atoll" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relancer Atoll" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll 재시작" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reiniciar Atoll" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезапустить Atoll" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atoll'u yeniden başlat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重启 Atoll" + } + } + } + }, + "Restart shell" : { + "comment" : "A button label that says \"Restart shell\".", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell 재시작" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезапустить shell" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kabuğu yeniden başlat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重启 Shell" + } + } + } + }, + "Restart Shell" : { + "comment" : "A button that restarts the shell process. Any unsaved work in the terminal will be lost.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell 재시작" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезапустить Shell" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kabuğu yeniden başlat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重启 Shell" + } + } + } + }, + "Restarts the shell process. Any unsaved work in the terminal will be lost." : { + "comment" : "A footer description for the \"Restart Shell\" button in the Terminal settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "셸 프로세스를 재시작합니다. 터미널에서 저장되지 않은 작업 내용은 삭제됩니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезапускает процесс shell. Несохраненные данные в терминале будут потеряны." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kabuk işlemini yeniden başlatır. Terminaldeki kaydedilmemiş çalışmalar kaybolacaktır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重启 Shell 进程。终端中未保存的工作将丢失。" + } + } + } + }, + "Restore" : { + "comment" : "The text for a button that restores default settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "qhrrn" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезапускает процессы оболочки. Любые несохраненные данные в терминале будут потеряны." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geri yükle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复" + } + } + } + }, + "Restore default timer presets?" : { + "comment" : "A confirmation dialog title asking if the user wants to restore the default timer presets.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본 타이머 프리셋을 복구하시겠습니까?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurar as predefinições padrão do temporizador?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восстановить пресеты таймера по умолчанию?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsayılan zamanlayıcı ön ayarları geri yüklensin mi?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复默认计时器预设?" + } + } + } + }, + "Restore Defaults" : { + "comment" : "A button that, when tapped, presents a confirmation dialog to reset all timer settings to their default values.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본값 복원" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir para Padrão" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восстановить настройки по умолчанию" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsayılanları Geri Yükle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复默认" + } + } + } + }, + "Restricted" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제한됨" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kısıtlı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "受限" + } + } + } + }, + "Resume" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reprendre" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "재개" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resumir" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Возобновить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devam et" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续" + } + } + } + }, + "Reverse open/close scroll gestures" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "열기/닫기 스크롤 제스처 방향 반전" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инвертировать жесты открытия/закрытия" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Açma/kapama kaydırma hareketlerini ters çevirme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "反转打开/关闭滚动手势" + } + } + } + }, + "Reverse swipe gestures" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스와이프 제스처 방향 반전" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инвертировать жесты смахивания" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ters kaydırma hareketleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "反转滑动手势" + } + } + } + }, + "Revoke Access" : { + "comment" : "A button that revokes access to an app.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "접근 권한 철회" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Revogar Acesso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отозвать доступ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erişimi İptal Et" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "撤销访问" + } + } + } + }, + "Rewind 10s" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "10초 되감기" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 saniyeyi geri sar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "后退 10 秒" + } + } + } + }, + "Rich Text" : { + "comment" : "Name for a clipboard item type that represents a rich text format.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리치 텍스트" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Форматированный текст" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zengin Metin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "富文本" + } + } + } + }, + "Right" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오른쪽" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direita" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Форматированный текст" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sağ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "右" + } + } + } + }, + "Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas." : { + + }, + "Ring" : { + "comment" : "The name of the timer progress style that shows a ring shape.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "링" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Кольцо" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yüzük" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "圆环" + } + } + } + }, + "Rotation:" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rotation:" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "회전:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Кольцо" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rotasyon:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "旋转:" + } + } + } + }, + "Running" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Running" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Running" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Läuft" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Running" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Running" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Running" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En cours d'exécution" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Running" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "In esecuzione" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "실행 중" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Uruchomiony" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Running" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запущено" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koşma" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Running" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "运行中" + } + } + } + }, + "Runs the real notch animation on the current target display. If an external screen is using Dynamic Island mode, the battery HUD is sent there first." : { + + }, + "Sampling can continue while the notch is closed when the timeout is disabled." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시간 제한이 비활성화된 경우, 노치가 닫혀 있는 동안에도 샘플링이 계속될 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A amostragem pode continuar enquanto o notch estiver fechado quando o tempo limite estiver desativado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сэмплинг может продолжаться при закрытом вырезе, если отключен таймаут." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zaman aşımı devre dışı bırakıldığında çentik kapalıyken örneklemeye devam edebilir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "禁用超时后,采样可以在刘海关闭时继续。" + } + } + } + }, + "Save" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salvar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kaydet" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存" + } + } + } + }, + "Save Changes" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer les changements" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "변경된 항목 저장" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salvar Mudanças" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить изменения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Değişiklikleri Kaydet" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存更改" + } + } + } + }, + "Save Configuration" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer la configuration" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "구성 저장" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salvar Configurações" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить конфигурацию" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yapılandırmayı Kaydet" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存配置" + } + } + } + }, + "Scale Down" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réduire l’échelle" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "축소" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diminuir" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уменьшить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ölçeği Azalt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "缩小" + } + } + } + }, + "Scale Up 2x" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doubler l’échelle" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "2배 확대" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aumentar 2x" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Увеличить 2x" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "2 Kat Artırın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "放大 2x" + } + } + } + }, + "Scale:" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Échelle :" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확대:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Масштаб:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ölçek:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "缩放:" + } + } + } + }, + "Scan Again" : { + "comment" : "A button to refresh the list of nearby devices.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다시 검색" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tekrar Tara" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新扫描" + } + } + } + }, + "Scanning for devices..." : { + "comment" : "A description of the scanning process for devices.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기 검색 중..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cihazlar taranıyor..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在扫描设备..." + } + } + } + }, + "Screen Assistant" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스크린 어시스턴트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assistente de Tela" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экранный помощник" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran Asistanı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "屏幕助手" + } + } + } + }, + "Screen Assistant feature is disabled" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스크린 어시스턴트 기능 비활성화 됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A ferramente de Assistente de Tela está desabilitada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Функция экранного помощника отключена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran Yardımcısı özelliği devre dışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "屏幕助手功能已禁用" + } + } + } + }, + "Screen Assistant:" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assistant d’écran :" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스크린 어이스턴트:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assistente de Tela:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экранный помощник:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran Yardımcısı:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "屏幕助手:" + } + } + } + }, + "Screen Padding: %lldpx" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "화면 여백: %lldpx" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Screen Padding: %lldpx" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отступы экрана: %lld px" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran Dolgusu: %lldpx" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "屏幕边距:%lld像素" + } + } + } + }, + "Screen Recording" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "화면 녹과" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gravação de tela" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запись экрана" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran Kaydı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "屏幕录制" + } + } + } + }, + "Screenshot options" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Options de capture d’écran" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캡처 옵션" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opções de captura de tela" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры снимка экрана" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran görüntüsü seçenekleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "截图选项" + } + } + } + }, + "Screenshot Type" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Format de la capture d’écran" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캡처 유형" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo de captura de tela" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тип снимка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran Görüntüsü Türü" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "截图类型" + } + } + } + }, + "Scroll" : { + "comment" : "A hint text that appears when scrolling horizontally in the control palette.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스크롤" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rolar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прокрутка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kaydırma" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "滚动" + } + } + } + }, + "Scroll device name in HUD" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD에서 기기 이름 스크롤" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прокручивать имя устройства в HUD" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD'de cihaz adını kaydırın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在 HUD 中滚动显示设备名称" + } + } + } + }, + "Scrollback" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스크롤백" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История прокрутки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geri kaydırma" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "回滚" + } + } + } + }, + "Scrollback lines" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스크롤백 줄 수" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Строк истории прокрутки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geri kaydırma çizgileri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "回滚行数" + } + } + } + }, + "Search clipboard..." : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechercher dans le presse-papiers…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드 검색..." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pesquisar Área de Transferência" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Строк обратной прокрутки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panoda ara..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索剪贴板..." + } + } + } + }, + "Search colors..." : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechercher des couleurs…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 검색..." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pesquisar cores..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Искать цвета..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renkleri ara..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索颜色..." + } + } + } + }, + "Search extensions..." : { + "comment" : "A placeholder text for a text field used to filter through a list of extensions.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확장 검색..." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pesquisar extensões..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Искать расширения..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzantıları ara..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索扩展..." + } + } + } + }, + "Search notes..." : { + "comment" : "A placeholder text for a search bar in the note list view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모 검색..." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pesquisar Notas…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Искать заметки..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notlarda ara..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索笔记..." + } + } + } + }, + "Search Settings" : { + "comment" : "A placeholder text for a search bar in the settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검색 설정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pesquisar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск настроек" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arama Ayarları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索设置" + } + } + } + }, + "Search..." : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechercher…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검색…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pesquisar…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ara..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索..." + } + } + } + }, + "seconds" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "seconds" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "secondes" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "초" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "секунды" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saniyeler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "秒" + } + } + } + }, + "Seconds" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secondes" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "초" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Секунды" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saniyeler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "秒" + } + } + } + }, + "Segmented" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Segmenté" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세그먼트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Segmentado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сегментированный" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bölümlere ayrılmış" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分段" + } + } + } + }, + "Select a color to view details" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세부 정보를 보려면 색상을 선택하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecione uma cor para ver os detalhes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите цвет для просмотра деталей" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayrıntıları görüntülemek için bir renk seçin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择颜色查看详情" + } + } + } + }, + "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, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머가 종료될 때 재생할 사용자 지정 사운드를 선택하세요. 지원되는 형식은 MP3, M4A, WAV, AIFF입니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecione um som personalizado para reproduzir quando um cronômetro terminar. Os formatos suportados incluem MP3, M4A, WAV e AIFF." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите звук для окончания таймера. Поддерживаются MP3, M4A, WAV и AIFF." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı sona erdiğinde çalmak için özel bir ses seçin. Desteklenen formatlar arasında MP3, M4A, WAV ve AIFF bulunur." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择计时器结束时播放的自定义声音。支持格式包括 MP3、M4A、WAV 和 AIFF。" + } + } + } + }, + "Select Calendars" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sélectionner des calendriers" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캘린더 선택" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecione Calendários" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать календари" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takvimleri Seçin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择日历" + } + } + } + }, + "Select Device" : { + "comment" : "A label displayed above the LocalSend device picker view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기 선택" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cihaz Seç" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择设备" + } + } + } + }, + "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.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "하나 이상의 프로필을 선택하여 사용자 환경을 맞춤 설정하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecione um ou mais perfis para personalizar sua experiência" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите один или несколько профилей для настройки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deneyiminizi özelleştirmek için bir veya daha fazla profil seçin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择一个或多个配置文件以自定义您的体验" + } + } + } + }, + "Select the music source you want to use. You can change this later in the app settings." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Select the music source you want to use. You can change this later in the app settings." + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Select the music source you want to use. You can change this later in the app settings." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle die Musik Quelle aus, die du benutzen willst. Du kannst dies später in den Einstellungen ändern." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select the music source you want to use. You can change this later in the app settings." + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select the music source you want to use. You can change this later in the app settings." + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Select the music source you want to use. You can change this later in the app settings." + } + }, + "fr" : { + "stringUnit" : { + "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." + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Select the music source you want to use. You can change this later in the app settings." + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Selezionare la sorgente musicale che si desidera utilizzare. È possibile modificare questa impostazione in seguito nelle impostazioni dell'app." + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음악 소스 선택하세요. 나중에 앱 설정에서 변경 가능합니다." + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wybierz źródło muzyki, którego chcesz użyć. Możesz to później zmienić w ustawieniach aplikacji." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecione a fonte de música que você deseja usar. Você pode alterar isso mais tarde nas configurações do aplicativo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите желаемый источник музыки. Вы сможете изменить его позже в настройках приложения." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kullanmak istediğiniz müzik kaynağını seçin. Bunu daha sonra uygulama ayarlarından değiştirebilirsiniz." + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Select the music source you want to use. You can change this later in the app settings." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "选择您想要使用的音乐源。您可以稍后在应用设置中更改此项。" + } + } + } + }, + "selected" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "selected" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "selected" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "ausgewählt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "selected" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "selected" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "selected" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sélectionné(s)" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "selected" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "selezionato" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "선택된" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zaznaczone" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "selecionado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "выбрано" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "seçilmiş" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "selected" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已选择" + } + } + } + }, + "Selected" : { + "comment" : "A label indicating that an animation is currently selected.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sélectionnée" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecionado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрано" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seçildi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已选择" + } + } + } + }, + "Selected animation" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Selected animation" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Selected animation" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgewählte Animation" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selected animation" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selected animation" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Selected animation" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animation sélectionnée" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Selected animation" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Animazione selezionata" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "선택된 애니메이션" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wybrana animacja" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animação de seleção" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбранная анимация" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seçilen animasyon" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Selected animation" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "所选动画" + } + } + } + }, + "Sensitivity" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensibilité" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "감도" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чувствительность" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hassasiyet" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "灵敏度" + } + } + } + }, + "Separate Tab" : { + "comment" : "Display name for the clipboard display mode option to open the clipboard in a separate tab.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "별도 탭" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отдельная вкладка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayrı Sekme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "独立标签页" + } + } + } + }, + "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { + "comment" : "A description of the \"Separate Tab\" clipboard display mode.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'별도 탭' 모드는 복사된 항목과 메모를 하나의 뷰로 통합합니다. 두 기능이 모두 활성화된 경우 메모는 오른쪽에, 클립보드는 왼쪽에 표시됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O modo Abas Separadas integra Itens Copiados e Notas em uma única visualização. Se ambos estiverem ativados, o Notes aparecerá à direita e a Área de Transferência à esquerda." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отдельная вкладка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayrı Sekme modu, Kopyalanan Öğeleri ve Notları tek bir görünümde birleştirir. Her ikisi de etkinse, sağda Notlar ve solda Pano görünür." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "独立标签页模式将复制项和笔记整合到单一视图中。如果两者都启用,笔记显示在右侧,剪贴板显示在左侧。" + } + } + } + }, + "Session · ↓ %@ · ↑ %@" : { + "comment" : "A row displaying the total downloaded and uploaded bytes for the network session.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Session · ↓ %1$@ · ↑ %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session · ↓ %1$@ · ↑ %2$@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 · ↓ %1$@ · ↑ %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sessão · ↓ %1$@ · ↑ %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сессия · ↓ %1$@ · ↑ %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oturum · ↓ %@ · ↑ %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话 · ↓ %@ · ↑ %@" + } + } + } + }, + "Session Avg" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 평균" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В среднем за сеанс" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oturum Ortalaması" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话平均" + } + } + } + }, + "Session Totals" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 총합" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Итого за сеанс" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oturum Toplamları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话总计" + } + } + } + }, + "Set" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Définir" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Definir" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayarla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" + } + } + } + }, + "Settings" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Settings" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Settings" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Settings" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paramètres" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Settings" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Impostazioni" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "설정" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ustawienia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurações" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ayarlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Settings" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "设置" + } + } + } + }, + "Settings icon in notch" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치에 설정 아이콘 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Значок настроек в вырезе" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentikteki ayarlar simgesi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "刘海中的设置图标" + } + } + } + }, + "Settings…" : { + "comment" : "A command that shows the settings window.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정..." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurações…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки…" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayarlar..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置…" + } + } + } + }, + "Shelf" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Shelf" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Shelf" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ablage" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shelf" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shelf" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Shelf" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiroir" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Shelf" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Scaffale" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "서랍" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Półka" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prateleira" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Панель файлов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raf" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Shelf" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暂存器" + } + } + } + }, + "Shell" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "셸" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kabuk" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell" + } + } + } + }, + "Shell path" : { + "comment" : "A setting option for the path to the shell to use in the terminal", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell 경로" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Путь к Shell" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kabuk yolu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shell 路径" + } + } + } + }, + "Shortcuts" : { + "comment" : "merge:ko:alt='바로가기'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Shortcuts" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Shortcuts" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tastenkombinationen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shortcuts" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shortcuts" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Shortcuts" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raccourcis" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Shortcuts" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Scorciatoie" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "바로가기들" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Skróty klawiszowe" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atalhos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сочетания клавиш" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kestirmeler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Shortcuts" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "快捷方式" + } + } + } + }, + "Show \"Change Media Output\" control" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show \"Change Media Output\" control" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "“미디어 출력 변경” 제어기 표시" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"Medya Çıkışını Değiştir\" kontrolünü göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示「更改媒体输出」控制" + } + } + } + }, + "Show All Color Formats" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 색상 포맷 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать все форматы цвета" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tüm Renk Formatlarını Göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示所有颜色格式" + } + } + } + }, + "Show AQI widget" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AQI 위젯 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать виджет AQI" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AQI widget'ını göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示空气质量小组件" + } + } + } + }, + "Show battery indicator" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show battery indicator" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show battery indicator" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Batterie-Symbol anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show battery indicator" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show battery indicator" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show battery indicator" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Afficher l'indicateur de la batterie" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show battery indicator" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show battery indicator" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "배터리 상태 표시하기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show battery indicator" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show battery indicator" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать индикатор заряда батареи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pil göstergesini göster" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show battery indicator" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "显示电量" + } + } + } + }, + "Show battery percentage" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 퍼센트 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать процент батареи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pil yüzdesini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示电池百分比" + } + } + } + }, + "Show battery percentage inside icon" : { + "comment" : "A toggle to show the battery percentage inside the system icon.", + "isCommentAutoGenerated" : true + }, + "Show battery percentage text in HUD" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD에 배터리 퍼센트 텍스트 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать процент в HUD" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pil yüzdesi metnini HUD'de göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在 HUD 中显示电池百分比文本" + } + } + } + }, + "Show Bluetooth battery" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth 배터리 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать заряд Bluetooth" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth pilini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示蓝牙电池" + } + } + } + }, + "Show Bluetooth device connections" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth 기기 연결 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать подключения Bluetooth устройств" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth cihaz bağlantılarını göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示蓝牙设备连接" + } + } + } + }, + "Show calendar" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캘린더 보기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать календарь" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takvimi göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示日历" + } + } + } + }, + "Show Caps Lock Indicator" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caps Lock 표시기 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать индикатор Caps Lock" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Büyük Harf Kilidi Göstergesini Göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示 Caps Lock 指示器" + } + } + } + }, + "Show Caps Lock label" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caps Lock 레이블 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать метку Caps Lock" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caps Lock etiketini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示 Caps Lock 标签" + } + } + } + }, + "Show Character Count" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "글자 개수 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать количество символов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karakter Sayısını Göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示字符数" + } + } + } + }, + "Show charging indicator" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show charging indicator" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher l’indicateur de charge" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "충전 표시기 보기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать индикатор зарядки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şarj göstergesini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示充电指示器" + } + } + } + }, + "Show charging percentage" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "충전 퍼센트 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать процент зарядки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şarj yüzdesini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示充电百分比" + } + } + } + }, + "Show charging status" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "충전 상태 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать статус зарядки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şarj durumunu göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示充电状态" + } + } + } + }, + "Show Clipboard Icon" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드 아이콘 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать значок буфера обмена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pano Simgesini Göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示剪贴板图标" + } + } + } + }, + "Show Color History" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher l’historique de couleur" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 기록 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar Histórico de Cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать историю цветов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Geçmişini Göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示颜色历史" + } + } + } + }, + "Show Color Picker Icon" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 선택기 아이콘 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать значок пипетки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Seçici Simgesini Göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示取色器图标" + } + } + } + }, + "Show Color Picker Panel" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 선택기 패널 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar Painel Seletor de Cores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать панель пипетки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Seçici Panelini Göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示取色器面板" + } + } + } + }, + "Show cool face animation while inactivity" : { + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show cool face animation while inactivity" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show cool face animation while inactivity" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show cool face animation while inactivity" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show cool face animation while inactivity" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show cool face animation while inactivity" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show cool face animation while inactivity" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Montrer une animation cool de visage pendant l'inactivité" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show cool face animation while inactivity" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show cool face animation while inactivity" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show cool face animation while inactivity" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show cool face animation while inactivity" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show cool face animation while inactivity" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать прикольную анимацию лица при бездействия" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hareketsizken havalı yüz animasyonu göster" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show cool face animation while inactivity" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "不活动时显示动画" + } + } + } + }, + "Show countdown" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "카운트다운 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar a contagem regressiva" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать обратный отсчет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geri sayımı göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示倒计时" + } + } + } + }, + "Show download progress" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show download progress" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher la vitesse de téléchargement" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 진행도 보기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar o progresso do download" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать прогресс загрузки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İndirme ilerlemesini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示下载进度" + } + } + } + }, + "Show event after it starts" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시작된 후의 일정 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать событие после начала" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinliği başladıktan sonra göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "事件开始后显示" + } + } + } + }, + "Show event for entire duration" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 시간 동안 이벤트 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать событие на протяжении всего времени" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinliği tüm süre boyunca göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在整个持续时间内显示事件" + } + } + } + }, + "Show events from all calendars" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 캘린더의 일정 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать события всех календарей" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tüm takvimlerdeki etkinlikleri göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示所有日历的事件" + } + } + } + }, + "Show events within the next" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다음 기간 내의 일정 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать события в ближайшие" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonraki etkinlikteki etkinlikleri göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示接下来的事件" + } + } + } + }, + "Show extension tabs" : { + "comment" : "Label for a toggle that enables or disables the display of tabs in notch experiences.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확장 탭 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать вкладки расширения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzantı sekmelerini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示扩展标签页" + } + } + } + }, + "Show floating media controls" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "플로팅 미디어 제어기 표시" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kayan medya kontrollerini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示浮动媒体控制" + } + } + } + }, + "Show floating pause/stop controls" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "플로팅 일시 정지/중단 제어기 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar controles flutuantes de pausa/parada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать вкладки расширений" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Değişken duraklatma/durdurma kontrollerini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示浮动暂停/停止控制" + } + } + } + }, + "Show Focus as brief toast" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "집중 모드를 간략한 토스트로 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать Фокус как краткое уведомление" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odağı kısa bir tost olarak göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "以简短提示显示专注模式" + } + } + } + }, + "Show Focus Indicator" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "집중 모드 표시기 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать индикатор Фокуса" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odak Göstergesini Göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示专注指示器" + } + } + } + }, + "Show Focus Label" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "집중 모드 레이블 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать метку Фокуса" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odak Etiketini Göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示专注标签" + } + } + } + }, + "Show focus widget" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "집중 모드 위젯 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать виджет Фокуса" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odak widget'ını göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示专注小组件" + } + } + } + }, + "Show full event titles" : { + "comment" : "A toggle that shows or hides the full event titles in the calendar view", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 이벤트 제목 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar títulos completos do evento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать полные названия событий" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinlik başlıklarının tamamını göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示完整事件标题" + } + } + } + }, + "Show live canvas in Dynamic Island" : { + + }, + "Show location label" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "위치 레이블 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать локацию" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konum etiketini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示位置标签" + } + } + } + }, + "Show lock screen media panel" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 미디어 패널 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать медиа-панель на экране блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı medya panelini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示锁屏媒体面板" + } + } + } + }, + "Show lock screen reminder" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 미리알림 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать напоминания на экране блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı hatırlatıcısını göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示锁屏提醒" + } + } + } + }, + "Show lock screen timer" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 타이머 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать таймер экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı zamanlayıcısını göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示锁屏计时器" + } + } + } + }, + "Show lock screen timer widget" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 타이머 위젯 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать виджет таймера экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı zamanlayıcı widget'ını göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示锁屏计时器小组件" + } + } + } + }, + "Show lock screen weather" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잠금 화면 날씨 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать погоду экрана блокировки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilit ekranı hava durumunu göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示锁屏天气" + } + } + } + }, + "Show media app icon" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 앱 아이콘 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать иконку медиаплеера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya uygulaması simgesini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示媒体应用图标" + } + } + } + }, + "Show media controls in Dynamic Island" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island에 미디어 제어기 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать элементы управления медиа в Dynamic Island" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya kontrollerini Dynamic Island'de göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在灵动岛中显示媒体控制" + } + } + } + }, + "Show merged AirPlay and output devices" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirPlay 및 출력 기기 통합 표시" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Birleştirilmiş AirPlay ve çıkış cihazlarını göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示合并的隔空播放和输出设备" + } + } + } + }, + "Show next calendar event" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다음 캘린더 이벤트 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать следующее событие календаря" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonraki takvim etkinliğini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示下一个日历事件" + } + } + } + }, + "Show on a specific display" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show on a specific display" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show on a specific display" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf einem bestimmten Display anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show on a specific display" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show on a specific display" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show on a specific display" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher sur un écran spécifique" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show on a specific display" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mostra su un display specifico" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "특정 디스플레이에 표시" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pokaż na określonym wyświetlaczu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar em uma tela específica" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать на определённом дисплее" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Belirli bir ekranda göster" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show on a specific display" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在指定显示器上显示" + } + } + } + }, + "Show on all displays" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show on all displays" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show on all displays" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf allen Displays anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show on all displays" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show on all displays" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show on all displays" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher sur tous les écrans" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show on all displays" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mostra su tutti gli schermi" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "모든 디스플레이에 표시" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pokaż na wszystkich wyświetlaczach" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar em todas as telas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать на всех дисплеях" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tüm ekranlarda göster" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show on all displays" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在所有显示器上显示" + } + } + } + }, + "Show panel border" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패널 테두리 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать рамки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panel kenarlığını göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示面板边框" + } + } + } + }, + "Show Percentage" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "퍼센트 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar Porcentagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать проценты" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yüzdeyi Göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示百分比" + } + } + } + }, + "Show percentages beside progress bars" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행률 표시줄 옆에 퍼센트 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать проценты рядом с индикаторами" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlerleme çubuklarının yanında yüzdeleri göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在进度条旁显示百分比" + } + } + } + }, + "Show power status icons" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전원 상태 아이콘 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать иконки статуса питания" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Güç durumu simgelerini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示电源状态图标" + } + } + } + }, + "Show power status notifications" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전원 상태 알림 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать уведомления статуса питания" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Güç durumu bildirimlerini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示电源状态通知" + } + } + } + }, + "Show preset list in timer tab" : { + "comment" : "A toggle that lets users decide whether to show a list of presets in the timer tab.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 탭에 프리셋 목록 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar lista predefinida na guia do temporizador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать список пресетов на вкладке таймера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı sekmesinde ön ayar listesini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在计时器标签页中显示预设列表" + } + } + } + }, + "Show progress" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행도 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar o progresso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать прогресс" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlerlemeyi göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示进度" + } + } + } + }, + "Show Recording Indicator" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "화면 기록 표시기 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать индикатор записи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kayıt Göstergesini Göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示录制指示器" + } + } + } + }, + "Show shuffle and repeat buttons" : { + "comment" : "merge:ko:alt='임의 재생 및 반복 버튼 표시'", + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show shuffle and repeat buttons" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show shuffle and repeat buttons" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeige Zufallswiedergabe und Wiederholung Tasten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show shuffle and repeat buttons" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show shuffle and repeat buttons" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show shuffle and repeat buttons" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher les boutons \"aléatoire\" et \"répéter\"" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show shuffle and repeat buttons" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Mostra pulsanti casuale e ripeti" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "랜덤과 반복 버튼 표시" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pokaż przyciski \"pomieszaj\" i \"powtórz\"" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show shuffle and repeat buttons" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать кнопки перемешивания и повтора" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karıştır ve tekrarla düğmelerini göster" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Show shuffle and repeat buttons" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "显示随机播放和重复按钮" + } + } + } + }, + "Show sneak peek on playback changes" : { + "comment" : "A toggle that enables showing sneak peek when the currently playing track changes.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "재생 상태 변경 시 미리보기 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar pré-vizualização nas alterações de reprodução" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Быстрый просмотр при изменении воспроизведения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oynatma değişikliklerine kısa bakış göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "播放变化时显示快速预览" + } + } + } + }, + "Show song title and artist on non-notch displays" : { + + }, + "Show start time after event begins" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이벤트 시작 후 시작 시간 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжать показывать время начала" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinlik başladıktan sonraki başlangıç saatini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "事件开始后显示开始时间" + } + } + } + }, + "Show sunrise time" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일출 시간 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать время восхода солнца" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Güneşin doğuş saatini göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示日出时间" + } + } + } + }, + "Show the active Focus name inside the indicator." : { + "comment" : "A description of why the \"Show Focus Label\" toggle is disabled when the \"Show Focus as brief toast\" toggle is enabled.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "표시기 내부에 집중 모드 이름 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostre o nome do Foco ativo dentro do indicador." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отображать имя Фокуса в индикаторе." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Göstergenin içinde etkin Odak adını gösterin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在指示器中显示活跃的专注模式名称。" + } + } + } + }, + "Show time remaining" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "남은 시간 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать оставшееся время" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kalan süreyi göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示剩余时间" + } + } + } + }, + "Show timer name" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 이름 표시" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar nome do temporizador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать название таймера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı adını göster" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示计时器名称" + } + } + } + }, + "Shows green camera icon and yellow microphone icon when in use. Uses event-driven CoreAudio and CoreMediaIO APIs." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용 중일 때 초록색 카메라 아이콘과 노란색 마이크 아이콘을 표시합니다. 이벤트 기반 CoreAudio 및 CoreMediaIO API를 사용합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra o ícone verde da câmera e o ícone amarelo do microfone quando em uso. Usa APIs CoreAudio e CoreMediaIO orientadas a eventos." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывает зеленую иконку камеры и желтую иконку микрофона во время их использования." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kullanımdayken yeşil kamera simgesi ve sarı mikrofon simgesi gösterir. Olay odaklı CoreAudio ve CoreMediaIO API'lerini kullanır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用时显示绿色相机图标和黄色麦克风图标。使用事件驱动的 CoreAudio 和 CoreMediaIO API。" + } + } + } + }, + "Shows screen assistant as a dropdown attached to the AI button" : { + "comment" : "Description of the screen assistant display mode option that shows the screen assistant as a dropdown attached to the AI button.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 버튼에 연결된 드롭다운으로 화면 어시스턴트 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экранный помощник открывается как всплывающее окно" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran yardımcısını AI düğmesine eklenen bir açılır menü olarak gösterir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将屏幕助手显示为附加到 AI 按钮的下拉菜单" + } + } + } + }, + "Shows screen assistant in a floating panel near the notch" : { + "comment" : "Description of the screen assistant display mode option that shows the screen assistant in a floating panel near the notch.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치 근처의 플로팅 패널에 화면 어시스턴트 표시" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экранный помощник открывается как плавающая панель возле выреза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentiğin yakınındaki kayan bir panelde ekran yardımcısını gösterir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在刘海附近的浮动面板中显示屏幕助手" + } + } + } + }, + "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." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최종 답변을 제공하기 전 모델의 추론 과정을 표시합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra o processo de raciocínio do modelo antes da resposta final" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывает экранный помощник во всплывающей панели рядом с вырезом" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nihai cevaptan önce modelin akıl yürütme sürecini gösterir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在最终答案之前显示模型的推理过程" + } + } + } + }, + "Shows the system Clock timer in the notch when available. Requires Accessibility permission to read the status item." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용 가능한 경우 시스템 '시계' 앱의 타이머를 노치에 표시합니다. 상태 항목을 읽으려면 접근성 권한이 필요합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra o temporizador do relógio do sistema no notch quando disponível. Requer permissão de Acessibilidade para ler o item de status." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отображает системный таймер в вырезе. Требуется разрешение к функциям Универсального доступа." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistem Saati zamanlayıcısını mevcut olduğunda çentikte gösterir. Durum öğesini okumak için Erişilebilirlik izni gerektirir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在可用时在刘海中显示系统时钟计时器。需要辅助功能权限来读取状态项。" + } + } + } + }, + "Shrinks the lock screen media panel while keeping the expanded view full width." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확장 뷰는 전체 너비로 유지하면서 잠금 화면 미디어 패널을 축소합니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genişletilmiş görünümü tam genişlikte korurken kilit ekranı medya panelini küçültür." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "缩小锁屏媒体面板同时保持展开视图全宽。" + } + } + } + }, + "Shuffle" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "임의 재생" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karıştır" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "随机播放" + } + } + } + }, + "Sign in to Spotify to capture the `sp_dc` cookie automatically, or paste it in below." : { + "comment" : "A description of how to sign in to Spotify to get the `sp_dc` cookie.", + "isCommentAutoGenerated" : true + }, + "Sign in with Spotify" : { + "comment" : "A button that opens a sheet for signing in with Spotify.", + "isCommentAutoGenerated" : true + }, + "Simple and minimal interface with just the essentials for everyday tasks." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일상적인 작업에 꼭 필요한 핵심 기능만 갖춘 심플하고 미니멀한 인터페이스입니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Простой минималистичный интерфейс только с самыми необходимыми функциями." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Günlük görevler için yalnızca temel öğeleri içeren basit ve minimal arayüz." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "简洁的界面,仅包含日常任务所需的基本功能。" + } + } + } + }, + "Size & Scale" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taille et échelle" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "크기 및 배율" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Простой и минималистичный интерфейс с самым необходимым для повседневных задач." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boyut ve Ölçek" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大小与缩放" + } + } + } + }, + "Size: %lldpx" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "크기: %lldpx" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamanho: %lld px" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Размер: %lld px" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boyut: %lldpx" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大小:%lld像素" + } + } + } + }, + "Skip buttons" : { + "comment" : "A section header that lists the skip behaviour options.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "건너뛰기 버튼" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Botões de pular" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скип-кнопки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düğmeleri atla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "跳过按钮" + } + } + } + }, + "Skip forward or backward by ten seconds" : { + "comment" : "Description of a music skip behavior option.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "10초 앞으로 또는 뒤로 건너뛰기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Промотать вперед/назад на 10 секунд" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "On saniye ileri veya geri atla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "快进或后退 10 秒" + } + } + } + }, + "Slider color" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Slider color" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Slider color" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slider Farbe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slider color" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slider colour" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Slider color" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Couleur de la barre de défilement" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Slider color" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Colore cursore" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "슬라이더 컬러" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kolor suwaka" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cor do controle deslizante" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет ползунка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kaydırıcı rengi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Slider color" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "滑块颜色" + } + } + } + }, + "Smooth color transitions" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "부드러운 색상 전환" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Плавные переходы цвета" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pürüzsüz renk geçişleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "平滑颜色过渡" + } + } + } + }, + "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, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 채우기 영역에서 초록색(0~60%), 노란색(60~85%), 빨간색(85~100%)이 부드럽게 혼합되어 표시됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transições suaves misturam Verde (0–60%), Amarelo (60–85%) e Vermelho (85–100%) em todo o preenchimento." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Плавный переход от зеленого (0-60%) к желтому (60-85%) и красному (85-100%)." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pürüzsüz geçişler Yeşil (%0–60), Sarı (%60–85) ve Kırmızıyı (%85–100) tüm dolgu boyunca harmanlar." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "平滑过渡在整个填充中混合绿色(0-60%)、黄色(60-85%)和红色(85-100%)。" + } + } + } + }, + "Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill. Adjust gradient behavior from Controls › Dynamic Island." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 채우기 영역에서 초록색(0~60%), 노란색(60~85%), 빨간색(85~100%)이 부드럽게 혼합되어 표시됩니다. '제어기 › Dynamic Island'에서 그라데이션 동작을 조정할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transições suaves misturam Verde (0–60%), Amarelo (60–85%) e Vermelho (85–100%) em todo o preenchimento. Ajuste o comportamento do gradiente em Controles › Dynamic Island." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Плавный переход от зеленого (0-60%) к желтому (60-85%) и красному (85-100%)." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yumuşak geçişler, tüm dolgu boyunca Yeşil (%0-60), Sarı (%60-85) ve Kırmızıyı (%85-100) harmanlar. Degrade davranışını Kontroller › Dynamic Island'den ayarlayın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "平滑过渡在整个填充中混合绿色(0-60%)、黄色(60-85%)和红色(85-100%)。从控制 › 灵动岛调整渐变行为。" + } + } + } + }, + "Sneak peek duration" : { + "comment" : "A label describing the duration of the \"sneak peek\" feature in the calendar settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "살짝 보기 시간" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duração da pré-visualização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Длительность быстрого просмотра" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kısa bakış süresi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "快速预览时长" + } + } + } + }, + "Sneak Peek shows the media title and artist under the notch for a few seconds." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek shows the media title and artist under the notch for a few seconds." + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek shows the media title and artist under the notch for a few seconds." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sneak Peek zeigt den Medium-Titel und den Künstler unter der Notch für ein paar Sekunden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sneak Peek shows the media title and artist under the notch for a few seconds." + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sneak Peek shows the media title and artist under the notch for a few seconds." + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek shows the media title and artist under the notch for a few seconds." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sneak Peek montre l'artiste et le titre du média sous l'encoche pendant quelques secondes." + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek shows the media title and artist under the notch for a few seconds." + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek mostra per alcuni secondi il titolo dei media e l'artista sotto la notch." + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "훔쳐보기가 몇 초 동안 미디어 제목과 아티스트명을 노치 아래에 보여줍니다." + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Podgląd pokazuje tytuł multimediów i artystę pod notchem przez kilka sekund." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pré-visualização mostra o título da mídia e o artista sob o notch por alguns segundos." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В режиме предварительного просмотра под вырезом в течение нескольких секунд отображается название медиа и исполнителя" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sneak Peek, medya başlığını ve sanatçıyı birkaç saniye boyunca çentiğin altında gösterir." + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek shows the media title and artist under the notch for a few seconds." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek 在Notch下显示媒体标题和艺术家几秒钟。" + } + } + } + }, + "Sneak Peek Style" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek Style" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek Style" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sneak Peek Stil" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sneak Peek Style" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sneak Peek Style" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek Style" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Style du Sneak Peek" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek Style" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Stile Sneak Peek" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "훔쳐보기 스타일" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Styl podglądu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek Style" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль предварительного просмотра" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sneak Peek Tarzı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek Style" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sneak Peek 样式" + } + } + } + }, + "Sneak peek style is locked to Standard in minimalistic mode" : { + "comment" : "A message displayed when the sneak peek style is locked to standard in minimalistic UI mode.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미니멀리즘 모드에서는 '살짝 보기' 스타일이 '표준'으로 고정됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O estilo de pré-visualização é bloqueado em Padrão no modo minimalista" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стиль быстрого просмотра зафиксирован в минималистичном режиме" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sneak peek stili minimalist modda Standart'a kilitlendi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "简约模式下快速预览样式锁定为标准" + } + } + } + }, + "Software updates" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Software updates" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Software updates" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Softwareupdates" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Software updates" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Software updates" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Software updates" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mises à jour logicielles" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Software updates" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Aggiornamenti software" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "소프트웨어 업데이트" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Aktualizacje oprogramowania" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atualizações de software" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновления ПО" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Yazılım güncellemeleri" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Software updates" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "软件更新" + } + } + } + }, + "Solid colour" : { + "comment" : "merge:ko:alt='단색'", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "든삭" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cor Sólida" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сплошной цвет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düz renk" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "纯色" + } + } + } + }, + "sp_dc cookie" : { + "comment" : "A label for the Spotify `sp_dc` cookie.", + "isCommentAutoGenerated" : true + }, + "Speed" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Speed" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Speed" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geschwindigkeit" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speed" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speed" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Speed" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vitesse" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Speed" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Velocità" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "속도" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Szybkość" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocidade" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hız" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Speed" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "速度" + } + } + } + }, + "Speed:" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vitesse:" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "속도:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hız:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "速度:" + } + } + } + }, + "Spotify" : { + "comment" : "Spotify media controller type.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sportify" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spotify" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spotify" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spotify" + } + } + } + }, + "Spotify Canvas Session" : { + "comment" : "A section that allows the user to configure Spotify's session.", + "isCommentAutoGenerated" : true + }, + "Spotify often blocks in-app logins. Sign in via your normal browser, then paste sp_dc in Settings." : { + "comment" : "A help message for the Spotify login sheet.", + "isCommentAutoGenerated" : true + }, + "Square" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Square" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Čtvercový" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quadratisch" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Square" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Square" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Square" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carré" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Square" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Quadrato" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사각형" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kwadrat" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quadrado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Квадрат" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kare" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Square" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "方形" + } + } + } + }, + "SS" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "초" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "SS" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "SN" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "秒" + } + } + } + }, + "Standard" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "표준" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стандартный" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standart" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标准" + } + } + } + }, + "Standard Notch" : { + "comment" : "Text for the \"Standard Notch\" option in the external display style picker.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "표준 노치" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стандартный вырез" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standart Çentik" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标准刘海" + } + } + } + }, + "Standard notch media controls are hidden while this toggle is off." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 토글이 꺼져 있는 동안에는 표준 노치 미디어 제어기가 숨겨집니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Элементы управления медиа в вырезе скрыты, пока этот переключатель выключен." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu geçiş kapalıyken standart çentikli medya kontrolleri gizlenir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此开关关闭时标准刘海媒体控制已隐藏。" + } + } + } + }, + "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "표준 노치 미디어 제어기가 숨겨져 있습니다. 다시 표시하려면 위의 토글을 다시 활성화하세요." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Элементы управления медиа в вырезе скрыты. Включите их выше для восстановления." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standart çentikli medya kontrolleri gizlidir. Bunları geri yüklemek için yukarıdaki geçişi yeniden etkinleştirin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标准刘海媒体控制已隐藏。重新启用上方开关以恢复。" + } + } + } + }, + "Standard previous/next track controls" : { + "comment" : "Description of the music skip behavior option to use standard previous/next track controls.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "표준 이전/다음 트랙 제어" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стандартные кнопки предыдущий/следующий трек" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standart önceki/sonraki parça kontrolleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标准上/下一首曲目控制" + } + } + } + }, + "Start" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Démarrer" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시작" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Começar " + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стандартные элементы управления предыдущим/следующим треком" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Başlat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始" + } + } + } + }, + "Start a conversation to see your chat history here" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commencez une conversation pour voir votre historique" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대화를 시작하면 여기에서 대화 기록을 확인할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicie uma conversa para ver seu histórico de bate-papo aqui" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начните беседу" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sohbet geçmişinizi görmek için burada bir sohbet başlatın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始对话以在此查看聊天记录" + } + } + } + }, + "Start Color Picking" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commencer à " + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 선택 시작" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comece a Escolher a Cor" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начать выбор цвета" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk Seçimini Başlat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始取色" + } + } + } + }, + "Start Custom Timer" : { + "comment" : "A button label that starts a custom timer.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 타이머 시작" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comece o Temporizador Customizado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запустить настраиваемый таймер" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel Zamanlayıcıyı Başlat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启动自定义计时器" + } + } + } + }, + "Start Demo Timer:" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lancer le minuteur de démo" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "데모 타이머 시작:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comece o Temporizador de Demonstração " + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запустить демо-таймер:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demo Zamanlayıcısını Başlat:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启动演示计时器:" + } + } + } + }, + "Start Monitoring" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commencer le suivi" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모니터링 시작" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comece a Monitorar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начать мониторинг" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İzlemeye Başla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始监控" + } + } + } + }, + "Start picking colors to build your history" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 선택을 시작하여 기록을 쌓아보세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comece a escolher cores para construir sua história" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начните выбирать цвета, чтобы создать историю" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarihinizi oluşturmak için renkleri seçmeye başlayın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始取色以建立历史记录" + } + } + } + }, + "Start recording" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commencer l’enregistrement" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "녹화 시작" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comece a gravar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начать запись" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kaydı başlat" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始录制" + } + } + } + }, + "Start typing..." : { + "comment" : "A placeholder text shown when a note's content is empty.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이핑 시작..." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comece a digital…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начните печатать..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yazmaya başlayın..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始输入..." + } + } + } + }, + "Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 실시간 현황 기능을 테스트하기 위한 5분 데모 타이머를 시작합니다. 이 기능은 타이머 기능이 활성화된 경우에만 작동합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicia um cronômetro de demonstração de 5 minutos para testar o recurso de atividade ao vivo do temporizador. Só funciona quando o recurso de temporizador está ativado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запускает демо-таймер на 5 минут для тестирования." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı canlı etkinlik özelliğini test etmek için 5 dakikalık bir demo zamanlayıcısı başlatır. Yalnızca zamanlayıcı özelliği etkinleştirildiğinde çalışır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启动 5 分钟演示计时器以测试实时活动功能。仅在启用计时器功能时有效。" + } + } + } + }, + "Stats" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statistiques" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상태" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статистика" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İstatistikler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "统计" + } + } + } + }, + "Stats Disabled" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statistiques désactivées" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상태 비활성화됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status Desabilitado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статистика отключена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İstatistikler Devre Dışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "统计已禁用" + } + } + } + }, + "Stats Feature Disabled" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La fonctionnalité de statistiques est désactivée" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상태 기능 비활성화됨" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Функция статистики отключена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İstatistik Özelliği Devre Dışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "统计功能已禁用" + } + } + } + }, + "Stats feature is disabled" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La fonctionnalité de statistiques est désactivée" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상태 기능 비활성화됨" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Функция статистики отключена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İstatistik özelliği devre dışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "统计功能已禁用" + } + } + } + }, + "Stats panel is disabled" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le panneau des statistiques est désactivé" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상태 패널 비활성화됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Painel de estatísticas está desabilitado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Панель статистики отключена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İstatistik paneli devre dışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "统计面板已禁用" + } + } + } + }, + "Stats Panel:" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panneau des statistiques" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상태 패널:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Painel de Estatísticas " + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Панель статистики:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İstatistik Paneli:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "统计面板:" + } + } + } + }, + "Stats update interval" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상태 업데이트 주기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo de atualizaçao das estatísticas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал обновления статистики" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İstatistik güncelleme aralığı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "统计更新间隔" + } + } + } + }, + "stats_resource_active" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Active" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용 중" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активно" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "stats_resource_active" + } + } + } + }, + "Status" : { + "comment" : "A label displayed next to the status indicator in the \"Third-party DDC provider\" section of the External Display Integrations settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상태" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durum" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "状态" + } + } + } + }, + "Status & Actions" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actions et statut" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상태 및 동작" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status & Ações" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус и действия" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durum ve Eylemler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "状态与操作" + } + } + } + }, + "Status:" : { + "comment" : "A label for the status of an extension.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durum:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "状态:" + } + } + }, + "shouldTranslate" : false + }, + "Stay organized with calendar, timer, and battery monitoring." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캘린더, 타이머 및 배터리 모니터링 기능으로 일정을 체계적으로 관리하세요." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Организуйте работу с календарем, таймером и мониторингом батареи." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takvim, zamanlayıcı ve pil izleme özellikleriyle düzenli kalın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用日历、计时器和电池监控保持井井有条。" + } + } + } + }, + "Step size" : { + + }, + "Stop" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrêter" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "중지" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Держите дела под контролем с календарём, таймером и мониторингом батареи." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durdur" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止" + } + } + } + }, + "Stop Monitoring" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mettre fin à le suivi" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모니터링 중지" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parar de Monitorar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить мониторинг" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İzlemeyi Durdur" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止监控" + } + } + } + }, + "Stop monitoring after closing the notch" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치가 닫히면 모니터링 중지" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Останавливать мониторинг при закрытии выреза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentiği kapattıktan sonra izlemeyi durdurun" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭刘海后停止监控" + } + } + } + }, + "Stop recording" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mettre fin à l’enregisterement" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "녹화 중지" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parar de gravar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить запись" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kaydı durdur" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止录制" + } + } + } + }, + "Stopped" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Stopped" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Stopped" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestoppt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stopped" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stopped" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Stopped" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrêté" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Stopped" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Interrotto" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "중지됨" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zatrzymano" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановлено" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durduruldu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Stopped" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已停止" + } + } + } + }, + "Storage Devices" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장 장치" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройства хранения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Depolama Cihazları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "存储设备" + } + } + } + }, + "Student" : { + "comment" : "Name of a user profile for a student.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "학생" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Студент" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öğrenci" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "学生" + } + } + } + }, + "Style" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스타일" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estilo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Студент" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stil" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "样式" + } + } + } + }, + "Sunrise at %@" : { + "comment" : "A view representing the sunrise time, displayed in a horizontal stack with an icon and the time itself.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일출 시간: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nascer do Sol às %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восход в %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@'de Gündoğumu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日出时间 %@" + } + } + } + }, + "Support Us" : { + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Support Us" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Support Us" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unterstütze uns" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Support Us" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Support Us" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Support Us" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Soutenez-nous" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Support Us" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Supportaci" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "후원하기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wesprzyj nas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Support Us" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поддержать нас" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Bizi Destekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Support Us" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "支持我们" + } + } + } + }, + "Supports reasoning mode" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supporte la réfléxion" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추론 모드 지원" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suporta o modo de raciocínio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поддерживает режим размышления" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akıl yürütme modunu destekler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "支持推理模式" + } + } + } + }, + "Swap" : { + "comment" : "A label for the section of the memory dashboard related to swap usage.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Espace d'échange" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файл подкачки (Swap)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takas" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "交换空间" + } + } + }, + "shouldTranslate" : false + }, + "Swap is disabled on this system." : { + "comment" : "A message indicating that swap memory is disabled on the user's system.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L’espace d’échange est désactivé sur ce système." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swap이 시스템에 의해 비활성화됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O Swap está desativado neste sistema." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swap отключен в системе." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu sistemde takas devre dışıdır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此系统已禁用交换空间。" + } + } + } + }, + "System" : { + "comment" : "A label displayed above the system, load average, and uptime information.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Système" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시스템" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistema" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Система" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistem" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "系统" + } + } + } + }, + "System features" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "System features" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "System features" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "System Funktionen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System features" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "System features" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "System features" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fonctionnalités du système" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "System features" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Funzionalità di sistema" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "시스템 기능" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Funkcje systemowe" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Funcionalidade do Sistema" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Системные функции" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sistem özellikleri" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "System features" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "系统功能" + } + } + } + }, + "System memory pressure is nominal." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시스템 메모리 압력이 정상입니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нагрузка на системную память в норме." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistem belleği basıncı nominaldir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "系统内存压力正常。" + } + } + } + }, + "System Monospaced" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시스템 고정폭" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Системный моноширинный" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistem Tek Aralıklı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "系统等宽字体" + } + } + } + }, + "System Performance Monitor" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivi des Performances Système" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시스템 성능 모니터" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Монитор производительности системы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistem Performansı Monitörü" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "系统性能监视器" + } + } + } + }, + "Tab" : { + "comment" : "Name of the \"Tab\" display mode.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "탭" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вкладка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sekme" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签页" + } + } + } + }, + "Take area screenshot" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prendre une capture d’écran de zone" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택 부분 캡처" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Captura de tela da área" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вкладка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alan ekran görüntüsünü al" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "区域截图" + } + } + } + }, + "Temperature" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온도" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Температура" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sıcaklık" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "温度" + } + } + } + }, + "Temperature unit" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온도 단위" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unidade de temperatura" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Единицы температуры" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sıcaklık birimi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "温度单位" + } + } + } + }, + "Terminal" : { + "comment" : "The title of the settings view related to the terminal.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Терминал" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "terminali" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终端" + } + } + }, + "shouldTranslate" : false + }, + "Terminal feature is disabled" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminal 기능 비활성화됨" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Функция терминала отключена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminal özelliği devre dışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终端功能已禁用" + } + } + } + }, + "Terminal is disabled" : { + "comment" : "A message displayed when the terminal feature is disabled.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminal 비활성화됨" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Терминал отключен" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminal devre dışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终端已禁用" + } + } + } + }, + "Terminal opacity" : { + "comment" : "A label displayed above a slider that controls the opacity of the terminal view", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "터미널 투명도" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Непрозрачность терминала" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminal opaklığı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终端不透明度" + } + } + } + }, + "Terminal opacity only affects the terminal backdrop; text stays fully opaque. Blur uses the system material behind the window." : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminal opaklığı yalnızca terminal arka planını etkiler; metin tamamen opak kalır. Blur, pencerenin arkasındaki sistem malzemesini kullanır." + } + } + } + }, + "Test charging HUD" : { + + }, + "Test full battery HUD" : { + + }, + "Test low battery HUD" : { + + }, + "Text" : { + "comment" : "Name of a clipboard item type that contains plain text.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "텍스트" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текст" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文本" + } + } + } + }, + "TheDynamicIsland" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "TheDynamicIsland" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "TheDynamicIsland" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "灵动岛" + } + } + }, + "shouldTranslate" : false + }, + "These controls mirror the Lock Screen tab so you can tune the media overlay while focusing on playback settings." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 제어기는 '잠금 화면' 탭과 연동되어 있어, 재생 설정에 집중하면서도 미디어 오버레이를 세밀하게 조정할 수 있습니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эти элементы управления дублируют настройки экрана блокировки для удобства." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu kontroller Kilit Ekranı sekmesini yansıtır, böylece oynatma ayarlarına odaklanırken medya katmanını ayarlayabilirsiniz." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这些控制镜像了锁屏标签页,方便您在关注播放设置的同时调整媒体覆盖层。" + } + } + } + }, + "These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 제어기는 타이머가 작동하는 동안 노치 옆에 표시됩니다. 공간 확보를 위해 타이머 이름은 표시되지 않습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esses controles ficam ao lado do notch enquanto um cronômetro é executado. Eles exigem que o nome do temporizador permaneça oculto para o espaçamento." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эти элементы управления дублируют вкладку «Экран блокировки», позволяя настроить панель медиа с акцентом на параметры воспроизведения." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu kontroller zamanlayıcı çalışırken çentiğin yanında yer alır. Boşluk için zamanlayıcı adının gizli kalması gerekir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器运行时这些控制位于刘海旁边。为了间距需要隐藏计时器名称。" + } + } + } + }, + "These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앱이 카메라 또는 마이크를 사용 중일 때 노치에 표시기가 나타나며, 이는 macOS의 기본 개인정보 보호 표시기와 동일하게 작동합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esses indicadores aparecem no notch quando os aplicativos estão usando sua câmera ou microfone, correspondendo aos indicadores de privacidade nativos do macOS." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эти индикаторы появляются в вырезе, когда приложения используют камеру или микрофон." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu göstergeler, uygulamalar kameranızı veya mikrofonunuzu kullanırken çentikte görünür ve macOS'un yerel gizlilik göstergeleriyle eşleşir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当应用使用您的相机或麦克风时,这些指示器会出现在刘海中,与 macOS 原生隐私指示器一致。" + } + } + } + }, + "These temporary HUDs recreate the charging, low-battery, and full-battery notch alerts." : { + + }, + "Third-party apps using AtollExtensionKit can display live activities, lock screen widgets, and dedicated notch experiences. Toggle features above or manage individual app permissions below." : { + "comment" : "A footer text that explains the purpose of the \"Global Settings\" section in the Extensions settings view. | A footer text that explains the purpose of the \"Global Settings\" section in the Extensions settings view. | merge:ko:alt='AtollExtensionKit을 사용하는 타사 앱은 실시간 현황, 잠금 화면 위젯 및 노치 전용 기능을 표시할 수 있습니다. 위에서 기능을 켜고 끄거나 아래에서 개별 앱 권한을 관리할 수 있습니다.'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AtollExtensionKit을 사용하는 서드파티 앱은 실시간 현황, 잠금 화면 위젯 및 전용 노치 경험을 표시할 수 있습니다. 위에서 기능을 켜거나 끄고, 아래에서 개별 앱 권한을 관리하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicativos de terceiros que usam o AtollExtensionKit podem exibir atividades ao vivo, widgets de tela de bloqueio e experiências de entalhe dedicadas. Alterne os recursos acima ou gerencie as permissões individuais do aplicativo abaixo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сторонние приложения с AtollExtensionKit могут отображать Live Activities и виджеты. Управляйте ими здесь." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AtollExtensionKit kullanan üçüncü taraf uygulamalar canlı etkinlikler, kilit ekranı widget'ları ve özel çentik deneyimleri gösterebilir. Yukarıdaki özellikleri açıp kapatın veya aşağıdan uygulama izinlerini yönetin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用 AtollExtensionKit 的第三方应用可以显示实时活动、锁屏小组件和专用刘海体验。在上方切换功能或在下方管理各应用权限。" + } + } + } + }, + "Third-party Calendar Integration" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타사 캘린더 통합" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Üçüncü Taraf Takvim Entegrasyonu" + } + } + } + }, + "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, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This duration powers the \"Custom\" option inside the timer popover for quick access." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정한 시간은 빠른 실행을 위해 타이머 팝오버의 '사용자 지정' 옵션에 적용됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Essa duração alimenta a opção \"Personalizado\" dentro do popover do temporizador para acesso rápido." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используется для быстрого доступа к кнопке «Особый»." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu süre, hızlı erişim için zamanlayıcı açılır penceresindeki \"Özel\" seçeneğini belirler." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此时长用于计时器弹出窗口中的「自定义」选项,方便快速访问。" + } + } + } + }, + "This is optional. You can change it any time from the menu bar." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 설정은 선택 사항입니다. 언제든지 메뉴 막대에서 변경할 수 있습니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это необязательно. Вы сможете изменить это позже из строки меню." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu isteğe bağlıdır. İstediğiniz zaman menü çubuğundan değiştirebilirsiniz." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这是可选的。您可以随时从菜单栏更改。" + } + } + } + }, + "This permanently removes every saved note and its attachments." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장된 모든 메모와 첨부 파일이 영구적으로 삭제됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Isso remove permanentemente todas as notas salvas e seus anexos." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это необязательный параметр. Вы можете изменить его в любое время в строке меню." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu, kaydedilen her notu ve eklerini kalıcı olarak kaldırır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这将永久删除所有已保存的笔记及其附件。" + } + } + } + }, + "This removes every saved clipboard item from the notch tab." : { + "comment" : "A message displayed when they clear their clipboard history.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치 탭에 저장된 모든 클립보드 항목을 삭제합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Isso remove todos os itens da área de transferência salvos da Aba do Notch." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это удалит все сохраненные элементы буфера обмена из выреза." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu, kaydedilen her pano öğesini çentik sekmesinden kaldırır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这将从刘海标签页中移除所有已保存的剪贴板项目。" + } + } + } + }, + "Tiler" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타일러" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Плиточный" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiremitçi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "窗口管理" + } + } + } + }, + "Time Range" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Période de temps" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시간 범위" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Временной диапазон" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zaman Aralığı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "时间范围" + } + } + } + }, + "Time to Full Charge: %lld min" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Time to Full Charge: %lld min" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Time to Full Charge: %lld min" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeit bis zur vollen Ladung: %lld min" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time to Full Charge: %lld min" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time to Full Charge: %lld min" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Time to Full Charge: %lld min" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temps avant la charge complète : %lld min" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Time to Full Charge: %lld min" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tempo alla carica completa: %lld min" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "완충 까지 남은 시간: %lld 분" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Czas do pełnego naładowania: %lld min" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo até Carregar Completamente: %lld min" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время до полной зарядки: %lld минут" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tam Şarja Kadar Süre: %lld dk." + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Time to Full Charge: %lld min" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "充电时间:%lld 分钟" + } + } + } + }, + "Timer" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minuteur" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temporizador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Таймер" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器" + } + } + } + }, + "Timer controls appear as" : { + "comment" : "A picker that lets the user choose how the timer controls are displayed.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 제어기 표시 방식" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controles do temporizador aparecem como" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Элементы управления таймером выглядят как" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı kontrolleri şu şekilde görünür:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器控制显示为" + } + } + } + }, + "Timer Disabled" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minuteur désactivé" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 비활성화" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temporizador Desabilitado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Таймер отключен" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı Devre Dışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器已禁用" + } + } + } + }, + "Timer Feature" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fonctionnalité du Minuteur" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 기능" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recurso do Temporizador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Функция таймера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı Özelliği" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器功能" + } + } + } + }, + "Timer feature is disabled" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La fonctionnalité du minuteur est désactivée" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 비활성화 됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O recurso do temporizador está desativado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Функция таймера отключена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı özelliği devre dışı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器功能已禁用" + } + } + } + }, + "Timer glass material" : { + "comment" : "A label displayed above a picker for selecting the material of the timer glass. | A label displayed above a picker for selecting the material of the timer glass. | merge:ko:alt='타이머 Glass Material'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 글래스 Material" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Timer glass material" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Материал стекла таймера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı cam malzemesi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器玻璃材质" + } + } + } + }, + "Timer liquid mode" : { + "comment" : "A segment control that lets the user choose between different customization modes for the timer glass. | A segment control that lets the user choose between different customization modes for the timer glass. | merge:ko:alt='타이머 Liquid 모드'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 리퀴드 모드" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Timer liquid mode" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим Liquid таймера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı sıvı modu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器液态模式" + } + } + } + }, + "Timer Presets" : { + "comment" : "A section header in the timer settings view, describing timer presets.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 프리셋" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presets do Temporizador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пресеты таймера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı Ön Ayarları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器预设" + } + } + } + }, + "Timer Sound" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son du Minuteur" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 소리" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Som do Temporizador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Звук таймера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı Sesi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器声音" + } + } + } + }, + "Timer surface" : { + "comment" : "A label displayed above the user's timer surface.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 표면" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Superfície do temporizador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поверхность таймера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı yüzeyi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器表面" + } + } + } + }, + "Timer tint" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 틴트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tom do temporizador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оттенок таймера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı tonu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器着色" + } + } + } + }, + "Timer Widget" : { + "comment" : "A section header for the timer widget settings.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 위젯" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget do Temporizador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виджет таймера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı Widget'ı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器小组件" + } + } + } + }, + "Timer widget variant" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 위젯 유형" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variante do widget do temporizador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вариант виджета таймера" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı widget'ı çeşidi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器小组件变体" + } + } + } + }, + "Timer Widget Width" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "타이머 위젯 너비" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcı Widget Genişliği" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器小组件宽度" + } + } + } + }, + "Title" : { + "comment" : "A label for the title input field in the note editor.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Título" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Название" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Başlık" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标题" + } + } + } + }, + "Toggle Notch Open:" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Toggle Notch Open:" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Toggle Notch Open:" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notch öffnen umschalten:" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toggle Notch Open:" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toggle Notch Open:" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Toggle Notch Open:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer/désactiver l’encoche :" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Toggle Notch Open:" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Apri Notch:" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치 토글:" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Włącz/wyłącz Notch:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativar Abertura do Notch" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переключить открытие выреза:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentik Açmayı Aç/Kapat:" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Toggle Notch Open:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "切换Notch:" + } + } + } + }, + "Toggle Sneak Peek:" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Toggle Sneak Peek:" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Toggle Sneak Peek:" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sneak Peek umschalten:" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toggle Sneak Peek:" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toggle Sneak Peek:" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Toggle Sneak Peek:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer/désactiver Sneak Peek:" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Toggle Sneak Peek:" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Apri Sneak Peek:" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "훔쳐보기 토글:" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Włącz/wyłącz podgląd:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativar Pré-Vizualiação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переключить предварительный просмотр:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kısa Bakış'ı aç/kapat:" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Toggle Sneak Peek:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "切换 Sneak Peek:" + } + } + } + }, + "Toggle Terminal Tab:" : { + "comment" : "Name for a keyboard shortcut that toggles the terminal tab in the notch.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "토글 Terminal 탭:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть/закрыть вкладку терминала:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminal Sekmesini Değiştir:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "切换终端标签页:" + } + } + } + }, + "Toggle the Dynamic Island open or closed from anywhere." : { + "comment" : "A description of the \"Toggle Navigation\" keyboard shortcut.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "어디서나 Dynamic Island를 열거나 닫을 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alterne o Dynamic Island aberto ou fechado de qualquer lugar." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открывайте и закрывайте Dynamic Island из любого места." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island'yi istediğiniz yerden açın veya kapatın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从任何位置切换灵动岛的打开或关闭。" + } + } + } + }, + "Top Processes" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상위 프로세서" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Топ процессов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En İyi Süreçler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活跃进程" + } + } + } + }, + "Total" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всего" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toplam" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "总计" + } + } + } + }, + "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, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total · %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total · %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всего · %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toplam · %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "总计 · %@" + } + } + }, + "shouldTranslate" : false + }, + "Track Skip" : { + "comment" : "Display name for the \"Track Skip\" music skip behavior.", + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "트랙 건너뛰기" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пропуск трека" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parça Atla" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "跳过曲目" + } + } + } + }, + "Transform" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transformez" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "변형" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пропустить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dönüştür" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "变换" + } + } + } + }, + "Transform your notch truly yours" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reprenez le contrôle sur votre encoche" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치를 나만의 스타일로 완성해 보세요." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сделайте вырез по-настоящему вашим" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentiklerinizi gerçekten kendinize ait hale getirin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将刘海变成真正属于你的空间" + } + } + } + }, + "Try adjusting your search terms" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Essayez d’ajuster vos termes de recherche" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검색어를 변경해 보세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tente ajustar seus termos de pesquisa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Попробуйте изменить поисковый запрос" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arama terimlerinizi ayarlamayı deneyin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请尝试调整搜索词" + } + } + } + }, + "Try different search terms" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Essayez d’autres termes de recherche" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다른 검색어로 시도해 보세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tente termos de pesquisa diferentes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Попробуйте другие условия поиска" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Farklı arama terimlerini deneyin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请尝试不同的搜索词" + } + } + } + }, + "Turn off 'Show event for entire duration' to use the post-start duration option." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시작 후 시간 옵션을 사용하려면 '전체 시간 동안 일정 표시'를 끄세요." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключите показ на всё время, чтобы использовать опцию после начала." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Başlangıç sonrası süre seçeneğini kullanmak için 'Etkinliği tüm süre boyunca göster' seçeneğini kapatın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭「在整个持续时间内显示事件」以使用开始后时长选项。" + } + } + } + }, + "Turn on customizable controls to rearrange media buttons." : { + "comment" : "A description of the feature that allows users to rearrange the order of media buttons.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 버튼을 재배열하려면 사용자 지정 제어기를 켜세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ative os controles personalizáveis para reorganizar os botões de mídia." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключите 'Показывать событие всю его длительность', чтобы использовать опцию отображения времени с начала события." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya düğmelerini yeniden düzenlemek için özelleştirilebilir kontrolleri açın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开启自定义控制以重新排列媒体按钮。" + } + } + } + }, + "Turn this off to include all-day entries in the notch calendar and reminder live activity." : { + "comment" : "A description of why the user can disable the \"Hide all-day events\" toggle.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노치 캘린더 및 미리 알림 실시간 현황에 종일 일정을 포함하려면 이 기능을 끄세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desative para incluir entradas de dia inteiro no calendário do notch e lembrete de atividades ao vivo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключите, чтобы включить события на весь день." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çentik takvimine tüm gün girişlerini dahil etmek ve canlı etkinliği hatırlatmak için bunu kapatın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭此项以在刘海日历和提醒实时活动中包含全天事件。" + } + } + } + }, + "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. | A footer text explaining the gesture control functionality. | merge:ko:alt='**포인터 올리기 시 노치 열기** 옵션이 꺼져있을 때, 손가락으로 노치를 위로 쓸어올려 닫거나 아래로 쓸어내려 엽니다.'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zwei-Finger Wischen nach oben zum Schließen der Notch, zwei-Finger wischen nach unten um die Notch zu öffnen, wenn die Option **Notch bei Mausberührung öffnen** deaktiviert ist" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" + } + }, + "fr" : { + "stringUnit" : { + "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" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Scorri due dita verso l'alto sulla notch per chiudere, scorri due dita verso il basso sulla tacca per aprirla quando l'opzione **Apri notch all'hover ** è disabilitata" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "노치에 가져다 대서 열기 옵션이 꺼져있을 때, 노치를 두 손가락을 위로 밀어서 닫고, 두 손가락을 아래로 내려서 열기" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Przesuń dwoma palcami w górę na notchu, aby zamknąć, przesuń dwoma palcami w dół na notchu, aby otworzyć, gdy opcja **Otwórz notch przy najechaniu palcem ** jest wyłączona" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deslize com dois dedos para cima no notch para fechar, deslize com dois dedos para baixo no notch para abrir quando a opção **Abrir notch no foco** estiver desativada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сделайте свайп двумя пальцами вверх, чтобы свернуть вырез и вниз - чтобы его развернуть, когда функция **Развернуть вырез при наведении курсора\" отключена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "**Fareyle üzerine gelince çentiği aç** seçeneği devre dışıyken kapatmak için çentik üzerinde iki parmakla yukarı, açmak için iki parmakla aşağı kaydırın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在Notch里双指向上滑动以关闭。当**悬停在Notch时打开**选项禁用时,双指向下滑动即可打开" + } + } + } + }, + "UI Mode" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mode UI" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "UI 모드" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo UI" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим интерфейса" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kullanıcı Arayüzü Modu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "界面模式" + } + } + } + }, + "Uninstall" : { + "comment" : "merge:ko:alt='제거'", + "extractionState" : "stale", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Uninstall" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Uninstall" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deinstallieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uninstall" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uninstall" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Uninstall" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désinstaller" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Uninstall" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Disinstalla" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "설치제거" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Odinstaluj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desisntalar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kaldır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Uninstall" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "卸载" + } + } + } + }, + "Unknown" : { + "comment" : "Description of a calendar authorization status that is not explicitly listed. | Description of a calendar authorization status that is not explicitly listed. | merge:ko:alt='알 수 없음'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알수없음" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестно" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilinmiyor" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知" + } + } + } + }, + "Unlock advanced features and improve your experience. Upgrade now for more customizations!" : { + "comment" : "merge:ko:alt='고급 기능을 잠금 해제하여 사용 경험을 향상하세요. 지금 업그레이드하고 더 많은 사용자화 기능을 즐겨보세요!'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schalte erweiterte Funktionen frei und verbessere deine Erfahrung. Upgrade jetzt für weitere Anpassungsmöglichkeiten!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déverrouillez des fonctionnalités avancées et améliorez votre expérience. Mettez à niveau maintenant pour plus de personnalisations !" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sblocca le funzionalità avanzate e migliora la tua esperienza. Aggiorna ora per ulteriori personalizzazioni!" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "고급 설정을 해제해 경험을 향상하세요. 더 많은 맞춤화 설정을 지금 바로 업그레이드해서 즐기세요!" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Odblokuj dodatkowe funkcje i ciesz się lepszymi możliwościami. Ulepsz teraz, aby móc bardziej dostosować ustawienia!" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desbloqueie recursos avançados e melhore sua experiência. Atualize agora para mais personalizações!" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Откройте для себя расширенные возможности и улучшите свой пользовательский опыт. Обновите подписку прямо сейчас, чтобы получить больше настроек!" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gelişmiş özelliklerin kilidini açın ve deneyiminizi geliştirin. Daha fazla özelleştirme için şimdi yükseltin!" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "解锁高级功能并提升您的体验。现在升级以获取更多自定义功能!" + } + } + } + }, + "unmuted" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "unmuted" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "unmuted" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht stummgeschaltet" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unmuted" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "unmuted" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "unmuted" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sans sourdine" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "unmuted" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "smutato" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음소거 해제됨" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wyciszenie wyłączone" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desmutado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "незаглушенный" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "sesi açık" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "unmuted" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "已取消静音" + } + } + } + }, + "Unplugged" : { + "comment" : "Text displayed in a notification when the device is plugged in.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전원 분리됨" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключено от сети" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fişi çekilmiş" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未充电" + } + } + } + }, + "Up" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업로드" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вверх" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yukarı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传" + } + } + } + }, + "Update interval" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업데이트 간격" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo de atualização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключено" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Güncelleme aralığı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新间隔" + } + } + } + }, + "Update Timer" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier le minuteur" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업데이트 타이머" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atualizar o Timer" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновить таймер" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamanlayıcıyı Güncelle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新计时器" + } + } + } + }, + "Upgrade to Pro" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Upgrade to Pro" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Upgrade to Pro" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf Pro upgraden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upgrade to Pro" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upgrade to Pro" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Upgrade to Pro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Passer à la version Pro" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Upgrade to Pro" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Passa a Pro" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "프로로 업그레이드" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ulepsz do wersji Pro" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atualizar para o Pro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновиться до Pro" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pro sürümüne Yükselt" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Upgrade to Pro" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "升级至 Pro" + } + } + } + }, + "Upload" : { + "comment" : "A label displayed above the user's upload statistics.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envois" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upload" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yükle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传" + } + } + }, + "shouldTranslate" : false + }, + "Uploaded" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업로드됨" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлено" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yüklendi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已上传" + } + } + } + }, + "Uptime" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "가동 시간" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время работы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çalışma süresi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "运行时间" + } + } + } + }, + "URL" : { + "comment" : "Name of a clipboard item type that represents a URL.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "链接" + } + } + } + }, + "Usage" : { + "comment" : "A label displayed above the CPU usage statistics.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilisation" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용량" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ссылка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kullanım" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用情况" + } + } + } + }, + "Usage Breakdown" : { + "comment" : "A heading for the breakdown of CPU usage.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Répartition de l’utilisation" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용량 분석" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estatísticas de Uso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Детализация использования" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kullanım Dağılımı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用详情" + } + } + } + }, + "Use accent color" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "강조 색상 사용" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать цвет акцента" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vurgu rengini kullan" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用强调色" + } + } + } + }, + "Use Accent Color" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "강조 색상 사용" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar Cor de Destaque" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать цвет акцента" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vurgu Rengini Kullan" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用强调色" + } + } + } + }, + "Use album art layout over fullscreen canvas" : { + + }, + "Use circular battery indicator" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원형 배터리 표시기 사용" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать круглый индикатор батареи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dairesel pil göstergesini kullan" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用圆形电池指示器" + } + } + } + }, + "Use colored gauges" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색상 게이지 사용" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать цветные шкалы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renkli göstergeler kullanın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用彩色仪表" + } + } + } + }, + "Use DevTools" : { + "comment" : "One option in the confirmation dialog in the `OnboardingView` that lets the user choose to use the developer tools for focus monitoring.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발도구 사용" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать DevTools" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "DevTools'u kullanın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用开发工具" + } + } + } + }, + "Use enhanced liquid border" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geliştirilmiş sıvı kenarlığı kullan" + } + } + } + }, + "Use MacBook icon when on battery" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 사용 시 MacBook 아이콘 사용" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать иконку MacBook при работе от батареи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pille çalışırken MacBook simgesini kullan" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用电池时显示 MacBook 图标" + } + } + } + }, + "Use music visualizer spectrogram" : { + "comment" : "merge:ko:alt='음악 비주얼라이저 스펙트로그램 사용'", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Use music visualizer spectrogram" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Use music visualizer spectrogram" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Musik-Visualizer Spektrogramm verwenden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use music visualizer spectrogram" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use music visualiser spectrogram" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Use music visualizer spectrogram" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliser le spectrogramme du visualiseur de musique" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Use music visualizer spectrogram" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Usa spettrogramma per la visualizzatore musicale" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음악 시각화 스펙트로그램 사용" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Użyj spektrogramu wizualizera muzyki" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Use o espectrograma visualizador de música" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать визуализацию музыки (спектрограмму)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Müzik görselleştirici spektrogramını kullanma" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Use music visualizer spectrogram" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "使用音乐可视化器谱图" + } + } + } + }, + "Use simpler close animation" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "단순한 닫기 애니메이션 사용" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать простую анимацию закрытия" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daha basit yakın animasyon kullanın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用更简单的关闭动画" + } + } + } + }, + "Use the Media tab to configure sneak peek, lyrics, and floating media controls." : { + "comment" : "A footer for the \"Media Live Activity\" section, explaining how to configure the media controls.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 탭에서 미리보기, 가사 및 플로팅 미디어 제어기를 설정할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use a Aba Mídia para configurar a pré-vizualização, letras e controles de mídia flutuantes." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используйте вкладку Media для настройки быстрого просмотра, текстов песен и др." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gizli bakış, şarkı sözleri ve kayan medya kontrollerini yapılandırmak için Medya sekmesini kullanın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用媒体标签页配置快速预览、歌词和浮动媒体控制。" + } + } + } + }, + "Use the sliders below to pick unique Apple liquid-glass variants for each widget." : { + "comment" : "merge:ko:alt='아래의 슬라이더를 사용하여 각 위젯의 고유한 Apple Liquid Glass 스타일을 선택하세요.'", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아래의 슬라이더를 사용하여 각 위젯의 고유한 Apple 리퀴드 글래스 스타일을 선택하세요." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use os controles deslizantes abaixo para escolher variantes exclusivas de liquid glass da Apple para cada widget." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используйте ползунки ниже для выбора вариантов liquid-glass для виджетов." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Her widget için benzersiz Apple sıvı cam çeşitlerini seçmek üzere aşağıdaki kaydırıcıları kullanın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用下方滑块为每个小组件选择独特的 Apple 液态玻璃变体。" + } + } + } + }, + "Use without DevTools" : { + "comment" : "An option in the onboarding flow that lets users choose to use focus detection without the option provided by the developer tools.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발도구 없이 사용" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать без DevTools" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "DevTools olmadan kullanın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不使用开发工具" + } + } + } + }, + "Used" : { + "comment" : "A label describing the percentage of memory that is currently in use.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilisé" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용 중" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать без инструментов разработчика (DevTools)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kullanılmış" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已使用" + } + } + } + }, + "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, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilisé · %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용 중 · %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usado · %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Исп. · %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kullanılmış · %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已使用 · %@" + } + } + } + }, + "User" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пользователь" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kullanıcı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用户" + } + } + } + }, + "Uses event-driven private API for real-time screen recording detection" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이벤트 기반 프라이빗 API를 사용하여 실시간으로 화면 기록 상태를 감지합니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usa API privada orientada por eventos para detecção de gravação de tela em tempo real" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отслеживает запись экрана в реальном времени" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gerçek zamanlı ekran kaydı algılama için olay odaklı özel API kullanır" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用事件驱动的私有 API 进行实时屏幕录制检测" + } + } + } + }, + "Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek." : { + "comment" : "Description of the Amazon Music media controller.", + "isCommentAutoGenerated" : true, + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amazon Music uygulaması etkin medya kaynağı olduğunda macOS Şimdi Yürütülüyor özelliğini kullanır. Oynatma kontrolleri sistemin Şimdi Yürütülüyor hedefini takip eder. Amazon Music uygulaması uzaktan aramayı desteklemiyorsa zaman çizelgesini temizleme çalışmayabilir." + } + } + } + }, + "Uses the frosted blur treatment while glass mode is enabled." : { + "comment" : "A description of the frosted blur treatment when glass mode is enabled. | A description of the frosted blur treatment when glass mode is enabled. | merge:ko:alt='Glass 모드가 활성화되어 있는 동안에는 Frosted 블러 효과가 적용됩니다.'", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "글래스 모드가 활성화되어 있는 동안에는 프로스티드 블러 효과가 적용됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Usa o tratamento de desfoque fosco enquanto o modo de vidro está ativado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использует матовое размытие при включенном режиме стекла." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cam modu etkinken buzlu bulanıklaştırma işlemini kullanır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用玻璃模式时使用磨砂模糊效果。" + } + } + } + }, + "Utilities" : { + "comment" : "Display name for the Utilities settings tab group.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "유틸리티" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Утилиты" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yardımcı programlar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实用工具" + } + } + } + }, + "Utilization" : { + "comment" : "A label displayed alongside the utilization percentage in the GPU detail view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilisation" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "점유율" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kullanım" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "利用率" + } + } + } + }, + "v%lld" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "v%lld" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "v%lld" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "v%lld" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "v%lld" + } + } + }, + "shouldTranslate" : false + }, + "Validate Cookie" : { + "comment" : "A button that validates the Spotify cookie.", + "isCommentAutoGenerated" : true + }, + "Validating..." : { + "comment" : "The text that appears when validating a Spotify cookie.", + "isCommentAutoGenerated" : true + }, + "Variant %lld" : { + "comment" : "A label for a specific variant of the Liquid Glass.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "변형 %lld" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varyant %lld" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "变体 %lld" + } + } + } + }, + "Version" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Version" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Version" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Version" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Version" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Versione" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "버전" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wersja" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versão" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Versiyon" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Version" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "版本" + } + } + } + }, + "Version info" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Version info" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Version info" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versionsinfo" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version info" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version info" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Version info" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informations de la version" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Version info" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Info versione" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "버전 설명" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Informacje o wersji" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informações da versão" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Информация о версии" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Versiyon bilgisi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Version info" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "版本信息" + } + } + } + }, + "Vertical Bar" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세로 막대" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dikey Çubuk" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "垂直条" + } + } + } + }, + "Vertical offset" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수직 오프셋" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вертикальное смещение" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dikey ofset" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "垂直偏移" + } + } + } + }, + "Vertical:" : { + "extractionState" : "stale", + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vertical :" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vertical:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вертикальный отступ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dikey:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "垂直:" + } + } + }, + "shouldTranslate" : false }, - "(%@)" : { + "Video" : { "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "(%@)" + "value" : "비디오" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Видео" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "video" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "视频" } } } }, - "%.0f seconds" : { - - }, - "%.1fs" : { - - }, - "%@" : { - - }, - "%d%%" : { - - }, - "%lld" : { - - }, - "%lld%%" : { - - }, - "• Bug fixes" : { - - }, - "• Improved performance" : { - - }, - "• New feature 1" : { - - }, - "About" : { + "View on GitHub: th-ch/youtube-music" : { "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "View on GitHub: th-ch/youtube-music" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "View on GitHub: th-ch/youtube-music" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf GitHub anschauen: th-ch/youtube-music" + } + }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "About" + "value" : "View on GitHub: th-ch/youtube-music" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "View on GitHub: th-ch/youtube-music" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "View on GitHub: th-ch/youtube-music" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher sur GitHub: th-ch/youtube-music" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "View on GitHub: th-ch/youtube-music" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Visualizza su GitHub: th-ch/youtube-music" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "GitHub에서 보기: th-ch/youtube-music" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zobacz na GitHub: th-ch/youtube-music" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ver no GitHub: th-ch/youtube-music" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Посмотреть на GitHub: th-ch/youtube-music" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub'da görüntüleyin: th-ch/youtube-music" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "View on GitHub: th-ch/youtube-music" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "在 GitHub 上查看:th-ch/youtube-music" } } } }, - "Accent color" : { - "extractionState" : "stale", + "Volume" : { + "comment" : "A button that sets the preview to show a volume OSD.", + "isCommentAutoGenerated" : true, "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Accent color" + "value" : "음량" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volume" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Громкость" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hacim" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "音量" } } } }, - "Access Denied" : { - - }, - "Activate" : { - - }, - "Activate your license" : { - - }, - "Activation" : { - - }, - "Active" : { - - }, - "Add" : { - - }, - "Add manually" : { - - }, - "Add new visualizer" : { - - }, - "Additional features" : { + "Volume fine step" : { }, - "AirDrop" : { - + "Volume HUD" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ATH du volume" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "음량 HUD" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD de Volume" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "HUD громкости" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hacim HUD" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "音量 HUD" + } + } + } }, - "AirDrop service not available" : { - + "Volume OSD" : { + "comment" : "A toggle that enables or disables the volume OSD", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "음량 OSD" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "OSD de Volume" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "OSD громкости" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cilt OSD" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "音量 OSD" + } + } + } }, - "All-day" : { + "Volume step" : { }, - "Allow Access" : { - + "W " : { + "comment" : "Label for the write data point in the disk usage graph.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "W" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "W " + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Y " + } + } + } }, - "Always hide in fullscreen" : { - + "Warning" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "경고" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждение" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uyarı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "警告" + } + } + } }, - "Always show tabs" : { - + "Weather" : { + "comment" : "A label and its accompanying text displayed in a lock screen panel.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "날씨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "З " + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hava Durumu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "天气" + } + } + } }, - "App icon" : { - + "Weather data provider" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "날씨 데이터 공급자" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provedor de dados do tempo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Провайдер данных погоды" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hava durumu veri sağlayıcısı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "天气数据源" + } + } + } }, - "Appearance" : { - + "Weather Widget" : { + "comment" : "A section header in the Lock Screen Settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "날씨 위젯" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget do Tempo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виджет погоды" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hava Durumu Widget'ı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "天气小组件" + } + } + } }, - "Automatically check for updates" : { + "Weather: %@ %@" : { + "comment" : "Weather description and temperature", "localizations" : { "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Weather: %1$@ %2$@" + } + }, + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Automatically check for updates" + "value" : "날씨: %1$@ %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo: %1$@ %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Погода: %1$@ %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hava Durumu: %@ %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "天气:%1$@ %2$@" } } } }, - "Automatically download updates" : { - - }, - "Battery" : { + "Weather: %@ %@ in %@" : { + "comment" : "Weather description, temperature, and location", "localizations" : { "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Weather: %1$@ %2$@ in %3$@" + } + }, + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Battery" + "value" : "%3$@ 날씨: %1$@ %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo: %1$@ %2$@ in %3$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Погода: %1$@ %2$@ в %3$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hava Durumu: %@ %@, %@ içinde" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "天气:%1$@ %2$@ 在 %3$@" } } } }, - "Battery Information" : { - - }, - "Battery Settings" : { - - }, - "Battery Status" : { - - }, - "Boost your productivity with Clipboard Manager" : { - - }, - "Boring Controls" : { - "extractionState" : "stale", + "Welcome" : { "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Welcome" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Welcome" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Willkommen" + } + }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Boring Controls" + "value" : "Welcome" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welcome" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Welcome" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bienvenue" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Welcome" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Benvenuto" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "환영합니다" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Witamy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bem Vindo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добро пожаловать" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hoş geldiniz" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Welcome" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "欢迎" } } } }, - "boring.notch" : { + "Welcome window" : { "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "boring.notch" + "value" : "Welcome window" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karşılama penceresi" } } } }, - "Both" : { + "What's New" : { "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "What's New" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "What's New" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Was ist neu" + } + }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Both" + "value" : "What's New" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "What's New" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "What's New" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nouveautés" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "What's New" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Novità" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "새로운 기능" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Co nowego" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O Que Há De Novo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Что нового" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Neler Yeni" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "What's New" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "更新内容" } } } }, - "Buy Me a Coffee" : { - "extractionState" : "stale", + "When bold-as-bright is off, bold text uses a heavier font weight instead of bright ANSI colors." : { "localizations" : { - "en" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'볼드체를 밝은 색으로 표시'가 꺼져 있으면, 볼드체 텍스트에 밝은 ANSI 색상 대신 더 두꺼운 폰트 굵기가 사용됩니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Утолщенный шрифт 대신 ярких цветов ANSI." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parlak kadar kalın seçeneği kapalıyken, kalın metinde parlak ANSI renkleri yerine daha ağır bir yazı tipi ağırlığı kullanılır." + } + }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Buy Me a Coffee" + "value" : "关闭粗体变亮时,粗体文本使用更粗的字体而非明亮的 ANSI 颜色。" } } } }, - "Buy us a coffee" : { - - }, - "Calendar" : { - - }, - "Calendar access is denied. Please enable it in System Settings." : { - - }, - "Cancel" : { - - }, - "Charging" : { + "When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls." : { "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Charging" + "value" : "비활성화하면 모든 키보드 단축키를 사용할 수 없습니다. UI 제어기는 계속 사용할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quando desativado, todos os atalhos de teclado estarão inativos. Você ainda pode usar os controles da interface do usuário." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Когда параметр «Жирный текст как яркие цвета» отключён, он отображается более толстым начертанием шрифта вместо использования ярких ANSI-цветов." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devre dışı bırakıldığında, tüm klavye kısayolları devre dışı bırakılacaktır. Kullanıcı arayüzü kontrollerini kullanmaya devam edebilirsiniz." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "禁用后,所有键盘快捷键将不可用。您仍然可以使用界面控制。" } } } }, - "Charging on Hold: Desktop Mode" : { - - }, - "Check for updates" : { - "extractionState" : "stale", + "When disabled, the notch music player stays visible with placeholder metadata even when playback is inactive." : { "localizations" : { - "en" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비활성화시, 재생 중이 아닐 때도 기본 정보가 표시된 노치 음악 플레이어가 계속 유지됩니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При отключении музыкальный плеер выреза остается видимым с пустыми данными даже без воспроизведения." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devre dışı bırakıldığında çentikli müzik çalar, oynatma etkin olmadığında bile yer tutucu meta verilerle birlikte görünür kalır." + } + }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Check for updates" + "value" : "禁用时,刘海音乐播放器即使在未播放时也会显示占位元数据。" } } } }, - "Check for Updates…" : { - - }, - "Choose a Music Source" : { - - }, - "Circle" : { - - }, - "Close" : { - - }, - "Coming soon" : { + "When enabled, clicking on calendar events will open the selected third-party calendar app instead of Apple Calendar." : { "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Coming soon" + "value" : "활성화하면 캘린더 이벤트를 클릭할 때 Apple 캘린더 대신 선택한 타사 캘린더 앱이 열립니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinleştirildiğinde, takvim etkinliklerine tıklandığında Apple Takvim yerine seçilen üçüncü taraf takvim uygulaması açılır." } } } }, - "Continue" : { - - }, - "Custom color" : { - "extractionState" : "stale", + "When enabled, Focus appears briefly (on/off) and then collapses instead of staying visible." : { + "comment" : "A help text for the setting that allows the focus indicator to collapse instead of staying visible.", + "isCommentAutoGenerated" : true, "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Custom color" + "value" : "활성화 시, 집중 모드가 계속 표시되는 대신 잠깐 나타났다가(켜짐/꺼짐) 축소됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quando ativado, o Foco aparece brevemente (ligado/desligado) e depois fecha em vez de permanecer visível." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При включении, Atoll будет получать экранные уведомления от BetterDisplay и отображать их в выбранном выше стиле панели. Это работает вместе с существующим перехватом мультимедийных клавиш — BetterDisplay управляет внешними дисплеями, а Atoll обеспечивает визуальную индикацию." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinleştirildiğinde Odak kısa bir süre görünür (açık/kapalı) ve ardından görünür kalmak yerine daraltılır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用时,专注模式短暂显示(开/关)后折叠而非保持可见。" } } } }, - "Custom height" : { - - }, - "Custom music live activity animation" : { - - }, - "Custom notch size - %.0f" : { - - }, - "Custom vizualizers (Lottie)" : { - - }, - "Customize in Settings" : { - - }, - "Default" : { - - }, - "Demo (10s)" : { - - }, - "Description" : { - - }, - "Disable" : { - - }, - "Disabled" : { - - }, - "Download" : { - - }, - "Download icon style" : { + "When enabled, stats monitoring stops a few seconds after the notch closes." : { "localizations" : { - "en" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "활성화 시, 노치가 닫히고 몇 초 후에 상태 모니터링이 중지됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quando ativado, o monitoramento de estatísticas para alguns segundos após o fechamento do entalhe." + } + }, + "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Download icon style" + "value" : "При включении мониторинг статистики останавливается через несколько секунд после закрытия выреза." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinleştirildiğinde, istatistik izleme çentik kapandıktan birkaç saniye sonra durur." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用后,刘海关闭几秒后统计监控将停止。" } } } }, - "Download indicator style" : { + "When enabled, the music visualizer displays real-time audio spectrum data synced to your music. Requires macOS 14.2+ and uses minimal CPU/GPU resources via the Accelerate framework." : { "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Download indicator style" + "value" : "활성화 시 음악 비주얼라이저가 음악에 동기화된 실시간 오디오 스펙트럼 데이터를 표시합니다. macOS 14.2 이상이 필요하며, Accelerate 프레임워크를 통해 최소한의 CPU/GPU 리소스만 사용합니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinleştirildiğinde, müzik görselleştirici müziğinizle senkronize edilmiş gerçek zamanlı ses spektrumu verilerini görüntüler. macOS 14.2+ gerektirir ve Accelerate çerçevesi aracılığıyla minimum CPU/GPU kaynaklarını kullanır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用时,音乐可视化将显示与音乐同步的实时音频频谱数据。需要 macOS 14.2+,通过 Accelerate 框架使用最少的 CPU/GPU 资源。" } } } }, - "Download indicators" : { - - }, - "Downloads" : { + "When enabled, the notch slides up and hides on external (non-notch) displays until you hover over it." : { + "comment" : "A description of the feature that hides the notch on non-notch displays.", + "isCommentAutoGenerated" : true, "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Downloads" + "value" : "활성화 시, 노치가 없는 외부 디스플레이에서는 마우스를 올릴 때까지 노치가 위로 슬라이드되어 숨겨집니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При включении вырез скрывается на внешних дисплеях, пока вы не наведете курсор." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinleştirildiğinde çentik yukarı kayar ve siz üzerine gelene kadar harici (çentiksiz) ekranlarda gizlenir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用时,刘海在外接(无刘海)显示器上会向上滑动隐藏,直到您将鼠标悬停在上方。" } } } }, - "Drop files here" : { - - }, - "Dynamic Island" : { - - }, - "dynamic.island" : { - - }, - "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" : { - - }, - "Edit layout" : { - - }, - "Email address" : { - - }, - "Enable colored spectrograms" : { - "extractionState" : "stale", + "When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : { "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Enable colored spectrograms" + "value" : "활성화 시, 통계 탭에 실시간 시스템 성능 그래프가 표시됩니다. 이 기능은 시스템 권한이 필요하며 배터리를 추가로 소모할 수 있습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quando ativada, a guia Estatísticas exibirá gráficos de desempenho do sistema em tempo real. Esse recurso requer permissões do sistema e pode usar bateria adicional." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При включении, вырез на экране сдвигается вверх и скрывается на внешних (не имеющих выреза) дисплеях, пока вы не наведете на него курсор." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinleştirildiğinde, İstatistikler sekmesi gerçek zamanlı sistem performans grafiklerini gösterecektir. Bu özellik sistem izinleri gerektirir ve ek pil kullanabilir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用后,统计标签页将显示实时系统性能图表。此功能需要系统权限,可能会消耗额外电量。" } } } }, - "Enable haptics" : { - "extractionState" : "stale", + "White" : { "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Enable haptics" + "value" : "흰색" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Белый" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beyaz" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "白色" } } } }, - "Enable HUD replacement" : { - - }, - "Enable music live activity" : { - - }, - "Enable sneak peek" : { - - }, - "Enable timer live activity" : { - - }, - "Enhance your experience with HUDs" : { - - }, - "Enjoy your free time!" : { - - }, - "Error" : { - - }, - "Exclude apps" : { + "Widget Updates" : { + "comment" : "A label describing the number of times the extension has updated its widget.", + "isCommentAutoGenerated" : true, "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Exclude apps" + "value" : "위젯 업데이트" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atualizações dos Widgets" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Белый" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget Güncellemeleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "小组件更新" } } } }, - "Exit" : { - - }, - "Extensions" : { - - }, - "Finish" : { - - }, - "General" : { + "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value." : { "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "General" + "value" : "너비 조절은 표준 노치 레이아웃에만 적용됩니다. 이 값을 수정하려면 미니멀리스트 UI를 비활성화하십시오." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genişlik ayarlamaları yalnızca standart çentik düzenine uygulanır. Bu değeri düzenlemek için Minimalist Kullanıcı Arayüzünü devre dışı bırakın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "宽度调整仅适用于标准刘海布局。禁用简约界面以编辑此值。" } } } }, - "Gesture control" : { - - }, - "Gesture sensitivity" : { - - }, - "Get started" : { - - }, - "GitHub" : { + "Width:" : { + "extractionState" : "stale", "localizations" : { - "en" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "GitHub" + "value" : "Largueur :" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "너비:" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Largura:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ширина:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genişlik:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "宽度:" } } } }, - "Got it!" : { - - }, - "Gradient" : { - - }, - "Hide DynamicIsland Options" : { - - }, - "Hide only when NowPlaying app is in fullscreen" : { - - }, - "Hierarchical" : { - - }, - "High" : { - - }, - "https://github.com/th-ch/youtube-music" : { - - }, - "HUD style" : { - - }, - "HUDs" : { - - }, - "In progress" : { - - }, - "Inline" : { - - }, - "Install updates automatically" : { - "extractionState" : "stale", + "Width: %lldpx" : { "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Install updates automatically" + "value" : "너비: %lldpx" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Largura: %lldpx" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ширина: %lld px" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genişlik: %lldpx" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "宽度:%lld像素" } } } }, - "Installed extensions" : { - - }, - "Later" : { - - }, - "License key" : { - - }, - "Lottie JSON URL" : { - - }, - "Low" : { - - }, - "Low Power Mode" : { - - }, - "Made with 🫶🏻 by not so boring not.people" : { + "Wired" : { "localizations" : { - "en" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "와이어드" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По проводу" + } + }, + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Made with 🫶🏻 by not so boring not.people" + "value" : "kablolu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "固定" } } } }, - "Match menubar height" : { - - }, - "Match real notch size" : { - - }, - "Max Capacity: %lld%%" : { - - }, - "Media" : { + "With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : { "localizations" : { - "en" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그래프 %lld개가 활성화되면 모든 그래프를 한 줄에 표시할 수 있도록 Dynamic Island가 가로로 확장됩니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Com gráficos %lld ativados, a Ilha Dinâmica se expandirá horizontalmente para acomodar todos os gráficos em uma única linha." + } + }, + "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Media" + "value" : "При %lld включенных графиках Dynamic Island расширится по горизонтали." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld grafikleri etkinleştirildiğinde, Dynamic Island tüm grafikleri tek bir satırda barındıracak şekilde yatay olarak genişleyecektir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用 %lld 个图表后,灵动岛将水平扩展以在单行中容纳所有图表。" } } } }, - "Media change with horizontal gestures" : { - - }, - "Media inactivity timeout" : { + "Works with most media apps, including browsers, to detect what's playing. Note: This may be removed in a future macOS version." : { + "comment" : "Description of a media controller type when it works with most media apps, including browsers, to detect what's playing. Note: This may be removed in a future macOS version.", + "isCommentAutoGenerated" : true, "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Media inactivity timeout" + "value" : "브라우저를 포함한 대부분의 미디어 앱에서 재생 중인 항목을 감지합니다. 참고: 이 기능은 향후 macOS 버전에서 제거될 수 있습니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Работает с большинством медиаплееров." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neyin oynatıldığını algılamak için tarayıcılar dahil çoğu medya uygulamasıyla çalışır. Not: Bu, gelecekteki bir macOS sürümünde kaldırılabilir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "支持大多数媒体应用(包括浏览器)来检测正在播放的内容。注意:此功能可能在未来的 macOS 版本中被移除。" } } } }, - "Media playback live activity" : { + "Write" : { + "comment" : "A label for the write section of the disk overview.", + "isCommentAutoGenerated" : true, "localizations" : { - "en" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Media playback live activity" + "value" : "Écriture" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "쓰기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escrever" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Работает со всеми медиа приложениями, включая браузеры, определяя, что воспроизводится. Заметка: функция может быть удалена в будущих версиях macOS." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yazmak" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "写入" } } } }, - "Media Source" : { - - }, - "Medium" : { - - }, - "Menubar icon" : { - "extractionState" : "stale", + "Write Only" : { "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Menubar icon" + "value" : "쓰기 전용" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yalnızca Yaz" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅写入" } } } }, - "Mic %@" : { - - }, - "min" : { - - }, - "Minimum hover duration" : { - - }, - "Mirror" : { - - }, - "Mirror shape" : { - - }, - "More" : { - - }, - "Music Source" : { - - }, - "muted" : { - - }, - "Name" : { - - }, - "Need Restart" : { - - }, - "Never hide" : { - - }, - "No custom animation available" : { - - }, - "No custom visualizer" : { - - }, - "No events today" : { - - }, - "No excluded apps" : { - - }, - "No excludes" : { - "extractionState" : "stale", + "Written" : { "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "No excludes" + "value" : "기록됨" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Записано" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yazılı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已写入" } } } }, - "No extension installed" : { - - }, - "Non-notch display height" : { - - }, - "Not Now" : { - - }, - "Notch behavior" : { - - }, - "Notch display height" : { - - }, - "Notch Height" : { - - }, - "OK" : { - - }, - "One or more files failed to load" : { - - }, - "Only app icon" : { + "You" : { "localizations" : { - "en" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Only app icon" + "value" : "Vous" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "당신" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Você" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "sen" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你" } } } }, - "Only download icon" : { + "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." : { "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du kannst die App nun genießen. Wenn du Dinge weiter anpassen möchtest, kannst du dies jederzeit in den Einstellungen tun." + } + }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Only download icon" + "value" : "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." + } + }, + "fr" : { + "stringUnit" : { + "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" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ora puoi goderti l'app. Se vuoi modificare ulteriormente le cose, puoi sempre visitare le impostazioni." + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이제 앱을 즐기실 수 있습니다. 추가로 설정을 조정하고 싶으시면 언제든지 설정 메뉴를 방문하시면 됩니다." + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Możesz już cieszyć się aplikacją. Jeśli chcesz wprowadzić dodatkowe zmiany, zajrzyj do ustawień." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agora você pode aproveitar o aplicativo. Se quiser ajustar ainda mais as coisas, você sempre pode visitar as configurações." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Теперь Вы можете пользоваться приложением. Если хотите внести дополнительные изменения, Вы всегда можете сделать это в настройках." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Artık uygulamayı kullanabilirsiniz. Daha fazla ayar yapmak isterseniz ayarlara her zaman dönebilirsiniz." + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "如果您想进一步调整内容,您可以随时访问设置。" } } } }, - "Open System Settings" : { - - }, - "Pause" : { - - }, - "Paused" : { - - }, - "Percentage" : { + "You're All Set!" : { "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "You're All Set!" + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "You're All Set!" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du bist fertig!" + } + }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Percentage" + "value" : "You're All Set!" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "You're All Set!" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "¡Todo listo!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tout est prêt !" + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "You're All Set!" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "È Tutto ok!" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "준비되었습니다!" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Wszystko gotowe!" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Está Tudo Configurado!" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всё готово!" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tamamen Hazırsın!" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "You're All Set!" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "一切都准备好了!" } } } }, - "Plugged In" : { - - }, - "Progress bar" : { + "Your calendar data is only used to show your events and is never shared." : { "localizations" : { - "en" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캘린더 데이터는 오직 일정을 표시하는 용도로만 사용되며 절대로 외부와 공유되지 않습니다." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Os dados do seu calendário são usados apenas para mostrar seus eventos e nunca são compartilhados." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные календаря используются только для показа событий и никуда не передаются." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Takvim verileriniz yalnızca etkinliklerinizi göstermek için kullanılır ve asla paylaşılmaz." + } + }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Progress bar" + "value" : "您的日历数据仅用于显示事件,绝不会被分享。" } } } }, - "Progressbar style" : { - - }, - "Quit" : { + "Your camera is never used without your consent, and nothing is recorded or stored." : { "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Quit" + "value" : "사용자의 동의 없이는 카메라를 절대로 사용하지 않으며, 어떠한 내용도 녹화하거나 저장하지 않습니다." } - } - } - }, - "Quit app" : { - - }, - "Quit boring.notch" : { - "extractionState" : "stale", - "localizations" : { - "en" : { + }, + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Quit boring.notch" + "value" : "Sua câmera nunca é usada sem o seu consentimento, e nada é gravado ou armazenado." } - } - } - }, - "Release name" : { - "localizations" : { - "en" : { + }, + "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Release name" + "value" : "Ваша камера не используется без согласия, ничего не записывается и не сохраняется." } - } - } - }, - "Remember last tab" : { - - }, - "Restart" : { - - }, - "Restart Dynamic Island" : { - - }, - "Resume" : { - - }, - "Running" : { - - }, - "seconds" : { - "extractionState" : "stale", - "localizations" : { - "en" : { + }, + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "seconds" + "value" : "Kameranız asla sizin izniniz olmadan kullanılmaz, hiçbir şey kaydedilmez veya saklanmaz." } - } - } - }, - "Select Calendars" : { - - }, - "Select the music source you want to use. You can change this later in the app settings." : { - - }, - "selected" : { - - }, - "Selected animation" : { - - }, - "Settings" : { - "localizations" : { - "en" : { + }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Settings" + "value" : "未经您同意,相机不会被使用,且不会录制或存储任何内容。" } } } }, - "Shelf" : { - - }, - "Shortcuts" : { - - }, - "Show battery indicator" : { - "extractionState" : "stale", + "Your support funds software development learning for students in 9th–12th grade." : { + "comment" : "A footer note explaining the app's funding.", + "isCommentAutoGenerated" : true, "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Show battery indicator" + "value" : "여러분의 후원금은 9~12학년 학생들의 소프트웨어 개발 교육을 지원하는 데 사용됩니다." } - } - } - }, - "Show calendar" : { - - }, - "Show charging indicator" : { - "extractionState" : "stale", - "localizations" : { - "en" : { + }, + "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Show charging indicator" + "value" : "Ваша поддержка финансирует обучение студентов программированию." } - } - } - }, - "Show cool face animation while inactivity" : { - "extractionState" : "stale", - "localizations" : { - "en" : { + }, + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Show cool face animation while inactivity" + "value" : "Desteğiniz, 9-12. sınıftaki öğrencilerin yazılım geliştirme öğrenimini finanse ediyor." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "您的支持将资助高中生的软件开发学习。" } } } }, - "Show download progress" : { + "Youtube Music" : { + "comment" : "Spotify music service.", "extractionState" : "stale", + "isCommentAutoGenerated" : true, "localizations" : { - "en" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Show download progress" + "value" : "Youtube Music" } - } - } - }, - "Show on a specific display" : { - - }, - "Show on all displays" : { - - }, - "Slider color" : { - - }, - "Sneak Peek shows the media title and artist under the notch for a few seconds." : { - - }, - "Sneak Peek Style" : { - - }, - "Software updates" : { - "localizations" : { - "en" : { + }, + "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Software updates" + "value" : "Youtube Music" } - } - } - }, - "Speed" : { - - }, - "Square" : { - - }, - "Start 5-min Demo Timer" : { - - }, - "Start Demo Timer:" : { - - }, - "Starts a 5-minute demo timer to test the timer live activity feature." : { - - }, - "Stop" : { - - }, - "Stop Timer" : { - - }, - "Stopped" : { - - }, - "Support Us" : { - "localizations" : { - "en" : { + }, + "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Support Us" + "value" : "YouTube Music" } - } - } - }, - "System features" : { - - }, - "TheDynamicIsland" : { - - }, - "Time to Full Charge: %lld min" : { - - }, - "Timer" : { - - }, - "Toggle Notch Open:" : { - - }, - "Toggle Sneak Peek:" : { - - }, - "Transform your notch truly yours" : { - - }, - "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" : { - - }, - "Uninstall" : { - - }, - "Unlock advanced features and improve your experience. Upgrade now for more customizations!" : { - - }, - "unmuted" : { - - }, - "Upgrade to Pro" : { - - }, - "Use music visualizer spectrogram" : { - - }, - "Version" : { - "localizations" : { - "en" : { + }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Version" + "value" : "YouTube Music" } } } }, - "Version info" : { + "YouTube Music requires this third-party app to be installed: " : { + "comment" : "merge:ko:alt='Youtube Music을 사용하려면 이 타사 앱을 설치해야 합니다:'", "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "YouTube Music requires this third-party app to be installed: " + } + }, + "cs" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "YouTube Music requires this third-party app to be installed: " + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "YouTube Musik braucht diese Drittanbieter-App installiert: " + } + }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Version info" + "value" : "YouTube Music requires this third-party app to be installed: " + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "YouTube Music requires this third-party app to be installed: " + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "YouTube Music requires this third-party app to be installed: " + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "YouTube Music requiert l'installation de cette application tierce : " + } + }, + "hu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "YouTube Music requires this third-party app to be installed: " + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "YouTube Music richiede l'installazione di questa app di terze parti: " + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "유튜브 뮤직 서드파티-앱 설치를 필요로 합니다 " + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "YouTube Music wymaga zainstalowania tej aplikacji firm trzecich: " + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "YouTube Music precisa da instalação desse aplicativo de terceiro:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для работы YouTube Music необходимо установить следующее стороннее приложение: " + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "YouTube Music, bu üçüncü taraf uygulamanın yüklenmesini gerektirir:" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "YouTube Music requires this third-party app to be installed: " + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "YouTube 音乐需要安装此第三方应用程序: " } } } - }, - "View on GitHub: th-ch/youtube-music" : { - - }, - "Welcome" : { - - }, - "What's New" : { - - }, - "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." : { - - }, - "You're All Set!" : { - - }, - "YouTube Music requires this third-party app to be installed: " : { - } }, - "version" : "1.0" + "version" : "1.2" } \ No newline at end of file diff --git a/DynamicIsland/MediaControllers/AmazonMusicController.swift b/DynamicIsland/MediaControllers/AmazonMusicController.swift new file mode 100644 index 00000000..c0c4fe84 --- /dev/null +++ b/DynamicIsland/MediaControllers/AmazonMusicController.swift @@ -0,0 +1,287 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Combine +import Foundation + +/// Media Remote stream filtered to `com.amazon.music` only. When another app owns +/// Now Playing, state idles so stale Amazon metadata is not shown. +final class AmazonMusicController: ObservableObject, MediaControllerProtocol { + static let bundleIdentifier = "com.amazon.music" + + func updatePlaybackInfo() async {} + + @Published private(set) var playbackState: PlaybackState = AmazonMusicController.makeIdlePlaybackState() + + var playbackStatePublisher: AnyPublisher { + $playbackState.eraseToAnyPublisher() + } + + var isWorking: Bool { + process != nil && process?.isRunning == true + } + + private let mediaRemoteBundle: CFBundle + private let MRMediaRemoteSendCommandFunction: @convention(c) (Int, AnyObject?) -> Void + private let MRMediaRemoteSetElapsedTimeFunction: @convention(c) (Double) -> Void + private let MRMediaRemoteSetShuffleModeFunction: @convention(c) (Int) -> Void + private let MRMediaRemoteSetRepeatModeFunction: @convention(c) (Int) -> Void + + private var process: Process? + private var pipeHandler: JSONLinesPipeHandler? + private var streamTask: Task? + + /// True only after a stream line explicitly identified Amazon Music as the now playing source. + private var amazonSessionActive = false + + init?() { + guard + let bundle = CFBundleCreate( + kCFAllocatorDefault, + NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")), + let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteSendCommand" as CFString), + let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteSetElapsedTime" as CFString), + let MRMediaRemoteSetShuffleModePointer = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteSetShuffleMode" as CFString), + let MRMediaRemoteSetRepeatModePointer = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteSetRepeatMode" as CFString) + else { return nil } + + self.mediaRemoteBundle = bundle + MRMediaRemoteSendCommandFunction = unsafeBitCast( + MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self) + MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast( + MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self) + MRMediaRemoteSetShuffleModeFunction = unsafeBitCast( + MRMediaRemoteSetShuffleModePointer, to: (@convention(c) (Int) -> Void).self) + MRMediaRemoteSetRepeatModeFunction = unsafeBitCast( + MRMediaRemoteSetRepeatModePointer, to: (@convention(c) (Int) -> Void).self) + + Task { await setupNowPlayingObserver() } + } + + deinit { + streamTask?.cancel() + + if let pipeHandler = self.pipeHandler { + Task { await pipeHandler.close() } + } + + if let process = self.process { + if process.isRunning { + process.terminate() + process.waitUntilExit() + } + } + + self.process = nil + self.pipeHandler = nil + } + + func play() async { + MRMediaRemoteSendCommandFunction(0, nil) + } + + func pause() async { + MRMediaRemoteSendCommandFunction(1, nil) + } + + func togglePlay() async { + MRMediaRemoteSendCommandFunction(2, nil) + } + + func nextTrack() async { + MRMediaRemoteSendCommandFunction(4, nil) + } + + func previousTrack() async { + MRMediaRemoteSendCommandFunction(5, nil) + } + + func seek(to time: Double) async { + await MainActor.run { + MRMediaRemoteSetElapsedTimeFunction(time) + } + } + + func isActive() -> Bool { + NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == Self.bundleIdentifier } + } + + func toggleShuffle() async { + MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 1 : 3) + playbackState.isShuffled.toggle() + } + + func toggleRepeat() async { + let newRepeatMode = (playbackState.repeatMode == .off) ? 3 : (playbackState.repeatMode.rawValue - 1) + playbackState.repeatMode = RepeatMode(rawValue: newRepeatMode) ?? .off + MRMediaRemoteSetRepeatModeFunction(newRepeatMode) + } + + private func setupNowPlayingObserver() async { + let process = Process() + guard + let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"), + let frameworkPath = + Bundle.main.resourceURL? + .appendingPathComponent("MediaRemoteAdapter.framework") + .path + else { + assertionFailure("Could not find mediaremote-adapter.pl script or framework path") + return + } + + process.executableURL = URL(fileURLWithPath: "/usr/bin/perl") + process.arguments = [scriptURL.path, frameworkPath, "stream"] + + let pipeHandler = JSONLinesPipeHandler() + process.standardOutput = await pipeHandler.getPipe() + + let stderrPipe = Pipe() + process.standardError = stderrPipe + stderrPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty, + let message = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !message.isEmpty + else { return } + print("AmazonMusicController [stderr]: \(message)") + } + + self.process = process + self.pipeHandler = pipeHandler + + do { + try process.run() + streamTask = Task { [weak self] in + await self?.processJSONStream() + } + } catch { + assertionFailure("Failed to launch mediaremote-adapter.pl: \(error)") + } + } + + private func processJSONStream() async { + guard let pipeHandler = self.pipeHandler else { return } + + await pipeHandler.readJSONLines(as: NowPlayingUpdate.self) { [weak self] update in + await self?.handleAdapterUpdate(update) + } + } + + private static func makeIdlePlaybackState() -> PlaybackState { + var state = PlaybackState(bundleIdentifier: Self.bundleIdentifier) + state.title = "Unknown" + state.artist = "Unknown" + state.album = "" + state.isPlaying = false + state.artwork = nil + state.duration = 0 + state.currentTime = 0 + state.isShuffled = false + state.repeatMode = .off + state.lastUpdated = Date() + return state + } + + private func applyIdleBecauseNonAmazonSource() { + amazonSessionActive = false + playbackState = Self.makeIdlePlaybackState() + } + + private func handleAdapterUpdate(_ update: NowPlayingUpdate) async { + let payload = update.payload + let diff = update.diff ?? false + + let explicitParent = payload.parentApplicationBundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let explicitBundle = payload.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let explicitSource: String? = { + if let p = explicitParent, !p.isEmpty { return p } + if let b = explicitBundle, !b.isEmpty { return b } + return nil + }() + + if let source = explicitSource { + if source != Self.bundleIdentifier { + applyIdleBecauseNonAmazonSource() + return + } + amazonSessionActive = true + } else if !diff { + applyIdleBecauseNonAmazonSource() + return + } else if !amazonSessionActive { + return + } + + var newPlaybackState = PlaybackState(bundleIdentifier: Self.bundleIdentifier) + + newPlaybackState.title = payload.title ?? (diff ? self.playbackState.title : "") + newPlaybackState.artist = payload.artist ?? (diff ? self.playbackState.artist : "") + newPlaybackState.album = payload.album ?? (diff ? self.playbackState.album : "") + newPlaybackState.duration = payload.duration ?? (diff ? self.playbackState.duration : 0) + newPlaybackState.currentTime = payload.elapsedTime ?? (diff ? self.playbackState.currentTime : 0) + + if let shuffleMode = payload.shuffleMode { + newPlaybackState.isShuffled = shuffleMode != 1 + } else if !diff { + newPlaybackState.isShuffled = false + } else { + newPlaybackState.isShuffled = self.playbackState.isShuffled + } + if let repeatModeValue = payload.repeatMode { + newPlaybackState.repeatMode = RepeatMode(rawValue: repeatModeValue) ?? .off + } else if !diff { + newPlaybackState.repeatMode = .off + } else { + newPlaybackState.repeatMode = self.playbackState.repeatMode + } + + if let artworkDataString = payload.artworkData { + newPlaybackState.artwork = Data( + base64Encoded: artworkDataString.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } else if !diff { + newPlaybackState.artwork = nil + } + + if let dateString = payload.timestamp, + let date = ISO8601DateFormatter().date(from: dateString) { + newPlaybackState.lastUpdated = date + } else if !diff { + newPlaybackState.lastUpdated = Date() + } else { + newPlaybackState.lastUpdated = self.playbackState.lastUpdated + } + + newPlaybackState.playbackRate = payload.playbackRate ?? (diff ? self.playbackState.playbackRate : 1.0) + newPlaybackState.isPlaying = payload.playing ?? (diff ? self.playbackState.isPlaying : false) + newPlaybackState.bundleIdentifier = Self.bundleIdentifier + + self.playbackState = newPlaybackState + } +} diff --git a/DynamicIsland/MediaControllers/AppleMusicController.swift b/DynamicIsland/MediaControllers/AppleMusicController.swift index cfdf94d1..554b3eff 100644 --- a/DynamicIsland/MediaControllers/AppleMusicController.swift +++ b/DynamicIsland/MediaControllers/AppleMusicController.swift @@ -1,127 +1,227 @@ -// -// AppleMusicController.swift -// DynamicIsland -// -// Created by Alexander Greco on 2025-03-29. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ -import Foundation +import AppKit import Combine -import SwiftUI +import Foundation + +private struct ITunesSearchResponse: Decodable { + let results: [ITunesTrack] +} + +private struct ITunesTrack: Decodable { + let trackName: String? + let collectionName: String? + let artworkUrl100: String? +} class AppleMusicController: MediaControllerProtocol { // MARK: - Properties @Published private var playbackState: PlaybackState = PlaybackState( - bundleIdentifier: "com.apple.Music" + bundleIdentifier: "com.apple.Music", + playbackRate: 1 ) - - var playbackStatePublisher: Published.Publisher { $playbackState } - + + var playbackStatePublisher: AnyPublisher { + $playbackState.eraseToAnyPublisher() + } + + var isWorking: Bool { + return true // AppleMusic controller always works + } + + /// Minimum byte count for artwork data to be considered valid. Anything + /// smaller is likely an empty descriptor or error string, not image data. + private static let minimumArtworkSize = 16 + + private var notificationTask: Task? + private var lastCatalogArtworkKey: String? + private var cachedCatalogArtwork: Data? + // MARK: - Initialization init() { setupPlaybackStateChangeObserver() - DispatchQueue.main.async { [weak self] in - if self?.isActive() == true { - self?.updatePlaybackInfo() + Task { + if isActive() { + await updatePlaybackInfo() } } } private func setupPlaybackStateChangeObserver() { - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(updatePlaybackInfo), - name: NSNotification.Name("com.apple.Music.playerInfo"), - object: nil - ) + notificationTask = Task { @Sendable [weak self] in + let notifications = DistributedNotificationCenter.default().notifications( + named: NSNotification.Name("com.apple.Music.playerInfo") + ) + + for await _ in notifications { + await self?.updatePlaybackInfo() + } + } } deinit { - DistributedNotificationCenter.default().removeObserver( - self, - name: NSNotification.Name("com.apple.Music.playerInfo"), - object: nil - ) - + notificationTask?.cancel() } // MARK: - Protocol Implementation - func play() { - executeCommand("play") + func play() async { + await executeCommand("play") } - func pause() { - executeCommand("pause") + func pause() async { + await executeCommand("pause") } - func togglePlay() { - executeCommand("playpause") + func togglePlay() async { + await executeCommand("playpause") } - func nextTrack() { - executeCommand("next track") + func nextTrack() async { + await executeCommand("next track") } - func previousTrack() { - executeCommand("previous track") + func previousTrack() async { + await executeCommand("previous track") } - func seek(to time: Double) { - executeCommand("set player position to \(time)") - updatePlaybackInfo() + func seek(to time: Double) async { + await executeCommand("set player position to \(time)") + await updatePlaybackInfo() + } + + func toggleShuffle() async { + await executeCommand("set shuffle enabled to not shuffle enabled") + try? await Task.sleep(for: .milliseconds(150)) + await updatePlaybackInfo() + } + + func toggleRepeat() async { + await executeCommand(""" + if song repeat is off then + set song repeat to all + else if song repeat is all then + set song repeat to one + else + set song repeat to off + end if + """) + try? await Task.sleep(for: .milliseconds(150)) + await updatePlaybackInfo() } func isActive() -> Bool { let runningApps = NSWorkspace.shared.runningApplications - return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } + return runningApps.contains { $0.bundleIdentifier == "com.apple.Music" } } - // MARK: - Private Methods - @objc func updatePlaybackInfo() { - guard let descriptor = fetchPlaybackInfo() else { return } + func updatePlaybackInfo() async { + guard let descriptor = try? await fetchPlaybackInfoAsync() else { return } guard descriptor.numberOfItems >= 8 else { return } - - let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false - let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown" - let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown" - let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown" - let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0 - let duration = descriptor.atIndex(6)?.doubleValue ?? 0 - let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false - let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false - let artworkData = descriptor.atIndex(9)?.data as Data? - - let updatedState = PlaybackState( - bundleIdentifier: "com.apple.Music", - isPlaying: isPlaying, - title: currentTrack, - artist: currentTrackArtist, - album: currentTrackAlbum, - currentTime: currentTime, - duration: duration, - playbackRate: 1, - isShuffled: isShuffled, - isRepeating: isRepeating, - lastUpdated: Date(), - artwork: (artworkData?.isEmpty ?? true) ? nil : artworkData - ) - - DispatchQueue.main.async { [weak self] in - self?.playbackState = updatedState + var updatedState = self.playbackState + + updatedState.isPlaying = descriptor.atIndex(1)?.booleanValue ?? false + updatedState.title = descriptor.atIndex(2)?.stringValue ?? "Unknown" + updatedState.artist = descriptor.atIndex(3)?.stringValue ?? "Unknown" + updatedState.album = descriptor.atIndex(4)?.stringValue ?? "Unknown" + updatedState.currentTime = descriptor.atIndex(5)?.doubleValue ?? 0 + updatedState.duration = descriptor.atIndex(6)?.doubleValue ?? 0 + updatedState.isShuffled = descriptor.atIndex(7)?.booleanValue ?? false + let repeatModeValue = descriptor.atIndex(8)?.int32Value ?? 0 + updatedState.repeatMode = RepeatMode(rawValue: Int(repeatModeValue)) ?? .off + + // AppleScript returns artwork data for library tracks. For streamed + // content not in the library it returns an empty descriptor, so we + // fall back to the iTunes Search API to fetch artwork by metadata. + if let artworkData = descriptor.atIndex(9)?.data as Data?, + artworkData.count > Self.minimumArtworkSize { + updatedState.artwork = artworkData + } else { + updatedState.artwork = await fetchArtworkFromCatalog( + title: updatedState.title, artist: updatedState.artist, album: updatedState.album + ) } + + updatedState.lastUpdated = Date() + self.playbackState = updatedState } - - private func executeCommand(_ command: String) { - let script = "tell application \"Music\" to \(command)" - Task { - try? await AppleScriptHelper.executeVoid(script) + + // MARK: - Private Methods + + private func fetchArtworkFromCatalog(title: String, artist: String, album: String) async -> Data? { + let key = "\(title)|\(artist)|\(album)" + + if key == lastCatalogArtworkKey, let cached = cachedCatalogArtwork { + return cached + } + + // Search with title + artist only; including album in the query can + // confuse the free-text search when names overlap. We validate the + // album from the structured response fields instead. + let query = "\(title) \(artist)" + guard !query.trimmingCharacters(in: .whitespaces).isEmpty, + let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=song&limit=10") + else { return nil } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + let response = try JSONDecoder().decode(ITunesSearchResponse.self, from: data) + guard !response.results.isEmpty else { return nil } + + let normalizedAlbum = album.lowercased() + let normalizedTitle = title.lowercased() + + let match = response.results.first(where: { + $0.trackName?.lowercased() == normalizedTitle && + $0.collectionName?.lowercased() == normalizedAlbum + }) ?? response.results.first(where: { + $0.collectionName?.lowercased() == normalizedAlbum + }) ?? response.results.first(where: { + $0.trackName?.lowercased() == normalizedTitle + }) ?? response.results.first + + guard let artworkURLString = match?.artworkUrl100 else { return nil } + + let highResURL = artworkURLString.replacingOccurrences(of: "100x100", with: "600x600") + guard let imageURL = URL(string: highResURL) else { return nil } + + let (imageData, _) = try await URLSession.shared.data(from: imageURL) + lastCatalogArtworkKey = key + cachedCatalogArtwork = imageData + return imageData + } catch { + return nil } } + + private func executeCommand(_ command: String) async { + let script = "tell application \"Music\" to \(command)" + try? await AppleScriptHelper.executeVoid(script) + } - private func fetchPlaybackInfo() -> NSAppleEventDescriptor? { + private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? { let script = """ tell application "Music" - set isRunning to true try set playerState to player state is playing set currentTrackName to name of current track @@ -129,26 +229,27 @@ class AppleMusicController: MediaControllerProtocol { set currentTrackAlbum to album of current track set trackPosition to player position set trackDuration to duration of current track - set shuffleState to false - set repeatState to false + set shuffleState to shuffle enabled + set repeatState to song repeat + if repeatState is off then + set repeatValue to 1 + else if repeatState is one then + set repeatValue to 2 + else if repeatState is all then + set repeatValue to 3 + end if + + set artData to "" try - set artData to data of artwork 1 of current track - on error - set artData to "" + set artData to raw data of artwork 1 of current track end try - return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artData} + + return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData} on error - return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, false, ""} + return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, 0, ""} end try end tell """ - var descriptor: NSAppleEventDescriptor? = nil - let semaphore = DispatchSemaphore(value: 0) - Task { - descriptor = try? await AppleScriptHelper.execute(script) - semaphore.signal() - } - semaphore.wait() - return descriptor + return try await AppleScriptHelper.execute(script) } } diff --git a/DynamicIsland/MediaControllers/MediaControllerProtocol.swift b/DynamicIsland/MediaControllers/MediaControllerProtocol.swift index 5d03c009..c58a1526 100644 --- a/DynamicIsland/MediaControllers/MediaControllerProtocol.swift +++ b/DynamicIsland/MediaControllers/MediaControllerProtocol.swift @@ -1,21 +1,40 @@ -// -// MediaControllerProtocol.swift -// DynamicIsland -// -// Created by Alexander Greco on 2025-03-29. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation import AppKit +import Combine protocol MediaControllerProtocol: ObservableObject { - var playbackStatePublisher: Published.Publisher { get } - func play() - func pause() - func seek(to time: Double) - func nextTrack() - func previousTrack() - func togglePlay() + var playbackStatePublisher: AnyPublisher { get } + var isWorking: Bool { get } + func play() async + func pause() async + func seek(to time: Double) async + func nextTrack() async + func previousTrack() async + func togglePlay() async + func toggleShuffle() async + func toggleRepeat() async func isActive() -> Bool - func updatePlaybackInfo() + func updatePlaybackInfo() async } diff --git a/DynamicIsland/MediaControllers/NowPlayingController.swift b/DynamicIsland/MediaControllers/NowPlayingController.swift index a4c3b0a8..1b863b79 100644 --- a/DynamicIsland/MediaControllers/NowPlayingController.swift +++ b/DynamicIsland/MediaControllers/NowPlayingController.swift @@ -1,197 +1,363 @@ -// -// NowPlayingController.swift -// DynamicIsland -// -// Created by Alexander on 2025-03-29. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ -import Combine import AppKit +import Combine import Foundation -class NowPlayingController: ObservableObject, MediaControllerProtocol { +final class NowPlayingController: ObservableObject, MediaControllerProtocol { + // Stub for now to conform with ControllerProtocol + func updatePlaybackInfo() async {} + // MARK: - Properties - @Published var playbackState: PlaybackState = .init( + @Published private(set) var playbackState: PlaybackState = .init( bundleIdentifier: "com.apple.Music" ) + + var playbackStatePublisher: AnyPublisher { + $playbackState.eraseToAnyPublisher() + } - var playbackStatePublisher: Published.Publisher { $playbackState } - private var cancellables = Set() - private var lastMusicItem: (title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)? - + var isWorking: Bool { + return process != nil && process?.isRunning == true + } + private var lastMusicItem: + (title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)? + // MARK: - Media Remote Functions private let mediaRemoteBundle: CFBundle - private let MRMediaRemoteGetNowPlayingInfo: @convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void - private let MRMediaRemoteRegisterForNowPlayingNotifications: @convention(c) (DispatchQueue) -> Void - private let MRMediaRemoteGetNowPlayingApplicationIsPlaying: @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void - private let MRMediaRemoteGetNowPlayingClient: @convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void - private let MRNowPlayingClientGetBundleIdentifier: @convention(c) (AnyObject?) -> String? - private let MRNowPlayingClientGetParentAppBundleIdentifier: @convention(c) (AnyObject?) -> String? - private let MRMediaRemoteSendCommandFunction:@convention(c) (Int, AnyObject?) -> Void + private let MRMediaRemoteSendCommandFunction: @convention(c) (Int, AnyObject?) -> Void private let MRMediaRemoteSetElapsedTimeFunction: @convention(c) (Double) -> Void + private let MRMediaRemoteSetShuffleModeFunction: @convention(c) (Int) -> Void + private let MRMediaRemoteSetRepeatModeFunction: @convention(c) (Int) -> Void + + private var process: Process? + private var pipeHandler: JSONLinesPipeHandler? + private var streamTask: Task? // MARK: - Initialization init?() { - guard let bundle = CFBundleCreate(kCFAllocatorDefault, NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")), - let MRMediaRemoteGetNowPlayingInfoPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingInfo" as CFString), - let MRMediaRemoteRegisterForNowPlayingNotificationsPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteRegisterForNowPlayingNotifications" as CFString), - let MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying" as CFString), - let MRMediaRemoteGetNowPlayingClientPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingClient" as CFString), - let MRNowPlayingClientGetBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetBundleIdentifier" as CFString), - let MRNowPlayingClientGetParentAppBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetParentAppBundleIdentifier" as CFString), - let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSendCommand" as CFString), - let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSetElapsedTime" as CFString) + guard + let bundle = CFBundleCreate( + kCFAllocatorDefault, + NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")), + let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteSendCommand" as CFString), + let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteSetElapsedTime" as CFString), + let MRMediaRemoteSetShuffleModePointer = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteSetShuffleMode" as CFString), + let MRMediaRemoteSetRepeatModePointer = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteSetRepeatMode" as CFString) + else { return nil } mediaRemoteBundle = bundle - MRMediaRemoteGetNowPlayingInfo = unsafeBitCast(MRMediaRemoteGetNowPlayingInfoPointer, to: (@convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void).self) - MRMediaRemoteRegisterForNowPlayingNotifications = unsafeBitCast(MRMediaRemoteRegisterForNowPlayingNotificationsPointer, to: (@convention(c) (DispatchQueue) -> Void).self) - MRMediaRemoteGetNowPlayingApplicationIsPlaying = unsafeBitCast(MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer, to: (@convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void).self) - MRMediaRemoteGetNowPlayingClient = unsafeBitCast(MRMediaRemoteGetNowPlayingClientPointer, to: (@convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void).self) - MRNowPlayingClientGetBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self) - MRNowPlayingClientGetParentAppBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetParentAppBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self) - MRMediaRemoteSendCommandFunction = unsafeBitCast(MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self) - MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast(MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self) - - setupNowPlayingObserver() - updatePlaybackInfo() + MRMediaRemoteSendCommandFunction = unsafeBitCast( + MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self) + MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast( + MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self) + MRMediaRemoteSetShuffleModeFunction = unsafeBitCast( + MRMediaRemoteSetShuffleModePointer, to: (@convention(c) (Int) -> Void).self) + MRMediaRemoteSetRepeatModeFunction = unsafeBitCast( + MRMediaRemoteSetRepeatModePointer, to: (@convention(c) (Int) -> Void).self) + + Task { await setupNowPlayingObserver() } } - + deinit { - // Clean up all Combine subscribers - cancellables.removeAll() + streamTask?.cancel() - // Remove distributed notification observers - DistributedNotificationCenter.default().removeObserver( - self, - name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), - object: nil - ) + if let pipeHandler = self.pipeHandler { + Task { await pipeHandler.close() + } + } - DistributedNotificationCenter.default().removeObserver( - self, - name: NSNotification.Name("com.apple.Music.playerInfo"), - object: nil - ) + if let process = self.process { + if process.isRunning { + process.terminate() + process.waitUntilExit() + } + } + + self.process = nil + self.pipeHandler = nil } - + // MARK: - Protocol Implementation - func play() { + func play() async { MRMediaRemoteSendCommandFunction(0, nil) } - - func pause() { + + func pause() async { MRMediaRemoteSendCommandFunction(1, nil) } - - func togglePlay() { + + func togglePlay() async { MRMediaRemoteSendCommandFunction(2, nil) } - - func nextTrack() { + + func nextTrack() async { MRMediaRemoteSendCommandFunction(4, nil) } - - func previousTrack() { + + func previousTrack() async { MRMediaRemoteSendCommandFunction(5, nil) } - - func seek(to time: Double) { + + func seek(to time: Double) async { MRMediaRemoteSetElapsedTimeFunction(time) } - + func isActive() -> Bool { return true } + func toggleShuffle() async { + // MRMediaRemoteSendCommandFunction(6, nil) + MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 1 : 3) + playbackState.isShuffled.toggle() + } + + func toggleRepeat() async { + // MRMediaRemoteSendCommandFunction(7, nil) + let newRepeatMode = (playbackState.repeatMode == .off) ? 3 : (playbackState.repeatMode.rawValue - 1) + playbackState.repeatMode = RepeatMode(rawValue: newRepeatMode) ?? .off + MRMediaRemoteSetRepeatModeFunction(newRepeatMode) + } + // MARK: - Setup Methods - private func setupNowPlayingObserver() { - MRMediaRemoteRegisterForNowPlayingNotifications(DispatchQueue.main) - - NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingInfoDidChangeNotification")) - .sink { [weak self] _ in self?.updatePlaybackInfo() } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingApplicationDidChangeNotification")) - .sink { [weak self] _ in self?.updateApp() } - .store(in: &cancellables) - - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(updatePlaybackInfo), - name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), - object: nil - ) + private func setupNowPlayingObserver() async { + let process = Process() + guard + let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"), + //let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework") + let frameworkPath = + Bundle.main.resourceURL? + .appendingPathComponent("MediaRemoteAdapter.framework") + .path - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(updatePlaybackInfo), - name: NSNotification.Name("com.apple.Music.playerInfo"), - object: nil - ) + else { + assertionFailure("Could not find mediaremote-adapter.pl script or framework path") + return + } + + process.executableURL = URL(fileURLWithPath: "/usr/bin/perl") + process.arguments = [scriptURL.path, frameworkPath, "stream"] + + let pipeHandler = JSONLinesPipeHandler() + process.standardOutput = await pipeHandler.getPipe() + + // Capture stderr so framework/script errors are logged + let stderrPipe = Pipe() + process.standardError = stderrPipe + stderrPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty, + let message = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !message.isEmpty + else { return } + print("NowPlayingController [stderr]: \(message)") + } + + self.process = process + self.pipeHandler = pipeHandler + + do { + try process.run() + streamTask = Task { [weak self] in + await self?.processJSONStream() + } + } catch { + assertionFailure("Failed to launch mediaremote-adapter.pl: \(error)") + } } - + + // MARK: - Async Stream Processing + private func processJSONStream() async { + guard let pipeHandler = self.pipeHandler else { return } + + await pipeHandler.readJSONLines(as: NowPlayingUpdate.self) { [weak self] update in + await self?.handleAdapterUpdate(update) + } + } + // MARK: - Update Methods - @objc func updateApp() { - // Get the now playing client - MRMediaRemoteGetNowPlayingClient(DispatchQueue.main) { [weak self] clientObj in - guard let clientObj = clientObj else { - DispatchQueue.main.async { - self?.playbackState.bundleIdentifier = "com.apple.Music" // Default fallback - } - return - } - - // Try to get parent bundle ID first, then fall back to direct bundle ID - var appBundleID = self?.MRNowPlayingClientGetParentAppBundleIdentifier(clientObj) - if appBundleID == nil { - appBundleID = self?.MRNowPlayingClientGetBundleIdentifier(clientObj) - } - - // Special case for WebKit.GPU which is often Safari - if appBundleID == "com.apple.WebKit.GPU" { - appBundleID = "com.apple.Safari" - } - - DispatchQueue.main.async { - self?.playbackState.bundleIdentifier = appBundleID ?? "com.apple.Music" - } + private func handleAdapterUpdate(_ update: NowPlayingUpdate) async { + let payload = update.payload + let diff = update.diff ?? false + + var newPlaybackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier) + + newPlaybackState.title = payload.title ?? (diff ? self.playbackState.title : "") + newPlaybackState.artist = payload.artist ?? (diff ? self.playbackState.artist : "") + newPlaybackState.album = payload.album ?? (diff ? self.playbackState.album : "") + newPlaybackState.duration = payload.duration ?? (diff ? self.playbackState.duration : 0) + + // Match boring.notch behavior: if elapsedTime is provided use it, + // if this update is a diff keep the previous currentTime, otherwise default to 0. + newPlaybackState.currentTime = payload.elapsedTime ?? (diff ? self.playbackState.currentTime : 0) + + + if let shuffleMode = payload.shuffleMode { + newPlaybackState.isShuffled = shuffleMode != 1 + } else if !diff { + newPlaybackState.isShuffled = false + } else { + newPlaybackState.isShuffled = self.playbackState.isShuffled + } + if let repeatModeValue = payload.repeatMode { + newPlaybackState.repeatMode = RepeatMode(rawValue: repeatModeValue) ?? .off + } else if !diff { + newPlaybackState.repeatMode = .off + } else { + newPlaybackState.repeatMode = self.playbackState.repeatMode } + + if let artworkDataString = payload.artworkData { + newPlaybackState.artwork = Data( + base64Encoded: artworkDataString.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } else if !diff { + newPlaybackState.artwork = nil + } + + if let dateString = payload.timestamp, + let date = ISO8601DateFormatter().date(from: dateString) { + newPlaybackState.lastUpdated = date + } else if !diff { + newPlaybackState.lastUpdated = Date() + } else { + newPlaybackState.lastUpdated = self.playbackState.lastUpdated + } + + newPlaybackState.playbackRate = payload.playbackRate ?? (diff ? self.playbackState.playbackRate : 1.0) + newPlaybackState.isPlaying = payload.playing ?? (diff ? self.playbackState.isPlaying : false) + newPlaybackState.bundleIdentifier = ( + payload.parentApplicationBundleIdentifier ?? + payload.bundleIdentifier ?? + (diff ? self.playbackState.bundleIdentifier : "") + ) + + self.playbackState = newPlaybackState } +} - @objc func updatePlaybackInfo() { - updateApp() +struct NowPlayingUpdate: Codable { + let payload: NowPlayingPayload + let diff: Bool? +} - MRMediaRemoteGetNowPlayingInfo(DispatchQueue.main) { [weak self] information in - guard let self = self else { return } +struct NowPlayingPayload: Codable { + let title: String? + let artist: String? + let album: String? + let duration: Double? + let elapsedTime: Double? + let shuffleMode: Int? + let repeatMode: Int? + let artworkData: String? + let timestamp: String? + let playbackRate: Double? + let playing: Bool? + let parentApplicationBundleIdentifier: String? + let bundleIdentifier: String? +} - let title = information["kMRMediaRemoteNowPlayingInfoTitle"] as? String ?? "" - let artist = information["kMRMediaRemoteNowPlayingInfoArtist"] as? String ?? "" - let album = information["kMRMediaRemoteNowPlayingInfoAlbum"] as? String ?? "" - let duration = information["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval ?? 0 - let currentTime = information["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? TimeInterval ?? 0 - let artworkData = information["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data - let timestamp = information["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date ?? Date() - let playbackRate = information["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 1 +actor JSONLinesPipeHandler { + private let pipe: Pipe + private let fileHandle: FileHandle + private var buffer = "" + + init() { + self.pipe = Pipe() + self.fileHandle = pipe.fileHandleForReading + } + + func getPipe() -> Pipe { + return pipe + } + + func readJSONLines(as type: T.Type, onLine: @escaping (T) async -> Void) async { + do { + try await self.processLines(as: type) { decodedObject in + await onLine(decodedObject) + } + } catch { + print("Error processing JSON stream: \(error)") + } + } + + private func processLines(as type: T.Type, onLine: @escaping (T) async -> Void) async throws { + while true { + let data = try await readData() + guard !data.isEmpty else { break } - // Update playback state - DispatchQueue.main.async { - self.playbackState.title = title - self.playbackState.artist = artist - self.playbackState.album = album - self.playbackState.duration = duration - self.playbackState.currentTime = currentTime - self.playbackState.artwork = artworkData - self.playbackState.lastUpdated = timestamp - self.playbackState.playbackRate = playbackRate + if let chunk = String(data: data, encoding: .utf8) { + buffer.append(chunk) - // Check playback state - self.MRMediaRemoteGetNowPlayingApplicationIsPlaying(DispatchQueue.main) { [weak self] isPlaying in - DispatchQueue.main.async { - self?.playbackState.isPlaying = isPlaying + while let range = buffer.range(of: "\n") { + let line = String(buffer[..(_ line: String, as type: T.Type, onLine: @escaping (T) async -> Void) async { + guard let data = line.data(using: .utf8) else { + return + } + do { + let decodedObject = try JSONDecoder().decode(T.self, from: data) + await onLine(decodedObject) + } catch { + // Ignore lines that can't be decoded + } + } + + private func readData() async throws -> Data { + return try await withCheckedThrowingContinuation { continuation in + + fileHandle.readabilityHandler = { handle in + let data = handle.availableData + handle.readabilityHandler = nil + continuation.resume(returning: data) + } + } + } + + func close() async { + do { + fileHandle.readabilityHandler = nil + try fileHandle.close() + try pipe.fileHandleForWriting.close() + } catch { + print("Error closing pipe handler: \(error)") + } + } } diff --git a/DynamicIsland/MediaControllers/SpotifyController.swift b/DynamicIsland/MediaControllers/SpotifyController.swift index 3ca74e46..5950adac 100644 --- a/DynamicIsland/MediaControllers/SpotifyController.swift +++ b/DynamicIsland/MediaControllers/SpotifyController.swift @@ -1,97 +1,149 @@ -// -// SpotifyController.swift -// DynamicIsland -// -// Created by Alexander Greco on 2025-03-29. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ -import Foundation import Combine +import Foundation import SwiftUI class SpotifyController: MediaControllerProtocol { + static let bundleIdentifier = "com.spotify.client" + // MARK: - Properties @Published private var playbackState: PlaybackState = PlaybackState( - bundleIdentifier: "com.spotify.client" + bundleIdentifier: SpotifyController.bundleIdentifier ) - - var playbackStatePublisher: Published.Publisher { $playbackState } - + + var playbackStatePublisher: AnyPublisher { + $playbackState.eraseToAnyPublisher() + } + + var isWorking: Bool { + true + } + + private var notificationTask: Task? + private var sessionChangeCancellable: AnyCancellable? + + // Constant for time between command and update + private let commandUpdateDelay: Duration = .milliseconds(25) + + private var lastArtworkURL: String? + private var artworkFetchTask: Task? + private var canvasFetchTask: Task? + private var currentCanvasTrackURI: String? + private var lastCanvasRequestTrackURI: String? + private var lastCanvasRequestDate: Date = .distantPast + private let canvasResolver = SpotifyCanvasResolver() + init() { setupPlaybackStateChangeObserver() - DispatchQueue.main.async { [weak self] in - if self?.isActive() == true { - self?.updatePlaybackInfo() + setupSessionChangeObserver() + Task { + if isActive() { + await updatePlaybackInfo() } } } - + private func setupPlaybackStateChangeObserver() { - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(updatePlaybackInfo), - name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), - object: nil - ) + notificationTask = Task { @Sendable [weak self] in + let notifications = DistributedNotificationCenter.default().notifications( + named: NSNotification.Name("com.spotify.client.PlaybackStateChanged") + ) + + for await _ in notifications { + await self?.updatePlaybackInfo() + } + } + } + + private func setupSessionChangeObserver() { + sessionChangeCancellable = NotificationCenter.default.publisher(for: .spotifyCanvasSessionDidChange) + .sink { [weak self] _ in + self?.lastCanvasRequestTrackURI = nil + self?.lastCanvasRequestDate = .distantPast + if let self { + var updatedState = self.playbackState + updatedState.liveArtworkURL = nil + self.playbackState = updatedState + } + Task { + await self?.updatePlaybackInfo() + } + } } - + deinit { - // Remove notification observer when controller is deallocated - DistributedNotificationCenter.default().removeObserver( - self, - name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), - object: nil - ) + notificationTask?.cancel() + artworkFetchTask?.cancel() + canvasFetchTask?.cancel() + sessionChangeCancellable?.cancel() } - + // MARK: - Protocol Implementation - func play() { - executeCommand("play") - } - - func pause() { - executeCommand("pause") - } - - func togglePlay() { - executeCommand("playpause") + func play() async { await executeCommand("play") } + func pause() async { await executeCommand("pause") } + func togglePlay() async { await executeCommand("playpause") } + func nextTrack() async { await executeCommand("next track") } + + func previousTrack() async { + await executeAndRefresh("previous track") } - - func nextTrack() { - executeCommand("next track") + + func seek(to time: Double) async { + await executeAndRefresh("set player position to \(time)") } - - func previousTrack() { - executeCommand("previous track") + + func toggleShuffle() async { + await executeAndRefresh("set shuffling to not shuffling") } - - func seek(to time: Double) { - executeCommand("set player position to \(time)") - updatePlaybackInfo() + + func toggleRepeat() async { + await executeAndRefresh("set repeating to not repeating") } - + func isActive() -> Bool { - let runningApps = NSWorkspace.shared.runningApplications - return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } - } - - // MARK: - Private Methods - @objc func updatePlaybackInfo() { - guard let descriptor = fetchPlaybackInfo() else { return } - guard descriptor.numberOfItems >= 9 else { return } - + NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } + } + + func updatePlaybackInfo() async { + guard let descriptor = try? await fetchPlaybackInfoAsync() else { return } + guard descriptor.numberOfItems >= 11 else { return } + let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown" let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown" let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown" let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0 - let duration = (descriptor.atIndex(6)?.doubleValue ?? 0)/1000 + let duration = (descriptor.atIndex(6)?.doubleValue ?? 0) / 1000 let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false let artworkURL = descriptor.atIndex(9)?.stringValue ?? "" - - let state = PlaybackState( - bundleIdentifier: "com.spotify.client", + let trackIdentifier = descriptor.atIndex(10)?.stringValue ?? "" + let spotifyURLString = descriptor.atIndex(11)?.stringValue ?? "" + let trackURI = Self.canonicalTrackURI(from: trackIdentifier, spotifyURLString: spotifyURLString) + + var state = PlaybackState( + bundleIdentifier: Self.bundleIdentifier, isPlaying: isPlaying, title: currentTrack, artist: currentTrackArtist, @@ -100,42 +152,115 @@ class SpotifyController: MediaControllerProtocol { duration: duration, playbackRate: 1, isShuffled: isShuffled, - isRepeating: isRepeating, + repeatMode: isRepeating ? .all : .off, lastUpdated: Date() ) - - DispatchQueue.main.async { [weak self] in - self?.playbackState = state + state.contentIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + state.contentURL = spotifyURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : spotifyURLString.trimmingCharacters(in: .whitespacesAndNewlines) + + if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork { + state.artwork = existingArtwork } - - // Load artwork asynchronously and update the state when complete + + let isSameCanvasTrack = trackURI == currentCanvasTrackURI + + if isSameCanvasTrack { + state.liveArtworkURL = playbackState.liveArtworkURL + } else { + state.liveArtworkURL = nil + currentCanvasTrackURI = trackURI.isEmpty ? nil : trackURI + canvasFetchTask?.cancel() + canvasFetchTask = nil + } + + playbackState = state + + if !trackURI.isEmpty { + scheduleCanvasFetchIfNeeded(for: trackURI) + } + if !artworkURL.isEmpty, let url = URL(string: artworkURL) { - DispatchQueue.global(qos: .background).async { [weak self] in + guard artworkURL != lastArtworkURL || state.artwork == nil else { return } + artworkFetchTask?.cancel() + + let currentState = state + let expectedTrackURI = trackURI + + artworkFetchTask = Task { do { - let artworkData = try Data(contentsOf: url) - DispatchQueue.main.async { - // Create a new state with the artwork data and update - var updatedState = state - updatedState.artwork = artworkData - self?.playbackState = updatedState + let data = try await ImageService.shared.fetchImageData(from: url) + + await MainActor.run { [weak self] in + guard let self = self else { return } + guard expectedTrackURI.isEmpty || self.currentCanvasTrackURI == expectedTrackURI else { + self.artworkFetchTask = nil + return + } + var updatedState = currentState + updatedState.artwork = data + updatedState.liveArtworkURL = self.playbackState.liveArtworkURL + self.playbackState = updatedState + self.lastArtworkURL = artworkURL + self.artworkFetchTask = nil } } catch { - print("Failed to load artwork: \(error)") + await MainActor.run { [weak self] in + self?.artworkFetchTask = nil + } } } - } else { - self.playbackState = state } } - - private func executeCommand(_ command: String) { - let script = "tell application \"Spotify\" to \(command)" - Task { - try? await AppleScriptHelper.executeVoid(script) + + private func scheduleCanvasFetchIfNeeded(for trackURI: String) { + guard !trackURI.isEmpty else { return } + guard playbackState.liveArtworkURL == nil else { return } + guard canvasFetchTask == nil else { return } + + if lastCanvasRequestTrackURI == trackURI, + Date().timeIntervalSince(lastCanvasRequestDate) < 5 + { + return + } + + lastCanvasRequestTrackURI = trackURI + lastCanvasRequestDate = Date() + + canvasFetchTask = Task { [weak self] in + guard let self else { return } + + let canvasURL = await self.canvasResolver.canvasURL(for: trackURI) + guard !Task.isCancelled else { return } + + await MainActor.run { + guard self.currentCanvasTrackURI == trackURI else { + self.canvasFetchTask = nil + return + } + + var updatedState = self.playbackState + updatedState.liveArtworkURL = canvasURL + self.playbackState = updatedState + self.canvasFetchTask = nil + } } } - - private func fetchPlaybackInfo() -> NSAppleEventDescriptor? { + private func executeCommand(_ command: String) async { + let script = "tell application \"Spotify\" to \(command)" + try? await AppleScriptHelper.executeVoid(script) + } + + private func executeAndRefresh(_ command: String) async { + await executeCommand(command) + try? await Task.sleep(for: commandUpdateDelay) + await updatePlaybackInfo() + } + + private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? { let script = """ tell application "Spotify" set isRunning to true @@ -149,20 +274,294 @@ class SpotifyController: MediaControllerProtocol { set shuffleState to shuffling set repeatState to repeating set artworkURL to artwork url of current track - return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL} + set trackID to id of current track + set spotifyURL to spotify url of current track + return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackID, spotifyURL} on error - return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, ""} + return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""} end try end tell """ - - var descriptor: NSAppleEventDescriptor? = nil - let semaphore = DispatchSemaphore(value: 0) - Task { - descriptor = try? await AppleScriptHelper.execute(script) - semaphore.signal() + + return try await AppleScriptHelper.execute(script) + } + + private static func canonicalTrackURI(from trackIdentifier: String, spotifyURLString: String) -> String { + let trimmedIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedIdentifier.hasPrefix("spotify:track:") { + return trimmedIdentifier + } + + if isLikelySpotifyTrackID(trimmedIdentifier) { + return "spotify:track:\(trimmedIdentifier)" + } + + if let trackID = extractTrackID(from: spotifyURLString) { + return "spotify:track:\(trackID)" + } + + return "" + } + + private static func extractTrackID(from spotifyURLString: String) -> String? { + guard let url = URL(string: spotifyURLString), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + + let pathComponents = components.path.split(separator: "/") + guard let trackIndex = pathComponents.firstIndex(of: "track"), + trackIndex + 1 < pathComponents.count + else { return nil } + + let identifier = String(pathComponents[trackIndex + 1]) + return isLikelySpotifyTrackID(identifier) ? identifier : nil + } + + private static func isLikelySpotifyTrackID(_ value: String) -> Bool { + value.count == 22 && value.unicodeScalars.allSatisfy { scalar in + CharacterSet.alphanumerics.contains(scalar) + } + } +} + +private actor SpotifyCanvasResolver { + private struct CacheEntry { + let url: URL? + let expiresAt: Date + } + + private var cache: [String: CacheEntry] = [:] + + func canvasURL(for trackURI: String) async -> URL? { + let now = Date() + if let entry = cache[trackURI], entry.expiresAt > now { + return entry.url + } + + let resolvedURL = await fetchCanvasURL(for: trackURI, retryOnUnauthorized: true) + let cacheLifetime: TimeInterval = resolvedURL == nil ? 300 : 3600 + cache[trackURI] = CacheEntry(url: resolvedURL, expiresAt: now.addingTimeInterval(cacheLifetime)) + return resolvedURL + } + + private func fetchCanvasURL(for trackURI: String, retryOnUnauthorized: Bool) async -> URL? { + guard let accessToken = await SpotifyAuthManager.shared.validAccessToken() else { + return nil + } + + var request = URLRequest(url: URL(string: "https://spclient.wg.spotify.com/canvaz-cache/v0/canvases")!) + request.httpMethod = "POST" + request.httpBody = SpotifyCanvasProtobuf.makeCanvasRequest(trackURI: trackURI) + request.setValue("application/protobuf", forHTTPHeaderField: "Accept") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("en", forHTTPHeaderField: "Accept-Language") + request.setValue("Spotify/9.0.34.593 iOS/18.4 (iPhone15,3)", forHTTPHeaderField: "User-Agent") + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return nil + } + + if httpResponse.statusCode == 401, retryOnUnauthorized { + await SpotifyAuthManager.shared.invalidateAccessToken() + return await fetchCanvasURL(for: trackURI, retryOnUnauthorized: false) + } + + guard (200..<300).contains(httpResponse.statusCode) else { + return nil + } + + return SpotifyCanvasProtobuf.parseCanvasURL(from: data, matching: trackURI) + } catch { + return nil + } + } +} + +private enum SpotifyCanvasProtobuf { + static func makeCanvasRequest(trackURI: String) -> Data { + let uriData = Data(trackURI.utf8) + + var trackMessage = Data() + trackMessage.append(protobufKey(fieldNumber: 1, wireType: 2)) + trackMessage.append(varint(for: uriData.count)) + trackMessage.append(uriData) + + var request = Data() + request.append(protobufKey(fieldNumber: 1, wireType: 2)) + request.append(varint(for: trackMessage.count)) + request.append(trackMessage) + return request + } + + static func parseCanvasURL(from data: Data, matching trackURI: String) -> URL? { + do { + var reader = ProtobufReader(data: data) + var matchingURLs: [URL] = [] + + while !reader.isAtEnd { + let key = try reader.readVarint() + let fieldNumber = Int(key >> 3) + let wireType = Int(key & 0x7) + + switch (fieldNumber, wireType) { + case (1, 2): + let canvasData = try reader.readLengthDelimited() + if let record = try parseCanvasRecord(from: canvasData), + record.trackURI == trackURI, + let canvasURL = record.canvasURL + { + matchingURLs.append(canvasURL) + } + default: + try reader.skipField(wireType: wireType) + } + } + + return matchingURLs.first { $0.absoluteString.lowercased().hasSuffix(".mp4") } ?? matchingURLs.first + } catch { + return nil + } + } + + private static func parseCanvasRecord(from data: Data) throws -> SpotifyCanvasRecord? { + var reader = ProtobufReader(data: data) + var trackURI = "" + var canvasURL: URL? + + while !reader.isAtEnd { + let key = try reader.readVarint() + let fieldNumber = Int(key >> 3) + let wireType = Int(key & 0x7) + + switch (fieldNumber, wireType) { + case (2, 2): + let urlString = try reader.readString() + canvasURL = URL(string: urlString) + case (5, 2): + trackURI = try reader.readString() + default: + try reader.skipField(wireType: wireType) + } + } + + guard !trackURI.isEmpty else { return nil } + return SpotifyCanvasRecord(trackURI: trackURI, canvasURL: canvasURL) + } + + private static func protobufKey(fieldNumber: Int, wireType: Int) -> Data { + var key = Data() + key.append(varint(for: (fieldNumber << 3) | wireType)) + return key + } + + private static func varint(for value: Int) -> Data { + var remaining = UInt64(value) + var buffer = Data() + + repeat { + var nextByte = UInt8(remaining & 0x7F) + remaining >>= 7 + if remaining != 0 { + nextByte |= 0x80 + } + buffer.append(nextByte) + } while remaining != 0 + + return buffer + } +} + +private struct SpotifyCanvasRecord { + let trackURI: String + let canvasURL: URL? +} + +private struct ProtobufReader { + enum ReaderError: Error { + case malformedVarint + case unexpectedEnd + case unsupportedWireType + } + + private let data: Data + private var index: Data.Index + + init(data: Data) { + self.data = data + self.index = data.startIndex + } + + var isAtEnd: Bool { + index >= data.endIndex + } + + mutating func readVarint() throws -> UInt64 { + var result: UInt64 = 0 + var shift: UInt64 = 0 + + while shift < 64 { + guard index < data.endIndex else { + throw ReaderError.unexpectedEnd + } + + let byte = data[index] + index = data.index(after: index) + + result |= UInt64(byte & 0x7F) << shift + if (byte & 0x80) == 0 { + return result + } + + shift += 7 + } + + throw ReaderError.malformedVarint + } + + mutating func readLengthDelimited() throws -> Data { + let length = Int(try readVarint()) + guard length >= 0 else { + throw ReaderError.unexpectedEnd + } + + let endIndex = data.index(index, offsetBy: length, limitedBy: data.endIndex) + guard let endIndex else { + throw ReaderError.unexpectedEnd + } + + let slice = data[index.. String { + let bytes = try readLengthDelimited() + return String(decoding: bytes, as: UTF8.self) + } + + mutating func skipField(wireType: Int) throws { + switch wireType { + case 0: + _ = try readVarint() + case 1: + try advance(by: 8) + case 2: + _ = try readLengthDelimited() + case 5: + try advance(by: 4) + default: + throw ReaderError.unsupportedWireType + } + } + + private mutating func advance(by count: Int) throws { + let endIndex = data.index(index, offsetBy: count, limitedBy: data.endIndex) + guard let endIndex else { + throw ReaderError.unexpectedEnd } - semaphore.wait() - return descriptor + index = endIndex } } diff --git a/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicAuthentication.swift b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicAuthentication.swift new file mode 100644 index 00000000..60cd64a1 --- /dev/null +++ b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicAuthentication.swift @@ -0,0 +1,102 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +// MARK: - Authentication Manager +actor YouTubeMusicAuthManager { + private var accessToken: String? + private var authenticationTask: Task? + private let httpClient: YouTubeMusicHTTPClient + + init(httpClient: YouTubeMusicHTTPClient) { + self.httpClient = httpClient + } + + var currentToken: String? { + accessToken + } + + func authenticate() async throws -> String { + // Return existing token if valid + if let token = accessToken { + return token + } + + // Wait for ongoing authentication if in progress + if let task = authenticationTask { + return try await task.value + } + + // Start new authentication + let task = Task { + do { + let token = try await httpClient.authenticate() + await setToken(token) + return token + } catch { + await clearAuthenticationTask() + throw error + } + } + + authenticationTask = task + return try await task.value + } + + func invalidateToken() async { + accessToken = nil + authenticationTask?.cancel() + authenticationTask = nil + } + + private func setToken(_ token: String) async { + accessToken = token + authenticationTask = nil + } + + private func clearAuthenticationTask() async { + authenticationTask = nil + } +} + +// MARK: - Authentication State +enum AuthenticationState: Sendable { + case unauthenticated + case authenticating + case authenticated(String) + case failed(Error) + + var isAuthenticated: Bool { + if case .authenticated = self { + return true + } + return false + } + + var token: String? { + if case .authenticated(let token) = self { + return token + } + return nil + } +} diff --git a/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift new file mode 100644 index 00000000..c8ba5e83 --- /dev/null +++ b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift @@ -0,0 +1,438 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Combine +import SwiftUI + +final class YouTubeMusicController: MediaControllerProtocol { + // MARK: - Published Properties + @Published var playbackState = PlaybackState( + bundleIdentifier: YouTubeMusicConfiguration.default.bundleIdentifier + ) + + private var artworkFetchTask: Task? + + var playbackStatePublisher: AnyPublisher { + $playbackState.eraseToAnyPublisher() + } + + var isWorking: Bool { + isActive() && (updateTimer != nil || webSocketClient != nil) + } + + // MARK: - Private Properties + private let configuration: YouTubeMusicConfiguration + private let httpClient: YouTubeMusicHTTPClient + private let authManager: YouTubeMusicAuthManager + private var webSocketClient: YouTubeMusicWebSocketClient? + + private var updateTimer: Timer? + private var appStateObserver: Task? + private var reconnectDelay: TimeInterval = 1.0 + + // MARK: - Initialization + init(configuration: YouTubeMusicConfiguration = .default) { + self.configuration = configuration + self.httpClient = YouTubeMusicHTTPClient(baseURL: configuration.baseURL) + self.authManager = YouTubeMusicAuthManager(httpClient: httpClient) + + setupAppStateObserver() + + Task { + await initializeIfAppActive() + } + } + + // MARK: - MediaControllerProtocol Implementation + func play() async { await sendCommand(endpoint: "/play", method: "POST") } + + func pause() async { await sendCommand(endpoint: "/pause", method: "POST") } + + func togglePlay() async { + if !isActive() { launchApp() } + await sendCommand(endpoint: "/toggle-play", method: "POST") + } + + func nextTrack() async { await sendCommand(endpoint: "/next", method: "POST") } + + func previousTrack() async { await sendCommand(endpoint: "/previous", method: "POST") } + + func seek(to time: Double) async { + let payload = ["seconds": time] + await sendCommand(endpoint: "/seek-to", method: "POST", body: payload) + } + func fetchShuffleState() async { await sendCommand(endpoint: "/shuffle", method: "GET", refresh: false) } + func fetchRepeatMode() async { await sendCommand(endpoint: "/repeat-mode", method: "GET", refresh: false) } + + func toggleShuffle() async { await sendCommand(endpoint: "/shuffle", method: "POST") } + func toggleRepeat() async { await sendCommand(endpoint: "/switch-repeat", method: "POST") } + + nonisolated func isActive() -> Bool { + NSWorkspace.shared.runningApplications.contains { + $0.bundleIdentifier == configuration.bundleIdentifier + } + } + + func updatePlaybackInfo() async { + guard isActive() else { + resetPlaybackState() + return + } + + do { + let token = try await authManager.authenticate() + let response = try await httpClient.getPlaybackInfo(token: token) + await updatePlaybackState(with: response) + } catch YouTubeMusicError.authenticationRequired { + await authManager.invalidateToken() + } catch { + print("[YouTubeMusicController] Failed to update playback info: \(error)") + } + } + + // MARK: - Private Methods + private func setupAppStateObserver() { + appStateObserver = Task { [weak self] in + await withTaskGroup(of: Void.self) { group in + group.addTask { + let launchNotifications = NSWorkspace.shared.notificationCenter.notifications( + named: NSWorkspace.didLaunchApplicationNotification + ) + + for await notification in launchNotifications { + await self?.handleAppLaunched(notification) + } + } + + group.addTask { + let terminateNotifications = NSWorkspace.shared.notificationCenter.notifications( + named: NSWorkspace.didTerminateApplicationNotification + ) + + for await notification in terminateNotifications { + await self?.handleAppTerminated(notification) + } + } + } + } + } + + private func handleAppLaunched(_ notification: Notification) async { + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == configuration.bundleIdentifier else { + return + } + + await initializeIfAppActive() + } + + private func handleAppTerminated(_ notification: Notification) async { + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == configuration.bundleIdentifier else { + return + } + + Task { @MainActor in + stopPeriodicUpdates() + appStateObserver?.cancel() + } + + Task { + await webSocketClient?.disconnect() + webSocketClient = nil + } + + resetPlaybackState() + } + + private func initializeIfAppActive() async { + guard isActive() else { return } + + do { + let token = try await authManager.authenticate() + await setupWebSocketIfPossible(token: token) + await startPeriodicUpdates() + await updatePlaybackInfo() + } catch { + print("[YouTubeMusicController] Failed to initialize: \(error)") + await scheduleReconnect() + } + } + + private func setupWebSocketIfPossible(token: String) async { + guard let wsURL = WebSocketURLBuilder.buildURL(from: configuration.baseURL) else { + print("[YouTubeMusicController] Failed to build WebSocket URL") + return + } + + let client = YouTubeMusicWebSocketClient( + onMessage: { [weak self] data in + await self?.handleWebSocketMessage(data) + }, + onDisconnect: { [weak self] in + await self?.handleWebSocketDisconnect() + } + ) + + do { + try await client.connect(to: wsURL, with: token) + webSocketClient = client + stopPeriodicUpdates() // WebSocket will provide real-time updates + reconnectDelay = configuration.reconnectDelay.lowerBound + } catch { + print("[YouTubeMusicController] WebSocket connection failed: \(error)") + await scheduleReconnect() + } + } + + private func handleWebSocketMessage(_ data: Data) async { + guard let message = WebSocketMessage(from: data) else { + if let response = try? JSONDecoder().decode(PlaybackResponse.self, from: data) { + await updatePlaybackState(with: response) + } + return + } + switch message.type { + case .playerInfo, .videoChanged, .playerStateChanged: + if let data = message.extractData(), + let response = PlaybackResponse.from(websocketData: data) { + await updatePlaybackState(with: response) + } + + case .positionChanged: + guard let data = message.extractData() else { return } + + var position: Double? = nil + if let pos = data["position"] as? Double { + position = pos + } else if let elapsed = data["elapsedSeconds"] as? Double { + position = elapsed + } + guard let newPosition = position else { return } + + var copied = playbackState + copied.currentTime = newPosition + copied.lastUpdated = Date() + playbackState = copied + + case .repeatChanged: + guard let data = message.extractData() else { return } + var copy = playbackState + + if let repeatStr = data["repeat"] as? String { + switch repeatStr.uppercased() { + case "NONE": copy.repeatMode = .off + case "ALL": copy.repeatMode = .all + case "ONE": copy.repeatMode = .one + default: break + } + } + copy.lastUpdated = Date() + playbackState = copy + + case .shuffleChanged: + guard let data = message.extractData() else { return } + var copy = playbackState + if let shuffle = data["shuffle"] as? Bool { copy.isShuffled = shuffle } + else if let shuffle = data["isShuffled"] as? Bool { copy.isShuffled = shuffle } + copy.lastUpdated = Date() + playbackState = copy + + case .volumeChanged: + break + } + } + + private func handleWebSocketDisconnect() async { + webSocketClient = nil + await startPeriodicUpdates() // Fallback to polling + await scheduleReconnect() + } + + private func scheduleReconnect() async { + try? await Task.sleep(for: .seconds(reconnectDelay)) + reconnectDelay = min(reconnectDelay * 2, configuration.reconnectDelay.upperBound) + + if isActive() { + await initializeIfAppActive() + } + } + + private func startPeriodicUpdates() async { + guard isActive() && webSocketClient == nil else { return } + + stopPeriodicUpdates() + + updateTimer = Timer.scheduledTimer(withTimeInterval: configuration.updateInterval, repeats: true) { [weak self] _ in + Task { @MainActor in + await self?.updatePlaybackInfo() + } + } + } + + private func stopPeriodicUpdates() { + updateTimer?.invalidate() + updateTimer = nil + } + + func pollPlaybackState() async { + if !isActive() { + return + } + + await fetchRepeatMode() + await fetchShuffleState() + await updatePlaybackInfo() + } + + private func sendCommand( + endpoint: String, + method: String = "POST", + body: (any Codable & Sendable)? = nil, + refresh: Bool = true + ) async { + do { + let token = try await authManager.authenticate() + + let data = try await httpClient.sendCommand( + endpoint: endpoint, + method: method, + body: body, + token: token + ) + // Lightweight endpoint-specific parsing + if endpoint == "/shuffle" { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let shuffleState = json["state"] as? Bool { + playbackState.isShuffled = shuffleState + } else { + playbackState.isShuffled = !playbackState.isShuffled + } + } else if endpoint == "/repeat-mode" { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let mode = json["mode"] as? String { updateRepeatMode(mode) } + } + } else if endpoint == "/switch-repeat" { + // Find next repeat mode + let nextMode: RepeatMode + switch playbackState.repeatMode { + case .off: nextMode = .all + case .all: nextMode = .one + case .one: nextMode = .off + } + playbackState.repeatMode = nextMode + } else if refresh && webSocketClient == nil { + try? await Task.sleep(for: .milliseconds(100)) + await updatePlaybackInfo() + } + } catch YouTubeMusicError.authenticationRequired { + await authManager.invalidateToken() + } catch { + print("[YouTubeMusicController] Command failed: \(error)") + } + } + + private func updatePlaybackState(with response: PlaybackResponse) async { + var newState = playbackState + + newState.isPlaying = !response.isPaused + + if let title = response.title { + newState.title = title + } + + if let artist = response.artist { + newState.artist = artist + } + + if let album = response.album { + newState.album = album + } + + if let elapsed = response.elapsedSeconds { + newState.currentTime = elapsed + } + + if let duration = response.songDuration { + newState.duration = duration + } + + newState.lastUpdated = Date() + + if let shuffled = response.isShuffled { + newState.isShuffled = shuffled + } + + if let mode = response.repeatMode { + switch mode { + case 0: newState.repeatMode = .off + case 1: newState.repeatMode = .all + case 2: newState.repeatMode = .one + default: break + } + } + + // Always update - removed comparison since PlaybackState doesn't conform to Equatable + playbackState = newState + + artworkFetchTask?.cancel() + artworkFetchTask = nil + + if let artworkURL = response.imageSrc, + let url = URL(string: artworkURL) { + artworkFetchTask = Task { + do { + let data = try await ImageService.shared.fetchImageData(from: url) + await MainActor.run { [weak self] in + self?.playbackState.artwork = data + } + } catch { /* ignore */ } + } + } + } + + private func resetPlaybackState() { + playbackState = PlaybackState( + bundleIdentifier: configuration.bundleIdentifier, + isPlaying: false + ) + } + + private func launchApp() { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: configuration.bundleIdentifier) else { + return + } + NSWorkspace.shared.open(url) + } + + private func updateRepeatMode(_ mode: String) { + var target: RepeatMode? = nil + switch mode { + case "NONE": target = .off + case "ALL": target = .all + case "ONE": target = .one + default: break + } + if let target, target != playbackState.repeatMode { playbackState.repeatMode = target } + } + +} diff --git a/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicModels.swift b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicModels.swift new file mode 100644 index 00000000..92ddd5f4 --- /dev/null +++ b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicModels.swift @@ -0,0 +1,172 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +// MARK: - Configuration +struct YouTubeMusicConfiguration: Sendable { + let baseURL: String + let bundleIdentifier: String + let reconnectDelay: ClosedRange + let updateInterval: TimeInterval + + static let `default` = YouTubeMusicConfiguration( + baseURL: "http://localhost:26538", + bundleIdentifier: "com.github.th-ch.youtube-music", + reconnectDelay: 1...60, + updateInterval: 2.0 + ) +} + +// MARK: - API Models +struct AuthResponse: Decodable, Sendable { + let accessToken: String +} + +struct PlaybackResponse: Decodable, Sendable { + let isPaused: Bool + let title: String? + let artist: String? + let album: String? + let elapsedSeconds: Double? + let songDuration: Double? + let imageSrc: String? + let repeatMode: Int? + let isShuffled: Bool? +} + +// MARK: - WebSocket Message Types +enum WebSocketMessageType: String, Sendable { + case playerInfo = "PLAYER_INFO" + case videoChanged = "VIDEO_CHANGED" + case playerStateChanged = "PLAYER_STATE_CHANGED" + case positionChanged = "POSITION_CHANGED" + case volumeChanged = "VOLUME_CHANGED" + case repeatChanged = "REPEAT_CHANGED" + case shuffleChanged = "SHUFFLE_CHANGED" +} + +struct WebSocketMessage { + let type: WebSocketMessageType + let rawData: Data + private let parsedJSON: [String: Any]? + + init?(from data: Data) { + let json = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) + guard let typeString = json?["type"] as? String, + let messageType = WebSocketMessageType(rawValue: typeString) else { + return nil + } + + self.type = messageType + self.rawData = data + self.parsedJSON = json + } + + func extractData() -> [String: Any]? { + parsedJSON + } +} + +// MARK: - Extensions +extension PlaybackResponse { + static func from(websocketData: [String: Any]) -> PlaybackResponse? { + let songData = websocketData["song"] as? [String: Any] + + let isPaused: Bool + if let paused = songData?["isPaused"] as? Bool { + isPaused = paused + } else if let playing = websocketData["isPlaying"] as? Bool { + isPaused = !playing + } else { + isPaused = true + } + + let title = (songData?["title"] as? String) ?? + (songData?["alternativeTitle"] as? String) ?? + (websocketData["title"] as? String) + let artist = (songData?["artist"] as? String) ?? (websocketData["artist"] as? String) + let album = songData?["album"] as? String + + let elapsed = extractDouble(from: songData, key: "elapsedSeconds") ?? + extractDouble(from: websocketData, key: "position") + + let duration = extractDouble(from: songData, key: "songDuration") ?? + extractDouble(from: websocketData, key: "songDuration") + + let imageSrc = (songData?["imageSrc"] as? String) ?? (websocketData["imageSrc"] as? String) + let isShuffled = (websocketData["shuffle"] as? Bool) ?? (songData?["isShuffled"] as? Bool) + + var repeatModeInt: Int? = nil + if let repeatVal = websocketData["repeat"] as? String { + switch repeatVal.uppercased() { + case "NONE": repeatModeInt = 0 + case "ALL": repeatModeInt = 1 + case "ONE": repeatModeInt = 2 + default: break + } + } else if let repeatStr = songData?["repeat"] as? String { + switch repeatStr.uppercased() { + case "NONE": repeatModeInt = 0 + case "ALL": repeatModeInt = 1 + case "ONE": repeatModeInt = 2 + default: break + } + } + + return PlaybackResponse( + isPaused: isPaused, + title: title, + artist: artist, + album: album, + elapsedSeconds: elapsed, + songDuration: duration, + imageSrc: imageSrc, + repeatMode: repeatModeInt, + isShuffled: isShuffled + ) + } + + func with(elapsedSeconds: Double) -> PlaybackResponse { + PlaybackResponse( + isPaused: isPaused, + title: title, + artist: artist, + album: album, + elapsedSeconds: elapsedSeconds, + songDuration: songDuration, + imageSrc: imageSrc, + repeatMode: repeatMode, + isShuffled: isShuffled + ) + } +} + +private func extractDouble(from dict: [String: Any]?, key: String) -> Double? { + guard let dict = dict else { return nil } + if let value = dict[key] as? Double { + return value + } else if let value = dict[key] as? Int { + return Double(value) + } + return nil +} diff --git a/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicNetworking.swift b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicNetworking.swift new file mode 100644 index 00000000..1e9e1414 --- /dev/null +++ b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicNetworking.swift @@ -0,0 +1,240 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +// MARK: - HTTP Client +final class YouTubeMusicHTTPClient: ObservableObject { + private let session: URLSession + private let baseURL: String + private static let decoder = JSONDecoder() + private static let encoder = JSONEncoder() + + init(baseURL: String) { + self.baseURL = baseURL + + let config = URLSessionConfiguration.default + config.requestCachePolicy = .reloadIgnoringLocalCacheData + config.urlCache = nil + config.timeoutIntervalForRequest = 5 + config.timeoutIntervalForResource = 10 + + self.session = URLSession(configuration: config) + } + + // MARK: - Authentication + func authenticate() async throws -> String { + guard let url = URL(string: "\(baseURL)/auth/DynamicIsland") else { + throw YouTubeMusicError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + + let (data, response) = try await session.data(for: request) + try validateResponse(response) + + let authResponse: AuthResponse = try Self.decoder.decode(AuthResponse.self, from: data) + return authResponse.accessToken + } + + // MARK: - Playback Info + func getPlaybackInfo(token: String) async throws -> PlaybackResponse { + let data = try await sendCommand( + endpoint: "/song", + method: "GET", + token: token + ) + return try Self.decoder.decode(PlaybackResponse.self, from: data) + } + + // MARK: - Commands + func sendCommand( + endpoint: String, + method: String = "POST", + body: (any Codable & Sendable)? = nil, + token: String + ) async throws -> Data { + let request = try createAuthenticatedRequest( + endpoint: "/api/v1\(endpoint)", + method: method, + body: body, + token: token + ) + + let (data, response) = try await session.data(for: request) + try validateResponse(response) + + return data + } + + // MARK: - Private Helpers + private func createAuthenticatedRequest( + endpoint: String, + method: String, + body: (any Codable & Sendable)? = nil, + token: String + ) throws -> URLRequest { + guard let url = URL(string: "\(baseURL)\(endpoint)") else { + throw YouTubeMusicError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + if let body = body { + request.httpBody = try Self.encoder.encode(body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + return request + } + + private func validateResponse(_ response: URLResponse) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw YouTubeMusicError.invalidResponse + } + + switch httpResponse.statusCode { + case 200..<300: + break + case 401, 403: + throw YouTubeMusicError.authenticationRequired + default: + throw YouTubeMusicError.httpError(httpResponse.statusCode) + } + } +} + +// MARK: - WebSocket Client +actor YouTubeMusicWebSocketClient { + private var task: URLSessionWebSocketTask? + private let session: URLSession + private let onMessage: @Sendable (Data) async -> Void + private let onDisconnect: @Sendable () async -> Void + + var isConnected: Bool { task != nil } + + init( + onMessage: @escaping @Sendable (Data) async -> Void, + onDisconnect: @escaping @Sendable () async -> Void, + session: URLSession = .shared + ) { + self.onMessage = onMessage + self.onDisconnect = onDisconnect + self.session = session + } + + func connect(to url: URL, with token: String) async throws { + await disconnect() + + var request = URLRequest(url: url) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let newTask = session.webSocketTask(with: request) + task = newTask + newTask.resume() + + Task { await listenForMessages() } + } + + func disconnect() async { + task?.cancel(with: .goingAway, reason: nil) + task = nil + } + + private func listenForMessages() async { + guard let currentTask = task else { return } + + while !Task.isCancelled && task != nil { + do { + let message = try await currentTask.receive() + + let data: Data + switch message { + case .data(let d): + data = d + case .string(let s): + data = s.data(using: .utf8) ?? Data() + @unknown default: + continue + } + + await onMessage(data) + } catch { + break + } + } + task = nil + await onDisconnect() + } +} + +// MARK: - WebSocket URL Helper +struct WebSocketURLBuilder { + static func buildURL(from baseURL: String) -> URL? { + guard var components = URLComponents(string: baseURL) else { return nil } + + switch components.scheme { + case "http": + components.scheme = "ws" + case "https": + components.scheme = "wss" + default: + break + } + + components.path = "/api/v1/ws" + return components.url + } +} + +// MARK: - Errors +enum YouTubeMusicError: Error, LocalizedError, Sendable { + case invalidURL + case invalidResponse + case httpError(Int) + case authenticationRequired + case webSocketNotConnected + case encodingFailed + case decodingFailed + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL" + case .invalidResponse: + return "Invalid response" + case .httpError(let code): + return "HTTP error: \(code)" + case .authenticationRequired: + return "Authentication required" + case .webSocketNotConnected: + return "WebSocket not connected" + case .encodingFailed: + return "Failed to encode data" + case .decodingFailed: + return "Failed to decode data" + } + } +} diff --git a/DynamicIsland/MediaControllers/YouTubeMusicController.swift b/DynamicIsland/MediaControllers/YouTubeMusicController.swift deleted file mode 100644 index d5cb82d6..00000000 --- a/DynamicIsland/MediaControllers/YouTubeMusicController.swift +++ /dev/null @@ -1,386 +0,0 @@ -// -// YouTubeMusicController.swift -// DynamicIsland -// -// Created By Alexander on 2025-03-30. -// - -import Foundation -import Combine -import SwiftUI - -class YouTubeMusicController: MediaControllerProtocol { - // MARK: - Properties - @Published var playbackState: PlaybackState = .init( - bundleIdentifier: "com.github.th-ch.youtube-music", - isPlaying: true - ) - - var playbackStatePublisher: Published.Publisher { $playbackState } - private let baseURL = "http://localhost:26538" - private var accessToken: String? - private var refreshTimer: Timer? - private var cancellables = Set() - private var playbackInfoCancellable: AnyCancellable? - private var isAuthenticating = false - private let authQueue = DispatchQueue(label: "com.dynamicisland.youtubemusicauth", qos: .background) - - init() { - setupAppStateObservers() - authQueue.async { [weak self] in - self?.authenticateAndSetup() - } - } - - deinit { - stopPeriodicUpdates() - cancellables.forEach { $0.cancel() } - NSWorkspace.shared.notificationCenter.removeObserver(self) - } - - // MARK: - Authentication - - private func authenticateAndSetup() { - // Prevent multiple concurrent authentication attempts - guard !isAuthenticating else { return } - - isAuthenticating = true - getAccessToken { [weak self] success in - guard let self = self else { return } - - DispatchQueue.main.async { - self.isAuthenticating = false - if success { - self.startPeriodicUpdates() - self.updatePlaybackInfo() - } else { - // Retry authentication after a delay if it fails - DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 5) { [weak self] in - self?.authenticateAndSetup() - } - } - } - } - } - - private func getAccessToken(completion: @escaping (Bool) -> Void) { - guard let url = URL(string: "\(baseURL)/auth/DynamicIsland") else { - completion(false) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - - URLSession.shared.dataTaskPublisher(for: request) - .map { data, response -> Data in - return data - } - .decode(type: AuthResponse.self, decoder: JSONDecoder()) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { completionStatus in - switch completionStatus { - case .failure(_): - completion(false) - case .finished: - break - } - }, receiveValue: { [weak self] response in - self?.accessToken = response.accessToken - completion(true) - }) - .store(in: &cancellables) - } - - // MARK: - Protocol Implementation - - func play() { - sendCommand(endpoint: "/play", method: "POST") - updatePlaybackInfo() - } - - func pause() { - sendCommand(endpoint: "/pause", method: "POST") - updatePlaybackInfo() - } - - func togglePlay() { - if !isActive() { - if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: playbackState.bundleIdentifier) { - NSWorkspace.shared.open(url) - } - } - sendCommand(endpoint: "/toggle-play", method: "POST") - updatePlaybackInfo() - } - - func nextTrack() { - sendCommand(endpoint: "/next", method: "POST") - updatePlaybackInfo() - } - - func previousTrack() { - sendCommand(endpoint: "/previous", method: "POST") - updatePlaybackInfo() - } - - func seek(to time: Double) { - guard let url = URL(string: "\(baseURL)/seek-to") else { return } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let token = accessToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } else { - authenticateAndSetup() - return - } - - // Create the request body with the seconds value - let seekData = ["seconds": time] - - do { - request.httpBody = try JSONEncoder().encode(seekData) - } catch { - return - } - - URLSession.shared.dataTaskPublisher(for: request) - .tryMap { data, response -> Data in - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { - throw URLError(.userAuthenticationRequired) - } else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 { - throw URLError(.badServerResponse) - } - - return data - } - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in - if case .failure(let error) = completion { - if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired { - self?.accessToken = nil - self?.authenticateAndSetup() - } - } - }, receiveValue: { [weak self] _ in - self?.updatePlaybackInfo() - }) - .store(in: &cancellables) - } - - func isActive() -> Bool { - let runningApps = NSWorkspace.shared.runningApplications - return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } - } - - // MARK: - Private Methods - - private func setupAppStateObservers() { - // Register for app launch notifications - NSWorkspace.shared.notificationCenter.addObserver( - self, - selector: #selector(handleAppStateChange), - name: NSWorkspace.didLaunchApplicationNotification, - object: nil - ) - - // Register for app termination notifications - NSWorkspace.shared.notificationCenter.addObserver( - self, - selector: #selector(handleAppStateChange), - name: NSWorkspace.didTerminateApplicationNotification, - object: nil - ) - } - - @objc private func handleAppStateChange(notification: Notification) { - guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, - app.bundleIdentifier == playbackState.bundleIdentifier else { - return - } - - if notification.name == NSWorkspace.didLaunchApplicationNotification { - startPeriodicUpdates() - } else if notification.name == NSWorkspace.didTerminateApplicationNotification { - // App has terminated - update state and stop timer - stopPeriodicUpdates() - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.playbackState = PlaybackState( - bundleIdentifier: self.playbackState.bundleIdentifier, - isPlaying: false - ) - } - } - } - - private func startPeriodicUpdates() { - stopPeriodicUpdates() // Ensure we don't create multiple timers - - // Only start the timer if the app is actually running - guard isActive() else { return } - - refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in - self?.updatePlaybackInfo() - } - refreshTimer?.tolerance = 0.5 - - // Initial update when timer starts - updatePlaybackInfo() - } - - private func stopPeriodicUpdates() { - refreshTimer?.invalidate() - refreshTimer = nil - } - - func updatePlaybackInfo() { - if !isActive() { - playbackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier, isPlaying: false) - stopPeriodicUpdates() - return - } - guard let request = createAuthenticatedRequest(for: "/api/v1/song") else { return } - - let backgroundQueue = DispatchQueue.global(qos: .utility) - - backgroundQueue.async { [weak self] in - guard let self = self else { return } - - playbackInfoCancellable?.cancel() - - playbackInfoCancellable = URLSession.shared.dataTaskPublisher(for: request) - .tryMap { data, response -> Data in - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - // Check for authentication errors - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { - // Re-authenticate and retry later - DispatchQueue.main.async { [weak self] in - self?.accessToken = nil - self?.authenticateAndSetup() - } - throw URLError(.userAuthenticationRequired) - } else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 { - throw URLError(.badServerResponse) - } - - return data - } - .decode(type: PlaybackResponse.self, decoder: JSONDecoder()) - .subscribe(on: backgroundQueue) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }, receiveValue: { [weak self] response in - self?.updatePlaybackState(with: response) - }) - } - } - - private func updatePlaybackState(with response: PlaybackResponse) { - playbackState.isPlaying = !response.isPaused - playbackState.title = response.title - playbackState.artist = response.artist - playbackState.album = response.album ?? "" - playbackState.currentTime = response.elapsedSeconds - playbackState.duration = response.songDuration - playbackState.lastUpdated = Date() - - // Load artwork if available - if let artworkURL = response.imageSrc, let url = URL(string: artworkURL) { - DispatchQueue.global(qos: .background).async { [weak self] in - do { - let artworkData = try Data(contentsOf: url) - DispatchQueue.main.async { - self?.playbackState.artwork = artworkData - } - } catch { return } - } - } - } - - private func sendCommand(endpoint: String, method: String = "GET") { - guard let url = URL(string: "\(baseURL)/api/v1\(endpoint)") else { return } - - var request = URLRequest(url: url) - request.httpMethod = method - - if let token = accessToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } else { - authenticateAndSetup() - return - } - - URLSession.shared.dataTaskPublisher(for: request) - .tryMap { data, response -> Data in - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { - throw URLError(.userAuthenticationRequired) - } else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 { - throw URLError(.badServerResponse) - } - - return data - } - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in - if case .failure(let error) = completion { - if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired { - // Authentication error - token might be expired - self?.accessToken = nil - self?.authenticateAndSetup() - - // Try the command again after authentication - DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 2) { [weak self] in - self?.sendCommand(endpoint: endpoint, method: method) - } - } - } - }, receiveValue: { [weak self] _ in - self?.updatePlaybackInfo() - }) - .store(in: &cancellables) - } - - private func createAuthenticatedRequest(for endpoint: String) -> URLRequest? { - guard let token = accessToken, let url = URL(string: "\(baseURL)\(endpoint)") else { - if accessToken == nil { - // Token is missing, try to authenticate again - authenticateAndSetup() - } - return nil - } - - var request = URLRequest(url: url) - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - return request - } -} - -// MARK: - API Response Models - -struct AuthResponse: Codable { - let accessToken: String -} - -struct PlaybackResponse: Codable { - let isPaused: Bool - let title: String - let artist: String - let album: String? - let elapsedSeconds: Double - let songDuration: Double - let imageSrc: String? -} diff --git a/DynamicIsland/Providers/CalendarServiceProviding.swift b/DynamicIsland/Providers/CalendarServiceProviding.swift index 8b76d79a..280b90d7 100644 --- a/DynamicIsland/Providers/CalendarServiceProviding.swift +++ b/DynamicIsland/Providers/CalendarServiceProviding.swift @@ -1,19 +1,34 @@ -// -// CalendarServiceProvider.swift -// Calendr -// -// Created by Paker on 31/12/20. -// Original source: Original source: https://github.com/pakerwreah/Calendr -// Modified by Alexander on 08/06/25 -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation import EventKit protocol CalendarServiceProviding { func requestAccess() async -> Bool + func requestAccess(to type: EKEntityType) async -> Bool func calendars() async -> [CalendarModel] func events(from start: Date, to end: Date, calendars: [String]) async -> [EventModel] + func setReminderCompleted(reminderID: String, completed: Bool) async } class CalendarService: CalendarServiceProviding { @@ -21,38 +36,35 @@ class CalendarService: CalendarServiceProviding { @MainActor func requestAccess() async -> Bool { + let eventsAccess = await requestAccess(to: .event) + let remindersAccess = await requestAccess(to: .reminder) + return eventsAccess || remindersAccess + } + + @MainActor + func requestAccess(to type: EKEntityType) async -> Bool { do { - let eventsAccess = try await requestAccess(to: .event) - let remindersAccess = try await requestAccess(to: .reminder) - return eventsAccess || remindersAccess // At least one should work + return try await performAccessRequest(for: type) } catch { print("Calendar access error: \(error)") return false } } - - private func requestAccess(to type: EKEntityType) async throws -> Bool { - if #available(macOS 14.0, *) { - switch type { - case .event: - return try await store.requestFullAccessToEvents() - case .reminder: - return try await store.requestFullAccessToReminders() - @unknown default: - return false - } - } else { - return try await store.requestAccess(to: type) + + private func performAccessRequest(for type: EKEntityType) async throws -> Bool { + switch type { + case .event: + return try await store.requestFullAccessToEvents() + case .reminder: + return try await store.requestFullAccessToReminders() + @unknown default: + return false } } private func hasAccess(to entityType: EKEntityType) -> Bool { let status = EKEventStore.authorizationStatus(for: entityType) - if #available(macOS 14.0, *) { - return status == .fullAccess - } else { - return status == .authorized - } + return status == .fullAccess } func calendars() async -> [CalendarModel] { @@ -93,47 +105,38 @@ class CalendarService: CalendarServiceProviding { } private func fetchReminders(from start: Date, to end: Date, calendars: [EKCalendar]) async -> [EventModel] { - await withTaskGroup(of: [EKReminder].self) { group in - var allReminders: [EKReminder] = [] - - // Fetch incomplete reminders - group.addTask { - await withCheckedContinuation { continuation in - let predicate = self.store.predicateForIncompleteReminders( - withDueDateStarting: start, - ending: end, - calendars: calendars - ) - self.store.fetchReminders(matching: predicate) { reminders in - continuation.resume(returning: reminders ?? []) - } + guard !calendars.isEmpty else { return [] } + + return await withCheckedContinuation { continuation in + let predicate = store.predicateForReminders(in: calendars) + store.fetchReminders(matching: predicate) { reminders in + guard let reminders else { + continuation.resume(returning: []) + return } - } - - // Fetch completed reminders - group.addTask { - await withCheckedContinuation { continuation in - let predicate = self.store.predicateForCompletedReminders( - withCompletionDateStarting: start, - ending: end, - calendars: calendars - ) - self.store.fetchReminders(matching: predicate) { reminders in - continuation.resume(returning: reminders ?? []) + + let filtered = reminders.compactMap { reminder -> EventModel? in + guard let dueDate = reminder.dueDateComponents?.date, + dueDate >= start, + dueDate <= end else { + return nil } + return EventModel(from: reminder) } + + continuation.resume(returning: filtered) } - - for await reminders in group { - allReminders.append(contentsOf: reminders) - } - - // Remove duplicates and convert to EventModel - let uniqueReminders = Dictionary(grouping: allReminders, by: \.calendarItemIdentifier) - .compactMapValues { $0.first } - .values - - return Array(uniqueReminders.compactMap { EventModel(from: $0) }) + } + } + + @MainActor + func setReminderCompleted(reminderID: String, completed: Bool) async { + guard let reminder = store.calendarItem(withIdentifier: reminderID) as? EKReminder else { return } + reminder.isCompleted = completed + do { + try store.save(reminder, commit: true) + } catch { + print("Failed to update reminder completion: \(error)") } } } @@ -143,11 +146,12 @@ class CalendarService: CalendarServiceProviding { extension CalendarModel { init(from calendar: EKCalendar) { self.init( + accountName: calendar.accountTitle, id: calendar.calendarIdentifier, - account: calendar.accountTitle, title: calendar.title, color: calendar.color, - isSubscribed: calendar.isSubscribed || calendar.isDelegate + isSubscribed: calendar.isSubscribed || calendar.isDelegate, + isReminder: calendar.allowedEntityTypes.contains(.reminder) ) } } @@ -170,7 +174,8 @@ extension EventModel { participants: .init(from: event), timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : event.timeZone, hasRecurrenceRules: event.hasRecurrenceRules || event.isDetached, - priority: nil + priority: nil, + conferenceURL: event.extractConferenceURL() ) } @@ -194,7 +199,8 @@ extension EventModel { participants: [], timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : reminder.timeZone, hasRecurrenceRules: reminder.hasRecurrenceRules, - priority: .init(from: reminder.priority) + priority: .init(from: reminder.priority), + conferenceURL: nil ) } } @@ -262,22 +268,18 @@ extension Priority { // MARK: - Helper Extensions -private extension EKCalendar { +extension EKCalendar { var accountTitle: String { switch source.sourceType { case .local, .subscribed, .birthdays: - return "Other" + return String(localized: "Other") default: return source.title } } var isDelegate: Bool { - if #available(macOS 13.0, *) { - return source.isDelegate - } else { - return false - } + return source.isDelegate } } @@ -293,6 +295,60 @@ private extension EKEvent { let endOfDay = calendar.dateInterval(of: .day, for: endDate)?.end return startDate == startOfDay && endDate == endOfDay } + + /// Extract conference call URL from various sources + func extractConferenceURL() -> URL? { + // First try the URL field if it's a conference URL + if let eventURL = url, isConferenceURL(eventURL) { + return eventURL + } + + // Then try to extract from location field + if let location = location, let conferenceURL = extractURLFromText(location) { + return conferenceURL + } + + // Finally try to extract from notes + if let notes = notes, let conferenceURL = extractURLFromText(notes) { + return conferenceURL + } + + return nil + } + + /// Check if a URL is likely a conference URL + private func isConferenceURL(_ url: URL) -> Bool { + let host = url.host?.lowercased() ?? "" + let conferenceHosts = [ + "zoom.us", + "teams.microsoft.com", + "meet.google.com", + "webex.com", + "gotomeeting.com", + "bluejeans.com", + "whereby.com", + "meet.jit.si", + "discord.gg", + "discord.com", + "facetime.apple.com" + ] + + return conferenceHosts.contains { host.contains($0) } + } + + /// Extract first valid conference URL from text + private func extractURLFromText(_ text: String) -> URL? { + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + let matches = detector?.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text)) + + for match in matches ?? [] { + if let url = match.url, isConferenceURL(url) { + return url + } + } + + return nil + } } private extension Calendar { diff --git a/DynamicIsland/Shortcuts/ShortcutConstants.swift b/DynamicIsland/Shortcuts/ShortcutConstants.swift index 0f765ea2..67f542b7 100644 --- a/DynamicIsland/Shortcuts/ShortcutConstants.swift +++ b/DynamicIsland/Shortcuts/ShortcutConstants.swift @@ -1,19 +1,36 @@ -// -// Constants.swift -// DynamicIsland -// -// Created by Richard Kunkli on 16/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import KeyboardShortcuts import SwiftUI extension KeyboardShortcuts.Name { static let clipboardHistoryPanel = Self("clipboardHistoryPanel", default: .init(.c, modifiers: [.shift, .command])) - static let toggleMicrophone = Self("toggleMicrophone", default: .init(.f5, modifiers: [.function])) + static let colorPickerPanel = Self("colorPickerPanel", default: .init(.p, modifiers: [.shift, .command])) + static let screenAssistantPanel = Self("screenAssistantPanel", default: .init(.a, modifiers: [.shift, .command])) static let decreaseBacklight = Self("decreaseBacklight", default: .init(.f1, modifiers: [.command])) static let increaseBacklight = Self("increaseBacklight", default: .init(.f2, modifiers: [.command])) static let toggleSneakPeek = Self("toggleSneakPeek", default: .init(.h, modifiers: [.command, .shift])) static let toggleNotchOpen = Self("toggleNotchOpen", default: .init(.i, modifiers: [.command, .shift])) + static let toggleTerminalTab = Self("toggleTerminalTab", default: .init(.backtick, modifiers: [.control])) static let startDemoTimer = Self("startDemoTimer", default: .init(.t, modifiers: [.command, .shift])) } diff --git a/DynamicIsland/animations/HelloAnimation.swift b/DynamicIsland/animations/HelloAnimation.swift index bf3315bd..4171b4ca 100644 --- a/DynamicIsland/animations/HelloAnimation.swift +++ b/DynamicIsland/animations/HelloAnimation.swift @@ -1,13 +1,24 @@ -// -// HelloAnimation.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 04/08/24. -// HelloAnimation.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 08/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI diff --git a/DynamicIsland/animations/drop.swift b/DynamicIsland/animations/drop.swift index eafce26a..f2134718 100644 --- a/DynamicIsland/animations/drop.swift +++ b/DynamicIsland/animations/drop.swift @@ -1,9 +1,24 @@ -// -// drop.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 04/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation import SwiftUI diff --git a/DynamicIsland/audio-feedback.m4a b/DynamicIsland/audio-feedback.m4a new file mode 100644 index 00000000..b0eb634d Binary files /dev/null and b/DynamicIsland/audio-feedback.m4a differ diff --git a/DynamicIsland/audio/AudioBridge.h b/DynamicIsland/audio/AudioBridge.h new file mode 100644 index 00000000..0134d0d8 --- /dev/null +++ b/DynamicIsland/audio/AudioBridge.h @@ -0,0 +1,34 @@ +/* + * Atoll (DynamicIsland) + * Original work Copyright (C) 2026 ZephyrCodesStuff (https://github.com/ZephyrCodesStuff/rtaudio) + * Modified work Copyright (C) 2026 Atoll Contributors + * + * Objective-C bridge to expose C++ AudioProcessor to Swift. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This file is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface AudioBridge : NSObject + +- (void)processBuffer:(const float *)buffer count:(int)count; +- (simd_float4)getSmoothedMagnitudes; + +@end + +NS_ASSUME_NONNULL_END diff --git a/DynamicIsland/audio/AudioBridge.mm b/DynamicIsland/audio/AudioBridge.mm new file mode 100644 index 00000000..6998f5cf --- /dev/null +++ b/DynamicIsland/audio/AudioBridge.mm @@ -0,0 +1,54 @@ +/* + * Atoll (DynamicIsland) + * Original work Copyright (C) 2026 ZephyrCodesStuff (https://github.com/ZephyrCodesStuff/rtaudio) + * Modified work Copyright (C) 2026 Atoll Contributors + * + * Objective-C++ implementation bridging AudioProcessor to Swift. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This file is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#import "AudioBridge.h" +#import "AudioProcessor.hpp" + +@implementation AudioBridge { + AudioProcessor *processor; +} + +- (instancetype)init { + self = [super init]; + if (self) { + processor = new AudioProcessor(); + } + return self; +} + +- (void)processBuffer:(const float *)buffer count:(int)count { + processor->process(buffer, count); +} + +- (simd_float4)getSmoothedMagnitudes { + return simd_make_float4( + processor->getBand(0), + processor->getBand(1), + processor->getBand(2), + processor->getBand(3) + ); +} + +- (void)dealloc { + delete processor; +} + +@end diff --git a/DynamicIsland/audio/AudioProcessor.cpp b/DynamicIsland/audio/AudioProcessor.cpp new file mode 100644 index 00000000..eaf3ce24 --- /dev/null +++ b/DynamicIsland/audio/AudioProcessor.cpp @@ -0,0 +1,141 @@ +/* + * Atoll (DynamicIsland) + * Original work Copyright (C) 2026 ZephyrCodesStuff (https://github.com/ZephyrCodesStuff/rtaudio) + * Modified work Copyright (C) 2026 Atoll Contributors + * + * Real-time audio processor using Accelerate framework for efficient RMS + * and biquad filtering. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This file is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "AudioProcessor.hpp" + +#include +#include +#include +#include + +AudioProcessor::AudioProcessor( float sr ) : sampleRate( sr ) +{ + // Single biquad per band + setupBiquad( 0, FilterType::LowPass, 250.0f, 0.707f ); + setupBiquad( 1, FilterType::BandPass, 707.0f, 0.40f ); + setupBiquad( 2, FilterType::BandPass, 3464.0f, 0.85f ); + setupBiquad( 3, FilterType::HighPass, 6000.0f, 0.707f ); +} + +AudioProcessor::~AudioProcessor() +{ + for ( int i = 0; i < kBands; ++i ) + { + vDSP_biquad_DestroySetup( setups[i] ); + } +} + +void AudioProcessor::process( const float* __restrict__ buffer, + int totalSamples ) +{ + if ( __builtin_expect( totalSamples <= 0, 0 ) ) + { + return; + } + + const float* src = buffer; + int remaining = totalSamples; + + while ( remaining > 0 ) + { + int toCopy = std::min( remaining, kBlockSize - writePos ); + memcpy( mono + writePos, src, toCopy * sizeof( float ) ); + writePos += toCopy; + src += toCopy; + remaining -= toCopy; + + if ( writePos >= kBlockSize ) + { + processBlock(); + writePos = 0; + } + } +} + +float AudioProcessor::getBand( int i ) const +{ + return bandParams[i].load( std::memory_order_relaxed ); +} + +void AudioProcessor::processBlock() +{ + for ( int i = 0; i < kBands; ++i ) + { + vDSP_biquad( setups[i], delays[i], mono, 1, filtered, 1, kBlockSize ); + + // RMS over the block — more stable than peak for driving animation + float rms = 0.0f; + vDSP_rmsqv( filtered, 1, &rms, kBlockSize ); + + if ( __builtin_expect( !std::isfinite( rms ), 0 ) ) + { + rms = 0.0f; + } + + float boostedRms = rms * kGains[i]; + boostedRms = std::min( boostedRms, 1.0f ); + + // Asymmetric envelope: attack fast, release slow + float prev = envelopes[i]; + envelopes[i] = ( boostedRms > prev ) + ? prev * ( 1.0f - kAttack ) + boostedRms * kAttack + : prev * ( 1.0f - kRelease ) + boostedRms * kRelease; + + bandParams[i].store( envelopes[i], std::memory_order_relaxed ); + } +} + +void AudioProcessor::setupBiquad( int idx, FilterType type, float freq, + float q ) +{ + const double w0 = 2.0 * M_PI * freq / sampleRate; + const double sinw = std::sin( w0 ); + const double cosw = std::cos( w0 ); + const double alpha = sinw / ( 2.0 * q ); + + double b0, b1, b2; + const double a0 = 1.0 + alpha; + const double a1 = -2.0 * cosw; + const double a2 = 1.0 - alpha; + + switch ( type ) + { + case FilterType::LowPass: + b0 = ( 1.0 - cosw ) * 0.5; + b1 = 1.0 - cosw; + b2 = b0; + break; + case FilterType::HighPass: + b0 = ( 1.0 + cosw ) * 0.5; + b1 = -( 1.0 + cosw ); + b2 = b0; + break; + case FilterType::BandPass: + b0 = alpha; + b1 = 0.0; + b2 = -alpha; + break; + } + + const double coeffs[5] = { b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0 }; + setups[idx] = vDSP_biquad_CreateSetup( coeffs, 1 ); +} diff --git a/DynamicIsland/audio/AudioProcessor.hpp b/DynamicIsland/audio/AudioProcessor.hpp new file mode 100644 index 00000000..a4296134 --- /dev/null +++ b/DynamicIsland/audio/AudioProcessor.hpp @@ -0,0 +1,68 @@ +/* + * Atoll (DynamicIsland) + * Original work Copyright (C) 2026 ZephyrCodesStuff (https://github.com/ZephyrCodesStuff/rtaudio) + * Modified work Copyright (C) 2026 Atoll Contributors + * + * Real-time audio processor using Accelerate framework for efficient RMS + * and biquad filtering. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This file is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef AUDIOPROCESSOR_HPP +#define AUDIOPROCESSOR_HPP + +#include +#include + +class AudioProcessor { + private: + static constexpr int kBlockSize = 512; + static constexpr int kBands = 4; + static constexpr float kAttack = 0.85f; // fast rise + static constexpr float kRelease = 0.40f; // slower fall — more musical + static constexpr float kGains[kBands] = { 3.5f, 6.0f, 9.0f, 20.0f }; + + float sampleRate; + int writePos = 0; + float mono[kBlockSize] = {}; + float filtered[kBlockSize] = {}; + + vDSP_biquad_Setup setups[kBands]; + alignas( 16 ) float delays[kBands][4] = {}; + + float envelopes[kBands] = {}; + + // Lock-free: audio thread writes, render thread reads + // One atomic per band — fits in a cache line + alignas( 64 ) std::atomic< float > bandParams[kBands] = {}; + + public: + explicit AudioProcessor( float sr = 48000.0f ); + ~AudioProcessor(); + + // Called by CoreAudio — must be real-time safe (no alloc, no locks) + void process( const float* __restrict__ buffer, int totalSamples ); + + // Called by render thread — lock-free read + float getBand( int i ) const; + + private: + enum class FilterType { LowPass, BandPass, HighPass }; + + void processBlock(); + void setupBiquad( int idx, FilterType type, float freq, float q ); +}; + +#endif // AUDIOPROCESSOR_HPP diff --git a/DynamicIsland/audio/AudioTap.swift b/DynamicIsland/audio/AudioTap.swift new file mode 100644 index 00000000..0336b70c --- /dev/null +++ b/DynamicIsland/audio/AudioTap.swift @@ -0,0 +1,355 @@ +/* + * Atoll (DynamicIsland) + * Original work Copyright (C) 2026 ZephyrCodesStuff (https://github.com/ZephyrCodesStuff/rtaudio) + * Modified work Copyright (C) 2026 Atoll Contributors + * + * CoreAudio tap for capturing real-time audio from music applications. + * Uses macOS 14.2+ Process Tap API for efficient audio capture. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This file is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import AudioToolbox +import CoreAudio +import simd +import os.log + +private let audioTapLog = OSLog(subsystem: "com.atoll.dynamicisland", category: "AudioTap") + +// Debug: track callback invocations +private var callbackCount: Int = 0 + +// CoreAudio fires this on a high-priority background real-time thread. +let audioIOProc: AudioDeviceIOProc = { + inDevice, inNow, inInputData, inInputTime, outOutputData, inOutputTime, clientData in + + guard let clientData = clientData else { return noErr } + let scanner = Unmanaged.fromOpaque(clientData).takeUnretainedValue() + + if scanner.isPaused { return noErr } + + let mutableInputData = UnsafeMutablePointer(mutating: inInputData) + let bufferList = UnsafeMutableAudioBufferListPointer(mutableInputData) + + if let firstBuffer = bufferList.first, let data = firstBuffer.mData { + // CoreAudio gives us byte size, divide by 4 (Float size) to get array length + let floatCount = Int32(firstBuffer.mDataByteSize) / Int32(MemoryLayout.size) + + let floatData = data.assumingMemoryBound(to: Float.self) + + // Pass the mono array directly to C++ + scanner.bridge.processBuffer(floatData, count: floatCount) + + // Debug: log periodically with audio level info + callbackCount += 1 + if callbackCount % 1000 == 0 { + // Calculate max absolute value in buffer to check if audio is present + var maxVal: Float = 0.0 + for i in 0.. maxVal { maxVal = absVal } + } + os_log(.debug, log: audioTapLog, "🔊 Audio callback fired %d times, buffer size: %d, max amplitude: %f", callbackCount, floatCount, maxVal) + + // Also log the current band values from the bridge + let mags = scanner.bridge.getSmoothedMagnitudes() + os_log(.debug, log: audioTapLog, "🎚️ Bridge magnitudes: [%f, %f, %f, %f]", mags.x, mags.y, mags.z, mags.w) + } + } + + return noErr +} + +private func getAudioObjectID(for pid: pid_t) -> AudioObjectID? { + var audioObjectID: AudioObjectID = kAudioObjectUnknown + var pidValue = pid + + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyTranslatePIDToProcessObject, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var size = UInt32(MemoryLayout.size) + let qualifierSize = UInt32(MemoryLayout.size) + + // We query the global system object (kAudioObjectSystemObject) + // We pass the PID as the "qualifier", and it returns the AudioObjectID + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, + qualifierSize, + &pidValue, + &size, + &audioObjectID + ) + + if status == noErr && audioObjectID != kAudioObjectUnknown { + return audioObjectID + } + + return nil +} + +/// Singleton class for real-time audio capture from music apps +class AudioTap: NSObject { + static let shared = AudioTap() + + let bridge = AudioBridge() + var isPaused: Bool = false + private var displayMagnitudes = simd_float4(0, 0, 0, 0) + + // CoreAudio stuff + private var tapID: AudioObjectID = kAudioObjectUnknown + private var aggregateDeviceID: AudioObjectID = kAudioObjectUnknown + private var ioProcID: AudioDeviceIOProcID? = nil + private var captureIsRunning = false + + // Serial queue to prevent race conditions + private let audioQueue = DispatchQueue(label: "com.atoll.audiotap", qos: .userInitiated) + + // Debounce restart requests + private var pendingRestartWorkItem: DispatchWorkItem? + + private let targetBundleIDs = [ + "com.apple.Music", + "com.spotify.client", + "com.amazon.music", + "com.apple.Safari", + "com.tidal.desktop", + "tv.plex.plexamp", + "com.roon.Roon", + "com.audirvana.Audirvana-Studio", + "com.vox.vox", + "com.coppertino.Vox", + ] + + private override init() { + super.init() + } + + // Helper function to smooth out the magnitudes for prettifying purposes + func getSmoothedMagnitudes() -> simd_float4 { + // Zero bridging overhead. Just passing 16 bytes of memory. + let targetLevels = bridge.getSmoothedMagnitudes() + + let smoothingFactor: Float = 0.4 + + // Vector math! This does all 4 calculations simultaneously. + let difference = targetLevels - displayMagnitudes + displayMagnitudes += difference * smoothingFactor + + return displayMagnitudes + } + + func startCapture() async { + await withCheckedContinuation { continuation in + audioQueue.async { [weak self] in + self?.startCaptureSync() + continuation.resume() + } + } + } + + private func startCaptureSync() { + guard !captureIsRunning else { + print("⚠️ [AudioTap] Capture already running, skipping start") + return + } + + let runningApps = NSWorkspace.shared.runningApplications + var targetPIDs: [AudioDeviceID] = [] + + for app in runningApps { + if let bundleID = app.bundleIdentifier, targetBundleIDs.contains(bundleID) { + if let deviceID = getAudioObjectID(for: app.processIdentifier) { + targetPIDs.append(deviceID) + print("🎯 [AudioTap] Found \(app.localizedName ?? "App") with PID: \(app.processIdentifier), AudioObjectID: \(deviceID)") + } + } + } + + if targetPIDs.isEmpty { + print("⚠️ [AudioTap] None of our target apps are running right now.") + return + } + + let description = CATapDescription() + description.processes = targetPIDs + description.isMixdown = true + description.isMono = true + + print("📋 [AudioTap] Creating tap for \(targetPIDs.count) processes: \(targetPIDs)") + + tapID = AudioObjectID(kAudioObjectUnknown) + var status = AudioHardwareCreateProcessTap(description, &tapID) + guard status == noErr else { + print("🛑 [AudioTap] Tap Error: \(status) (\(fourCharCodeToString(status)))") + return + } + print("✅ [AudioTap] Created process tap with ID: \(tapID)") + + // Get the tap's unique hardware UID + var tapUID: CFString = "" as CFString + var propertySize = UInt32(MemoryLayout.stride) + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioTapPropertyUID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + status = withUnsafeMutablePointer(to: &tapUID) { uidPtr in + AudioObjectGetPropertyData(tapID, &propertyAddress, 0, nil, &propertySize, uidPtr) + } + guard status == noErr else { + print("🛑 [AudioTap] UID Error: \(status) (\(fourCharCodeToString(status)))") + cleanupPartialSetup() + return + } + print("✅ [AudioTap] Got tap UID: \(tapUID)") + + // Create the Aggregate Device (a "virtual microphone" that we can route the tap into) + let tapList = [[kAudioSubTapUIDKey: tapUID]] + let aggregateDict: [String: Any] = [ + kAudioAggregateDeviceNameKey: "Atoll_Virtual_Tap", + kAudioAggregateDeviceUIDKey: UUID().uuidString, + kAudioAggregateDeviceIsPrivateKey: true, // Hides it from the user's sound settings + kAudioAggregateDeviceTapListKey: tapList, + ] + + aggregateDeviceID = AudioObjectID(kAudioObjectUnknown) + status = AudioHardwareCreateAggregateDevice( + aggregateDict as CFDictionary, &aggregateDeviceID) + guard status == noErr else { + print("🛑 [AudioTap] Aggregate Error: \(status) (\(fourCharCodeToString(status)))") + cleanupPartialSetup() + return + } + print("✅ [AudioTap] Created aggregate device with ID: \(aggregateDeviceID)") + + // Bind the Callback to the device + let selfPointer = Unmanaged.passUnretained(self).toOpaque() + status = AudioDeviceCreateIOProcID(aggregateDeviceID, audioIOProc, selfPointer, &ioProcID) + + guard status == noErr, let validIOProcID = ioProcID else { + print("🛑 [AudioTap] IOProc Error: \(status) (\(fourCharCodeToString(status)))") + cleanupPartialSetup() + return + } + print("✅ [AudioTap] Created IO proc") + + // Start listening + status = AudioDeviceStart(aggregateDeviceID, validIOProcID) + guard status == noErr else { + print("🛑 [AudioTap] Start Error: \(status) (\(fourCharCodeToString(status)))") + cleanupPartialSetup() + return + } + + captureIsRunning = true + callbackCount = 0 + print("🟢 [AudioTap] CoreAudio CATap flowing through Aggregate Device!") + } + + private func cleanupPartialSetup() { + if let validIOProcID = ioProcID, aggregateDeviceID != kAudioObjectUnknown { + AudioDeviceDestroyIOProcID(aggregateDeviceID, validIOProcID) + } + if aggregateDeviceID != kAudioObjectUnknown { + AudioHardwareDestroyAggregateDevice(aggregateDeviceID) + } + if tapID != kAudioObjectUnknown { + AudioHardwareDestroyProcessTap(tapID) + } + tapID = kAudioObjectUnknown + aggregateDeviceID = kAudioObjectUnknown + ioProcID = nil + } + + func restartCapture() { + // Cancel any pending restart + pendingRestartWorkItem?.cancel() + + // Debounce: wait 500ms before actually restarting + let workItem = DispatchWorkItem { [weak self] in + self?.audioQueue.async { + print("🔄 [AudioTap] Restarting capture...") + self?.stopCaptureSync() + // Small delay to let CoreAudio fully release resources + Thread.sleep(forTimeInterval: 0.1) + self?.startCaptureSync() + } + } + pendingRestartWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem) + } + + func stopCapture() { + audioQueue.sync { [weak self] in + self?.stopCaptureSync() + } + } + + private func stopCaptureSync() { + guard captureIsRunning else { return } + + // Stop listening + if let validIOProcID = ioProcID, aggregateDeviceID != kAudioObjectUnknown { + AudioDeviceStop(aggregateDeviceID, validIOProcID) + AudioDeviceDestroyIOProcID(aggregateDeviceID, validIOProcID) + } + + // Destroy resources + if aggregateDeviceID != kAudioObjectUnknown { + AudioHardwareDestroyAggregateDevice(aggregateDeviceID) + } + + if tapID != kAudioObjectUnknown { + AudioHardwareDestroyProcessTap(tapID) + } + + tapID = kAudioObjectUnknown + aggregateDeviceID = kAudioObjectUnknown + ioProcID = nil + captureIsRunning = false + + // Reset display magnitudes + displayMagnitudes = simd_float4(0, 0, 0, 0) + + print("🔴 [AudioTap] CoreAudio CATap capture stopped") + } + + var isCapturing: Bool { + captureIsRunning + } + + deinit { + stopCaptureSync() + } +} + +// Helper to convert OSStatus to readable string +private func fourCharCodeToString(_ code: OSStatus) -> String { + let bytes = [ + UInt8((code >> 24) & 0xFF), + UInt8((code >> 16) & 0xFF), + UInt8((code >> 8) & 0xFF), + UInt8(code & 0xFF) + ] + if bytes.allSatisfy({ $0 >= 32 && $0 < 127 }) { + return String(bytes: bytes, encoding: .ascii) ?? "????" + } + return String(code) +} diff --git a/DynamicIsland/audio/AudioVisualizerView.swift b/DynamicIsland/audio/AudioVisualizerView.swift new file mode 100644 index 00000000..3433382f --- /dev/null +++ b/DynamicIsland/audio/AudioVisualizerView.swift @@ -0,0 +1,38 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Unified audio visualizer that conditionally uses real-time audio spectrum + * or the original animated spectrum based on user preference. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +/// Unified audio visualizer view that switches between real-time and animated based on user preference +struct AudioVisualizerView: View { + @Binding var isPlaying: Bool + @Default(.enableRealTimeWaveform) private var enableRealTimeWaveform + + var body: some View { + if enableRealTimeWaveform { + RealTimeAudioSpectrumView(isPlaying: $isPlaying) + } else { + AudioSpectrumView(isPlaying: $isPlaying) + } + } +} + +#Preview("Animated") { + AudioVisualizerView(isPlaying: .constant(true)) + .frame(width: 16, height: 20) + .padding() +} diff --git a/DynamicIsland/audio/RealTimeAudioSpectrum.swift b/DynamicIsland/audio/RealTimeAudioSpectrum.swift new file mode 100644 index 00000000..407bc4ee --- /dev/null +++ b/DynamicIsland/audio/RealTimeAudioSpectrum.swift @@ -0,0 +1,169 @@ +/* + * Atoll (DynamicIsland) + * Original work Copyright (C) 2026 ZephyrCodesStuff (https://github.com/ZephyrCodesStuff/rtaudio) + * Modified work Copyright (C) 2026 Atoll Contributors + * + * Real-time audio spectrum visualization using CoreAudio tap data. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This file is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Cocoa +import SwiftUI +import simd + +/// NSView-based real-time audio spectrum visualizer +class RealTimeAudioSpectrum: NSView { + private var barLayers: [CAShapeLayer] = [] + private var isPlaying: Bool = true + private var animationTimer: Timer? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + setupBars() + } + + deinit { + stopAnimating() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + setupBars() + } + + private func setupBars() { + let barWidth: CGFloat = 2 + let barCount = 4 + let spacing: CGFloat = barWidth + let totalWidth = CGFloat(barCount) * (barWidth + spacing) + let totalHeight: CGFloat = 14 + frame.size = CGSize(width: totalWidth, height: totalHeight) + + for i in 0 ..< barCount { + let xPosition = CGFloat(i) * (barWidth + spacing) + let barLayer = CAShapeLayer() + barLayer.frame = CGRect(x: xPosition, y: 0, width: barWidth, height: totalHeight) + barLayer.position = CGPoint(x: xPosition + barWidth / 2, y: totalHeight / 2) + barLayer.fillColor = NSColor.white.cgColor + + let path = NSBezierPath(roundedRect: CGRect(x: 0, y: 0, width: barWidth, height: totalHeight), + xRadius: barWidth / 2, + yRadius: barWidth / 2) + barLayer.path = path.cgPath + + barLayers.append(barLayer) + layer?.addSublayer(barLayer) + } + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + stopAnimating() + } else if isPlaying { + startAnimating() + } + } + + private func startAnimating() { + guard animationTimer == nil else { return } + // Use a timer at ~30fps for smooth animation + animationTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in + self?.updateBarsFromAudio() + } + } + + private func stopAnimating() { + animationTimer?.invalidate() + animationTimer = nil + resetBars() + } + + private var debugLogCounter = 0 + + private func updateBarsFromAudio() { + guard isPlaying else { + resetBars() + return + } + + // Get real-time magnitudes from AudioTap + let magnitudes = AudioTap.shared.getSmoothedMagnitudes() + + // Debug: log magnitudes periodically + debugLogCounter += 1 + if debugLogCounter % 60 == 0 { // Every 2 seconds at 30fps + print("📊 [Spectrum] Magnitudes: [\(magnitudes.x), \(magnitudes.y), \(magnitudes.z), \(magnitudes.w)]") + } + + // Update each bar with its corresponding band magnitude + for (index, barLayer) in barLayers.enumerated() { + let magnitude = magnitudes[index] + // Map magnitude (0-1) to scale (0.2 - 1.0) for visual appeal + let scale = max(0.2, min(1.0, CGFloat(magnitude) * 1.5 + 0.2)) + + CATransaction.begin() + CATransaction.setDisableActions(true) + barLayer.transform = CATransform3DMakeScale(1, scale, 1) + CATransaction.commit() + } + } + + private func resetBars() { + for barLayer in barLayers { + CATransaction.begin() + CATransaction.setDisableActions(true) + barLayer.transform = CATransform3DMakeScale(1, 0.2, 1) + CATransaction.commit() + } + } + + func setPlaying(_ playing: Bool) { + isPlaying = playing + if isPlaying { + startAnimating() + } else { + stopAnimating() + } + } +} + +/// SwiftUI wrapper for RealTimeAudioSpectrum +struct RealTimeAudioSpectrumView: NSViewRepresentable { + @Binding var isPlaying: Bool + + func makeNSView(context: Context) -> RealTimeAudioSpectrum { + let spectrum = RealTimeAudioSpectrum() + spectrum.setPlaying(isPlaying) + return spectrum + } + + func updateNSView(_ nsView: RealTimeAudioSpectrum, context: Context) { + nsView.setPlaying(isPlaying) + } + + static func dismantleNSView(_ nsView: RealTimeAudioSpectrum, coordinator: ()) { + nsView.setPlaying(false) + } +} + +#Preview { + RealTimeAudioSpectrumView(isPlaying: .constant(true)) + .frame(width: 16, height: 20) + .padding() +} diff --git a/DynamicIsland/components/AnimatedFace.swift b/DynamicIsland/components/AnimatedFace.swift deleted file mode 100644 index 51913b79..00000000 --- a/DynamicIsland/components/AnimatedFace.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// AnimatedFace.swift -// -// Created by Harsh Vardhan Goswami on 04/08/24. -// - -import SwiftUI - -struct MinimalFaceFeatures: View { - @State private var isBlinking = false - @State var height:CGFloat = 20; - @State var width:CGFloat = 30; - - var body: some View { - VStack(spacing: 4) { // Adjusted spacing to fit within 30x30 - // Eyes - HStack(spacing: 4) { // Adjusted spacing to fit within 30x30 - Eye(isBlinking: $isBlinking) - Eye(isBlinking: $isBlinking) - } - - // Nose and mouth combined - VStack(spacing: 2) { // Adjusted spacing to fit within 30x30 - // Nose - RoundedRectangle(cornerRadius: 2) - .fill(Color.white) - .frame(width: 3, height: 4) - - // Mouth (happy) - GeometryReader { geometry in - Path { path in - let width = geometry.size.width - let height = geometry.size.height - path.move(to: CGPoint(x: 0, y: height / 2)) - path.addQuadCurve(to: CGPoint(x: width, y: height / 2), control: CGPoint(x: width / 2, y: height)) - } - .stroke(Color.white, lineWidth: 2) - } - .frame(width: 14, height: 10) - } - } - .frame(width: self.width, height: self.height) // Maximum size of face - .onAppear { - startBlinking() - } - } - - func startBlinking() { - Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in - withAnimation(.spring(duration: 0.2)) { - isBlinking = true - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - withAnimation(.spring(duration: 0.2)) { - isBlinking = false - } - } - } - } -} - -struct Eye: View { - @Binding var isBlinking: Bool - - var body: some View { - RoundedRectangle(cornerRadius: 10) - .fill(Color.white) - .frame(width: 4, height: isBlinking ? 1 : 4) - .frame(maxWidth: 15, maxHeight: 15) // Adjusted max size - .animation(.easeInOut(duration: 0.1), value: isBlinking) - } -} - -struct MinimalFaceFeatures_Previews: PreviewProvider { - static var previews: some View { - ZStack { - Color.black - MinimalFaceFeatures() - } - .previewLayout(.fixed(width: 60, height: 60)) // Adjusted preview size for better visibility - } -} diff --git a/DynamicIsland/components/BottomRoundedRectangle.swift b/DynamicIsland/components/BottomRoundedRectangle.swift index fa951e7f..a8928f6d 100644 --- a/DynamicIsland/components/BottomRoundedRectangle.swift +++ b/DynamicIsland/components/BottomRoundedRectangle.swift @@ -1,4 +1,24 @@ - +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI diff --git a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift index 4dfd91f9..c876a1ec 100644 --- a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift +++ b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift @@ -1,22 +1,32 @@ -// -// DynamicIslandCalendar.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 08/09/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import Defaults import EventKit struct Config: Equatable { -// var count: Int = 10 // 3 days past + today + 7 days future - var past: Int = 3 - var future: Int = 7 - var steps: Int = 1 // Each step is one day - var spacing: CGFloat = 1 + var past: Int = 7 + var future: Int = 14 + var steps: Int = 1 + var spacing: CGFloat = 0 var showsText: Bool = true - var offset: Int = 2 // Number of dates to the left of the selected date + var offset: Int = 2 } struct WheelPicker: View { @@ -26,26 +36,28 @@ struct WheelPicker: View { @State private var haptics: Bool = false @State private var byClick: Bool = false let config: Config - + var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: config.spacing) { - let totalSteps = config.steps * (config.past + config.future) let spacerNum = config.offset - ForEach(0.. totalSteps + spacerNum - 1){ - Spacer().frame(width: 24, height: 24).id(index) + let dateCount = totalDateItems() + let totalItems = dateCount + 2 * spacerNum + ForEach(0..= spacerNum + dateCount { + Spacer() + .frame(width: 24, height: 24) + .id(index) } else { - let offset = -config.offset - config.past - let date = dateForIndex(index, offset: offset) - let isSelected = isDateSelected(index, offset: offset) - dateButton(date: date, isSelected: isSelected, offset: offset){ + let date = dateForItemIndex(index: index, spacerNum: spacerNum) + let isSelected = Calendar.current.isDate(date, inSameDayAs: selectedDate) + dateButton(date: date, isSelected: isSelected, id: index) { selectedDate = date byClick = true - withAnimation{ - scrollPosition = indexForDate(date, offset: offset) - config.offset + withAnimation { + scrollPosition = index } - if (Defaults[.enableHaptics]) { + if Defaults[.enableHaptics] { haptics.toggle() } } @@ -56,85 +68,109 @@ struct WheelPicker: View { .scrollTargetLayout() } .scrollIndicators(.never) - .scrollPosition(id: $scrollPosition, anchor: .leading) - .scrollTargetBehavior(.viewAligned) // Ensures scroll view snaps to button center + .scrollPosition(id: $scrollPosition, anchor: .center) + .scrollTargetBehavior(.viewAligned) .safeAreaPadding(.horizontal) .sensoryFeedback(.alignment, trigger: haptics) - .onChange(of: scrollPosition) { oldValue, newValue in - if(!byClick){ + .onChange(of: scrollPosition) { _, newValue in + if !byClick { handleScrollChange(newValue: newValue, config: config) - }else{ + } else { byClick = false } } .onAppear { scrollToToday(config: config) } + .onChange(of: selectedDate) { _, newValue in + let targetIndex = indexForDate(newValue) + if scrollPosition != targetIndex { + byClick = true + withAnimation { + scrollPosition = targetIndex + } + } + } } - - private func dateButton(date: Date, isSelected: Bool, offset: Int, onClick:@escaping()->Void) -> some View { - Button(action: onClick) { - VStack(spacing: 2) { - dayText(date: dateToString(for: date), isSelected: isSelected) - dateCircle(date: date, isSelected: isSelected) + + private func dateButton(date: Date, isSelected: Bool, id: Int, onClick: @escaping () -> Void) -> some View { + let isToday = Calendar.current.isDateInToday(date) + return Button(action: onClick) { + VStack(spacing: 8) { + dayText(date: dateToString(for: date), isToday: isToday, isSelected: isSelected) + dateCircle(date: date, isToday: isToday, isSelected: isSelected) } + .padding(.vertical, 4) + .padding(.horizontal, 4) + .background(isSelected ? Color.effectiveAccentBackground : Color.clear) + .cornerRadius(8) } .buttonStyle(PlainButtonStyle()) - .id(indexForDate(date, offset: offset)) + .id(id) } - - private func dayText(date: String, isSelected: Bool) -> some View { + + private func dayText(date: String, isToday: Bool, isSelected: Bool) -> some View { Text(date) - .font(.caption2) - .foregroundStyle(isSelected ? Color.accentColor : .gray) + .font(.caption) + .foregroundColor(isSelected ? .white : Color(white: 0.65)) } - private func dateCircle(date: Date, isSelected: Bool) -> some View { + private func dateCircle(date: Date, isToday: Bool, isSelected: Bool) -> some View { ZStack { Circle() - .fill(isSelected ? Color.accentColor : .clear) - .frame(width: 24, height: 24) - Text("\(date.date)") - .font(.title3) - .foregroundStyle(isSelected ? .white : .gray) + .fill(isToday ? Color.effectiveAccent : .clear) + .frame(width: 20, height: 20) + Text(date.date) + .font(.body) + .fontWeight(.medium) + .foregroundColor(isSelected ? .white : Color(white: isToday ? 0.9 : 0.65)) } } - + func handleScrollChange(newValue: Int?, config: Config) { - let offset = -config.offset - config.past - let todayIndex = indexForDate(Date(), offset: offset) guard let newIndex = newValue else { return } - let targetDateIndex = newIndex + config.offset - switch targetDateIndex { - case todayIndex - config.past ..< todayIndex + config.future: - selectedDate = dateForIndex(targetDateIndex, offset: offset) - if (Defaults[.enableHaptics]) { + let spacerNum = config.offset + let dateCount = totalDateItems() + guard (spacerNum..<(spacerNum + dateCount)).contains(newIndex) else { return } + let date = dateForItemIndex(index: newIndex, spacerNum: spacerNum) + if !Calendar.current.isDate(date, inSameDayAs: selectedDate) { + selectedDate = date + if Defaults[.enableHaptics] { haptics.toggle() } - default: - return } } private func scrollToToday(config: Config) { let today = Date() - let todayIndex = indexForDate(today, offset: -config.offset - config.past) byClick = true - scrollPosition = todayIndex - config.offset + scrollPosition = indexForDate(today) selectedDate = today } - private func indexForDate(_ date: Date, offset: Int) -> Int { - let calendar = Calendar.current - let startDate = calendar.startOfDay(for: calendar.date(byAdding: .day, value: offset, to: Date()) ?? Date()) - let targetDate = calendar.startOfDay(for: date) - let daysDifference = calendar.dateComponents([.day], from: startDate, to: targetDate).day ?? 0 - return daysDifference + private func indexForDate(_ date: Date) -> Int { + let spacerNum = config.offset + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + let startDate = cal.startOfDay(for: cal.date(byAdding: .day, value: -config.past, to: today) ?? today) + let target = cal.startOfDay(for: date) + let days = cal.dateComponents([.day], from: startDate, to: target).day ?? 0 + let stepIndex = max(0, min(days / max(config.steps, 1), totalDateItems() - 1)) + return spacerNum + stepIndex + } + + private func dateForItemIndex(index: Int, spacerNum: Int) -> Date { + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + let startDate = cal.date(byAdding: .day, value: -config.past, to: today) ?? today + let stepIndex = index - spacerNum + return cal.date(byAdding: .day, value: stepIndex * max(config.steps, 1), to: startDate) ?? today } - private func dateForIndex(_ index: Int, offset: Int) -> Date { - let startDate = Calendar.current.date(byAdding: .day, value: offset, to: Date()) ?? Date() - return Calendar.current.date(byAdding: .day, value: index, to: startDate) ?? Date() + private func totalDateItems() -> Int { + let range = config.past + config.future + let step = max(config.steps, 1) + return Int(ceil(Double(range) / Double(step))) + 1 } private func dateToString(for date: Date) -> String { @@ -142,136 +178,900 @@ struct WheelPicker: View { formatter.dateFormat = "E" return formatter.string(from: date) } - - private func isDateSelected(_ index: Int, offset: Int) -> Bool { - Calendar.current.isDate(dateForIndex(index, offset: offset), inSameDayAs: selectedDate) - } } struct CalendarView: View { @EnvironmentObject var vm: DynamicIslandViewModel - @StateObject private var calendarManager = CalendarManager() + @ObservedObject private var calendarManager = CalendarManager.shared @State private var selectedDate = Date() - + @Default(.hideAllDayEvents) private var hideAllDayEvents + @Default(.hideCompletedReminders) private var hideCompletedReminders + var body: some View { - VStack(spacing: 8) { - HStack { - Text("\(selectedDate, format: .dateTime.month())") - .font(.system(size: 18)) - .fontWeight(.semibold) - ZStack { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading) { + Text(selectedDate.formatted(.dateTime.month(.abbreviated))) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.white) + Text(selectedDate.formatted(.dateTime.year())) + .font(.title3) + .fontWeight(.light) + .foregroundColor(Color(white: 0.65)) + } + + ZStack(alignment: .top) { WheelPicker(selectedDate: $selectedDate, config: Config()) - HStack { + HStack(alignment: .top) { LinearGradient( - colors: [.black, .clear], startPoint: .leading, endPoint: .trailing + colors: [Color.black, .clear], startPoint: .leading, endPoint: .trailing ) .frame(width: 20) Spacer() LinearGradient( - colors: [.clear, .black], startPoint: .leading, endPoint: .trailing + colors: [.clear, Color.black], startPoint: .leading, endPoint: .trailing ) .frame(width: 20) } } } - if calendarManager.events.isEmpty { - EmptyEventsView() + + let filteredEvents = EventListView.filteredEvents( + events: calendarManager.events, + hideCompletedReminders: hideCompletedReminders, + hideAllDayEvents: hideAllDayEvents + ) + if filteredEvents.isEmpty { + EmptyEventsView(selectedDate: selectedDate) + Spacer(minLength: 0) } else { EventListView(events: calendarManager.events) } } .listRowBackground(Color.clear) - .onChange(of: selectedDate) { _, newDate in + .frame(height: 120) + .onChange(of: selectedDate) { Task { - await calendarManager.updateCurrentDate(newDate) + await calendarManager.updateCurrentDate(selectedDate) } } .onChange(of: vm.notchState) { _, _ in Task { await calendarManager.updateCurrentDate(Date.now) + selectedDate = Date.now } } .onAppear { Task { await calendarManager.updateCurrentDate(Date.now) + selectedDate = Date.now + } + } + } +} + +struct StandaloneCalendarView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject private var calendarManager = CalendarManager.shared + @State private var selectedDate = Date() + @State private var displayedMonth = Date() + @State private var datePickerScrollTarget: Date? + @Default(.hideAllDayEvents) private var hideAllDayEvents + @Default(.hideCompletedReminders) private var hideCompletedReminders + + private let calendar = Calendar.current + + private var weekdaySymbols: [String] { + let symbols = calendar.veryShortStandaloneWeekdaySymbols + guard !symbols.isEmpty else { return symbols } + + let firstWeekdayIndex = max(0, min(symbols.count - 1, calendar.firstWeekday - 1)) + var ordered = Array(symbols[firstWeekdayIndex...]) + ordered.append(contentsOf: symbols[.. 0 ? height : openNotchSize.height + } + + private var headerHeight: CGFloat { + max(24, vm.effectiveClosedNotchHeight) + } + + private var maxTabContentHeight: CGFloat { + let available = resolvedNotchHeight - headerHeight - 36 + return max(130, available) + } + + var body: some View { + GeometryReader { geometry in + let paneSpacing: CGFloat = 12 + let paneWidth = max((geometry.size.width - paneSpacing) / 2, 0) + let paneHeight = max(0, geometry.size.height) + + HStack(alignment: .top, spacing: paneSpacing) { + leftPickerPane + .frame(width: paneWidth, alignment: .topLeading) + .frame(height: paneHeight, alignment: .topLeading) + .layoutPriority(1) + + rightEventsPane + .frame(width: paneWidth, alignment: .topLeading) + .frame(height: paneHeight, alignment: .topLeading) + .layoutPriority(1) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .clipped() + } + .frame(height: maxTabContentHeight) + .clipped() + .onAppear { + selectedDate = Date.now + displayedMonth = selectedDate.startOfMonth + requestDatePickerCenterOnCurrentDate() + Task { + await calendarManager.updateCurrentDate(selectedDate) + } + } + .onChange(of: selectedDate) { _, newDate in + withAnimation(.smooth(duration: 0.22)) { + displayedMonth = newDate.startOfMonth + } + Task { + await calendarManager.updateCurrentDate(newDate) + } + } + .onChange(of: vm.notchState) { _, newState in + guard newState == .open else { return } + selectedDate = Date.now + displayedMonth = selectedDate.startOfMonth + requestDatePickerCenterOnCurrentDate() + Task { + await calendarManager.updateCurrentDate(selectedDate) + } + } + } + + private var leftPickerPane: some View { + GeometryReader { geometry in + let pickerViewportHeight = max(96, geometry.size.height - 56) + + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(monthTitle) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(.white) + Text(yearTitle) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(Color(white: 0.65)) + } + Spacer() + HStack(spacing: 6) { + Button(action: showPreviousMonth) { + Image(systemName: "chevron.left") + .font(.system(size: 11, weight: .bold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + + Button(action: showNextMonth) { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .bold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + .foregroundStyle(.white) + } + + ScrollViewReader { proxy in + VStack(spacing: 6) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 14), spacing: 6), count: 7), spacing: 6) { + ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in + Text(symbol.prefix(1)) + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(Color(white: 0.55)) + .frame(maxWidth: .infinity) + } + } + + ZStack { + ScrollView(.vertical, showsIndicators: false) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 14), spacing: 6), count: 7), spacing: 6) { + ForEach(monthDays, id: \.self) { day in + dayCell(for: day) + .id(calendar.startOfDay(for: day)) + } + } + .padding(.bottom, 2) + } + .onChange(of: datePickerScrollTarget) { _, target in + guard let target else { return } + centerDatePicker(on: target, proxy: proxy) + } + + LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .frame(maxHeight: .infinity, alignment: .top) + + LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .frame(maxHeight: .infinity, alignment: .bottom) + } + .frame(height: max(0, pickerViewportHeight - 22)) + .clipped() + } + .frame(height: pickerViewportHeight) + } + .frame(height: pickerViewportHeight) + .clipped() + } + .frame(maxHeight: .infinity, alignment: .top) + } + .padding(.horizontal, 6) + .padding(.top, 4) + .clipped() + } + + private var rightEventsPane: some View { + Group { + if filteredEvents.isEmpty { + EmptyEventsView(selectedDate: selectedDate) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + StandaloneEventCardList( + events: filteredEvents, + showFullEventTitles: Defaults[.showFullEventTitles], + onToggleReminder: { reminderID, completed in + Task { + await calendarManager.setReminderCompleted(reminderID: reminderID, completed: completed) + } + } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } + + private func dayCell(for day: Date) -> some View { + let isCurrentMonth = calendar.isDate(day, equalTo: displayedMonth, toGranularity: .month) + let isSelected = calendar.isDate(day, inSameDayAs: selectedDate) + let isToday = calendar.isDateInToday(day) + + return Button { + withAnimation(.smooth(duration: 0.18)) { + selectedDate = day + } + } label: { + ZStack { + if isSelected { + Circle() + .fill(Color.effectiveAccent) + .frame(width: 28, height: 28) + } + + Text(day.formatted(.dateTime.day())) + .font(.system(size: 12, weight: isSelected ? .semibold : .medium)) + .foregroundStyle(dayTextColor(isCurrentMonth: isCurrentMonth, isSelected: isSelected, isToday: isToday)) + } + .frame(maxWidth: .infinity, minHeight: 30) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func dayTextColor(isCurrentMonth: Bool, isSelected: Bool, isToday: Bool) -> Color { + if isSelected { return .white } + if !isCurrentMonth { return Color(white: 0.35) } + if isToday { return Color.effectiveAccent } + return .white + } + + private func showPreviousMonth() { + guard let newMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) else { return } + withAnimation(.smooth(duration: 0.22)) { + displayedMonth = newMonth.startOfMonth + selectedDate = newMonth.startOfMonth + } + } + + private func showNextMonth() { + guard let newMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) else { return } + withAnimation(.smooth(duration: 0.22)) { + displayedMonth = newMonth.startOfMonth + selectedDate = newMonth.startOfMonth + } + } + + private func requestDatePickerCenterOnCurrentDate() { + datePickerScrollTarget = calendar.startOfDay(for: selectedDate) + } + + private func centerDatePicker(on target: Date, proxy: ScrollViewProxy) { + let normalizedTarget = calendar.startOfDay(for: target) + DispatchQueue.main.async { + withAnimation(.smooth(duration: 0.24)) { + proxy.scrollTo(normalizedTarget, anchor: .center) + } + if datePickerScrollTarget == normalizedTarget { + datePickerScrollTarget = nil } } } } struct EmptyEventsView: View { + let selectedDate: Date + var body: some View { - ScrollView { - Text("No events today") - .font(.headline) - .foregroundStyle(.white) - Text("Enjoy your free time!") + VStack { + Image(systemName: "calendar.badge.checkmark") + .font(.title) + .foregroundColor(Color(white: 0.65)) + Text(Calendar.current.isDateInToday(selectedDate) ? "No events today" : "No events") .font(.subheadline) - .foregroundColor(.gray) + .foregroundColor(.white) + Text("Enjoy your free time!") + .font(.caption) + .foregroundColor(Color(white: 0.65)) } } } +private extension Date { + var startOfMonth: Date { + Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: self)) ?? self + } +} + +private struct StandaloneEventCardList: View { + @Environment(\.openURL) private var openURL + let events: [EventModel] + let showFullEventTitles: Bool + let onToggleReminder: (String, Bool) -> Void + + var body: some View { + ZStack { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(events) { event in + eventCard(event) + } + } + .padding(.vertical, 2) + } + .clipped() + + LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .frame(maxHeight: .infinity, alignment: .top) + + LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .frame(maxHeight: .infinity, alignment: .bottom) + } + .clipped() + } + + @ViewBuilder + private func eventCard(_ event: EventModel) -> some View { + if event.type.isReminder { + Button { + if let url = event.calendarAppURL() { + openURL(url) + } + } label: { + reminderCard(event) + } + .buttonStyle(.plain) + .padding(.horizontal, 2) + } else { + calendarEventCard(event) + .contentShape(Rectangle()) + .onTapGesture { + if let url = event.calendarAppURL() { + openURL(url) + } + } + .padding(.horizontal, 2) + } + } + + private func reminderCard(_ event: EventModel) -> some View { + let isCompleted: Bool + if case .reminder(let completed) = event.type { + isCompleted = completed + } else { + isCompleted = false + } + + return HStack(spacing: 10) { + ReminderToggle( + isOn: Binding( + get: { isCompleted }, + set: { newValue in onToggleReminder(event.id, newValue) } + ), + color: Color(event.calendar.color) + ) + + VStack(alignment: .leading, spacing: 4) { + Text(event.title) + .font(.callout) + .fontWeight(.medium) + .foregroundColor(.white) + .lineLimit(showFullEventTitles ? nil : 2) + + if event.isAllDay { + Text("All-day") + .font(.caption) + .foregroundColor(Color(white: 0.65)) + } + } + + Spacer(minLength: 8) + + if !event.isAllDay { + Text(event.start, style: .time) + .font(.caption) + .foregroundColor(Color(white: 0.75)) + } + } + .padding(.horizontal, 11) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, minHeight: 50, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.white.opacity(0.05)) + ) + .opacity(isCompleted ? 0.55 : 1) + } + + private func calendarEventCard(_ event: EventModel) -> some View { + HStack(alignment: .top, spacing: 10) { + RoundedRectangle(cornerRadius: 2) + .fill(Color(event.calendar.color)) + .frame(width: 4, height: 36) + + VStack(alignment: .leading, spacing: 4) { + Text(event.title) + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.white) + .lineLimit(showFullEventTitles ? nil : 2) + + if let location = event.location, !location.isEmpty { + Text(location) + .font(.caption) + .foregroundColor(Color(white: 0.65)) + .lineLimit(1) + } + + if let conferenceURL = event.conferenceURL { + ConferenceJoinButton(url: conferenceURL, event: event) + } + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 2) { + if event.isAllDay { + Text("All-day") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.white) + } else { + Text(event.start, style: .time) + .font(.caption) + .foregroundColor(.white) + Text(event.end, style: .time) + .font(.caption2) + .foregroundColor(Color(white: 0.65)) + } + } + } + .padding(.horizontal, 11) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, minHeight: 50, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.white.opacity(0.05)) + ) + .opacity(event.eventStatus == .ended && Calendar.current.isDateInToday(event.start) ? 0.6 : 1) + } +} + struct EventListView: View { @Environment(\.openURL) private var openURL + @ObservedObject private var calendarManager = CalendarManager.shared let events: [EventModel] + @Default(.autoScrollToNextEvent) private var autoScrollToNextEvent + @Default(.showFullEventTitles) private var showFullEventTitles + @Default(.hideCompletedReminders) private var hideCompletedReminders + @Default(.hideAllDayEvents) private var hideAllDayEvents + + static func filteredEvents( + events: [EventModel], + hideCompletedReminders: Bool, + hideAllDayEvents: Bool + ) -> [EventModel] { + events.filter { event in + if event.type.isReminder { + if case .reminder(let completed) = event.type { + return !completed || !hideCompletedReminders + } + } + if event.isAllDay && hideAllDayEvents { + return false + } + return true + } + } + + private var filteredEvents: [EventModel] { + Self.filteredEvents( + events: events, + hideCompletedReminders: hideCompletedReminders, + hideAllDayEvents: hideAllDayEvents + ) + } + + private func scrollToRelevantEvent(proxy: ScrollViewProxy) { + guard autoScrollToNextEvent else { return } + let now = Date() + let nonAllDayUpcoming = filteredEvents.first(where: { !$0.isAllDay && $0.end > now }) + let firstAllDay = filteredEvents.first(where: { $0.isAllDay }) + let lastEvent = filteredEvents.last + guard let target = nonAllDayUpcoming ?? firstAllDay ?? lastEvent else { return } + + Task { @MainActor in + withTransaction(Transaction(animation: nil)) { + proxy.scrollTo(target.id, anchor: .top) + } + } + } var body: some View { - ScrollView(showsIndicators: false) { - HStack(alignment: .top) { - VStack(alignment: .trailing, spacing: 5) { - ForEach(events.indices, id: \.self) { index in - VStack(alignment: .trailing) { - if events[index].isAllDay { - Text("All-day") - } else { - Text("\(events[index].start, style: .time)") - Text("\(events[index].end, style: .time)") + ScrollViewReader { proxy in + ZStack { + List { + ForEach(filteredEvents) { event in + Button(action: { + if let url = event.calendarAppURL() { + openURL(url) } + }) { + eventRow(event) } - .multilineTextAlignment(.trailing) - .padding(.bottom, 8) - .font(.caption2) + .id(event.id) + .padding(.leading, -5) + .buttonStyle(PlainButtonStyle()) + .listRowSeparator(.automatic) + .listRowSeparatorTint(.gray.opacity(0.2)) + .listRowBackground(Color.clear) } } + .listStyle(.plain) + .scrollIndicators(.never) + .scrollContentBackground(.hidden) + .background(Color.clear) - VStack(alignment: .leading, spacing: 5) { - ForEach(events.indices, id: \.self) { index in - HStack(alignment: .top) { - VStack(spacing: 5) { - Image( - systemName: events[index].eventStatus == .ended - ? "checkmark.circle" : "circle" - ) - .foregroundColor(events[index].eventStatus == .ended ? .green : .gray) - .font(.footnote) - Rectangle() - .frame(width: 1) - .foregroundStyle(.gray.opacity(0.5)) - .opacity(index == events.count - 1 ? 0 : 1) - } - .padding(.top, 1) + LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .alignmentGuide(.top) { d in d[.top] } + .frame(maxHeight: .infinity, alignment: .top) + + LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .alignmentGuide(.bottom) { d in d[.bottom] } + .frame(maxHeight: .infinity, alignment: .bottom) + } + .onAppear { + scrollToRelevantEvent(proxy: proxy) + } + .onChange(of: filteredEvents) { _, _ in + scrollToRelevantEvent(proxy: proxy) + } + } + Spacer(minLength: 0) + } - Button { - if let url = events[index].calendarAppURL() { - openURL(url) + private func eventRow(_ event: EventModel) -> some View { + if event.type.isReminder { + let isCompleted: Bool + if case .reminder(let completed) = event.type { + isCompleted = completed + } else { + isCompleted = false + } + return AnyView( + HStack(spacing: 8) { + ReminderToggle( + isOn: Binding( + get: { isCompleted }, + set: { newValue in + Task { + await calendarManager.setReminderCompleted( + reminderID: event.id, completed: newValue + ) } - } label: { - Text(events[index].title) - .font(.footnote) - .foregroundStyle(.gray) } - .buttonStyle(.plain) + ), + color: Color(event.calendar.color) + ) + .opacity(1.0) + HStack { + Text(event.title) + .font(.callout) + .foregroundColor(.white) + .lineLimit(showFullEventTitles ? nil : 1) + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: 4) { + if event.isAllDay { + Text("All-day") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.white) + .lineLimit(1) + } else { + Text(event.start, style: .time) + .foregroundColor(.white) + .font(.caption) + } + } + } + .opacity( + isCompleted + ? 0.4 + : event.start < Date.now && Calendar.current.isDateInToday(event.start) + ? 0.6 : 1.0 + ) + } + .padding(.vertical, 4) + ) + } else { + return AnyView( + HStack(alignment: .top, spacing: 4) { + Rectangle() + .fill(Color(event.calendar.color)) + .frame(width: 3) + .cornerRadius(1.5) + + VStack(alignment: .leading, spacing: 2) { + Text(event.title) + .font(.callout) + .fontWeight(.medium) + .foregroundColor(.white) + .lineLimit(showFullEventTitles ? nil : 2) - Spacer(minLength: 0) + if let location = event.location, !location.isEmpty { + Text(location) + .font(.caption) + .foregroundColor(Color(white: 0.65)) + .lineLimit(1) + } + + // Show Join button if conference URL is available + if let conferenceURL = event.conferenceURL { + ConferenceJoinButton(url: conferenceURL, event: event) + } + } + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: 4) { + if event.isAllDay { + Text("All-day") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.white) + .lineLimit(1) + } else { + Text(event.start, style: .time) + .foregroundColor(.white) + Text(event.end, style: .time) + .foregroundColor(Color(white: 0.65)) } - .opacity((events[index].eventStatus == .ended) ? 0.6 : 1) } + .font(.caption) + .frame(minWidth: 44, alignment: .trailing) + } + .opacity( + event.eventStatus == .ended && Calendar.current.isDateInToday(event.start) + ? 0.6 : 1.0) + ) + } + } +} + +struct ReminderToggle: View { + @Binding var isOn: Bool + var color: Color + + var body: some View { + Button(action: { + isOn.toggle() + }) { + ZStack { + Circle() + .strokeBorder(color, lineWidth: 2) + .frame(width: 14, height: 14) + if isOn { + Circle() + .fill(color) + .frame(width: 8, height: 8) } + Circle() + .fill(Color.black.opacity(0.001)) + .frame(width: 14, height: 14) } } - .scrollIndicators(.never) - .scrollTargetBehavior(.viewAligned) + .buttonStyle(PlainButtonStyle()) + .padding(0) + .accessibilityLabel(isOn ? "Mark as incomplete" : "Mark as complete") + } +} + +// MARK: - Conference Provider + +enum ConferenceProvider: CaseIterable { + case zoom, teams, meet, webex, facetime, gotomeeting, bluejeans, whereby, jitsi, discord, generic + + var name: String { + switch self { + case .zoom: return "Zoom" + case .teams: return "Teams" + case .meet: return "Meet" + case .webex: return "Webex" + case .facetime: return "FaceTime" + case .gotomeeting: return "GoToMeeting" + case .bluejeans: return "BlueJeans" + case .whereby: return "Whereby" + case .jitsi: return "Jitsi" + case .discord: return "Discord" + case .generic: return "" + } + } + + var color: Color { + switch self { + case .zoom: return Color(red: 0.16, green: 0.52, blue: 0.95) + case .teams: return Color(red: 0.36, green: 0.42, blue: 0.89) + case .meet: return Color(red: 0.0, green: 0.65, blue: 0.42) + case .webex: return Color(red: 0.0, green: 0.71, blue: 0.84) + case .facetime: return Color(red: 0.2, green: 0.78, blue: 0.35) + case .gotomeeting: return Color(red: 0.95, green: 0.5, blue: 0.13) + case .bluejeans: return Color(red: 0.0, green: 0.48, blue: 0.87) + case .whereby: return Color(red: 0.27, green: 0.51, blue: 0.96) + case .jitsi: return Color(red: 0.16, green: 0.68, blue: 0.95) + case .discord: return Color(red: 0.35, green: 0.39, blue: 0.98) + case .generic: return Color.accentColor + } + } + + private var hostIdentifiers: [String] { + switch self { + case .zoom: return ["zoom.us"] + case .teams: return ["teams.microsoft.com"] + case .meet: return ["meet.google.com"] + case .webex: return ["webex.com"] + case .facetime: return ["facetime.apple.com"] + case .gotomeeting: return ["gotomeeting.com"] + case .bluejeans: return ["bluejeans.com"] + case .whereby: return ["whereby.com"] + case .jitsi: return ["meet.jit.si", "jitsi"] + case .discord: return ["discord.gg", "discord.com"] + case .generic: return [] + } + } + + var nativeURLScheme: String? { + switch self { + case .zoom: return "zoommtg" + case .teams: return "msteams" + case .facetime: return "facetime" + case .discord: return "discord" + default: return nil + } + } + + static func detect(from url: URL) -> ConferenceProvider { + let host = url.host?.lowercased() ?? "" + return allCases.first { provider in + provider.hostIdentifiers.contains { host.contains($0) } + } ?? .generic + } +} + +// MARK: - Conference Join Button + +struct ConferenceJoinButton: View { + let url: URL + let event: EventModel + @Environment(\.openURL) private var openURL + + private var provider: ConferenceProvider { .detect(from: url) } + private var isJoinable: Bool { + event.start.addingTimeInterval(-15 * 60) <= Date() + } + + private var buttonText: String { + event.eventStatus == .inProgress ? "Rejoin" : "Join" + } + + var body: some View { + Button(action: { + if let scheme = provider.nativeURLScheme, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + components.scheme = scheme + if let nativeURL = components.url { + openURL(nativeURL) + return + } + } + openURL(url) + }) { + HStack(spacing: 4) { + Image(systemName: "video.fill") + .font(.system(size: 9)) + Text(provider.name.isEmpty ? buttonText : "\(buttonText) \(provider.name)") + .font(.caption2) + .fontWeight(.medium) + } + .foregroundColor(isJoinable ? .white : Color(white: 0.5)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(isJoinable ? provider.color.opacity(0.85) : Color.gray.opacity(0.3)) + .cornerRadius(6) + } + .buttonStyle(PlainButtonStyle()) + .disabled(!isJoinable) + .help(isJoinable ? "Join the meeting" : "Meeting starts at \(event.start.formatted(date: .omitted, time: .shortened))") } } diff --git a/DynamicIsland/components/Calendar/ReminderLiveActivity.swift b/DynamicIsland/components/Calendar/ReminderLiveActivity.swift new file mode 100644 index 00000000..569ec0e5 --- /dev/null +++ b/DynamicIsland/components/Calendar/ReminderLiveActivity.swift @@ -0,0 +1,233 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +#if canImport(AppKit) +import AppKit +private typealias ReminderFont = NSFont +#elseif canImport(UIKit) +import UIKit +private typealias ReminderFont = UIFont +#endif + +struct ReminderLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var manager = ReminderLiveActivityManager.shared + + @Default(.reminderPresentationStyle) private var presentationStyle + + private let wingPadding: CGFloat = 16 + private let ringStrokeWidth: CGFloat = 3 + + private var notchContentHeight: CGFloat { + max(0, vm.effectiveClosedNotchHeight) + } + + var body: some View { + if let reminder = manager.activeReminder { + content(for: reminder, now: manager.currentDate) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + } + + @ViewBuilder + private func content(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View { + HStack(spacing: 0) { + Color.clear + .frame(width: leftWingWidth, height: notchContentHeight) + .background(alignment: .leading) { + iconSection(for: reminder, now: now) + .padding(.leading, wingPadding / 2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width, height: notchContentHeight) + + Color.clear + .frame(width: rightWingWidth(for: reminder, now: now), height: notchContentHeight) + .background(alignment: .trailing) { + rightSection(for: reminder, now: now) + .padding(.trailing, wingPadding / 2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + } + .frame(height: notchContentHeight, alignment: .center) + } + + private func iconSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View { + let diameter = iconDiameter + let accent = accentColor(for: reminder, now: now) + + return Image(systemName: iconName(for: reminder, now: now)) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(accent) + .frame(width: diameter, height: diameter) + .frame(width: iconDiameter, height: notchContentHeight, alignment: .center) + } + + @ViewBuilder + private func rightSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View { + let accent = accentColor(for: reminder, now: now) + switch presentationStyle { + case .ringCountdown: + ringCountdownSection(for: reminder, now: now, accent: accent) + case .digital: + digitalSection(for: reminder, now: now, accent: accent) + case .minutes: + minutesSection(for: reminder, now: now, accent: accent) + } + } + + private func ringCountdownSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date, accent: Color) -> some View { + let progressValue = progress(for: reminder, now: now) + let diameter = ringDiameter + + return ZStack { + Circle() + .stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth) + Circle() + .trim(from: 0, to: progressValue) + .stroke(accent, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: progressValue) + } + .frame(width: diameter, height: diameter) + .frame(height: notchContentHeight, alignment: .center) + } + + private func digitalSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date, accent: Color) -> some View { + let countdown = digitalCountdown(for: reminder, now: now) + return Text(countdown) + .font(.system(size: 16, weight: .semibold, design: .monospaced)) + .foregroundColor(accent) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: countdown) + .frame(height: notchContentHeight, alignment: .center) + } + + private func minutesSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date, accent: Color) -> some View { + VStack(alignment: .trailing, spacing: 2) { + Text(minutesCountdown(for: reminder, now: now)) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(accent) + } + .frame(height: notchContentHeight, alignment: .center) + } + + private func progress(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Double { + guard reminder.leadTime > 0 else { return 1 } + let remaining = max(reminder.event.start.timeIntervalSince(now), 0) + let elapsed = reminder.leadTime - remaining + let ratio = elapsed / reminder.leadTime + return min(max(ratio, 0), 1) + } + + private func digitalCountdown(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + let remaining = max(reminder.event.start.timeIntervalSince(now), 0) + let totalSeconds = Int(remaining.rounded(.down)) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + private func minutesCountdown(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + let remaining = max(reminder.event.start.timeIntervalSince(now), 0) + let minutes = max(1, Int(ceil(remaining / 60))) + return minutes == 1 ? "in 1 min" : "in \(minutes) min" + } + + private func accentColor(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Color { + if isCritical(for: reminder, now: now) { + return .red + } + return Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7) + } + + private var leftWingWidth: CGFloat { + wingPadding + iconDiameter + } + + private func rightWingWidth(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> CGFloat { + var width = wingPadding + switch presentationStyle { + case .ringCountdown: + width += ringDiameter + case .digital: + width += countdownWidth(for: reminder, now: now) + case .minutes: + width += minutesWidth(for: reminder, now: now) + } + return width + } + + private func countdownWidth(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> CGFloat { + let text = digitalCountdown(for: reminder, now: now) + let width = measureTextWidth(text, font: monospacedDigitFont(size: 16, weight: ReminderFont.Weight.semibold)) + return max(width + 18, 76) + } + + private func minutesWidth(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> CGFloat { + let text = minutesCountdown(for: reminder, now: now) + let width = measureTextWidth(text, font: systemFont(size: 13, weight: ReminderFont.Weight.semibold)) + return max(width + 18, 88) + } + + private var iconDiameter: CGFloat { + max(notchContentHeight - 8, 26) + } + + private var ringDiameter: CGFloat { + max(min(notchContentHeight - 12, 22), 16) + } + + private func measureTextWidth(_ text: String, font: ReminderFont) -> CGFloat { + let attributes: [NSAttributedString.Key: Any] = [.font: font] + return ceil(NSAttributedString(string: text, attributes: attributes).size().width) + } + + private func systemFont(size: CGFloat, weight: ReminderFont.Weight) -> ReminderFont { + #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: ReminderFont.Weight) -> ReminderFont { + #if canImport(AppKit) + return NSFont.monospacedDigitSystemFont(ofSize: size, weight: weight) + #else + return UIFont.monospacedDigitSystemFont(ofSize: size, weight: weight) + #endif + } + + private func iconName(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + isCritical(for: reminder, now: now) ? ReminderLiveActivityManager.criticalIconName : ReminderLiveActivityManager.standardIconName + } + + private func isCritical(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Bool { + let window = TimeInterval(Defaults[.reminderSneakPeekDuration]) + guard window > 0 else { return false } + let remaining = reminder.event.start.timeIntervalSince(now) + return remaining > 0 && remaining <= window + } +} diff --git a/DynamicIsland/components/Clipboard/ClipboardPanel.swift b/DynamicIsland/components/Clipboard/ClipboardPanel.swift new file mode 100644 index 00000000..6972bba3 --- /dev/null +++ b/DynamicIsland/components/Clipboard/ClipboardPanel.swift @@ -0,0 +1,477 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI + +private func applyClipboardCornerMask(_ view: NSView, radius: CGFloat) { + view.wantsLayer = true + view.layer?.masksToBounds = true + view.layer?.cornerRadius = radius + view.layer?.backgroundColor = NSColor.clear.cgColor + if #available(macOS 13.0, *) { + view.layer?.cornerCurve = .continuous + } +} + +class ClipboardPanel: NSPanel { + + init() { + super.init( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + + setupWindow() + setupContentView() + } + + // Override to allow the panel to become key window (required for TextField focus) + override var canBecomeKey: Bool { + return true + } + + // Override to allow the panel to become main window (required for text input) + override var canBecomeMain: Bool { + return true + } + + private func setupWindow() { + backgroundColor = .clear + isOpaque = false + hasShadow = true + level = .floating + isMovableByWindowBackground = true // Enable dragging + titlebarAppearsTransparent = true + titleVisibility = .hidden + isFloatingPanel = true // Mark as floating panel for proper behavior + + // Allow dragging from any part of the window + styleMask.insert(.fullSizeContentView) + + collectionBehavior = [ + .canJoinAllSpaces, + .stationary, + .fullScreenAuxiliary // Float above full-screen apps + ] + + ScreenCaptureVisibilityManager.shared.register(self, scope: .panelsOnly) + + // Accept mouse moved events for proper hover behavior + acceptsMouseMovedEvents = true + } + + private func setupContentView() { + let contentView = ClipboardPanelView { + self.close() + } + + let hostingView = NSHostingView(rootView: contentView) + applyClipboardCornerMask(hostingView, radius: 12) + self.contentView = hostingView + + // Set initial size + let preferredSize = CGSize(width: 320, height: 400) + hostingView.setFrameSize(preferredSize) + setContentSize(preferredSize) + } + + func positionNearNotch() { + guard let screen = NSScreen.main else { return } + + let screenFrame = screen.visibleFrame + let panelFrame = frame + + // Check if we have a saved position + if let savedPosition = getSavedPosition() { + // Validate saved position is still on screen + let savedFrame = NSRect(origin: savedPosition, size: panelFrame.size) + if screenFrame.intersects(savedFrame) { + setFrameOrigin(savedPosition) + return + } + } + + // Default to center of screen (not top center) + let xPosition = (screenFrame.width - panelFrame.width) / 2 + screenFrame.minX + let yPosition = (screenFrame.height - panelFrame.height) / 2 + screenFrame.minY + + setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) + } + + private func getSavedPosition() -> NSPoint? { + let defaults = UserDefaults.standard + let x = defaults.double(forKey: "clipboardPanelPositionX") + let y = defaults.double(forKey: "clipboardPanelPositionY") + + // Check if we have valid saved coordinates (not default 0.0) + if x != 0.0 || y != 0.0 { + return NSPoint(x: x, y: y) + } + return nil + } + + private func saveCurrentPosition() { + let currentOrigin = frame.origin + let defaults = UserDefaults.standard + defaults.set(currentOrigin.x, forKey: "clipboardPanelPositionX") + defaults.set(currentOrigin.y, forKey: "clipboardPanelPositionY") + } + + override func setFrameOrigin(_ point: NSPoint) { + super.setFrameOrigin(point) + // Save position whenever it changes (user dragging) + saveCurrentPosition() + } + + func positionNearMouse() { + let mouseLocation = NSEvent.mouseLocation + let panelFrame = frame + + // Position near mouse but ensure it stays on screen + guard let screen = NSScreen.main else { return } + let screenFrame = screen.visibleFrame + + var xPosition = mouseLocation.x - panelFrame.width / 2 + var yPosition = mouseLocation.y - panelFrame.height - 20 + + // Keep within screen bounds + xPosition = max(screenFrame.minX + 10, min(xPosition, screenFrame.maxX - panelFrame.width - 10)) + yPosition = max(screenFrame.minY + 10, min(yPosition, screenFrame.maxY - panelFrame.height - 10)) + + setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) + } + +} + +struct ClipboardPanelView: View { + let onClose: () -> Void + @ObservedObject var clipboardManager = ClipboardManager.shared + @State private var selectedTab: ClipboardTab = .history + @State private var searchText = "" + @State private var hoveredItemId: UUID? + + var filteredItems: [ClipboardItem] { + let allItems = selectedTab == .history ? clipboardManager.regularHistory : clipboardManager.pinnedItems + + if searchText.isEmpty { + return allItems + } else { + return allItems.filter { item in + item.preview.localizedCaseInsensitiveContains(searchText) || + item.type.displayName.localizedCaseInsensitiveContains(searchText) + } + } + } + + var body: some View { + VStack(spacing: 0) { + // Header with tabs + ClipboardPanelHeader( + selectedTab: $selectedTab, + searchText: $searchText, + onClose: onClose + ) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Content + if filteredItems.isEmpty { + ClipboardPanelEmptyState( + hasSearch: !searchText.isEmpty, + isHistoryTab: selectedTab == .history + ) + } else { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(filteredItems) { item in + ClipboardPanelItemRow( + item: item, + isHovered: hoveredItemId == item.id, + isPinned: clipboardManager.pinnedItems.contains(where: { $0.id == item.id }) + ) { hoverId in + hoveredItemId = hoverId + } + } + } + .padding(.vertical, 8) + } + } + } + .frame(width: 320, height: 400) + .background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10) + } +} + +struct ClipboardPanelHeader: View { + @Binding var selectedTab: ClipboardTab + @Binding var searchText: String + let onClose: () -> Void + @ObservedObject var clipboardManager = ClipboardManager.shared + @FocusState private var isSearchFieldFocused: Bool + + var body: some View { + VStack(spacing: 8) { + // Title and close button + HStack { + // Close button + NativeStyleCloseButton(action: onClose) + + Image(systemName: "doc.on.clipboard") + .foregroundColor(.primary) + .font(.system(size: 16, weight: .medium)) + + Text("Clipboard Manager") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + + // Clear button + Button(action: { + if selectedTab == .history { + clipboardManager.clearHistory() + } else { + clipboardManager.pinnedItems.removeAll() + clipboardManager.savePinnedItemsToDefaults() + } + }) { + Image(systemName: "trash") + .foregroundColor(.red) + .font(.system(size: 12)) + } + .buttonStyle(PlainButtonStyle()) + .disabled(selectedTab == .history ? clipboardManager.clipboardHistory.isEmpty : clipboardManager.pinnedItems.isEmpty) + } + .padding(.horizontal, 16) + .padding(.top, 12) + + // Tab selector + HStack(spacing: 0) { + ForEach(ClipboardTab.allCases, id: \.self) { tab in + ClipboardTabButton(tab: tab, isSelected: selectedTab == tab) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedTab = tab + } + } + } + + Spacer() + } + .padding(.horizontal, 16) + + // Search bar (always visible) + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .font(.system(size: 12)) + + TextField("Search clipboard...", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 12)) + .focused($isSearchFieldFocused) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + .font(.system(size: 10)) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray.opacity(0.1)) + ) + .padding(.horizontal, 16) + } + .padding(.bottom, 8) + } +} + +struct ClipboardPanelEmptyState: View { + let hasSearch: Bool + let isHistoryTab: Bool + + var body: some View { + VStack(spacing: 12) { + Image(systemName: hasSearch ? "magnifyingglass" : (isHistoryTab ? "doc.on.clipboard" : "heart.fill")) + .font(.system(size: 32)) + .foregroundColor(.secondary) + + if hasSearch { + Text("No results found") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + + Text("Try adjusting your search terms") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } else { + Text(isHistoryTab ? "No clipboard history" : "No favorites") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + + Text(isHistoryTab ? "Copy something to get started" : "Pin items to add them to favorites") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct ClipboardPanelItemRow: View { + let item: ClipboardItem + let isHovered: Bool + let isPinned: Bool + let onHover: (UUID?) -> Void + @ObservedObject var clipboardManager = ClipboardManager.shared + @State private var justCopied = false + + var body: some View { + HStack(alignment: .top, spacing: 12) { + // Type icon + Image(systemName: item.type.icon) + .font(.system(size: 14)) + .foregroundColor(.blue) + .frame(width: 20) + + // Content + VStack(alignment: .leading, spacing: 4) { + Text(item.preview) + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + HStack { + Text(item.type.displayName) + .font(.system(size: 10)) + .foregroundColor(.secondary) + + Spacer() + + Text(timeAgoString(from: item.timestamp)) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + + Spacer() + + // Action buttons (shown on hover) + if isHovered { + HStack(spacing: 6) { + // Pin/Unpin button + Button(action: { + if isPinned { + clipboardManager.unpinItem(item) + } else { + clipboardManager.pinItem(item) + } + }) { + Image(systemName: isPinned ? "heart.fill" : "heart") + .font(.system(size: 11)) + .foregroundColor(isPinned ? .red : .gray) + } + .buttonStyle(PlainButtonStyle()) + + // Copy button + Button(action: { + clipboardManager.copyToClipboard(item) + + withAnimation(.easeInOut(duration: 0.2)) { + justCopied = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.easeInOut(duration: 0.2)) { + justCopied = false + } + } + }) { + Image(systemName: justCopied ? "checkmark.circle.fill" : "doc.on.doc") + .font(.system(size: 11)) + .foregroundColor(justCopied ? .green : .green) + } + .buttonStyle(PlainButtonStyle()) + + // Delete button + Button(action: { + if isPinned { + clipboardManager.unpinItem(item) + } else { + clipboardManager.deleteItem(item) + } + }) { + Image(systemName: "trash") + .font(.system(size: 11)) + .foregroundColor(.red) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovered ? Color.gray.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + onHover(hovering ? item.id : nil) + } + } + .onTapGesture { + clipboardManager.copyToClipboard(item) + } + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return String(localized: "Just now") + } else if interval < 3600 { + let minutes = Int(interval / 60) + return String(localized: "\(minutes)m ago") + } else if interval < 86400 { + let hours = Int(interval / 3600) + return String(localized: "\(hours)h ago") + } else { + let days = Int(interval / 86400) + return String(localized: "\(days)d ago") + } + } +} + +#Preview { + ClipboardPanelView { + print("Close panel") + } +} diff --git a/DynamicIsland/components/Clipboard/ClipboardPopover.swift b/DynamicIsland/components/Clipboard/ClipboardPopover.swift new file mode 100644 index 00000000..f0855871 --- /dev/null +++ b/DynamicIsland/components/Clipboard/ClipboardPopover.swift @@ -0,0 +1,319 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct ClipboardPopover: View { + @ObservedObject var clipboardManager = ClipboardManager.shared + @State private var selectedTab: ClipboardTab = .history + @State private var searchText = "" + @State private var hoveredItemId: UUID? + + var filteredItems: [ClipboardItem] { + let allItems = selectedTab == .history ? clipboardManager.regularHistory : clipboardManager.pinnedItems + + if searchText.isEmpty { + return allItems + } else { + return allItems.filter { item in + item.preview.localizedCaseInsensitiveContains(searchText) || + item.type.displayName.localizedCaseInsensitiveContains(searchText) + } + } + } + + var body: some View { + VStack(spacing: 0) { + // Header with tabs + ClipboardPopoverHeader( + selectedTab: $selectedTab, + searchText: $searchText + ) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Content + if filteredItems.isEmpty { + ClipboardPopoverEmptyState( + hasSearch: !searchText.isEmpty, + isHistoryTab: selectedTab == .history + ) + } else { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(filteredItems) { item in + ClipboardPopoverItemRow( + item: item, + isHovered: hoveredItemId == item.id, + isPinned: clipboardManager.pinnedItems.contains(where: { $0.id == item.id }) + ) { hoverId in + hoveredItemId = hoverId + } + } + } + .padding(.vertical, 8) + } + } + } + .frame(width: 280, height: 320) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(.regularMaterial) + .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4) + ) + } +} + +struct ClipboardPopoverHeader: View { + @Binding var selectedTab: ClipboardTab + @Binding var searchText: String + @ObservedObject var clipboardManager = ClipboardManager.shared + @FocusState private var isSearchFieldFocused: Bool + + var body: some View { + VStack(spacing: 8) { + // Title + HStack { + Image(systemName: "doc.on.clipboard") + .foregroundColor(.primary) + .font(.system(size: 14, weight: .medium)) + + Text("Clipboard") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + + // Clear button + Button(action: { + if selectedTab == .history { + clipboardManager.clearHistory() + } else { + clipboardManager.pinnedItems.removeAll() + clipboardManager.savePinnedItemsToDefaults() + } + }) { + Image(systemName: "trash") + .foregroundColor(.red) + .font(.system(size: 11)) + } + .buttonStyle(PlainButtonStyle()) + .disabled(selectedTab == .history ? clipboardManager.clipboardHistory.isEmpty : clipboardManager.pinnedItems.isEmpty) + } + .padding(.horizontal, 14) + .padding(.top, 10) + + // Tab selector + HStack(spacing: 0) { + ForEach(ClipboardTab.allCases, id: \.self) { tab in + ClipboardTabButton(tab: tab, isSelected: selectedTab == tab) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedTab = tab + } + } + } + + Spacer() + } + .padding(.horizontal, 14) + + // Search bar + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .font(.system(size: 11)) + + TextField("Search...", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 11)) + .focused($isSearchFieldFocused) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + .font(.system(size: 9)) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(Color.gray.opacity(0.1)) + ) + .padding(.horizontal, 14) + } + .padding(.bottom, 6) + } +} + +struct ClipboardPopoverEmptyState: View { + let hasSearch: Bool + let isHistoryTab: Bool + + var body: some View { + VStack(spacing: 10) { + Image(systemName: hasSearch ? "magnifyingglass" : (isHistoryTab ? "doc.on.clipboard" : "heart.fill")) + .font(.system(size: 24)) + .foregroundColor(.secondary) + + if hasSearch { + Text("No results") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.primary) + + Text("Try different search terms") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } else { + Text(isHistoryTab ? "No clipboard history" : "No favorites") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.primary) + + Text(isHistoryTab ? "Copy something to start" : "Pin items to add favorites") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(20) + } +} + +struct ClipboardPopoverItemRow: View { + let item: ClipboardItem + let isHovered: Bool + let isPinned: Bool + let onHover: (UUID?) -> Void + @ObservedObject var clipboardManager = ClipboardManager.shared + + var body: some View { + HStack(alignment: .top, spacing: 10) { + // Type icon + Image(systemName: item.type.icon) + .font(.system(size: 12)) + .foregroundColor(.blue) + .frame(width: 16) + + // Content + VStack(alignment: .leading, spacing: 3) { + Text(item.preview) + .font(.system(size: 11)) + .foregroundColor(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + HStack { + Text(item.type.displayName) + .font(.system(size: 9)) + .foregroundColor(.secondary) + + Spacer() + + Text(timeAgoString(from: item.timestamp)) + .font(.system(size: 9)) + .foregroundColor(.secondary) + } + } + + Spacer() + + // Action buttons (shown on hover) + if isHovered { + HStack(spacing: 4) { + // Pin/Unpin button + Button(action: { + if isPinned { + clipboardManager.unpinItem(item) + } else { + clipboardManager.pinItem(item) + } + }) { + Image(systemName: isPinned ? "heart.fill" : "heart") + .font(.system(size: 9)) + .foregroundColor(isPinned ? .red : .gray) + } + .buttonStyle(PlainButtonStyle()) + + // Copy button + Button(action: { + clipboardManager.copyToClipboard(item) + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 9)) + .foregroundColor(.green) + } + .buttonStyle(PlainButtonStyle()) + + // Delete button + Button(action: { + if isPinned { + clipboardManager.unpinItem(item) + } else { + clipboardManager.deleteItem(item) + } + }) { + Image(systemName: "trash") + .font(.system(size: 9)) + .foregroundColor(.red) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isHovered ? Color.gray.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + onHover(hovering ? item.id : nil) + } + } + .onTapGesture { + clipboardManager.copyToClipboard(item) + } + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return "now" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "\(minutes)m" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "\(hours)h" + } else { + let days = Int(interval / 86400) + return "\(days)d" + } + } +} + +#Preview { + ClipboardPopover() +} diff --git a/DynamicIsland/components/Clipboard/ClipboardShared.swift b/DynamicIsland/components/Clipboard/ClipboardShared.swift new file mode 100644 index 00000000..e30acc57 --- /dev/null +++ b/DynamicIsland/components/Clipboard/ClipboardShared.swift @@ -0,0 +1,86 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +enum ClipboardTab: String, CaseIterable { + case history = "History" + case favorites = "Favorites" + + var icon: String { + switch self { + case .history: return "clock" + case .favorites: return "heart.fill" + } + } + + var localizedName: String { + switch self { + case .history: return String(localized: "History") + case .favorites: return String(localized: "Favorites") + } + } +} + +struct ClipboardTabButton: View { + let tab: ClipboardTab + let isSelected: Bool + let action: () -> Void + @ObservedObject var clipboardManager = ClipboardManager.shared + + var itemCount: Int { + switch tab { + case .history: + return clipboardManager.regularHistory.count + case .favorites: + return clipboardManager.pinnedItems.count + } + } + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: tab.icon) + .font(.system(size: 11)) + + Text(tab.localizedName) + .font(.system(size: 11, weight: .medium)) + + if itemCount > 0 { + Text("\(itemCount)") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(isSelected ? .white : .secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + Capsule() + .fill(isSelected ? Color.white.opacity(0.3) : Color.gray.opacity(0.2)) + ) + } + } + .foregroundColor(isSelected ? .white : .secondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isSelected ? Color.blue : Color.clear) + ) + } + .buttonStyle(PlainButtonStyle()) + } +} diff --git a/DynamicIsland/components/Clipboard/ClipboardWindow.swift b/DynamicIsland/components/Clipboard/ClipboardWindow.swift new file mode 100644 index 00000000..23e8aa40 --- /dev/null +++ b/DynamicIsland/components/Clipboard/ClipboardWindow.swift @@ -0,0 +1,314 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct ClipboardWindow: View { + @ObservedObject var clipboardManager = ClipboardManager.shared + @State private var selectedTab: ClipboardTab = .history + @State private var searchText = "" + + var body: some View { + VStack(spacing: 0) { + // Header with tabs and search + ClipboardWindowHeader(selectedTab: $selectedTab, searchText: $searchText) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Content area + ClipboardWindowContent(selectedTab: selectedTab, searchText: searchText) + } + .frame(width: 400, height: 300) + .background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10) + } +} + +struct ClipboardWindowHeader: View { + @Binding var selectedTab: ClipboardTab + @Binding var searchText: String + @ObservedObject var clipboardManager = ClipboardManager.shared + + var body: some View { + VStack(spacing: 0) { + // Title and controls + HStack { + Image(systemName: "doc.on.clipboard") + .foregroundColor(.primary) + .font(.system(size: 16, weight: .medium)) + + Text("Clipboard Manager") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + + // Clear button + Button(action: { + if selectedTab == .history { + clipboardManager.clearHistory() + } else { + // Clear favorites + clipboardManager.pinnedItems.removeAll() + clipboardManager.savePinnedItemsToDefaults() + } + }) { + Image(systemName: "trash") + .foregroundColor(.red) + .font(.system(size: 12)) + } + .buttonStyle(PlainButtonStyle()) + .disabled(selectedTab == .history ? clipboardManager.clipboardHistory.isEmpty : clipboardManager.pinnedItems.isEmpty) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + // Tab selector + HStack(spacing: 0) { + ForEach(ClipboardTab.allCases, id: \.self) { tab in + ClipboardTabButton(tab: tab, isSelected: selectedTab == tab) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedTab = tab + } + } + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + + // Search bar + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .font(.system(size: 12)) + + TextField("Search clipboard...", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 12)) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + .font(.system(size: 10)) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + } +} + +struct ClipboardWindowContent: View { + let selectedTab: ClipboardTab + let searchText: String + @ObservedObject var clipboardManager = ClipboardManager.shared + + var filteredItems: [ClipboardItem] { + let items = selectedTab == .history ? clipboardManager.regularHistory : clipboardManager.pinnedItems + + if searchText.isEmpty { + return items + } else { + return items.filter { item in + item.preview.localizedCaseInsensitiveContains(searchText) || + item.type.displayName.localizedCaseInsensitiveContains(searchText) + } + } + } + + var body: some View { + if filteredItems.isEmpty { + ClipboardEmptyState(tab: selectedTab, hasSearch: !searchText.isEmpty) + } else { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(filteredItems) { item in + ClipboardWindowItemRow(item: item, tab: selectedTab) + } + } + .padding(.vertical, 8) + } + } + } +} + +struct ClipboardEmptyState: View { + let tab: ClipboardTab + let hasSearch: Bool + + var body: some View { + VStack(spacing: 12) { + Image(systemName: hasSearch ? "magnifyingglass" : tab.icon) + .font(.system(size: 32)) + .foregroundColor(.secondary) + + if hasSearch { + Text("No results found") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + + Text("Try adjusting your search terms") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } else { + Text(tab == .history ? "No clipboard history" : "No favorites") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + + Text(tab == .history ? "Copy something to get started" : "Pin items from history to save them here") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct ClipboardWindowItemRow: View { + let item: ClipboardItem + let tab: ClipboardTab + @ObservedObject var clipboardManager = ClipboardManager.shared + @State private var isHovering = false + + var isPinned: Bool { + item.isPinned || clipboardManager.pinnedItems.contains(where: { $0.id == item.id }) + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + // Type icon + Image(systemName: item.type.icon) + .font(.system(size: 14)) + .foregroundColor(.blue) + .frame(width: 20) + + // Content + VStack(alignment: .leading, spacing: 4) { + Text(item.preview) + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + HStack { + Text(item.type.displayName) + .font(.system(size: 10)) + .foregroundColor(.secondary) + + Spacer() + + Text(timeAgoString(from: item.timestamp)) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + + Spacer() + + // Action buttons (shown on hover) + if isHovering { + HStack(spacing: 6) { + // Pin/Unpin button + Button(action: { + clipboardManager.togglePin(for: item) + }) { + Image(systemName: isPinned ? "heart.fill" : "heart") + .font(.system(size: 11)) + .foregroundColor(isPinned ? .red : .gray) + } + .buttonStyle(PlainButtonStyle()) + + // Copy button + Button(action: { + clipboardManager.copyToClipboard(item) + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 11)) + .foregroundColor(.green) + } + .buttonStyle(PlainButtonStyle()) + + // Delete button + Button(action: { + if isPinned { + clipboardManager.unpinItem(item) + } else { + clipboardManager.deleteItem(item) + } + }) { + Image(systemName: "trash") + .font(.system(size: 11)) + .foregroundColor(.red) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovering ? Color.gray.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = hovering + } + } + .onTapGesture { + clipboardManager.copyToClipboard(item) + } + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return "Just now" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "\(minutes)m ago" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "\(hours)h ago" + } else { + let days = Int(interval / 86400) + return "\(days)d ago" + } + } +} + +#Preview { + ClipboardWindow() + .frame(width: 400, height: 300) + .background(Color.gray.opacity(0.3)) +} diff --git a/DynamicIsland/components/ColorCodedProgressBar.swift b/DynamicIsland/components/ColorCodedProgressBar.swift new file mode 100644 index 00000000..9e637610 --- /dev/null +++ b/DynamicIsland/components/ColorCodedProgressBar.swift @@ -0,0 +1,205 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +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 mode: ColorCodingMode + let width: CGFloat + let height: CGFloat + let smoothGradient: Bool // If true: smooth gradient, if false: discrete colors + + 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.mode = mode + self.width = width + self.height = height + self.smoothGradient = smoothGradient + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + // Background track + RoundedRectangle(cornerRadius: height / 2) + .fill(Color.white.opacity(0.2)) + .frame(width: width, height: height) + + // Filled track with color gradient or solid color + RoundedRectangle(cornerRadius: height / 2) + .fill(fillStyle) + .frame(width: width * value, height: height) + } + } + .frame(width: width, height: height) + } + + 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)) + } + } +} + +// MARK: - Convenience Extensions + +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, 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, 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 + +// MARK: - Palette Helpers + +enum ColorCodedPalette { + static let greenComponents: (CGFloat, CGFloat, CGFloat) = (0.2, 0.78, 0.35) + static let yellowComponents: (CGFloat, CGFloat, CGFloat) = (0.93, 0.75, 0.2) + 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)) + } +} + +// MARK: - Preview + +#Preview("Battery Mode") { + VStack(spacing: 20) { + ForEach([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], id: \.self) { value in + HStack { + Text("\(Int(value * 100))%") + .frame(width: 50, alignment: .trailing) + .foregroundColor(.white) + ColorCodedProgressBar.battery(value: value, width: 200) + } + } + } + .padding() + .background(Color.black) +} + +#Preview("Volume Mode (Reversed)") { + VStack(spacing: 20) { + ForEach([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], id: \.self) { value in + HStack { + Text("\(Int(value * 100))%") + .frame(width: 50, alignment: .trailing) + .foregroundColor(.white) + ColorCodedProgressBar.volume(value: value, width: 200) + } + } + } + .padding() + .background(Color.black) +} diff --git a/DynamicIsland/components/ColorPicker/ColorPickedFeedbackView.swift b/DynamicIsland/components/ColorPicker/ColorPickedFeedbackView.swift new file mode 100644 index 00000000..3c300896 --- /dev/null +++ b/DynamicIsland/components/ColorPicker/ColorPickedFeedbackView.swift @@ -0,0 +1,77 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct ColorPickedFeedbackView: View { + let color: PickedColor + @Binding var isShowing: Bool + + var body: some View { + VStack(spacing: 12) { + // Color preview + RoundedRectangle(cornerRadius: 12) + .fill(color.color) + .frame(width: 60, height: 60) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.8), lineWidth: 2) + ) + .shadow(color: .black.opacity(0.3), radius: 8) + + // Color info + VStack(spacing: 4) { + Text("Color Picked!") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + + Text(color.hexString) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.2)) + .cornerRadius(6) + } + } + .padding(16) + .background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.3), radius: 12) + .scaleEffect(isShowing ? 1.0 : 0.8) + .opacity(isShowing ? 1.0 : 0.0) + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: isShowing) + .onTapGesture { + // Copy to clipboard on tap + ColorPickerManager.shared.copyToClipboard(color.hexString) + + if Defaults[.enableHaptics] { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } + } + } +} + +#Preview { + ColorPickedFeedbackView( + color: PickedColor(nsColor: NSColor.blue, point: CGPoint(x: 100, y: 100)), + isShowing: .constant(true) + ) + .frame(width: 300, height: 200) +} diff --git a/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift b/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift new file mode 100644 index 00000000..4e7b019d --- /dev/null +++ b/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift @@ -0,0 +1,712 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Cocoa +import Defaults + +private func applyColorPickerCornerMask(_ view: NSView, radius: CGFloat) { + view.wantsLayer = true + view.layer?.masksToBounds = true + view.layer?.cornerRadius = radius + view.layer?.backgroundColor = NSColor.clear.cgColor + if #available(macOS 13.0, *) { + view.layer?.cornerCurve = .continuous + } +} + +class ColorPickerPanelManager: ObservableObject { + static let shared = ColorPickerPanelManager() + + private var panel: ColorPickerPanel? + + private init() {} + + func showColorPickerPanel() { + hideColorPickerPanel() // Close any existing panel + + let newPanel = ColorPickerPanel() + self.panel = newPanel + + // Position the panel + newPanel.positionPanel() + + // Make the panel visible and focused + newPanel.makeKeyAndOrderFront(nil) + newPanel.orderFrontRegardless() + + // Activate the app to ensure proper focus handling + NSApp.activate(ignoringOtherApps: true) + + // Ensure the panel becomes the key window for input + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + newPanel.makeKey() + } + + print("ColorPicker: Panel shown and positioned") + } + + func hideColorPickerPanel() { + panel?.close() + panel = nil + print("ColorPicker: Panel hidden") + } + + func toggleColorPickerPanel() { + if let panel = panel, panel.isVisible { + hideColorPickerPanel() + } else { + showColorPickerPanel() + } + } + + var isPanelVisible: Bool { + return panel?.isVisible ?? false + } +} + +class ColorPickerPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + + init() { + super.init( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + + setupPanel() + } + + private func setupPanel() { + backgroundColor = .clear + isOpaque = false + hasShadow = true + level = .floating + isMovableByWindowBackground = true + titlebarAppearsTransparent = true + titleVisibility = .hidden + isFloatingPanel = true + + // Allow dragging from any part of the window + styleMask.insert(.fullSizeContentView) + + collectionBehavior = [ + .canJoinAllSpaces, + .stationary, + .fullScreenAuxiliary // Float above full-screen apps + ] + + ScreenCaptureVisibilityManager.shared.register(self, scope: .panelsOnly) + + // Accept mouse moved events for proper hover behavior + acceptsMouseMovedEvents = true + + let panelView = ColorPickerPanelView { + ColorPickerPanelManager.shared.hideColorPickerPanel() + } + + let hostingView = NSHostingView(rootView: panelView) + applyColorPickerCornerMask(hostingView, radius: 12) + contentView = hostingView + + // Set initial size + let preferredSize = CGSize(width: 450, height: 600) + hostingView.setFrameSize(preferredSize) + setContentSize(preferredSize) + } + + func positionPanel() { + guard let screen = NSScreen.main else { return } + + let screenFrame = screen.visibleFrame + let panelFrame = frame + + // Check if we have a saved position + if let savedPosition = getSavedPosition() { + // Validate saved position is still on screen + let savedFrame = NSRect(origin: savedPosition, size: panelFrame.size) + if screenFrame.intersects(savedFrame) { + setFrameOrigin(savedPosition) + return + } + } + + // Default to center of screen (not top center) + let xPosition = (screenFrame.width - panelFrame.width) / 2 + screenFrame.minX + let yPosition = (screenFrame.height - panelFrame.height) / 2 + screenFrame.minY + + setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) + } + + private func getSavedPosition() -> NSPoint? { + let defaults = UserDefaults.standard + let x = defaults.double(forKey: "colorPickerPanelPositionX") + let y = defaults.double(forKey: "colorPickerPanelPositionY") + + // Check if we have valid saved coordinates (not default 0.0) + if x != 0.0 || y != 0.0 { + return NSPoint(x: x, y: y) + } + return nil + } + + private func saveCurrentPosition() { + let currentOrigin = frame.origin + let defaults = UserDefaults.standard + defaults.set(currentOrigin.x, forKey: "colorPickerPanelPositionX") + defaults.set(currentOrigin.y, forKey: "colorPickerPanelPositionY") + } + + override func setFrameOrigin(_ point: NSPoint) { + super.setFrameOrigin(point) + // Save position whenever it changes (user dragging) + saveCurrentPosition() + } + + deinit { + ScreenCaptureVisibilityManager.shared.unregister(self) + } +} + +struct ColorPickerPanelView: View { + let onClose: () -> Void + @ObservedObject var colorPickerManager = ColorPickerManager.shared + @State private var searchText = "" + @State private var selectedColor: PickedColor? + @State private var showingDeleteAlert = false + @State private var colorToDelete: PickedColor? + + var filteredColors: [PickedColor] { + if searchText.isEmpty { + return colorPickerManager.colorHistory + } else { + return colorPickerManager.colorHistory.filter { color in + color.hexString.localizedCaseInsensitiveContains(searchText) || + color.rgbString.localizedCaseInsensitiveContains(searchText) || + color.hslString.localizedCaseInsensitiveContains(searchText) + } + } + } + + var body: some View { + VStack(spacing: 0) { + headerSection + + Divider() + .background(Color.gray.opacity(0.3)) + + contentSection + } + .background(ColorPickerVisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(12) + .alert("Delete Color", isPresented: $showingDeleteAlert) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { + if let color = colorToDelete { + colorPickerManager.removeColor(color) + } + } + } message: { + Text("Are you sure you want to delete this color from your history?") + } + } + + private var headerSection: some View { + VStack(spacing: 12) { + // Title and controls + HStack { + // Close button + NativeStyleCloseButton(action: onClose) + + HStack(spacing: 8) { + Image(systemName: "eyedropper.halffull") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + + Text("Color Picker") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary) + } + + Spacer() + + // Controls + HStack(spacing: 8) { + // Pick Color Button + Button(action: { + colorPickerManager.startColorPicking() + onClose() // Close panel when starting to pick + }) { + HStack(spacing: 6) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 14, weight: .medium)) + Text("Pick Color") + .font(.system(size: 14, weight: .medium)) + } + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + + // Clear History Button + Button(action: { + colorPickerManager.clearHistory() + }) { + Image(systemName: "trash") + .font(.system(size: 14)) + .foregroundColor(.red) + } + .buttonStyle(PlainButtonStyle()) + .disabled(colorPickerManager.colorHistory.isEmpty) + } + } + + // Search bar + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .font(.system(size: 14)) + + TextField("Search colors...", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 14)) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + .font(.system(size: 12)) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + .padding(16) + } + + private var contentSection: some View { + HSplitView { + // Left side - Color list + colorListSection + .frame(minWidth: 200) + + // Right side - Color details + colorDetailsSection + .frame(minWidth: 220) + } + .frame(maxHeight: .infinity) + } + + private var colorListSection: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Recent Colors") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + + Text("\(filteredColors.count)") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.gray.opacity(0.2)) + .cornerRadius(4) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.gray.opacity(0.05)) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Color list + if filteredColors.isEmpty { + emptyStateView + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(filteredColors) { color in + ColorListRow( + color: color, + isSelected: selectedColor?.id == color.id, + onSelect: { selectedColor = color }, + onDelete: { + colorToDelete = color + showingDeleteAlert = true + } + ) + } + } + } + } + } + } + + private var colorDetailsSection: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Color Details") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.gray.opacity(0.05)) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Details content + if let color = selectedColor { + ColorDetailsView(color: color) + } else { + noSelectionView + } + } + } + + private var emptyStateView: some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "eyedropper") + .font(.system(size: 32)) + .foregroundColor(.secondary) + + VStack(spacing: 8) { + Text("No Colors Found") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + + if searchText.isEmpty { + Text("Start picking colors to build your history") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } else { + Text("No colors match '\(searchText)'") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + private var noSelectionView: some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "hand.point.up.left") + .font(.system(size: 32)) + .foregroundColor(.secondary) + + Text("Select a color to view details") + .font(.system(size: 14)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} + +struct ColorListRow: View { + let color: PickedColor + let isSelected: Bool + let onSelect: () -> Void + let onDelete: () -> Void + @State private var isHovered = false + + var body: some View { + HStack(spacing: 12) { + // Color preview + Circle() + .fill(color.color) + .frame(width: 32, height: 32) + .overlay( + Circle() + .stroke(Color.white.opacity(0.8), lineWidth: 1) + ) + .overlay( + Circle() + .stroke(Color.black.opacity(0.2), lineWidth: 1) + ) + + // Color info + VStack(alignment: .leading, spacing: 2) { + Text(color.hexString) + .font(.system(size: 14, weight: .medium, design: .monospaced)) + .foregroundColor(.primary) + + Text(timeAgoString(from: color.timestamp)) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + + Spacer() + + // Actions (shown on hover) + if isHovered { + HStack(spacing: 4) { + Button(action: { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(color.hexString, forType: .string) + + if Defaults[.enableHaptics] { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 12)) + .foregroundColor(.blue) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: onDelete) { + Image(systemName: "trash") + .font(.system(size: 12)) + .foregroundColor(.red) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + Rectangle() + .fill(isSelected ? Color.blue.opacity(0.2) : (isHovered ? Color.gray.opacity(0.1) : Color.clear)) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovered = hovering + } + } + .onTapGesture { + onSelect() + } + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return String(localized: "Just now") + } else if interval < 3600 { + let minutes = Int(interval / 60) + return String(localized: "\(minutes)m ago") + } else if interval < 86400 { + let hours = Int(interval / 3600) + return String(localized: "\(hours)h ago") + } else { + let days = Int(interval / 86400) + return String(localized: "\(days)d ago") + } + } +} + +struct ColorDetailsView: View { + let color: PickedColor + @Default(.showColorFormats) var showColorFormats + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Large color preview + VStack(spacing: 12) { + Rectangle() + .fill(color.color) + .frame(height: 80) + .overlay( + Rectangle() + .stroke(Color.white.opacity(0.8), lineWidth: 2) + ) + .cornerRadius(8) + .shadow(color: .black.opacity(0.2), radius: 4) + + Text("Picked \(timeAgoString(from: color.timestamp))") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + + // Color formats + VStack(alignment: .leading, spacing: 8) { + Text("Color Formats") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + + ForEach(color.allFormats) { format in + ColorFormatDetailRow(format: format, color: color) + } + } + + Spacer() + } + .padding(16) + } + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return String(localized: "just now") + } else if interval < 3600 { + let minutes = Int(interval / 60) + return String(localized: "\(minutes) minute\(minutes == 1 ? "" : "s") ago") + } else if interval < 86400 { + let hours = Int(interval / 3600) + return String(localized: "\(hours) hour\(hours == 1 ? "" : "s") ago") + } else { + let days = Int(interval / 86400) + return String(localized: "\(days) day\(days == 1 ? "" : "s") ago") + } + } +} + +struct ColorFormatDetailRow: View { + let format: ColorFormat + let color: PickedColor + @State private var isHovered = false + @State private var justCopied = false + + var body: some View { + HStack(spacing: 8) { + Text(format.name) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + .frame(width: 60, alignment: .leading) + + Text(format.value) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.primary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: copyFormat) { + Image(systemName: justCopied ? "checkmark.circle.fill" : "doc.on.doc") + .font(.system(size: 12)) + .foregroundColor(justCopied ? .green : .blue) + } + .buttonStyle(PlainButtonStyle()) + .opacity(isHovered || justCopied ? 1 : 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovered ? Color.white.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovered = hovering + } + } + .onTapGesture { + copyFormat() + } + } + + private func copyFormat() { + ColorPickerManager.shared.copyColorToClipboard(color, format: format) + + withAnimation(.easeInOut(duration: 0.2)) { + justCopied = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.easeInOut(duration: 0.2)) { + justCopied = false + } + } + } +} + +// Helper view for visual effects +struct ColorPickerVisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +// MARK: - Native Style Close Button Component +struct NativeStyleCloseButton: View { + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .medium)) + .foregroundColor(.white) + .frame(width: 13, height: 13) + .background(isHovered ? Color.red.opacity(0.8) : Color.red) + .clipShape(Circle()) + .scaleEffect(isHovered ? 1.1 : 1.0) + } + .buttonStyle(PlainButtonStyle()) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.1)) { + isHovered = hovering + } + } + } +} + +#Preview { + ColorPickerPanelView { + print("Close panel") + } + .frame(width: 450, height: 600) + .onAppear { + ColorPickerManager.shared.colorHistory = PickedColor.sampleColors + } +} diff --git a/DynamicIsland/components/ColorPicker/ColorPickerPopover.swift b/DynamicIsland/components/ColorPicker/ColorPickerPopover.swift new file mode 100644 index 00000000..46f35095 --- /dev/null +++ b/DynamicIsland/components/ColorPicker/ColorPickerPopover.swift @@ -0,0 +1,141 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct ColorPickerPopover: View { + @ObservedObject var colorPickerManager = ColorPickerManager.shared + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 16) { + // Header + HStack { + HStack(spacing: 8) { + Image(systemName: "eyedropper.halffull") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + + Text("Color Picker") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + } + + Spacer() + + Button(action: { + dismiss() + }) { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + } + + // Main actions + VStack(spacing: 12) { + // Pick Color Button + Button(action: { + colorPickerManager.startColorPicking() + dismiss() // Close popover when starting to pick + }) { + HStack(spacing: 8) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 14, weight: .medium)) + Text("Pick Color") + .font(.system(size: 14, weight: .medium)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.blue) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + + // Show Panel Button + Button(action: { + ColorPickerPanelManager.shared.showColorPickerPanel() + dismiss() + }) { + HStack(spacing: 8) { + Image(systemName: "rectangle.grid.2x2") + .font(.system(size: 14, weight: .medium)) + Text("Show Color History") + .font(.system(size: 14, weight: .medium)) + } + .foregroundColor(.primary) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.gray.opacity(0.2)) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + } + + // Recent colors preview (if any) + if !colorPickerManager.colorHistory.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Recent Colors") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 6), spacing: 4) { + ForEach(Array(colorPickerManager.colorHistory.prefix(12))) { color in + Button(action: { + colorPickerManager.copyToClipboard(color.hexString) + + if Defaults[.enableHaptics] { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } + + dismiss() + }) { + Circle() + .fill(color.color) + .frame(width: 24, height: 24) + .overlay( + Circle() + .stroke(Color.white.opacity(0.8), lineWidth: 1) + ) + .overlay( + Circle() + .stroke(Color.black.opacity(0.2), lineWidth: 1) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + } + } + .padding(16) + .frame(width: 240) + .background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(12) + } +} + +#Preview { + ColorPickerPopover() + .onAppear { + ColorPickerManager.shared.colorHistory = PickedColor.sampleColors + } +} diff --git a/DynamicIsland/components/Downloads/DownloadLiveActivity.swift b/DynamicIsland/components/Downloads/DownloadLiveActivity.swift new file mode 100644 index 00000000..400e3e00 --- /dev/null +++ b/DynamicIsland/components/Downloads/DownloadLiveActivity.swift @@ -0,0 +1,113 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct DownloadLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @State private var downloadManager = DownloadManager.shared + + @State private var isHovering: Bool = false + @State private var gestureProgress: CGFloat = 0 + @State private var isExpanded: Bool = false + + private var tint: Color { + .accentColor + } + + var body: some View { + HStack(spacing: 0) { + // Left side: download icon capsule + Color.clear + .background { + if isExpanded { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(tint.opacity(0.14)) + + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + } + .frame( + width: vm.effectiveClosedNotchHeight - 12, + height: vm.effectiveClosedNotchHeight - 12 + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + } + .frame( + width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + + // Center: closed notch body (slightly wider during downloads) + Rectangle() + .fill(.black) + .frame( + width: vm.closedNotchSize.width + + (isHovering ? 8 : 0) + + (downloadManager.isDownloading ? 40 : 0) + ) + + // Right side: indeterminate-style progress bar + Color.clear + .background { + if isExpanded { + HStack { + if downloadManager.isDownloadCompleted { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + } else if Defaults[.selectedDownloadIndicatorStyle] == .circle { + SpinningCircleDownloadView() + .padding(.trailing, 6) + } else { + ProgressView() + .progressViewStyle(.linear) + .tint(.accentColor) + .frame(width: 40) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + } + .frame( + width: isExpanded ? max(60, vm.effectiveClosedNotchHeight) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .onAppear { + withAnimation(.smooth(duration: 0.35)) { + isExpanded = true + } + } + .onChange(of: downloadManager.isDownloading) { _, newValue in + withAnimation(.smooth(duration: 0.35)) { + isExpanded = newValue + } + } + } +} + + diff --git a/DynamicIsland/components/Downloads/SpinningCircleDownloadView.swift b/DynamicIsland/components/Downloads/SpinningCircleDownloadView.swift new file mode 100644 index 00000000..eac564c3 --- /dev/null +++ b/DynamicIsland/components/Downloads/SpinningCircleDownloadView.swift @@ -0,0 +1,46 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct SpinningCircleDownloadView: View { + @State private var isRotating = false + + var body: some View { + ZStack { + // Gray track + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 3.5) + + // Blue spinning segment + Circle() + .trim(from: 0, to: 0.25) + .stroke( + Color.accentColor, + style: StrokeStyle(lineWidth: 3.5, lineCap: .round) + ) + .rotationEffect(Angle(degrees: isRotating ? 360 : 0)) + .onAppear { + withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { + isRotating = true + } + } + } + .frame(width: 16, height: 16) + } +} diff --git a/DynamicIsland/components/DynamicIslandSystemTiles.swift b/DynamicIsland/components/DynamicIslandSystemTiles.swift index 20cea65e..db10a123 100644 --- a/DynamicIsland/components/DynamicIslandSystemTiles.swift +++ b/DynamicIsland/components/DynamicIslandSystemTiles.swift @@ -1,13 +1,20 @@ -// -// DynamicIslandSystemTiles.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 04/08/24. -// DynamicIslandSystemTiles.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 16/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation import SwiftUI @@ -72,35 +79,27 @@ func logout() { } struct DynamicIslandSystemTiles: View { - @EnvironmentObject var vm: DynamicIslandViewModel - @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared - - struct ItemButton { - var icon: String - var onTap: () -> Void - } - var body: some View { - Grid { - GridRow { -// SystemItemButton(icon: "clipboard", onTap: { -// vm.openClipboard() -// }, label: "Clipboard History", showEmojis: Defaults[.showEmojis], emoji: "✨") - // SystemItemButton(icon: "keyboard", onTap: { - // vm?.close() - // vm?.togglesneakPeek(status: true, type: .backlight, value: 1) - // }, label: "💡 Keyboard Backlight") - } - GridRow { - SystemItemButton(icon: coordinator.currentMicStatus ? "mic" : "mic.slash", onTap: { - coordinator.toggleMic() - vm.close() - }, label: "Toggle Microphone", showEmojis: Defaults[.showEmojis], emoji: coordinator.currentMicStatus ? "😀" : "🤫") - // SystemItemButton(icon: "lock", onTap: { - // logout() - // }, label: "🔒 Lock My Device") + VStack(alignment: .leading, spacing: 6) { + Label { + Text("Microphone privacy indicator runs automatically whenever apps access audio.") + .font(.caption) + .foregroundStyle(.gray) + .lineLimit(3) + .multilineTextAlignment(.leading) + } icon: { + Image(systemName: "shield.lefthalf.filled") + .foregroundStyle(.gray) } + + Text("Manage indicator preferences from Settings → Privacy. No manual microphone toggle is required.") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(3) + .multilineTextAlignment(.leading) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) } } diff --git a/DynamicIsland/components/EmptyState.swift b/DynamicIsland/components/EmptyState.swift index 9ca82923..1da3b4e5 100644 --- a/DynamicIsland/components/EmptyState.swift +++ b/DynamicIsland/components/EmptyState.swift @@ -1,8 +1,24 @@ -// -// EmptyState.swift -// -// Created by Harsh Vardhan Goswami on 04/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI @@ -12,8 +28,10 @@ struct EmptyStateView: View { var body: some View { HStack { - MinimalFaceFeatures( - height: 70, width: 80) + Image(systemName: "music.note") + .font(.system(size: 24)) + .foregroundColor(.gray) + .frame(width: 80, height: 70) Text(message) .font(.system(size:14)) .foregroundColor(.gray) diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift new file mode 100644 index 00000000..fbaf69d4 --- /dev/null +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -0,0 +1,397 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults +import AtollExtensionKit + +struct ExtensionStandaloneLayout { + let totalWidth: CGFloat + let outerHeight: CGFloat + let contentHeight: CGFloat + let leadingWidth: CGFloat + let centerWidth: CGFloat + let trailingWidth: CGFloat +} + +struct ExtensionLiveActivityStandaloneView: View { + let payload: ExtensionLiveActivityPayload + let layout: ExtensionStandaloneLayout + let isHovering: Bool + + private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } + private var contentHeight: CGFloat { layout.contentHeight } + private var accentColor: Color { descriptor.accentColor.swiftUIColor } + private var resolvedLeadingContent: AtollTrailingContent { + resolvedExtensionLeadingContent(for: descriptor) + } + var body: some View { + HStack(spacing: 0) { + ExtensionLeadingContentView( + content: resolvedLeadingContent, + badge: descriptor.badgeIcon, + accent: accentColor, + frameWidth: layout.leadingWidth, + frameHeight: contentHeight, + defaultIcon: descriptor.leadingIcon + ) + .frame(width: layout.leadingWidth, height: contentHeight) + + Rectangle() + .fill(Color.black) + .frame(width: layout.centerWidth, height: contentHeight) + .overlay(EmptyView()) + + ExtensionMusicWingView( + payload: payload, + notchHeight: contentHeight, + trailingWidth: layout.trailingWidth + ) + .frame(width: layout.trailingWidth, height: contentHeight) + } + .frame(width: layout.totalWidth, height: layout.outerHeight + (isHovering ? 8 : 0)) + .transition( + .asymmetric( + insertion: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.4, dampingFraction: 0.8)), + removal: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.3, dampingFraction: 0.9)) + ) + ) + .animation(.smooth(duration: 0.25), value: payload.id) + .onAppear { + logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) for \(payload.bundleIdentifier) as standalone view") + } + .onDisappear { + logExtensionDiagnostics("Hid extension live activity \(payload.descriptor.id) standalone view") + } + } + +} + +struct ExtensionMusicWingView: View { + let payload: ExtensionLiveActivityPayload + let notchHeight: CGFloat + let trailingWidth: CGFloat + + private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } + private var accentColor: Color { descriptor.accentColor.swiftUIColor } + private var trailingRenderable: ExtensionTrailingRenderable { + resolvedExtensionTrailingRenderable(for: descriptor) + } + + var body: some View { + VStack(alignment: .trailing, spacing: 6) { + switch trailingRenderable { + case let .content(content): + if case .none = content { + Spacer(minLength: 0) + } else { + ExtensionEdgeContentView( + content: content, + accent: accentColor, + availableWidth: trailingWidth, + alignment: .trailing + ) + .frame(maxWidth: .infinity, alignment: .trailing) + } + case let .indicator(indicator): + ExtensionProgressIndicatorView( + indicator: indicator, + progress: descriptor.progress, + accent: accentColor, + estimatedDuration: descriptor.estimatedDuration, + maxVisualHeight: notchHeight + ) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(.trailing, 8) + .padding(.vertical, 6) + .onAppear { + logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) within music wing") + } + .onDisappear { + logExtensionDiagnostics("Hid extension live activity \(payload.descriptor.id) from music wing") + } + } +} + +struct ExtensionLeadingContentView: View { + let content: AtollTrailingContent + let badge: AtollIconDescriptor? + let accent: Color + let frameWidth: CGFloat + let frameHeight: CGFloat + let defaultIcon: AtollIconDescriptor + + var body: some View { + Group { + switch content { + case let .icon(iconDescriptor): + ExtensionCompositeIconView( + leading: iconDescriptor, + badge: badge, + accent: accent, + size: frameHeight + ) + case let .animation(data, size): + let resolvedSize = CGSize( + width: min(size.width, frameHeight), + height: min(size.height, frameHeight) + ) + ExtensionLottieView(data: data, size: resolvedSize) + .frame(width: frameHeight, height: frameHeight) + .background( + RoundedRectangle(cornerRadius: frameHeight * 0.18, style: .continuous) + .fill(Color.white.opacity(0.08)) + ) + default: + ExtensionCompositeIconView( + leading: defaultIcon, + badge: badge, + accent: accent, + size: frameHeight + ) + } + } + .frame(width: frameWidth, height: frameHeight) + } +} + +private func logExtensionDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) +} + +struct ExtensionNotchExperienceTabView: View { + let payload: ExtensionNotchExperiencePayload + + @Default(.enableExtensionNotchInteractiveWebViews) private var interactiveWebViewsEnabled + + private var descriptor: AtollNotchExperienceDescriptor { payload.descriptor } + private var tabConfiguration: AtollNotchExperienceDescriptor.TabConfiguration? { descriptor.tab } + private var accentColor: Color { descriptor.accentColor.swiftUIColor } + private var allowInteractiveWebViews: Bool { + interactiveWebViewsEnabled && (tabConfiguration?.allowWebInteraction ?? false) + } + + var body: some View { + Group { + if let tabConfiguration { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 18) { + header(for: tabConfiguration) + ForEach(Array(tabConfiguration.sections.enumerated()), id: \.offset) { index, section in + ExtensionNotchSectionView( + section: section, + accent: accentColor, + allowWebInteraction: allowInteractiveWebViews + ) + .accessibilityIdentifier("extension-notch-section-\(payload.descriptor.id)-\(index)") + } + if let webDescriptor = tabConfiguration.webContent { + ExtensionWebContentView(descriptor: webDescriptor, allowInteraction: allowInteractiveWebViews) + .frame(height: webDescriptor.preferredHeight) + .frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + if let footnote = tabConfiguration.footnote { + Text(footnote) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.65)) + .lineLimit(2) + } + } + .padding(.vertical, 14) + .padding(.horizontal, 16) + } + } else { + Text("Extension tab unavailable") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.6)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(tabBackground) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + } + + @ViewBuilder + private func header(for configuration: AtollNotchExperienceDescriptor.TabConfiguration) -> some View { + HStack(spacing: 10) { + Group { + if let badgeIcon = configuration.badgeIcon { + ExtensionIconView( + descriptor: badgeIcon, + tint: accentColor, + size: CGSize(width: 32, height: 32), + cornerRadius: 10 + ) + } else { + Image(systemName: configuration.iconSymbolName ?? "puzzlepiece.extension") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(accentColor.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } + VStack(alignment: .leading, spacing: 2) { + Text(configuration.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + } + Spacer(minLength: 0) + } + } + + private var tabBackground: some View { + AnyView( + LinearGradient( + colors: [Color.white.opacity(0.04), accentColor.opacity(0.08)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } +} + +struct ExtensionNotchSectionView: View { + let section: AtollNotchContentSection + let accent: Color + let allowWebInteraction: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ExtensionNotchSectionHeader(section: section) + layoutContent + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + + @ViewBuilder + private var layoutContent: some View { + switch section.layout { + case .stack: + VStack(alignment: .leading, spacing: 10) { + elementViews + } + case .columns: + LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 12) { + elementViews + } + case .metrics: + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { + elementViews + } + } + } + + @ViewBuilder + private var elementViews: some View { + ForEach(Array(section.elements.enumerated()), id: \.offset) { index, element in + ExtensionWidgetElementView( + element: element, + accent: accent, + allowWebInteraction: allowWebInteraction + ) + .accessibilityIdentifier("extension-notch-element-\(index)") + } + } +} + +struct ExtensionNotchSectionHeader: View { + let section: AtollNotchContentSection + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let title = section.title { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + } + if let subtitle = section.subtitle { + Text(subtitle) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.7)) + } + } + } +} + +struct ExtensionMinimalisticExperienceView: View { + let payload: ExtensionNotchExperiencePayload + let albumArtNamespace: Namespace.ID + + @Default(.enableExtensionNotchInteractiveWebViews) private var interactiveWebViewsEnabled + + private var descriptor: AtollNotchExperienceDescriptor { payload.descriptor } + private var configuration: AtollNotchExperienceDescriptor.MinimalisticConfiguration? { descriptor.minimalistic } + private var accent: Color { descriptor.accentColor.swiftUIColor } + + var body: some View { + Group { + if let configuration { + let hasWebContent = configuration.webContent != nil + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + if let headline = configuration.headline { + Text(headline) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + if let subtitle = configuration.subtitle { + Text(subtitle) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.75)) + } + ForEach(Array(configuration.sections.enumerated()), id: \.offset) { index, section in + ExtensionNotchSectionView( + section: section, + accent: accent, + allowWebInteraction: interactiveWebViewsEnabled + ) + .accessibilityIdentifier("extension-minimalistic-section-\(payload.descriptor.id)-\(index)") + } + if let webDescriptor = configuration.webContent { + ExtensionWebContentView( + descriptor: webDescriptor, + allowInteraction: interactiveWebViewsEnabled + ) + .frame(height: webDescriptor.preferredHeight) + .frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + } + .padding(.horizontal, 14) + .padding(.top, 10) + .padding(.bottom, hasWebContent ? 0 : 10) + } + } else { + MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) + } + } + } +} diff --git a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift new file mode 100644 index 00000000..b4de7756 --- /dev/null +++ b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift @@ -0,0 +1,482 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults +import AtollExtensionKit +import WebKit +import AppKit + +struct ExtensionLockScreenWidgetView: View { + let payload: ExtensionLockScreenWidgetPayload + + private var descriptor: AtollLockScreenWidgetDescriptor { payload.descriptor } + private var accentColor: Color { descriptor.accentColor.swiftUIColor } + private var appearance: AtollWidgetAppearanceOptions? { descriptor.appearance } + + var body: some View { + let shape = RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) + ZStack { + backgroundLayer(shape: shape) + contentView + .padding(resolvedContentInsets) + } + .frame(width: descriptor.size.width, height: descriptor.size.height) + .clipShape(shape) + .overlay(borderOverlay(shape: shape)) + .shadow(color: shadowColor, radius: shadowRadius, x: shadowOffset.width, y: shadowOffset.height) + .onAppear { + logWidgetDiagnostics("Rendering extension lock screen widget \(payload.descriptor.id) for \(payload.bundleIdentifier)") + } + .onDisappear { + logWidgetDiagnostics("Lock screen widget \(payload.descriptor.id) removed") + } + } + + @ViewBuilder + private var contentView: some View { + switch descriptor.layoutStyle { + case .inline, .card, .custom: + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(descriptor.content.enumerated()), id: \.offset) { index, element in + view(for: element) + .frame(maxWidth: .infinity, alignment: alignment(for: element)) + .accessibilityIdentifier("extension-widget-element-\(payload.id)-\(index)") + } + } + case .circular: + ZStack { + ForEach(Array(descriptor.content.enumerated()), id: \.offset) { index, element in + view(for: element) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .accessibilityIdentifier("extension-widget-element-\(payload.id)-\(index)") + } + } + } + } + + private func view(for element: AtollWidgetContentElement) -> some View { + ExtensionWidgetElementView( + element: element, + accent: accentColor, + allowWebInteraction: false + ) + } + + private func alignment(for element: AtollWidgetContentElement) -> Alignment { + switch element { + case let .text(_, _, _, alignment): + return alignment.swiftUI + default: + return .leading + } + } + + private var resolvedContentInsets: EdgeInsets { + if let custom = appearance?.contentInsets { + return EdgeInsets(top: custom.top, leading: custom.leading, bottom: custom.bottom, trailing: custom.trailing) + } + switch descriptor.layoutStyle { + case .circular: + return EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10) + default: + return EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16) + } + } + + @ViewBuilder + private func backgroundLayer(shape: RoundedRectangle) -> some View { + if descriptor.material == .liquid { + liquidGlassBackground(shape: shape) + } else if shouldUseGlassHighlight { + if #available(macOS 26.0, *) { + ZStack { + shape + .fill(Color.clear) + .glassEffect( + .clear.interactive(), + in: .rect(cornerRadius: descriptor.cornerRadius) + ) + .allowsHitTesting(false) + .accessibilityHidden(true) + tintOverlay(shape: shape) + } + } else { + ZStack { + shape.fill(AnyShapeStyle(.regularMaterial)) + tintOverlay(shape: shape) + } + } + } else { + ZStack { + shape.fill(baseMaterialStyle) + tintOverlay(shape: shape) + } + } + } + + @ViewBuilder + private func liquidGlassBackground(shape: RoundedRectangle) -> some View { + LiquidGlassBackground(variant: requestedLiquidGlassVariant, cornerRadius: descriptor.cornerRadius) { + Color.black.opacity(0.08) + } + .overlay { + tintOverlay(shape: shape) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + private var baseMaterialStyle: AnyShapeStyle { + switch descriptor.material { + case .frosted: + return AnyShapeStyle(.ultraThinMaterial) + case .liquid: + return AnyShapeStyle(.regularMaterial) + case .solid: + return AnyShapeStyle(resolvedSolidFillColor.opacity(0.95)) + case .semiTransparent: + return AnyShapeStyle(resolvedSolidFillColor.opacity(0.35)) + case .clear: + return AnyShapeStyle(Color.clear) + } + } + + private var resolvedSolidFillColor: Color { + if let tint = appearance?.tintColor?.swiftUIColor { + return tint + } + return accentColor + } + + @ViewBuilder + private func tintOverlay(shape: RoundedRectangle) -> some View { + Group { + if shouldApplyTintOverlay, let overlay = tintedOverlayColor { + shape + .fill(overlay) + .allowsHitTesting(false) + } + } + } + + private var tintedOverlayColor: Color? { + guard let descriptor = appearance, let tint = descriptor.tintColor, descriptor.tintOpacity > 0 else { + return nil + } + return tint.swiftUIColor.opacity(descriptor.tintOpacity) + } + + private var shouldApplyTintOverlay: Bool { + guard appearance?.tintColor != nil else { return false } + switch descriptor.material { + case .solid, .semiTransparent: + return false + default: + return true + } + } + + private var shouldUseGlassHighlight: Bool { + (appearance?.enableGlassHighlight ?? false) && descriptor.material != .liquid + } + + private var requestedLiquidGlassVariant: LiquidGlassVariant { + guard let requested = appearance?.liquidGlassVariant?.rawValue else { + return LiquidGlassVariant.defaultVariant + } + return LiquidGlassVariant.clamped(requested) + } + + @ViewBuilder + private func borderOverlay(shape: RoundedRectangle) -> some View { + if let border = appearance?.border { + shape + .stroke(border.color.swiftUIColor.opacity(border.opacity), lineWidth: border.width) + } else { + shape + .stroke(Color.white.opacity(0.04), lineWidth: 1) + } + } + + private var shadowColor: Color { + guard let shadow = appearance?.shadow else { return .clear } + return shadow.color.swiftUIColor.opacity(shadow.opacity) + } + + private var shadowRadius: CGFloat { + appearance?.shadow?.radius ?? 0 + } + + private var shadowOffset: CGSize { + appearance?.shadow?.offset ?? .zero + } +} + +private func logWidgetDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) +} + +struct ExtensionWidgetElementView: View { + let element: AtollWidgetContentElement + let accent: Color + let allowWebInteraction: Bool + + var body: some View { + switch element { + case let .text(text, font: font, color: color, alignment: _): + Text(text) + .font(font.swiftUIFont()) + .foregroundStyle((color?.swiftUIColor) ?? Color.white.opacity(0.9)) + .lineLimit(2) + case let .icon(iconDescriptor, tint): + ExtensionIconView( + descriptor: iconDescriptor, + tint: (tint?.swiftUIColor) ?? accent, + size: CGSize(width: 28, height: 28), + cornerRadius: 8 + ) + case let .progress(indicator, value, color): + ExtensionProgressIndicatorView( + indicator: indicator, + progress: value, + accent: (color?.swiftUIColor) ?? accent, + estimatedDuration: nil + ) + case let .graph(data, color, size): + ExtensionGraphView(data: data, color: color.swiftUIColor, size: size) + .frame(width: size.width, height: size.height) + case let .gauge(value, minValue, maxValue, style, color): + ExtensionGaugeView( + value: value, + minValue: minValue, + maxValue: maxValue, + style: style, + accent: (color?.swiftUIColor) ?? accent + ) + .frame(maxWidth: .infinity) + case let .spacer(height): + Color.clear + .frame(height: height) + case let .divider(color, thickness): + Rectangle() + .fill(color.swiftUIColor.opacity(0.4)) + .frame(height: thickness) + case let .webView(webDescriptor): + ExtensionWebContentView(descriptor: webDescriptor, allowInteraction: allowWebInteraction) + .frame(height: webDescriptor.preferredHeight) + .frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity) + } + } +} + +struct ExtensionWebContentView: NSViewRepresentable { + let descriptor: AtollWidgetWebContentDescriptor + let allowInteraction: Bool + + func makeCoordinator() -> Coordinator { + Coordinator(descriptor: descriptor) + } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.suppressesIncrementalRendering = false + configuration.preferences.javaScriptCanOpenWindowsAutomatically = false + let webView = ConfigurableWKWebView(frame: .zero, configuration: configuration) + webView.allowInteraction = allowInteraction + webView.navigationDelegate = context.coordinator + webView.wantsLayer = true + applyConfiguration(descriptor, to: webView) + context.coordinator.lastHTML = descriptor.html + webView.loadHTMLString(descriptor.html, baseURL: nil) + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + context.coordinator.descriptor = descriptor + webView.navigationDelegate = context.coordinator + applyConfiguration(descriptor, to: webView) + if let configurableView = webView as? ConfigurableWKWebView { + configurableView.allowInteraction = allowInteraction + } + if context.coordinator.lastHTML != descriptor.html { + webView.loadHTMLString(descriptor.html, baseURL: nil) + context.coordinator.lastHTML = descriptor.html + } + } + + private func applyConfiguration(_ descriptor: AtollWidgetWebContentDescriptor, to webView: WKWebView) { + webView.allowsBackForwardNavigationGestures = false + webView.allowsLinkPreview = false + if descriptor.isTransparent { + webView.setValue(false, forKey: "drawsBackground") + webView.layer?.backgroundColor = NSColor.clear.cgColor + } else { + let fallbackColor = descriptor.backgroundColor?.nsColor ?? NSColor.windowBackgroundColor + webView.layer?.backgroundColor = fallbackColor.cgColor + } + } + + private final class ConfigurableWKWebView: WKWebView { + var allowInteraction = false + + override func hitTest(_ point: NSPoint) -> NSView? { + guard allowInteraction else { return nil } + return super.hitTest(point) + } + + override var acceptsFirstResponder: Bool { + allowInteraction + } + } + + final class Coordinator: NSObject, WKNavigationDelegate { + var descriptor: AtollWidgetWebContentDescriptor + var lastHTML: String? + + init(descriptor: AtollWidgetWebContentDescriptor) { + self.descriptor = descriptor + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let url = navigationAction.request.url else { + decisionHandler(.cancel) + return + } + if isAllowed(url: url) { + decisionHandler(.allow) + } else { + logWidgetDiagnostics("Blocked external navigation to \(url.absoluteString)") + decisionHandler(.cancel) + } + } + + private func isAllowed(url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { return false } + if scheme == "about" || scheme == "data" { + return true + } + if (scheme == "http" || scheme == "https") { + if allowsRemoteRequests() { + return true + } + guard descriptor.allowLocalhostRequests else { + return false + } + let host = url.host?.lowercased() + return host == "localhost" || host == "127.0.0.1" + } + return false + } + + private func allowsRemoteRequests() -> Bool { + guard let data = try? JSONEncoder().encode(descriptor), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let value = json["allowRemoteRequests"] as? Bool else { + return false + } + return value + } + } +} + +private struct ExtensionGraphView: View { + let data: [Double] + let color: Color + let size: CGSize + + var body: some View { + GeometryReader { proxy in + let minValue = data.min() ?? 0 + let maxValue = data.max() ?? 1 + let range = max(maxValue - minValue, 0.0001) + let step = proxy.size.width / CGFloat(max(data.count - 1, 1)) + Path { path in + for (index, value) in data.enumerated() { + let x = CGFloat(index) * step + let normalized = (value - minValue) / range + let y = proxy.size.height - (CGFloat(normalized) * proxy.size.height) + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(color, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + } + .frame(width: size.width, height: size.height) + } +} + +private struct ExtensionGaugeView: View { + let value: Double + let minValue: Double + let maxValue: Double + let style: AtollWidgetContentElement.GaugeStyle + let accent: Color + + var body: some View { + switch style { + case .circular: + ZStack { + Circle() + .stroke(Color.white.opacity(0.15), lineWidth: 5) + Circle() + .trim(from: 0, to: CGFloat(normalizedValue)) + .stroke(accent, style: StrokeStyle(lineWidth: 5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.3), value: normalizedValue) + Text("\(Int(normalizedValue * 100))%") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .monospacedDigit() + } + .frame(width: 54, height: 54) + case .linear: + GeometryReader { proxy in + Capsule() + .fill(Color.white.opacity(0.2)) + .frame(height: 8) + .overlay(alignment: .leading) { + Capsule() + .fill(accent) + .frame(width: proxy.size.width * CGFloat(normalizedValue), height: 8) + .animation(.smooth(duration: 0.3), value: normalizedValue) + } + } + .frame(height: 8) + } + } + + private var normalizedValue: Double { + guard maxValue > minValue else { return 0 } + return min(max((value - minValue) / (maxValue - minValue), 0), 1) + } +} + +private extension AtollWidgetContentElement.TextAlignment { + var swiftUI: Alignment { + switch self { + case .leading: return .leading + case .center: return .center + case .trailing: return .trailing + } + } +} diff --git a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift new file mode 100644 index 00000000..346d8a59 --- /dev/null +++ b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift @@ -0,0 +1,636 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import AppKit +import AtollExtensionKit +import Lottie +import LottieUI +import CryptoKit + +// MARK: - Color Conversion + +extension AtollColorDescriptor { + var swiftUIColor: Color { + if isAccent { + return .accentColor + } + return Color(red: red, green: green, blue: blue, opacity: alpha) + } + + var nsColor: NSColor { + if isAccent { + return NSColor.controlAccentColor + } + return NSColor(red: red, green: green, blue: blue, alpha: alpha) + } + + func resolvedColor(fallback accent: Color) -> Color { + isAccent ? accent : swiftUIColor + } +} + +// MARK: - Font Conversion + +extension AtollFontDescriptor { + func swiftUIFont() -> Font { + let design = self.design.swiftUI + let weight = self.weight.swiftUI + var font = Font.system(size: size, weight: weight, design: design) + if isMonospacedDigit { + font = font.monospacedDigit() + } + return font + } + + func nsFont() -> NSFont { + let weight = weight.nsFont + let font: NSFont + switch design { + case .serif: + font = NSFont.userFont(ofSize: size) ?? NSFont.systemFont(ofSize: size, weight: weight) + case .rounded: + if let descriptor = NSFont.systemFont(ofSize: size, weight: weight).fontDescriptor.withDesign(.rounded) { + font = NSFont(descriptor: descriptor, size: size) ?? NSFont.systemFont(ofSize: size, weight: weight) + } else { + font = NSFont.systemFont(ofSize: size, weight: weight) + } + case .monospaced: + font = NSFont.monospacedSystemFont(ofSize: size, weight: weight) + case .default: + font = NSFont.systemFont(ofSize: size, weight: weight) + } + return font + } +} + +private extension AtollFontWeight { + var swiftUI: Font.Weight { + switch self { + case .ultraLight: return .ultraLight + case .thin: return .thin + case .light: return .light + case .regular: return .regular + case .medium: return .medium + case .semibold: return .semibold + case .bold: return .bold + case .heavy: return .heavy + case .black: return .black + } + } + + var nsFont: NSFont.Weight { + switch self { + case .ultraLight: return .ultraLight + case .thin: return .thin + case .light: return .light + case .regular: return .regular + case .medium: return .medium + case .semibold: return .semibold + case .bold: return .bold + case .heavy: return .heavy + case .black: return .black + } + } +} + +private extension AtollFontDesign { + var swiftUI: Font.Design { + switch self { + case .default: return .default + case .serif: return .serif + case .rounded: return .rounded + case .monospaced: return .monospaced + } + } +} + +// MARK: - Icon Rendering + +struct ExtensionIconView: View { + let descriptor: AtollIconDescriptor + let tint: Color + let size: CGSize + let cornerRadius: CGFloat + + var body: some View { + switch descriptor { + case let .symbol(name, glyphSize, weight): + Image(systemName: name) + .font(.system(size: glyphSize, weight: weight.swiftUI)) + .foregroundStyle(tint) + .frame(width: size.width, height: size.height) + case let .image(data, targetSize, radius): + if let image = NSImage(data: data) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: targetSize.width, height: targetSize.height) + .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) + } else { + fallbackSymbol + } + case let .appIcon(bundleIdentifier, targetSize, radius): + if let icon = AppIconAsNSImage(for: bundleIdentifier) { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: targetSize.width, height: targetSize.height) + .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) + } else { + fallbackSymbol + } + case let .lottie(animationData, targetSize): + let resolvedSize = CGSize(width: min(targetSize.width, size.width), height: min(targetSize.height, size.height)) + ExtensionLottieView(data: animationData, size: resolvedSize) + .frame(width: size.width, height: size.height) + .background( + RoundedRectangle(cornerRadius: cornerRadius(for: descriptor), style: .continuous) + .fill(Color.white.opacity(0.08)) + ) + case .none: + fallbackSymbol + } + } + + private var fallbackSymbol: some View { + Image(systemName: "app.dashed") + .font(.system(size: size.width * 0.6, weight: .medium)) + .foregroundStyle(tint) + .frame(width: size.width, height: size.height) + .background( + RoundedRectangle(cornerRadius: cornerRadius(for: descriptor), style: .continuous) + .fill(tint.opacity(0.15)) + ) + } + + private func cornerRadius(for descriptor: AtollIconDescriptor) -> CGFloat { + switch descriptor { + case .image(_, _, let radius): return radius + case .appIcon(_, _, let radius): return radius + default: return cornerRadius + } + } +} + +struct ExtensionCompositeIconView: View { + let leading: AtollIconDescriptor + let badge: AtollIconDescriptor? + let accent: Color + let size: CGFloat + + var body: some View { + ZStack(alignment: .bottomTrailing) { + ExtensionIconView( + descriptor: leading, + tint: accent, + size: CGSize(width: size, height: size), + cornerRadius: size * 0.18 + ) + if let badge { + ExtensionIconView( + descriptor: badge, + tint: .white, + size: CGSize(width: max(size * 0.35, 12), height: max(size * 0.35, 12)), + cornerRadius: size * 0.12 + ) + .background( + Circle() + .fill(Color.black.opacity(0.7)) + .frame(width: size * 0.4, height: size * 0.4) + ) + } + } + .frame(width: size, height: size) + } +} + +struct ExtensionBadgeIconView: View { + let descriptor: AtollIconDescriptor + let accent: Color + let size: CGFloat + + private var symbolSize: CGFloat { size * 0.5 } + private var imageSize: CGFloat { size * 0.62 } + private var imageCornerRadius: CGFloat { size * 0.18 } + + var body: some View { + Group { + switch descriptor { + case let .symbol(name, _, weight): + Image(systemName: name) + .font(.system(size: symbolSize, weight: weight.swiftUI)) + .foregroundStyle(accent) + case let .image(data, _, _): + if let image = NSImage(data: data) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: imageSize, height: imageSize) + .clipShape(RoundedRectangle(cornerRadius: imageCornerRadius, style: .continuous)) + } else { + fallbackSymbol + } + case let .appIcon(bundleIdentifier, _, _): + if let icon = AppIconAsNSImage(for: bundleIdentifier) { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: imageSize, height: imageSize) + .clipShape(RoundedRectangle(cornerRadius: imageCornerRadius, style: .continuous)) + } else { + fallbackSymbol + } + case let .lottie(animationData, _): + ExtensionLottieView(data: animationData, size: CGSize(width: imageSize, height: imageSize)) + case .none: + fallbackSymbol + } + } + .frame(width: size, height: size) + } + + private var fallbackSymbol: some View { + Image(systemName: "app.dashed") + .font(.system(size: symbolSize, weight: .medium)) + .foregroundStyle(accent) + .frame(width: imageSize, height: imageSize) + .background( + RoundedRectangle(cornerRadius: imageCornerRadius, style: .continuous) + .fill(accent.opacity(0.15)) + ) + } +} + +// MARK: - Lottie Rendering + +struct ExtensionLottieView: View { + let data: Data + let size: CGSize + var loopMode: LottieLoopMode = .loop + @State private var cachedURL: URL? + + var body: some View { + Group { + if let cachedURL { + LottieView( + state: LUStateData( + type: .loadedFrom(cachedURL), + speed: 1.0, + loopMode: loopMode + ) + ) + } else { + ProgressView() + .controlSize(.small) + .tint(.white.opacity(0.7)) + } + } + .frame(width: size.width, height: size.height) + .task { + if cachedURL == nil { + cachedURL = ExtensionLottieCache.shared.url(for: data) + } + } + } +} + +private final class ExtensionLottieCache { + static let shared = ExtensionLottieCache() + private let queue = DispatchQueue(label: "com.atoll.extension-lottie-cache") + private var cache: [String: URL] = [:] + + func url(for data: Data) -> URL? { + queue.sync { + let key = Self.hash(data: data) + if let existing = cache[key], FileManager.default.fileExists(atPath: existing.path) { + return existing + } + + let url = FileManager.default.temporaryDirectory.appendingPathComponent("extension-lottie-\(key).json", conformingTo: .json) + do { + try data.write(to: url, options: .atomic) + cache[key] = url + return url + } catch { + cache.removeValue(forKey: key) + return nil + } + } + } + + private static func hash(data: Data) -> String { + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } +} + +// MARK: - Progress Rendering + +struct ExtensionProgressIndicatorView: View { + let indicator: AtollProgressIndicator + let progress: Double + let accent: Color + let estimatedDuration: TimeInterval? + let maxVisualHeight: CGFloat? + + init( + indicator: AtollProgressIndicator, + progress: Double, + accent: Color, + estimatedDuration: TimeInterval?, + maxVisualHeight: CGFloat? = nil + ) { + self.indicator = indicator + self.progress = progress + self.accent = accent + self.estimatedDuration = estimatedDuration + self.maxVisualHeight = maxVisualHeight + } + + var body: some View { + switch indicator { + case let .ring(diameter, strokeWidth, color): + let ringColor = color?.resolvedColor(fallback: accent) ?? accent + let resolvedDiameter = resolvedRingDiameter(requested: diameter) + ZStack { + Circle() + .stroke(Color.white.opacity(0.15), lineWidth: strokeWidth) + Circle() + .trim(from: 0, to: CGFloat(max(0, min(progress, 1)))) + .stroke(ringColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: progress) + } + .frame(width: resolvedDiameter, height: resolvedDiameter) + case let .bar(width, height, cornerRadius, color): + let barColor = color?.resolvedColor(fallback: accent) ?? accent + Capsule() + .fill(Color.white.opacity(0.18)) + .frame(width: width ?? 80, height: height) + .overlay(alignment: .leading) { + Capsule() + .fill(barColor) + .frame(width: (width ?? 80) * CGFloat(max(0, min(progress, 1))), height: height) + .animation(.smooth(duration: 0.25), value: progress) + } + case let .percentage(font, color): + let textColor = color?.resolvedColor(fallback: accent) ?? accent + Text("\(Int(progress * 100))%") + .font(font.swiftUIFont()) + .foregroundStyle(textColor) + .monospacedDigit() + case let .countdown(font, color): + let textColor = color?.resolvedColor(fallback: accent) ?? accent + let text = countdownText + Text(text) + .font(font.swiftUIFont()) + .foregroundStyle(textColor) + .monospacedDigit() + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: text) + case .lottie: + Image(systemName: "sparkles") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(accent) + case .none: + EmptyView() + } + } + + private var countdownText: String { + guard let estimatedDuration else { return formatPercent } + let remaining = max(estimatedDuration * (1 - progress), 0) + let hours = Int(remaining) / 3600 + let minutes = (Int(remaining) % 3600) / 60 + let seconds = Int(remaining) % 60 + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%02d:%02d", minutes, seconds) + } + + private var formatPercent: String { + "\(Int(progress * 100))%" + } + + private func resolvedRingDiameter(requested: CGFloat) -> CGFloat { + guard let maxVisualHeight else { return requested } + let target = max(min(maxVisualHeight - 12, 24), 16) + return min(requested, target) + } +} + +// MARK: - Edge Content Rendering + +struct ExtensionEdgeContentView: View { + let content: AtollTrailingContent + let accent: Color + let availableWidth: CGFloat + let alignment: Alignment + + var body: some View { + switch content { + case let .text(value, font: font, color: color): + Text(value) + .font(font.swiftUIFont()) + .foregroundStyle(resolvedColor(color)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: alignment) + case let .marquee(value, font: font, minDuration: duration, color: color): + MarqueeText( + .constant(value), + font: font.swiftUIFont(), + nsFont: .body, + textColor: resolvedColor(color), + minDuration: duration, + frameWidth: max(24, availableWidth - 12) + ) + case let .countdownText(targetDate, font: font, color: color): + ExtensionCountdownTextView(targetDate: targetDate, font: font, accent: accent, customColor: color?.resolvedColor(fallback: accent)) + .frame(maxWidth: .infinity, alignment: alignment) + case let .icon(descriptor): + ExtensionIconView( + descriptor: descriptor, + tint: accent, + size: CGSize(width: 26, height: 26), + cornerRadius: 6 + ) + case let .spectrum(color: colorDescriptor): + Rectangle() + .fill((colorDescriptor.isAccent ? accent : colorDescriptor.swiftUIColor).gradient) + .frame(width: 48, height: 14) + .mask { + AudioVisualizerView(isPlaying: .constant(true)) + .frame(width: 16, height: 12) + } + case let .animation(data, size): + let resolvedWidth = min(size.width, availableWidth) + ExtensionLottieView(data: data, size: CGSize(width: resolvedWidth, height: size.height)) + case .none: + EmptyView() + } + } + + private func resolvedColor(_ descriptorColor: AtollColorDescriptor?) -> Color { + descriptorColor?.resolvedColor(fallback: accent) ?? accent + } +} + +struct ExtensionCountdownTextView: View { + let targetDate: Date + let font: AtollFontDescriptor + let accent: Color + let customColor: Color? + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + let remainingText = formattedRemaining(since: context.date) + Text(remainingText) + .font(font.swiftUIFont()) + .foregroundStyle(customColor ?? accent) + .monospacedDigit() + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: remainingText) + } + } + + private func formattedRemaining(since date: Date) -> String { + let remaining = max(Int(targetDate.timeIntervalSince(date)), 0) + let hours = remaining / 3600 + let minutes = (remaining % 3600) / 60 + let seconds = remaining % 60 + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%02d:%02d", minutes, seconds) + } +} + +// MARK: - Layout Metrics + +enum ExtensionLayoutMetrics { + static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat { + let renderable = resolvedExtensionTrailingRenderable(for: payload.descriptor) + var width: CGFloat + switch renderable { + case let .content(content): + width = edgeWidth(for: content, baseWidth: baseWidth, maxWidth: maxWidth) + case let .indicator(indicator): + width = edgeWidth(for: .none, baseWidth: baseWidth, maxWidth: maxWidth) + width = max(width, widthForProgress(indicator)) + } + if let maxWidth { + width = min(width, maxWidth) + } + return width + } + + static func edgeWidth(for content: AtollTrailingContent, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat { + var width = widthForContent(content, baseWidth: baseWidth) + if let maxWidth { + width = min(width, maxWidth) + } + return width + } + + private static func widthForContent(_ content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat { + switch content { + case let .text(text, font: font, color: _): + let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont()) + return max(baseWidth, measured + 32) + case let .marquee(text, font: font, minDuration: _, color: _): + let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont()) + return max(baseWidth, min(measured + 40, baseWidth * 2)) + case let .countdownText(targetDate, font: font, color: _): + let sample = targetDate.timeIntervalSinceNow >= 3600 ? "00:00:00" : "00:00" + let measured = ExtensionTextMeasurer.width(for: sample, font: font.nsFont()) + return max(baseWidth, measured + 24) + case .icon: + return max(baseWidth, 52) + case .spectrum: + return max(baseWidth, 56) + case let .animation(data: _, size: size): + return max(baseWidth, size.width + 16) + case .none: + return baseWidth + } + } + + private static func widthForProgress(_ indicator: AtollProgressIndicator) -> CGFloat { + switch indicator { + case let .ring(diameter, _, _): + return diameter + 20 + case let .bar(width, _, _, _): + return (width ?? 72) + 12 + case .percentage(_, _): + return 60 + case .countdown(_, _): + return 74 + case let .lottie(_, size): + return size.width + 16 + case .none: + return 0 + } + } +} + +enum ExtensionTextMeasurer { + static func width(for text: String, font: NSFont) -> CGFloat { + let attributes: [NSAttributedString.Key: Any] = [.font: font] + return max(1, text.size(withAttributes: attributes).width) + } +} + +enum ExtensionTrailingRenderable { + case content(AtollTrailingContent) + case indicator(AtollProgressIndicator) +} + +func resolvedExtensionLeadingContent(for descriptor: AtollLiveActivityDescriptor) -> AtollTrailingContent { + guard let override = descriptor.leadingContent else { + return .icon(descriptor.leadingIcon) + } + + switch override { + case .icon, .animation: + return override + default: + return .icon(descriptor.leadingIcon) + } +} + +func resolvedExtensionTrailingRenderable(for descriptor: AtollLiveActivityDescriptor) -> ExtensionTrailingRenderable { + if let indicator = descriptor.progressIndicator, + indicator.isRenderable, + descriptor.trailingContent == .none { + return .indicator(indicator) + } + return .content(descriptor.trailingContent) +} + +private extension AtollProgressIndicator { + var isRenderable: Bool { + switch self { + case .none: + return false + default: + return true + } + } +} diff --git a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift new file mode 100644 index 00000000..8e40421f --- /dev/null +++ b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift @@ -0,0 +1,533 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Defaults +import SwiftUI + +struct DoNotDisturbLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var manager = DoNotDisturbManager.shared + @Default(.showDoNotDisturbLabel) private var showLabelSetting + @Default(.focusIndicatorNonPersistent) private var focusToastMode + + @State private var isExpanded = false + @State private var showInactiveIcon = false + @State private var iconScale: CGFloat = 1.0 + @State private var scaleResetTask: Task? + @State private var collapseTask: Task? + @State private var cleanupTask: Task? + + private enum ToastTiming { + static let activeDisplay: UInt64 = 1800 // focus enabled toast linger + static let inactiveDisplay: UInt64 = 1500 // focus disabled toast linger + } + + var body: some View { + HStack(spacing: 0) { + iconWing + .frame(width: iconWingWidth, height: wingHeight) + + Rectangle() + .fill(Color.black) + .frame(width: centerSegmentWidth) + + labelWing + .frame(width: labelWingWidth, height: wingHeight) + } + .frame(width: notchEnvelopeWidth, height: vm.effectiveClosedNotchHeight) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription) + .onAppear(perform: handleInitialState) + .onChange(of: manager.isDoNotDisturbActive, handleFocusStateChange) + .onChange(of: manager.focusToastTrigger, handleFocusModeSwitchToast) + .onChange(of: focusToastMode, handleToastModeSettingChange) + .onDisappear(perform: cancelPendingTasks) + } + + // MARK: - Layout helpers + + private var collapsedNotchWidth: CGFloat { + let width = currentClosedNotchWidth + let scale = (resolvedScreen?.backingScaleFactor).flatMap { $0 > 0 ? $0 : nil } + ?? NSScreen.main?.backingScaleFactor + ?? 2 + let aligned = (width * scale).rounded(.down) / scale + return max(0, aligned) + } + + private var currentClosedNotchWidth: CGFloat { + if vm.notchState == .closed, vm.notchSize.width > 0 { + return vm.notchSize.width + } + return vm.closedNotchSize.width + } + + private var resolvedScreen: NSScreen? { + if let name = vm.screen, + let match = NSScreen.screens.first(where: { $0.localizedName == name }) { + return match + } + return NSScreen.main + } + + private var wingHeight: CGFloat { + max(vm.effectiveClosedNotchHeight - 10, 20) + } + + private var iconWingWidth: CGFloat { + (isExpanded || showInactiveIcon) ? minimalWingWidth : 0 + } + + private var labelWingWidth: CGFloat { + guard shouldShowLabel else { + return focusToastMode ? 0 : ((isExpanded || showInactiveIcon) ? minimalWingWidth : 0) + } + + if focusToastMode { + return max(labelIntrinsicWidth + 26, minimalWingWidth) + } + + return max(desiredLabelWidth, minimalWingWidth) + } + + private var notchEnvelopeWidth: CGFloat { + centerSegmentWidth + iconWingWidth + labelWingWidth + } + + private var minimalWingWidth: CGFloat { + max(vm.effectiveClosedNotchHeight - 12, 24) + } + + private var closedNotchContentInset: CGFloat { + cornerRadiusInsets.closed.top + cornerRadiusInsets.closed.bottom + } + + private var collapsedToastBaseWidth: CGFloat { + max(0, collapsedNotchWidth - closedNotchContentInset) + } + + private var centerSegmentWidth: CGFloat { + if focusToastMode && iconWingWidth == 0 && labelWingWidth == 0 { + return collapsedToastBaseWidth + } + return collapsedNotchWidth + } + + private var desiredLabelWidth: CGFloat { + let fallbackWidth = max(collapsedNotchWidth * 0.52, 136) + var width = fallbackWidth + + if focusMode == .doNotDisturb && shouldShowLabel { + width = max(width, 164) + } + + if !shouldMarqueeLabel { + width = max(width, labelIntrinsicWidth + 8) + } + + return width + } + + private var shouldShowLabel: Bool { + focusToastMode ? (isExpanded && !labelText.isEmpty) : (showLabelSetting && isExpanded && !labelText.isEmpty) + } + + private var labelIntrinsicWidth: CGFloat { + guard !labelText.isEmpty else { return 0 } + return (labelText as NSString).size(withAttributes: [.font: focusLabelNSFont]).width + } + + private var shouldMarqueeLabel: Bool { + shouldShowLabel && labelIntrinsicWidth > focusLabelBaselineWidth + } + + private var marqueeFrameWidth: CGFloat { + max(48, labelWingWidth - 8) + } + + private var focusLabelFont: Font { + .system(size: 12, weight: .semibold, design: .rounded) + } + + private var focusLabelNSFont: NSFont { + NSFont.systemFont(ofSize: 12, weight: .semibold) + } + + private var focusLabelBaselineWidth: CGFloat { + FocusLabelMetrics.baselineWidth + } + + // MARK: - Focus metadata + + private var focusMode: FocusModeType { + FocusModeType.resolve( + identifier: manager.currentFocusModeIdentifier, + name: manager.currentFocusModeName + ) + } + + private var activeAccentColor: Color { + focusMode.accentColor + } + + private var labelText: String { + if focusToastMode { + return manager.isDoNotDisturbActive ? "On" : "Off" + } + + let trimmed = manager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines) + if showLabelSetting { + if !trimmed.isEmpty { + return trimmed + } else if focusMode == .doNotDisturb { + return "Do Not Disturb" + } else { + let fallback = focusMode.displayName + return fallback.isEmpty ? "Focus" : fallback + } + } + + return "" + } + + private var accessibilityDescription: String { + if manager.isDoNotDisturbActive { + return "Focus active: \(labelText)" + } else { + return "Focus inactive" + } + } + + private var currentIcon: Image { + if manager.isDoNotDisturbActive { + return focusMode.resolvedActiveIcon(usePrivateSymbol: true) + } else if showInactiveIcon { + return inactiveIconMatchingActiveStyle + } else { + return focusMode.resolvedActiveIcon(usePrivateSymbol: true) + } + } + + private var inactiveIconMatchingActiveStyle: Image { + if focusMode == .work { + return focusMode.resolvedActiveIcon(usePrivateSymbol: true).renderingMode(.template) + } + + if focusMode == .gaming, + SymbolAvailabilityCache.shared.isSymbolAvailable("rocket.circle.fill") { + return Image(systemName: "rocket.circle.fill") + } + + if let internalName = focusMode.internalSymbolName { + if let outlineName = outlineVariant(for: internalName), + SymbolAvailabilityCache.shared.isSymbolAvailable(outlineName), + let outlinedImage = Image(internalSystemName: outlineName) { + return outlinedImage.renderingMode(.template) + } + + if let filledImage = Image(internalSystemName: internalName) { + return filledImage.renderingMode(.template) + } + } + + return Image(systemName: focusMode.inactiveSymbol) + } + + private func outlineVariant(for internalName: String) -> String? { + guard internalName.hasSuffix(".fill") else { return nil } + let trimmed = String(internalName.dropLast(5)) + return trimmed.isEmpty ? nil : trimmed + } + + private var currentIconColor: Color { + if manager.isDoNotDisturbActive { + return activeAccentColor + } else if showInactiveIcon { + return .white + } else { + return activeAccentColor + } + } + + // MARK: - Subviews + + private var iconWing: some View { + Color.clear + .overlay(alignment: .center) { + if iconWingWidth > 0 { + currentIcon + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(currentIconColor) + .contentTransition(.opacity) + .scaleEffect(iconScale) + .animation(.none, value: iconScale) + } + } + .animation(.smooth(duration: 0.3), value: iconWingWidth) + } + + private var labelWing: some View { + Color.clear + .overlay(alignment: .trailing) { + if shouldShowLabel { + Group { + if shouldMarqueeLabel { + MarqueeText( + .constant(labelText), + font: focusLabelFont, + nsFont: .caption1, + textColor: labelColor, + minDuration: 0.4, + frameWidth: marqueeFrameWidth + ) + .frame(width: marqueeFrameWidth, alignment: .trailing) + } else { + Text(labelText) + .font(focusLabelFont) + .foregroundColor(labelColor) + .lineLimit(1) + .minimumScaleFactor(0.8) + .contentTransition(.opacity) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .padding(.horizontal, 4) + } + } + .animation(.smooth(duration: 0.3), value: shouldShowLabel) + } + + private var labelColor: Color { + if focusToastMode { + return manager.isDoNotDisturbActive ? activeAccentColor : .white + } + return activeAccentColor + } + + // MARK: - State transitions + + private func handleInitialState() { + if manager.isDoNotDisturbActive { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + isExpanded = true + } + if focusToastMode { + scheduleTransientCollapse() + } + } + } + + private func handleFocusStateChange(_ oldValue: Bool, _ isActive: Bool) { + cancelPendingTasks() + + if isActive { + // Force a clean collapsed baseline before animating ON. + // This avoids cases where prior OFF state leaves the view mid-transition. + withAnimation(.none) { + showInactiveIcon = false + isExpanded = false + iconScale = 1.0 + } + + // Run the expansion on the next runloop tick so the reset takes effect first. + DispatchQueue.main.async { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + iconScale = 1.0 + isExpanded = true + } + if focusToastMode { + scheduleTransientCollapse() + } + } + } else { + triggerInactiveAnimation() + } + } + + private func handleFocusModeSwitchToast(_ oldValue: UUID, _ newValue: UUID) { + // Only show a toast when Focus is currently active. + guard manager.isDoNotDisturbActive else { return } + + // Only relevant for the non-persistent (brief toast) mode. + guard focusToastMode else { return } + + // Cancel any pending collapse so the new mode can animate in cleanly. + cancelPendingTasks() + + // Force a clean collapsed baseline before animating ON for the *new* mode. + // Without this, a mode switch can appear to start then cancel. + withAnimation(.none) { + showInactiveIcon = false + isExpanded = false + iconScale = 1.0 + } + + // Run the expansion on the next runloop tick so the reset takes effect first. + DispatchQueue.main.async { + guard manager.isDoNotDisturbActive else { return } + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + iconScale = 1.0 + isExpanded = true + } + scheduleTransientCollapse() + } + } + + /// When the user toggles between toast / persistent mode while focus is active, + /// re-expand the view so the wings become visible again. + private func handleToastModeSettingChange(_ oldValue: Bool, _ newToastMode: Bool) { + guard manager.isDoNotDisturbActive else { return } + + cancelPendingTasks() + + if newToastMode { + // Switched TO toast mode — show a brief expansion then collapse. + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + isExpanded = true + showInactiveIcon = false + iconScale = 1.0 + } + scheduleTransientCollapse() + } else { + // Switched TO persistent mode — ensure wings are expanded. + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + isExpanded = true + showInactiveIcon = false + iconScale = 1.0 + } + } + } + + private func triggerInactiveAnimation() { + withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { + isExpanded = true + } + withAnimation(.smooth(duration: 0.2)) { + showInactiveIcon = true + } + + if focusToastMode { + iconScale = 1.0 + scaleResetTask?.cancel() + } else { + withAnimation(.interpolatingSpring(stiffness: 220, damping: 12)) { + iconScale = 1.2 + } + + scaleResetTask?.cancel() + scaleResetTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(450)) + withAnimation(.interpolatingSpring(stiffness: 180, damping: 18)) { + iconScale = 1.0 + } + withAnimation(.smooth(duration: 0.2)) { + showInactiveIcon = false + } + } + } + + collapseTask?.cancel() + collapseTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(focusToastMode ? ToastTiming.inactiveDisplay : 320)) + withAnimation(.smooth(duration: 0.32)) { + isExpanded = false + if focusToastMode { + showInactiveIcon = false + } + } + } + + cleanupTask?.cancel() + guard !focusToastMode else { return } + cleanupTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(650)) + withAnimation(.smooth(duration: 0.2)) { + showInactiveIcon = false + } + } + } + + private func scheduleTransientCollapse() { + collapseTask?.cancel() + cleanupTask?.cancel() + + collapseTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(ToastTiming.activeDisplay)) + withAnimation(.smooth(duration: 0.32)) { + isExpanded = false + showInactiveIcon = false + } + } + } + + private func cancelPendingTasks() { + scaleResetTask?.cancel() + collapseTask?.cancel() + cleanupTask?.cancel() + scaleResetTask = nil + collapseTask = nil + cleanupTask = nil + } +} + +#Preview { + DoNotDisturbLiveActivity() + .environmentObject(DynamicIslandViewModel()) + .frame(width: 320, height: 54) + .background(Color.black) +} + +private final class SymbolAvailabilityCache { + static let shared = SymbolAvailabilityCache() + private var cache: [String: Bool] = [:] + private let lock = NSLock() + + func isSymbolAvailable(_ name: String) -> Bool { + lock.lock() + if let cached = cache[name] { + lock.unlock() + return cached + } + lock.unlock() + + #if canImport(AppKit) + let available = NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil + #else + let available = false + #endif + + lock.lock() + cache[name] = available + lock.unlock() + return available + } +} + +private enum FocusLabelMetrics { + static let baselineText = "Do Not Disturb" + + static let baselineWidth: CGFloat = { + #if canImport(AppKit) + let font = NSFont.systemFont(ofSize: 12, weight: .semibold) + return (baselineText as NSString).size(withAttributes: [.font: font]).width + #else + return 0 + #endif + }() +} diff --git a/DynamicIsland/components/HoverButton.swift b/DynamicIsland/components/HoverButton.swift index d1f6d0ef..8414dd00 100644 --- a/DynamicIsland/components/HoverButton.swift +++ b/DynamicIsland/components/HoverButton.swift @@ -1,25 +1,50 @@ -// -// HoverButton.swift -// DynamicIsland -// -// Created by Kraigo on 04.09.2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI struct HoverButton: View { var icon: String - var iconColor: Color = .white; + var iconColor: Color = .white var scale: Image.Scale = .medium + var pressEffect: PressEffect? = nil + var contentTransition: ContentTransition = .symbolEffect + var externalTriggerToken: Int? = nil + var externalTriggerEffect: PressEffect? = nil var action: () -> Void - var contentTransition: ContentTransition = .symbolEffect; @State private var isHovering = false + @State private var pressOffset: CGFloat = 0 + @State private var wiggleAngle: Double = 0 + @State private var wiggleToken: Int = 0 + @State private var lastExternalTriggerToken: Int? var body: some View { let size = CGFloat(scale == .large ? 40 : 30) - Button(action: action) { + Button(action: { + triggerPressEffect() + action() + }) { Rectangle() .fill(.clear) .contentShape(Rectangle()) @@ -29,18 +54,80 @@ struct HoverButton: View { .fill(isHovering ? Color.gray.opacity(0.2) : .clear) .frame(width: size, height: size) .overlay { - Image(systemName: icon) + let baseImage = Image(systemName: icon) .foregroundColor(iconColor) .contentTransition(contentTransition) .font(scale == .large ? .largeTitle : .body) + + if case .wiggle = pressEffect { + if #available(macOS 15.0, *) { + baseImage + .symbolEffect( + .wiggle.byLayer, + options: .nonRepeating, + value: wiggleToken + ) + } else { + baseImage + } + } else { + baseImage + } } } } .buttonStyle(PlainButtonStyle()) + .offset(x: pressOffset) + .rotationEffect(.degrees(wiggleAngle)) .onHover { hovering in withAnimation(.smooth(duration: 0.3)) { isHovering = hovering } } + .onChange(of: externalTriggerToken) { _, newToken in + guard let newToken, newToken != lastExternalTriggerToken else { return } + lastExternalTriggerToken = newToken + triggerPressEffect(override: externalTriggerEffect) + } + } + + private func triggerPressEffect(override: PressEffect? = nil) { + guard let effect = override ?? pressEffect else { return } + + switch effect { + case .nudge(let amount): + withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) { + pressOffset = amount + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { + withAnimation(.spring(response: 0.28, dampingFraction: 0.72)) { + pressOffset = 0 + } + } + case .wiggle(let direction): + guard #available(macOS 14.0, *) else { return } + wiggleToken += 1 + let angle: Double = direction == .clockwise ? 10 : -10 + + withAnimation(.spring(response: 0.18, dampingFraction: 0.5)) { + wiggleAngle = angle + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { + withAnimation(.spring(response: 0.32, dampingFraction: 0.72)) { + wiggleAngle = 0 + } + } + } + } + + enum PressEffect { + case nudge(CGFloat) + case wiggle(WiggleDirection) + } + + enum WiggleDirection { + case clockwise + case counterClockwise } } diff --git a/DynamicIsland/components/IdleAnimationView.swift b/DynamicIsland/components/IdleAnimationView.swift new file mode 100644 index 00000000..01382cb4 --- /dev/null +++ b/DynamicIsland/components/IdleAnimationView.swift @@ -0,0 +1,97 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Lottie +import LottieUI +import Defaults + +struct IdleAnimationView: View { + @Default(.selectedIdleAnimation) var selectedAnimation + @Default(.animationTransformOverrides) var overrides + + var body: some View { + Group { + if let animation = selectedAnimation { + AnimationContentView(animation: animation) + .id("\(animation.id)-\(overrides[animation.id.uuidString]?.hashValue ?? 0)") // Force recreation when override changes + } else { + // No animation selected + EmptyView() + } + } + } +} + +/// Internal view that renders the actual animation content +private struct AnimationContentView: View { + let animation: CustomIdleAnimation + + var body: some View { + let config = animation.getTransformConfig() + + // Debug logging + let _ = print("🎨 [IdleAnimationView] Rendering animation: \(animation.name)") + let _ = print("🎨 [IdleAnimationView] Config: scale=\(config.scale), offset=(\(config.offsetX), \(config.offsetY)), opacity=\(config.opacity)") + + switch animation.source { + case .lottieFile(let url): + LottieView(state: LUStateData( + type: .loadedFrom(url), + speed: animation.speed, + loopMode: config.loopMode.lottieLoopMode + )) + .id(animation.id) // Force reload when animation changes + .frame( + width: config.cropWidth * config.scale, + height: config.cropHeight * config.scale + ) + .offset(x: config.offsetX, y: config.offsetY) + .rotationEffect(.degrees(config.rotation)) + .opacity(config.opacity) + .padding(.bottom, config.paddingBottom) + .clipped() + + case .lottieURL(let url): + LottieView(state: LUStateData( + type: .loadedFrom(url), + speed: animation.speed, + loopMode: config.loopMode.lottieLoopMode + )) + .id(animation.id) // Force reload when animation changes + .frame( + width: config.cropWidth * config.scale, + height: config.cropHeight * config.scale + ) + .offset(x: config.offsetX, y: config.offsetY) + .rotationEffect(.degrees(config.rotation)) + .opacity(config.opacity) + .padding(.bottom, config.paddingBottom) + .clipped() + } + } +} + +// MARK: - Preview +#Preview { + ZStack { + Color.black + IdleAnimationView() + } + .frame(width: 100, height: 50) +} diff --git a/DynamicIsland/components/Live activities/DownloadView.swift b/DynamicIsland/components/Live activities/DownloadView.swift index 0c5ccf06..caa28684 100644 --- a/DynamicIsland/components/Live activities/DownloadView.swift +++ b/DynamicIsland/components/Live activities/DownloadView.swift @@ -1,9 +1,24 @@ -// -// DownloadView.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 17/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation import SwiftUI diff --git a/DynamicIsland/components/Live activities/DynamicIslandBattery.swift b/DynamicIsland/components/Live activities/DynamicIslandBattery.swift index e5ec1731..6d13b574 100644 --- a/DynamicIsland/components/Live activities/DynamicIslandBattery.swift +++ b/DynamicIsland/components/Live activities/DynamicIslandBattery.swift @@ -1,15 +1,34 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + import SwiftUI import Defaults /// A view that displays the battery status with an icon and charging indicator. struct BatteryView: View { - + @Default(.showPowerStatusIcons) var showPowerStatusIcons var levelBattery: Float var isPluggedIn: Bool var isCharging: Bool var isInLowPowerMode: Bool var batteryWidth: CGFloat = 26 var isForNotification: Bool + var showPercentInside: Bool = false var animationStyle: DynamicIslandAnimations = DynamicIslandAnimations() @@ -61,7 +80,31 @@ struct BatteryView: View { ) .padding(.leading, 2) - if iconStatus != "" && (isForNotification || Defaults[.showPowerStatusIcons]) { + if showPercentInside { + let showsStatusGlyph = iconStatus != "" && (isForNotification || showPowerStatusIcons) + let bodyHeight = (batteryWidth - 2.75) - 18 + let glyphColor: Color = isCharging ? .white : .black + let statusSymbol: String? = { + guard showsStatusGlyph else { return nil } + if isCharging { return "bolt.fill" } + if isPluggedIn { return "powerplug.fill" } + return nil + }() + HStack(spacing: 0.5) { + Text("\(Int(levelBattery))") + .font(.system(size: batteryWidth * 0.42, weight: .heavy, design: .rounded)) + .foregroundStyle(glyphColor) + .minimumScaleFactor(0.5) + .lineLimit(1) + if let statusSymbol { + Image(systemName: statusSymbol) + .font(.system(size: batteryWidth * 0.22, weight: .black)) + .foregroundStyle(glyphColor) + } + } + .frame(width: batteryWidth - 7, height: bodyHeight, alignment: .center) + .padding(.leading, 2) + } else if iconStatus != "" && (isForNotification || showPowerStatusIcons) { ZStack { Image(iconStatus) .resizable() @@ -78,6 +121,100 @@ struct BatteryView: View { } } +/// Pill battery for the minimalist notch, matching the charging HUD style. +struct MinimalisticBatteryView: View { + @Default(.showPowerStatusIcons) var showPowerStatusIcons + var levelBattery: Float + var isPluggedIn: Bool + var isCharging: Bool + var isInLowPowerMode: Bool + var bodyWidth: CGFloat = 28 + var bodyHeight: CGFloat = 16 + var isForNotification: Bool + var showPercentInside: Bool = false + + private var clamped: CGFloat { + max(0, min(CGFloat(levelBattery), 100)) + } + + private var batteryColor: Color { + if isInLowPowerMode { + return .yellow + } else if clamped <= 20 && !isCharging && !isPluggedIn { + return .red + } else if isCharging || isPluggedIn || clamped == 100 { + return .green + } else { + return .white + } + } + + private var showsStatusGlyph: Bool { + (isCharging || isPluggedIn) && (isForNotification || showPowerStatusIcons) + } + + private var statusSymbol: String? { + guard showsStatusGlyph else { return nil } + if isCharging { return "bolt.fill" } + if isPluggedIn { return "powerplug.fill" } + return nil + } + + private var glyphColor: Color { + isCharging ? .white : .black + } + + private var terminalHeight: CGFloat { + max(4, bodyHeight * 0.38) + } + + var body: some View { + HStack(spacing: 1.5) { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(batteryColor.opacity(0.3)) + + GeometryReader { geo in + Rectangle() + .fill(batteryColor.gradient) + .frame(width: max(0, (clamped / 100) * geo.size.width)) + } + + if showPercentInside { + HStack(spacing: 0.5) { + Text("\(Int(clamped))") + .font(.system(size: bodyHeight * 0.6, weight: .heavy, design: .rounded)) + .foregroundStyle(glyphColor) + .minimumScaleFactor(0.5) + .lineLimit(1) + if let statusSymbol { + Image(systemName: statusSymbol) + .font(.system(size: bodyHeight * 0.42, weight: .black)) + .foregroundStyle(glyphColor) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(.horizontal, 2) + } else if let statusSymbol { + Image(systemName: statusSymbol) + .font(.system(size: bodyHeight * 0.6, weight: .heavy)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + .frame(width: bodyWidth, height: bodyHeight) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(clamped == 100 ? batteryColor.gradient : batteryColor.opacity(0.4).gradient) + .frame(width: 2, height: terminalHeight) + } + .animation(.smooth(duration: 0.18), value: clamped) + .animation(.smooth(duration: 0.18), value: isCharging) + .animation(.smooth(duration: 0.18), value: isPluggedIn) + } +} + /// A view that displays detailed battery information and settings. struct BatteryMenuView: View { @@ -164,6 +301,7 @@ struct BatteryMenuView: View { /// A view that displays the battery status and allows interaction to show detailed information. struct DynamicIslandBatteryView: View { + @Default(.showBatteryPercentage) var showBatteryPercentage @State var batteryWidth: CGFloat = 26 var isCharging: Bool = false var isInLowPowerMode: Bool = false @@ -181,10 +319,18 @@ struct DynamicIslandBatteryView: View { var body: some View { HStack { - if Defaults[.showBatteryPercentage] { - Text("\(Int32(levelBattery))%") - .font(.callout) - .foregroundStyle(.white) + if showBatteryPercentage { + ZStack(alignment: .trailing) { + Text("100%") + .font(.callout) + .hidden() + + Text("\(Int32(levelBattery))%") + .font(.callout) + .foregroundStyle(.white) + .lineLimit(1) + } + .fixedSize(horizontal: true, vertical: false) } BatteryView( levelBattery: levelBattery, @@ -242,6 +388,443 @@ struct DynamicIslandBatteryView: View { } } + +private struct BatteryTemporaryHUDMetrics { + let width: CGFloat + let height: CGFloat + let topRadius: CGFloat + let bottomRadius: CGFloat +} + +private extension BatteryTemporaryHUDKind { + func metrics( + style: BatteryNotificationStyle, + closedNotchWidth: CGFloat, + baseHeight: CGFloat + ) -> BatteryTemporaryHUDMetrics { + let compactBaseRadius = max(baseHeight / 2, 16) + let compactTopRadius = max(12, compactBaseRadius - 4) + + switch (self, style) { + case (.charging, _), (.lowBattery, .compact), (.fullBattery, .compact): + return BatteryTemporaryHUDMetrics( + width: closedNotchWidth + 180, + height: baseHeight, + topRadius: compactTopRadius, + bottomRadius: compactBaseRadius + ) + case (.lowBattery, .standard): + return BatteryTemporaryHUDMetrics( + width: closedNotchWidth + 150, + height: baseHeight + 75, + topRadius: 22, + bottomRadius: 40 + ) + case (.fullBattery, .standard): + return BatteryTemporaryHUDMetrics( + width: closedNotchWidth + 140, + height: baseHeight + 70, + topRadius: 18, + bottomRadius: 36 + ) + } + } +} + +private struct BatteryCompactStatusRow: View { + let title: String + let batteryLevel: Int + let tint: Color + + var body: some View { + HStack { + Text(verbatim: title) + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.8)) + + Spacer() + + HStack(spacing: 6) { + Text("\(batteryLevel)%") + .font(.system(size: 14)) + .foregroundColor(tint) + + HStack(spacing: 1.5) { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(tint.opacity(0.3)) + + GeometryReader { geo in + let clamped = max(0, min(batteryLevel, 100)) + let width = CGFloat(clamped) / 100 * geo.size.width + Rectangle() + .fill(tint.gradient) + .frame(width: max(0, width)) + } + } + .frame(width: 28, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(batteryLevel == 100 ? tint.gradient : tint.opacity(0.3).gradient) + .frame(width: 2, height: 6) + } + } + } + .padding(.horizontal, 16) + } +} + +struct BatteryTemporaryActivityView: View { + let kind: BatteryTemporaryHUDKind + let batteryLevel: Int + let isLowPowerMode: Bool + let closedNotchWidth: CGFloat + let baseHeight: CGFloat + let isDynamicIslandMode: Bool + let topCornerRadius: CGFloat + @Default(.lowBatteryHUDStyle) var lowBatteryHUDStyle + @Default(.fullBatteryHUDStyle) var fullBatteryHUDStyle + var styleOverride: BatteryNotificationStyle? = nil + + @State private var pulse = false + @State private var showBatteryIndicator = false + @State private var changeBatteryIndicator = true + + private var style: BatteryNotificationStyle { + if kind == .charging { + return .compact + } + if let styleOverride { + return styleOverride + } + switch kind { + case .charging: + return .compact + case .lowBattery: + return lowBatteryHUDStyle + case .fullBattery: + return fullBatteryHUDStyle + } + } + + private var metrics: BatteryTemporaryHUDMetrics { + kind.metrics( + style: style, + closedNotchWidth: closedNotchWidth, + baseHeight: baseHeight + ) + } + + private var batteryTint: Color { + switch kind { + case .charging: + if isLowPowerMode { + return .yellow + } else if batteryLevel <= 20 { + return .red + } + return .green + case .lowBattery: + return isLowPowerMode ? .yellow : .red + case .fullBattery: + return isLowPowerMode ? .yellow : .green + } + } + + + private var surfaceShape: AnyShape { + if isDynamicIslandMode { + return AnyShape(DynamicIslandPillShape(cornerRadius: dynamicIslandPillCornerRadiusInsets.opened)) + } else { + return AnyShape(NotchShape(topCornerRadius: topCornerRadius, bottomCornerRadius: metrics.bottomRadius)) + } + } + + var body: some View { + ZStack(alignment: .bottom) { + content + } + .frame(width: metrics.width, height: metrics.height, alignment: .bottom) + .clipShape(surfaceShape) + .onAppear(perform: prepareAnimations) + } + + @ViewBuilder + private var content: some View { + if style == .compact { + BatteryCompactStatusRow( + title: compactTitle, + batteryLevel: batteryLevel, + tint: batteryTint + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + VStack { + Spacer() + + HStack { + VStack(alignment: .leading, spacing: kind == .lowBattery ? 2 : 3) { + standardTitle + standardDescription + } + + Spacer() + + standardIndicator + } + .padding(.leading, kind == .lowBattery ? 40 : 35) + .padding(.trailing, kind == .lowBattery ? 45 : 40) + .padding(.bottom, 20) + } + } + } + + private var compactTitle: String { + switch kind { + case .charging: + return "Charging" + case .lowBattery: + return "Low Battery" + case .fullBattery: + return "Full Battery" + } + } + + @ViewBuilder + private var standardTitle: some View { + HStack(spacing: 5) { + Text(verbatim: kind == .lowBattery ? "Battery Low" : "Full Battery") + .font(.system(size: kind == .lowBattery ? 13 : 13, weight: .semibold)) + .foregroundStyle(.white.opacity(0.82)) + .lineLimit(1) + + Text("\(batteryLevel)%") + .font(.system(size: kind == .lowBattery ? 12 : 13, weight: .semibold)) + .foregroundStyle(batteryTint) + } + } + + @ViewBuilder + private var standardDescription: some View { + switch kind { + case .charging: + EmptyView() + case .lowBattery: + if isLowPowerMode { + ( + Text(verbatim: "Low Power Mode enabled") + .foregroundColor(.yellow) + .font(.system(size: 10, weight: .medium)) + + + Text(verbatim: ", it is recommended to charge it.") + .foregroundColor(.gray.opacity(0.6)) + .font(.system(size: 10, weight: .medium)) + ) + } else { + Text(verbatim: "Turn on Low Power Mode or it\nis recommended to charge it.") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.gray.opacity(0.6)) + .lineLimit(2) + } + case .fullBattery: + Text(verbatim: "Your Mac is fully charged.") + .font(.system(size: 10)) + .foregroundStyle(.gray.opacity(0.6)) + .fontWeight(.medium) + .lineLimit(1) + } + } + + @ViewBuilder + private var standardIndicator: some View { + switch kind { + case .charging: + EmptyView() + case .lowBattery: + if isLowPowerMode { + yellowLowIndicator + } else { + redLowIndicator + } + case .fullBattery: + if showBatteryIndicator { + if isLowPowerMode { + yellowFullIndicator + .transition(.opacity.combined(with: .scale)) + } else { + greenFullIndicator + .transition(.opacity.combined(with: .scale)) + } + } else { + magSafeIndicator + .transition(.opacity.combined(with: .scale)) + } + } + } + + private var redLowIndicator: some View { + ZStack { + RoundedRectangle(cornerRadius: 30) + .fill(.red.opacity(0.2)) + .frame(width: 70, height: 40) + + HStack(spacing: 2) { + RoundedRectangle(cornerRadius: 10) + .fill(.red.opacity(0.4)) + .frame(width: 40, height: 24) + + RoundedRectangle(cornerRadius: 10) + .fill(.red.opacity(0.4)) + .frame(width: 3, height: 8) + } + .padding(.trailing, 5) + + RoundedRectangle(cornerRadius: 8) + .fill(Color.red.gradient) + .frame(width: 8, height: 14) + .opacity(pulse ? 1 : 0.3) + .offset(x: -15) + + RoundedRectangle(cornerRadius: 30) + .stroke(Color.red.opacity(0.9), lineWidth: 1.5) + .frame(width: pulse ? 8 : 30, height: pulse ? 14 : 32) + .offset(x: -15) + .opacity(pulse ? 0.3 : 1) + } + } + + private var yellowLowIndicator: some View { + ZStack { + RoundedRectangle(cornerRadius: 30) + .fill(.yellow.opacity(0.2)) + .frame(width: 70, height: 40) + + HStack(spacing: 2) { + RoundedRectangle(cornerRadius: 10) + .fill(.yellow.opacity(0.4)) + .frame(width: 40, height: 24) + + RoundedRectangle(cornerRadius: 10) + .fill(.yellow.opacity(0.4)) + .frame(width: 3, height: 8) + } + .padding(.trailing, 5) + + RoundedRectangle(cornerRadius: 8) + .fill(.yellow.gradient) + .frame(width: 8, height: 14) + .offset(x: -15) + } + } + + private var greenFullIndicator: some View { + ZStack { + RoundedRectangle(cornerRadius: 30) + .fill(.green.opacity(0.2)) + .frame(width: 70, height: 40) + + HStack(spacing: 2) { + RoundedRectangle(cornerRadius: 10) + .fill(.green.opacity(0.4)) + .frame(width: 44, height: 24) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(Color.green.gradient) + .frame(width: 34, height: 14) + .opacity(pulse ? 1 : 0.4) + ) + + RoundedRectangle(cornerRadius: 10) + .fill(.green.opacity(0.4)) + .frame(width: 3, height: 8) + } + } + } + + private var yellowFullIndicator: some View { + ZStack { + RoundedRectangle(cornerRadius: 30) + .fill(.yellow.opacity(0.2)) + .frame(width: 70, height: 40) + + HStack(spacing: 2) { + RoundedRectangle(cornerRadius: 10) + .fill(.yellow.opacity(0.4)) + .frame(width: 44, height: 24) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(Color.yellow.gradient) + .frame(width: 34, height: 14) + .opacity(pulse ? 1 : 0.4) + ) + + RoundedRectangle(cornerRadius: 10) + .fill(.yellow.opacity(0.4)) + .frame(width: 3, height: 8) + } + } + } + + private var magSafeIndicator: some View { + HStack(spacing: 0) { + Rectangle() + .fill(.gray.opacity(0.15)) + .frame(width: 30, height: 5) + + ZStack { + RoundedRectangle(cornerRadius: 2) + .fill(.gray.opacity(0.2).gradient) + .frame(width: 30, height: 40) + + Circle() + .fill(changeBatteryIndicator ? .orange : .green) + .shadow(color: changeBatteryIndicator ? .orange : .green, radius: 5) + .frame(width: 5, height: 5) + } + + Rectangle() + .fill(.white.opacity(0.4)) + .frame(width: 3, height: 32) + } + } + + private func prepareAnimations() { + pulse = false + showBatteryIndicator = kind == .fullBattery && style == .standard + changeBatteryIndicator = true + + guard style == .standard else { return } + + switch kind { + case .charging: + break + case .lowBattery: + if !isLowPowerMode { + withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { + pulse = true + } + } + case .fullBattery: + withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { + pulse = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation(.spring(duration: 0.4)) { + showBatteryIndicator = false + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + withAnimation(.spring(duration: 0.2)) { + changeBatteryIndicator = false + } + } + } + } +} + #Preview { DynamicIslandBatteryView( batteryWidth: 30, diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift index f41e4698..d62a6186 100644 --- a/DynamicIsland/components/Live activities/InlineHUD.swift +++ b/DynamicIsland/components/Live activities/InlineHUD.swift @@ -1,11 +1,84 @@ -// -// InlineHUDs.swift -// DynamicIsland -// -// Created by Richard Kunkli on 14/09/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI +import AppKit +import AVFoundation +import Defaults + +// MARK: - Inline HUD looping .mov icon + +private final class LoopingPlayerController { + let player: AVQueuePlayer + private var looper: AVPlayerLooper? + + init(url: URL) { + let item = AVPlayerItem(url: url) + self.player = AVQueuePlayer() + self.player.isMuted = true + self.player.actionAtItemEnd = .none + self.looper = AVPlayerLooper(player: self.player, templateItem: item) + self.player.play() + } + + deinit { + player.pause() + looper = nil + } +} + +private struct LoopingVideoIcon: NSViewRepresentable { + let url: URL + let size: CGSize + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: NSRect(origin: .zero, size: size)) + view.wantsLayer = true + + let layer = AVPlayerLayer() + layer.videoGravity = .resizeAspect + layer.frame = view.bounds + + view.layer?.addSublayer(layer) + + context.coordinator.attach(layer: layer, url: url) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + // No-op; the animation loops via AVPlayerLooper. + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator { + private var controller: LoopingPlayerController? + + func attach(layer: AVPlayerLayer, url: URL) { + controller = LoopingPlayerController(url: url) + layer.player = controller?.player + } + } +} struct InlineHUD: View { @EnvironmentObject var vm: DynamicIslandViewModel @@ -14,14 +87,122 @@ struct InlineHUD: View { @Binding var icon: String @Binding var hoverAnimation: Bool @Binding var gestureProgress: CGFloat + + @Default(.useColorCodedBatteryDisplay) var useColorCodedBatteryDisplay + @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 + @Default(.useBluetoothHUD3DIcon) var useBluetoothHUD3DIcon + @Default(.enableMinimalisticUI) var enableMinimalisticUI + @Default(.showCapsLockLabel) var showCapsLockLabel + @Default(.capsLockIndicatorTintMode) var capsLockTintMode + @ObservedObject var bluetoothManager = BluetoothAudioManager.shared + + @State private var displayName: String = "" + var body: some View { - HStack { + let useCircularIndicator = useCircularBluetoothBatteryIndicator + let hasBatteryLevel = value > 0 + let capsLockAccentColor = capsLockTintMode.color + + // Widths stay constant whether hovered or not so HUD text/icons do + // not slide toward the notch edges when the user moves over it. + // Mini BT marquee widths are reduced to keep both wings inside the + // minimalistic open notch (420pt) on wider hardware notches. + let baseInfoWidth: CGFloat = { + if type == .bluetoothAudio { + if showBluetoothDeviceNameMarquee { + return enableMinimalisticUI ? 96 : 132 + } + return enableMinimalisticUI ? 56 : 64 + } + + if type == .capsLock && !showCapsLockLabel { + return enableMinimalisticUI ? 48 : 56 + } + + return 92 + }() + + let infoWidth: CGFloat = { + let width = baseInfoWidth + gestureProgress / 2 + let minimum: CGFloat = { + if type == .bluetoothAudio { + if showBluetoothDeviceNameMarquee { + return enableMinimalisticUI ? 84 : 112 + } + return enableMinimalisticUI ? 48 : 60 + } + + if type == .capsLock && !showCapsLockLabel { + return enableMinimalisticUI ? 36 : 44 + } + + return 88 + }() + return max(width, minimum) + }() + + let baseTrailingWidth: CGFloat = { + if type == .bluetoothAudio { + if !hasBatteryLevel { + return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 88 : 110) : (enableMinimalisticUI ? 66 : 80) + } + + if useCircularIndicator { + return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 92 : 112) : (enableMinimalisticUI ? 64 : 76) + } + + return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 100 : 128) : (enableMinimalisticUI ? 84 : 100) + } + + if type == .capsLock { + if showCapsLockLabel { + return enableMinimalisticUI ? 76 : 88 + } + return 0 + } + + return 92 + }() + + let trailingWidth: CGFloat = { + let width = baseTrailingWidth + gestureProgress / 2 + let minimum: CGFloat = { + if type == .bluetoothAudio { + if !hasBatteryLevel { + return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 80 : 102) : (enableMinimalisticUI ? 56 : 80) + } + + if useCircularIndicator { + return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 80 : 102) : (enableMinimalisticUI ? 48 : 64) + } + + return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 88 : 112) : (enableMinimalisticUI ? 64 : 82) + } + + if type == .capsLock { + return showCapsLockLabel ? (enableMinimalisticUI ? 60 : 72) : 0 + } + + return 90 + }() + return max(width, minimum) + }() + + return HStack { HStack(spacing: 5) { Group { switch (type) { case .volume: if icon.isEmpty { - Image(systemName: SpeakerSymbol(value)) + // Show headphone icon if Bluetooth audio is connected, otherwise speaker + let baseIcon = bluetoothManager.isBluetoothAudioConnected ? "headphones" : SpeakerSymbol(value) + Image(systemName: baseIcon) .contentTransition(.interpolate) .symbolVariant(value > 0 ? .none : .slash) .frame(width: 20, height: 15, alignment: .leading) @@ -33,11 +214,11 @@ struct InlineHUD: View { .frame(width: 20, height: 15, alignment: .leading) } case .brightness: - Image(systemName: BrightnessSymbol(value)) + Image(systemName: !icon.isEmpty ? icon : BrightnessSymbol(value)) .contentTransition(.interpolate) .frame(width: 20, height: 15, alignment: .center) case .backlight: - Image(systemName: "keyboard") + Image(systemName: BacklightSymbol(value)) .contentTransition(.interpolate) .frame(width: 20, height: 15, alignment: .center) case .mic: @@ -51,6 +232,24 @@ struct InlineHUD: View { .symbolRenderingMode(.hierarchical) .contentTransition(.interpolate) .frame(width: 20, height: 15, alignment: .center) + case .bluetoothAudio: + if useBluetoothHUD3DIcon, + let deviceType = bluetoothManager.lastConnectedDevice?.deviceType, + let url = Bundle.main.url(forResource: deviceType.inlineHUDAnimationBaseName, withExtension: "mov") { + LoopingVideoIcon(url: url, size: CGSize(width: 20, height: 20)) + .frame(width: 20, height: 20, alignment: .center) + } else { + Image(systemName: icon.isEmpty ? "dot.radiowaves.left.and.right" : icon) + .symbolRenderingMode(.hierarchical) + .contentTransition(.interpolate) + .frame(width: 20, height: 15, alignment: .center) + } + case .capsLock: + Image(systemName: "capslock.fill") + .symbolRenderingMode(.hierarchical) + .contentTransition(.interpolate) + .frame(width: 20, height: 15, alignment: .center) + .foregroundStyle(capsLockAccentColor) default: EmptyView() } @@ -58,14 +257,29 @@ struct InlineHUD: View { .foregroundStyle(.white) .symbolVariant(.fill) - Text(Type2Name(type)) - .font(.subheadline) - .fontWeight(.medium) - .lineLimit(1) - .allowsTightening(true) - .contentTransition(.numericText()) + // Use marquee text for device names to handle long names + if type == .bluetoothAudio { + if showBluetoothDeviceNameMarquee { + MarqueeText( + $displayName, + font: .system(size: 13, weight: .medium), + nsFont: .body, + textColor: .white, + minDuration: 0.2, + frameWidth: infoWidth + ) + } + } else if type != .capsLock { + Text(Type2Name(type)) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + .allowsTightening(true) + .contentTransition(.numericText()) + .foregroundStyle(.white) + } } - .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) @@ -88,27 +302,167 @@ struct InlineHUD: View { .multilineTextAlignment(.trailing) .frame(maxWidth: .infinity, alignment: .trailing) .contentTransition(.interpolate) + } else if (type == .capsLock) { + if showCapsLockLabel { + Text("Caps Lock") + .foregroundStyle(capsLockAccentColor) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + .allowsTightening(true) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity, alignment: .trailing) + .contentTransition(.interpolate) + } + } else if (type == .bluetoothAudio) { + if hasBatteryLevel { + let indicatorSpacing: CGFloat = { + if useCircularIndicator { + return showBluetoothBatteryPercentageText ? 8 : 2 + } + return showBluetoothBatteryPercentageText ? 6 : 4 + }() + + HStack(spacing: indicatorSpacing) { + if useCircularIndicator { + CircularBatteryIndicator( + value: value, + useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented, + smoothGradient: useSmoothColorGradient + ) + .allowsHitTesting(false) + } else { + LinearBatteryIndicator( + value: value, + useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented, + smoothGradient: useSmoothColorGradient + ) + .allowsHitTesting(false) + } + + if showBluetoothBatteryPercentageText { + Text("\(Int(value * 100))%") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.white) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } } else { - HStack { - DraggableProgressBar(value: $value) - if (type == .volume && value.isZero) { - Text("muted") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.gray) - .lineLimit(1) - .allowsTightening(true) - .multilineTextAlignment(.trailing) + // 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 { + HStack(spacing: 6) { + DraggableProgressBar(value: $value) + PercentageLabel(value: value, isVisible: showProgressPercentages) + } } } + .frame(maxWidth: .infinity, alignment: .trailing) } } - .padding(.trailing, 4) - .frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center) + .padding(.trailing, trailingWidth > 0 ? 4 : 0) + .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) + + Circle() + .trim(from: 0, to: max(clampedValue, 0.015)) + .rotation(.degrees(-90)) + .stroke(indicatorColor, style: StrokeStyle(lineWidth: 2.2, lineCap: .round)) + } + .frame(width: 16, height: 16) + .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: @@ -134,17 +488,28 @@ struct InlineHUD: View { return "sun.min" } } + + func BacklightSymbol(_ value: CGFloat) -> String { + if value >= 0.5 { + return "light.max" + } + return "light.min" + } func Type2Name(_ type: SneakContentType) -> String { switch(type) { case .volume: - return "Volume" + return String(localized: "Volume") case .brightness: - return "Brightness" + return String(localized: "Brightness") case .backlight: - return "Backlight" + return String(localized: "Backlight") case .mic: - return "Mic" + return String(localized: "Mic") + case .bluetoothAudio: + return BluetoothAudioManager.shared.lastConnectedDevice?.name ?? "Bluetooth" + case .capsLock: + return String(localized: "Caps Lock") default: return "" } diff --git a/DynamicIsland/components/Live activities/LiveActivityModifier.swift b/DynamicIsland/components/Live activities/LiveActivityModifier.swift index 051b7790..24ba39aa 100644 --- a/DynamicIsland/components/Live activities/LiveActivityModifier.swift +++ b/DynamicIsland/components/Live activities/LiveActivityModifier.swift @@ -1,9 +1,24 @@ -// -// LiveActivityModifier.swift -// DynamicIsland -// -// Created by Richard Kunkli on 12/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI diff --git a/DynamicIsland/components/Live activities/MarqueeTextView.swift b/DynamicIsland/components/Live activities/MarqueeTextView.swift index dd3f61bc..a20be48a 100644 --- a/DynamicIsland/components/Live activities/MarqueeTextView.swift +++ b/DynamicIsland/components/Live activities/MarqueeTextView.swift @@ -1,9 +1,24 @@ -// -// MarqueeTextView.swift -// DynamicIsland -// -// Created by Richard Kunkli on 08/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI @@ -31,9 +46,9 @@ struct MarqueeText: View { let minDuration: Double let frameWidth: CGFloat - @State private var animate = false @State private var textSize: CGSize = .zero @State private var offset: CGFloat = 0 + @State private var isAnimating: Bool = false init(_ text: Binding, font: Font = .body, nsFont: NSFont.TextStyle = .body, textColor: Color = .primary, backgroundColor: Color = .clear, minDuration: Double = 3.0, frameWidth: CGFloat = 200) { _text = text @@ -50,44 +65,233 @@ struct MarqueeText: View { } var body: some View { - GeometryReader { geometry in - ZStack(alignment: .leading) { - HStack(spacing: 20) { - Text(text) - Text(text) - .opacity(needsScrolling ? 1 : 0) - } - .id(text) - .font(font) - .foregroundColor(textColor) - .fixedSize(horizontal: true, vertical: false) - .offset(x: self.animate ? offset : 0) - .animation( - self.animate ? - .linear(duration: Double(textSize.width / 30)) - .delay(minDuration) - .repeatForever(autoreverses: false) : .none, - value: self.animate - ) - .background(backgroundColor) - .modifier(MeasureSizeModifier()) - .onPreferenceChange(SizePreferenceKey.self) { size in - self.textSize = CGSize(width: size.width / 2, height: NSFont.preferredFont(forTextStyle: nsFont).pointSize) - self.animate = false - self.offset = 0 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01){ - if needsScrolling { - self.animate = true - self.offset = -(textSize.width + 10) - - } - } - } + ZStack(alignment: .leading) { + HStack(spacing: 20) { + Text(text) + Text(text) + .opacity(needsScrolling ? 1 : 0) + } + .id(text) + .font(font) + .foregroundColor(textColor) + .fixedSize(horizontal: true, vertical: false) + .offset(x: offset) + .background(backgroundColor) + .modifier(MeasureSizeModifier()) + .onPreferenceChange(SizePreferenceKey.self) { size in + self.textSize = CGSize(width: size.width / 2, height: NSFont.preferredFont(forTextStyle: nsFont).pointSize) + resetAndStart() + } + .onChange(of: text) { _, _ in + resetAndStart() + } + .onAppear { + resetAndStart() + } + .onDisappear { + isAnimating = false } - .frame(width: frameWidth, alignment: .leading) - .clipped() } + .frame(width: frameWidth, alignment: .leading) + .clipped() .frame(height: textSize.height * 1.3) + } + + private func resetAndStart() { + isAnimating = false + withAnimation(.none) { + offset = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if needsScrolling { + isAnimating = true + startAnimationLoop() + } + } + } + + private func startAnimationLoop() { + guard isAnimating && needsScrolling else { return } + // Duration based on speed (approx 30 pts per second) + let duration = Double(textSize.width / 30) + + // 1. Initial/Restart Pause + DispatchQueue.main.asyncAfter(deadline: .now() + minDuration) { + guard isAnimating else { return } + + // 2. Linear Move + withAnimation(.linear(duration: duration)) { + offset = -(textSize.width + 20) // Text width + spacing + } + + // 3. Wait for move to finish + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + guard isAnimating else { return } + + // 4. Snap back instantly + withAnimation(.none) { + offset = 0 + } + + // 5. Repeat + startAnimationLoop() + } + } + } +} + +struct MusicExplicitBadge: View { + var label: String = "E" + var fontSize: CGFloat = 9 + var height: CGFloat = 14 + var horizontalPadding: CGFloat = 4 + var minWidth: CGFloat? = nil + var foregroundColor: Color = .white.opacity(0.92) + var backgroundColor: Color = .white.opacity(0.18) + var cornerRadius: CGFloat? = nil + var body: some View { + Text(label) + .font(.system(size: fontSize, weight: .bold, design: .rounded)) + .foregroundColor(foregroundColor) + .padding(.horizontal, max(horizontalPadding, height * 0.2)) + .frame(minWidth: minWidth, minHeight: height) + .background( + RoundedRectangle( + cornerRadius: cornerRadius ?? max(4, height * 0.28), + style: .continuous + ) + .fill(backgroundColor) + ) + .fixedSize() + } +} + +struct MusicTitleMarqueeView: View { + let text: String + let isExplicit: Bool + let font: Font + let nsFont: NSFont.TextStyle + let textColor: Color + let backgroundColor: Color + let minDuration: Double + let frameWidth: CGFloat + let alignment: Alignment + let badgeSpacing: CGFloat + let badgeLabel: String + let badgeFontSize: CGFloat? + let badgeHeight: CGFloat? + let badgeForegroundColor: Color + let badgeBackgroundColor: Color + let badgeHorizontalPadding: CGFloat + let badgeMinWidth: CGFloat? + let badgeCornerRadius: CGFloat? + + init( + text: String, + isExplicit: Bool, + font: Font = .body, + nsFont: NSFont.TextStyle = .body, + textColor: Color = .primary, + backgroundColor: Color = .clear, + minDuration: Double = 3.0, + frameWidth: CGFloat = 200, + alignment: Alignment = .leading, + badgeSpacing: CGFloat = 6, + badgeLabel: String = "E", + badgeFontSize: CGFloat? = nil, + badgeHeight: CGFloat? = nil, + badgeForegroundColor: Color = .white.opacity(0.92), + badgeBackgroundColor: Color = .white.opacity(0.18), + badgeHorizontalPadding: CGFloat = 4, + badgeMinWidth: CGFloat? = nil, + badgeCornerRadius: CGFloat? = nil + ) { + self.text = text + self.isExplicit = isExplicit + self.font = font + self.nsFont = nsFont + self.textColor = textColor + self.backgroundColor = backgroundColor + self.minDuration = minDuration + self.frameWidth = frameWidth + self.alignment = alignment + self.badgeSpacing = badgeSpacing + self.badgeLabel = badgeLabel + self.badgeFontSize = badgeFontSize + self.badgeHeight = badgeHeight + self.badgeForegroundColor = badgeForegroundColor + self.badgeBackgroundColor = badgeBackgroundColor + self.badgeHorizontalPadding = badgeHorizontalPadding + self.badgeMinWidth = badgeMinWidth + self.badgeCornerRadius = badgeCornerRadius + } + + private var resolvedBadgeHeight: CGFloat { + badgeHeight ?? max(12, NSFont.preferredFont(forTextStyle: nsFont).pointSize * 1.12) + } + + private var resolvedBadgeFontSize: CGFloat { + badgeFontSize ?? max(8, resolvedBadgeHeight * 0.6) + } + + private var reservedBadgeWidth: CGFloat { + guard isExplicit else { return 0 } + return resolvedBadgeHeight + (resolvedBadgeHeight * 0.7) + badgeSpacing + } + + private var titleFrameWidth: CGFloat { + max(0, frameWidth - reservedBadgeWidth) + } + + private var measurementFont: NSFont { + NSFont.preferredFont(forTextStyle: nsFont) + } + + private var measuredTextWidth: CGFloat { + let attributes: [NSAttributedString.Key: Any] = [.font: measurementFont] + return ceil((text as NSString).size(withAttributes: attributes).width) + } + + private var needsScrolling: Bool { + measuredTextWidth > titleFrameWidth + } + + var body: some View { + HStack(spacing: badgeSpacing) { + if needsScrolling { + MarqueeText( + .constant(text), + font: font, + nsFont: nsFont, + textColor: textColor, + backgroundColor: backgroundColor, + minDuration: minDuration, + frameWidth: titleFrameWidth + ) + } else { + Text(text) + .font(font) + .foregroundColor(textColor) + .lineLimit(1) + .truncationMode(.tail) + .fixedSize(horizontal: true, vertical: false) + } + + if isExplicit { + MusicExplicitBadge( + label: badgeLabel, + fontSize: resolvedBadgeFontSize, + height: resolvedBadgeHeight, + horizontalPadding: badgeHorizontalPadding, + minWidth: badgeMinWidth, + foregroundColor: badgeForegroundColor, + backgroundColor: badgeBackgroundColor, + cornerRadius: badgeCornerRadius + ) + } + } + .frame(width: frameWidth, alignment: alignment) } } diff --git a/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift b/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift index 70f33c7f..1b2d4896 100644 --- a/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift +++ b/DynamicIsland/components/Live activities/SystemEventIndicatorModifier.swift @@ -1,9 +1,24 @@ - // - // SystemEventIndicatorModifier.swift - // DynamicIsland - // - // Created by Richard Kunkli on 12/08/2024. - // +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import Defaults @@ -22,6 +37,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) { @@ -45,7 +61,7 @@ struct SystemEventIndicatorModifier: View { .frame(width: 20, height: 15) .foregroundStyle(.white) case .backlight: - Image(systemName: "keyboard") + Image(systemName: BacklightSymbol(value)) .contentTransition(.symbolEffect) .frame(width: 20, height: 15) .foregroundStyle(.white) @@ -55,16 +71,33 @@ struct SystemEventIndicatorModifier: View { .contentTransition(.interpolate) .frame(width: 20, height: 15) .foregroundStyle(.white) + case .bluetoothAudio: + if !icon.isEmpty { + Image(systemName: icon) + .contentTransition(.interpolate) + .frame(width: 20, height: 15) + .foregroundStyle(.white) + } 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) + case .bluetoothAudio: + ProgressSection(value: $value, showPercentages: showProgressPercentages, colorMode: .battery) + default: + ProgressSection(value: $value, showPercentages: showProgressPercentages) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -86,35 +119,123 @@ struct SystemEventIndicatorModifier: View { return "speaker.wave.2" } } + + func BacklightSymbol(_ value: CGFloat) -> String { + value >= 0.5 ? "light.max" : "light.min" + } +} + +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 - ZStack(alignment: .leading) { - Capsule() - .fill(.tertiary) - Group { - if Defaults[.enableGradient] { - 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) - } else { + Group { + if progressBarStyle == .segmented { + // Segmented progress bar - completely different layout + SegmentedProgressContent(value: value, geometry: geo) + } else { + // Traditional capsule-based progress bar + ZStack(alignment: .leading) { 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) + .fill(.tertiary) + progressFill(width: geo.size.width) + .opacity(value.isZero ? 0 : 1) } } - .opacity(value.isZero ? 0 : 1) } .gesture( DragGesture(minimumDistance: 0) @@ -131,7 +252,7 @@ struct DraggableProgressBar: View { } ) } - .frame(height: Defaults[.inlineHUD] ? isDragging ? 8 : 5 : isDragging ? 9 : 6) + .frame(height: inlineHUD ? (isDragging ? 8 : 5) : (isDragging ? 9 : 6)) } } @@ -141,4 +262,159 @@ 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 { + let value: CGFloat + let geometry: GeometryProxy + + private let segmentCount = 16 + @State private var glowIndex: Int? = nil + @State private var lastValue: CGFloat = 0 + + 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, computed) + let activeCount = Int(round(value * CGFloat(segmentCount))) + + HStack(spacing: spacing) { + ForEach(0.. Color { + if isActive { + if Defaults[.systemEventIndicatorUseAccent] { + return Defaults[.accentColor] + } + return .white + } else { + return .white.opacity(0.15) + } + } + + private func glowShadowColor(for isActive: Bool, index: Int) -> Color { + if isActive { + if let glowIndex = glowIndex, index == glowIndex { + return Defaults[.systemEventIndicatorUseAccent] + ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.8) + : .white + } else { + return Defaults[.systemEventIndicatorUseAccent] + ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) + : .white + } + } + return .clear + } + + private func handleGlow(oldValue: CGFloat, newValue: CGFloat) { + defer { lastValue = newValue } + let oldIndex = Int(round(oldValue * CGFloat(segmentCount))) + let newIndex = Int(round(newValue * CGFloat(segmentCount))) + guard oldIndex != newIndex else { return } + + if newIndex > oldIndex { + animateWave(from: oldIndex, to: newIndex, step: 1) + } else { + animateWave(from: oldIndex - 1, to: newIndex - 1, step: -1) + } + } + + private func animateWave(from start: Int, to end: Int, step: Int) { + let clampedStart = max(0, min(segmentCount - 1, start)) + let clampedEnd = max(-1, min(segmentCount - 1, end)) + let range = stride(from: clampedStart, through: clampedEnd, by: step) + var delay: Double = 0 + for i in range { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { + glowIndex = i + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + delay + 0.2) { + if glowIndex == i { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + glowIndex = nil + } + } + } + delay += 0.015 + } + } } diff --git a/DynamicIsland/components/LockScreen/LockIconAnimationView.swift b/DynamicIsland/components/LockScreen/LockIconAnimationView.swift new file mode 100644 index 00000000..ea1b522f --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockIconAnimationView.swift @@ -0,0 +1,132 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Lottie + +@MainActor +final class LockIconAnimator: ObservableObject { + @Published private(set) var progress: CGFloat + + private var animationTask: Task? + private let animationDuration: TimeInterval = 0.35 + private let animationSteps: Int = 48 + + init(initiallyLocked: Bool) { + progress = initiallyLocked ? 1.0 : 0.0 + } + + deinit { + animationTask?.cancel() + } + + func update(isLocked: Bool, animated: Bool = true) { + let target = isLocked ? 1.0 : 0.0 + let clampedTarget = max(0.0, min(1.0, target)) + + if !animated { + animationTask?.cancel() + progress = clampedTarget + return + } + + guard abs(progress - clampedTarget) > 0.0005 else { + progress = clampedTarget + return + } + + animationTask?.cancel() + + let startProgress = progress + let delta = clampedTarget - startProgress + let stepDuration = animationDuration / Double(animationSteps) + let stepNanoseconds = UInt64(stepDuration * 1_000_000_000) + + animationTask = Task { [weak self] in + guard let self else { return } + + for step in 0...animationSteps { + if Task.isCancelled { return } + + if step > 0 { + try? await Task.sleep(nanoseconds: stepNanoseconds) + } + + let fraction = Double(step) / Double(animationSteps) + let eased = easeOutCubic(fraction) + progress = startProgress + CGFloat(eased) * CGFloat(delta) + } + + progress = clampedTarget + } + } + + private func easeOutCubic(_ t: Double) -> Double { + let clamped = max(0.0, min(1.0, t)) + return 1.0 - pow(1.0 - clamped, 3) + } +} + +struct LockIconProgressView: View { + var progress: CGFloat + var iconColor: Color = .white + + var body: some View { + if LockIconLottieView.isAvailable { + Rectangle() + .fill(iconColor) + .mask { + LockIconLottieView(progress: 1 - progress) + .scaleEffect(1.12) + } + } else { + Image(systemName: progress >= 0.5 ? "lock.fill" : "lock.open.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(iconColor) + } + } +} + +struct LockIconLottieView: View { + var progress: CGFloat + + private static let animation: LottieAnimation? = { + if let animation = LottieAnimation.named("Lock") { + return animation + } else { + print("⚠️ [LockIconLottieView] Missing Lock.json animation – falling back to SF Symbols") + return nil + } + }() + + static var isAvailable: Bool { + animation != nil + } + + var body: some View { + Group { + if let animation = Self.animation { + LottieView(animation: animation) + .currentProgress(progress) + .configuration(.init(renderingEngine: .mainThread)) + } else { + Color.clear + } + } + } +} diff --git a/DynamicIsland/components/LockScreen/LockScreenCalendarEventRow.swift b/DynamicIsland/components/LockScreen/LockScreenCalendarEventRow.swift new file mode 100644 index 00000000..3f45be24 --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenCalendarEventRow.swift @@ -0,0 +1,79 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct LockScreenCalendarEventRow: View { + let line: String + let isVisible: Bool + let renderToken: Int + let font: Font + let alignment: Alignment + let iconName: String + let iconRenderingMode: SymbolRenderingMode + let iconColors: [Color] + + var body: some View { + HStack(alignment: .center, spacing: 6) { + calendarIcon + .frame(width: 26, height: 26) + + Text(line) + .font(font) + .lineLimit(1) + .truncationMode(.tail) + .minimumScaleFactor(0.85) + .allowsTightening(true) + .layoutPriority(10) + } + .frame(maxWidth: .infinity, alignment: alignment) + .padding(.horizontal, 2) + .id(renderToken) + .opacity(isVisible ? 1 : 0) + .accessibilityHidden(!isVisible) + .allowsHitTesting(false) + } + + @ViewBuilder + private var calendarIcon: some View { + let baseImage = Image(systemName: iconName) + .font(.system(size: 20, weight: .semibold)) + .id(iconRenderingModeIdentifier) + + if iconRenderingModeIdentifier.lowercased().contains("palette"), iconColors.count >= 2 { + baseImage + .symbolRenderingMode(.palette) + .foregroundStyle(iconColors[0], iconColors[1]) + } else if iconColors.count >= 2 { + baseImage + .symbolRenderingMode(iconRenderingMode) + .foregroundStyle(iconColors[0], iconColors[1]) + } else if iconColors.count == 1 { + baseImage + .symbolRenderingMode(iconRenderingMode) + .foregroundStyle(iconColors[0]) + } else { + baseImage + .symbolRenderingMode(iconRenderingMode) + } + } + + private var iconRenderingModeIdentifier: String { + String(describing: iconRenderingMode) + } +} diff --git a/DynamicIsland/components/LockScreen/LockScreenDynamicIslandReplica.swift b/DynamicIsland/components/LockScreen/LockScreenDynamicIslandReplica.swift new file mode 100644 index 00000000..9553cde4 --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenDynamicIslandReplica.swift @@ -0,0 +1,18 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift new file mode 100644 index 00000000..da418050 --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift @@ -0,0 +1,128 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct LockScreenLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject private var lockScreenManager = LockScreenManager.shared + @StateObject private var iconAnimator = LockIconAnimator(initiallyLocked: LockScreenManager.shared.isLocked) + @State private var isHovering: Bool = false + @State private var gestureProgress: CGFloat = 0 + @State private var isExpanded: Bool = false + + private var expandAnimation: Animation { + .smooth(duration: LockScreenAnimationTimings.lockExpand) + } + + private var collapseAnimation: Animation { + .smooth(duration: LockScreenAnimationTimings.unlockCollapse) + } + + private var iconColor: Color { + .white + } + + private var indicatorDimension: CGFloat { + max(0, vm.effectiveClosedNotchHeight - 12) + } + + private var indicatorSideHeight: CGFloat { + max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + } + + private var expandedIndicatorSideWidth: CGFloat { + max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) + } + + private var indicatorSideWidth: CGFloat { + isExpanded ? expandedIndicatorSideWidth : 0 + } + + private var indicatorVisibilityProgress: CGFloat { + guard expandedIndicatorSideWidth > 0 else { return 0 } + return min(max(indicatorSideWidth / expandedIndicatorSideWidth, 0), 1) + } + + private var indicatorOpacity: Double { + let fadeThreshold: CGFloat = 0.16 + guard indicatorVisibilityProgress < fadeThreshold else { return 1 } + return Double(max(0, indicatorVisibilityProgress / fadeThreshold)) + } + + var body: some View { + HStack(spacing: 0) { + // Left - Lock icon with subtle glow + Color.clear + .overlay(alignment: .leading) { + LockIconProgressView(progress: iconAnimator.progress, iconColor: iconColor) + .frame(width: indicatorDimension, height: indicatorDimension) + .opacity(indicatorOpacity) + .scaleEffect(0.96 + (indicatorVisibilityProgress * 0.04)) + } + .frame(width: indicatorSideWidth, height: indicatorSideHeight, alignment: .leading) + .clipped() + + // Center - Black fill + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0)) + + // Right - Empty for symmetry with animation + Color.clear + .frame(width: indicatorSideWidth, height: indicatorSideHeight) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .onAppear { + iconAnimator.update(isLocked: lockScreenManager.isLocked, animated: false) + let shouldStartExpanded = lockScreenManager.isLocked || !lockScreenManager.isLockIdle + withAnimation(expandAnimation) { + isExpanded = shouldStartExpanded + } + } + .onDisappear { + // Collapse immediately when removed from hierarchy + isExpanded = false + } + .onChange(of: lockScreenManager.isLockIdle) { _, newValue in + if newValue { + withAnimation(collapseAnimation) { + isExpanded = false + } + } else if lockScreenManager.isLocked { + withAnimation(expandAnimation) { + isExpanded = true + } + } + } + .onChange(of: lockScreenManager.isLocked) { _, newValue in + iconAnimator.update(isLocked: newValue) + if newValue { + withAnimation(expandAnimation) { + isExpanded = true + } + } else { + withAnimation(collapseAnimation) { + isExpanded = false + } + } + } + .animation(.spring(response: 0.5, dampingFraction: 0.85), value: lockScreenManager.isLocked) + } +} diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift new file mode 100644 index 00000000..9d58864d --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift @@ -0,0 +1,108 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +final class LockScreenLiveActivityOverlayModel: ObservableObject { + @Published var scale: CGFloat = 0.6 + @Published var opacity: Double = 0 +} + +struct LockScreenLiveActivityOverlay: View { + @ObservedObject var model: LockScreenLiveActivityOverlayModel + @ObservedObject var animator: LockIconAnimator + let notchSize: CGSize + var isDynamicIslandMode: Bool = false + + private var indicatorSize: CGFloat { + max(0, notchSize.height - 12) + } + + private var horizontalPadding: CGFloat { + isDynamicIslandMode ? 8 : cornerRadiusInsets.closed.bottom + } + + private var totalWidth: CGFloat { + notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2) + } + + private var collapsedScale: CGFloat { + Self.collapsedScale(for: notchSize) + } + + private var topOffset: CGFloat { + isDynamicIslandMode ? dynamicIslandTopOffset : 0 + } + + @State private var isHovering: Bool = false + + var body: some View { + HStack(spacing: 0) { + Color.clear + .overlay(alignment: .leading) { + LockIconProgressView(progress: animator.progress) + .frame(width: indicatorSize, height: indicatorSize) + } + .frame(width: indicatorSize, height: notchSize.height) + + Rectangle() + .fill(.black) + .frame(width: notchSize.width, height: notchSize.height) + + Color.clear + .frame(width: indicatorSize, height: notchSize.height) + } + .frame(width: notchSize.width + (indicatorSize * 2), height: notchSize.height) + .padding(.horizontal, horizontalPadding) + .background(Color.black) + .clipShape( + isDynamicIslandMode + ? AnyShape(DynamicIslandPillShape( + cornerRadius: max(notchSize.height / 2, dynamicIslandPillCornerRadiusInsets.closed.standard) + )) + : AnyShape(NotchShape( + topCornerRadius: cornerRadiusInsets.closed.top, + bottomCornerRadius: cornerRadiusInsets.closed.bottom + )) + ) + .padding(.top, topOffset) + .frame(width: totalWidth, height: notchSize.height + topOffset) + .scaleEffect(x: max(model.scale, collapsedScale) * (isHovering ? 1.03 : 1.0), + y: 1 * (isHovering ? 1.03 : 1.0), + anchor: .center) + .opacity(model.opacity) + .onHover { hovering in + withAnimation(.smooth(duration: 0.2)) { + isHovering = hovering + } + if hovering { + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .default) + } + } + } +} + +extension LockScreenLiveActivityOverlay { + static func collapsedScale(for notchSize: CGSize, isDynamicIslandMode: Bool = false) -> CGFloat { + let indicatorSize = max(0, notchSize.height - 12) + let horizontalPadding = isDynamicIslandMode ? 8.0 : cornerRadiusInsets.closed.bottom + let totalWidth = notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2) + guard totalWidth > 0 else { return 1 } + return notchSize.width / totalWidth + } +} diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift new file mode 100644 index 00000000..c93631d1 --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -0,0 +1,1616 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct LockScreenMusicPanel: View { + private struct GlassLogSnapshot: Equatable { + let style: LockScreenGlassStyle + let customizationMode: LockScreenGlassCustomizationMode + let variantRawValue: Int + let usesLiquidGlass: Bool + } + + static let collapsedHeight: CGFloat = 180 + static let defaultCollapsedWidth: CGFloat = 420 + static var collapsedSize: CGSize { + CGSize(width: CGFloat(Defaults[.lockScreenMusicPanelWidth]), height: collapsedHeight) + } + static let expandedSize = CGSize(width: 720, height: 340) + + @ObservedObject var musicManager = MusicManager.shared + @ObservedObject private var routeManager = AudioRouteManager.shared + @ObservedObject private var fullscreenArtworkManager = FullScreenArtworkWindowManager.shared + @StateObject private var volumeModel = MediaOutputVolumeViewModel() + @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared + @ObservedObject private var animator: LockScreenPanelAnimator + @State private var sliderValue: Double = 0 + @State private var dragging: Bool = false + @State private var lastDragged: Date = .distantPast + @State private var isActive = true + @State private var isExpanded = false + @State private var isVolumeSliderVisible = false + @State private var isAirPlayPopoverPresented = false + @State private var isArtworkFullscreen = false + + @State private var collapseWorkItem: DispatchWorkItem? + @State private var parallaxResumeWorkItem: DispatchWorkItem? + @State private var isParallaxSuspended = false + @State private var lastLoggedGlassSnapshot: GlassLogSnapshot? + @Default(.lockScreenGlassStyle) var lockScreenGlassStyle + @Default(.lockScreenGlassCustomizationMode) private var glassCustomizationMode + @Default(.lockScreenMusicLiquidGlassVariant) private var musicGlassVariant + @Default(.lockScreenShowAppIcon) var showAppIcon + @Default(.lockScreenPanelShowsBorder) var showPanelBorder + @Default(.lockScreenMusicUsesEnhancedLiquidBorder) private var useEnhancedLiquidBorder + @Default(.lockScreenPanelUsesBlur) var enableBlur + @Default(.showMediaOutputControl) private var showMediaOutputControl + @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat + @Default(.musicControlSlots) private var slotConfig + @Default(.musicSkipBehavior) private var musicSkipBehavior + @Default(.lockScreenMusicMergedAirPlayOutput) private var mergedAirPlayOutput + @Default(.enableLyrics) private var enableLyrics + @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled + @Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth + @Default(.lockScreenMusicFullscreenArtworkEnabled) private var fullscreenArtworkEnabled + @Default(.lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) private var keepAlbumArtVisibleDuringFullscreenArtwork + @Default(.lockScreenMusicFullscreenVideoArtwork) private var fullscreenVideoArtwork + + init(animator: LockScreenPanelAnimator) { + _animator = ObservedObject(wrappedValue: animator) + } + + private let collapsedPanelCornerRadius: CGFloat = 28 + private let expandedPanelCornerRadius: CGFloat = 52 + private let collapsedAlbumArtCornerRadius: CGFloat = 16 + 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 let collapsedLyricsExtraHeight: CGFloat = 64 + private let expandedLyricsExtraHeight: CGFloat = 96 + + private var shouldUseFrostedBlur: Bool { + enableBlur && !usesLiquidGlass + } + + private var currentSize: CGSize { + let base = isExpanded ? Self.expandedSize : collapsedPanelSize + return CGSize(width: base.width, height: base.height + totalExtraHeight) + } + + private var collapsedPanelSize: CGSize { + CGSize(width: CGFloat(collapsedPanelWidth), height: Self.collapsedHeight) + } + + private var panelCornerRadius: CGFloat { + isExpanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius + } + + private var usesCustomLiquidGlass: Bool { + glassCustomizationMode == .customLiquid + } + + private var usesStandardLiquidGlass: Bool { + guard glassCustomizationMode == .standard else { return false } + if #available(macOS 26.0, *) { + return lockScreenGlassStyle == .liquid + } + return false + } + + private var usesLiquidGlass: Bool { + usesCustomLiquidGlass || usesStandardLiquidGlass + } + + private var usesEnhancedCustomLiquidBorder: Bool { + usesCustomLiquidGlass && useEnhancedLiquidBorder + } + + private var hidesInlineArtworkForSpotifyCanvasFallback: Bool { + fullscreenArtworkManager.isShowingSpotifyCanvasFallback + } + + private var usesSpotifyCanvasFallbackContentPresentation: Bool { + fullscreenArtworkManager.isShowingSpotifyCanvasFallback + } + + private var showsDetachedFullscreenLyrics: Bool { + usesSpotifyCanvasFallbackContentPresentation && enableLyrics + } + + private var shouldShowInlineLyrics: Bool { + enableLyrics && !showsDetachedFullscreenLyrics + } + + var body: some View { + if isActive && musicManager.hasActiveSession { + panelContent + } else { + Color.clear + .frame(width: collapsedPanelSize.width, height: collapsedPanelSize.height) + } + } + + private var panelContent: some View { + ZStack(alignment: .topLeading) { + panelBackgroundLayer + panelForeground + .shadow( + color: usesSpotifyCanvasFallbackContentPresentation ? Color.black.opacity(0.55) : .clear, + radius: usesSpotifyCanvasFallbackContentPresentation ? 10 : 0, + x: 0, + y: usesSpotifyCanvasFallbackContentPresentation ? 2 : 0 + ) + } + .frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading) + .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) + .overlay { + if showPanelBorder && !usesSpotifyCanvasFallbackContentPresentation { + panelBorderOverlay + } + } + .shadow( + color: usesSpotifyCanvasFallbackContentPresentation ? Color.black.opacity(0.35) : Color.black.opacity(0.3), + radius: usesSpotifyCanvasFallbackContentPresentation ? 26 : 20, + x: 0, + y: usesSpotifyCanvasFallbackContentPresentation ? 14 : 10 + ) + .contentShape(Rectangle()) + .animation(.easeInOut(duration: 0.28), value: isExpanded) + .animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider) + .onAppear { + sliderValue = musicManager.elapsedTime + isActive = true + logPanelAppearance() + updatePanelSize(animated: false) + routeManager.refreshDevices() + if isAppleMusicActive { + Task { await airPlayManager.refreshDevices() } + } + logGlassState(reason: "Panel appeared") + } + .onDisappear { + isActive = false + cancelCollapseTimer() + isVolumeSliderVisible = false + parallaxResumeWorkItem?.cancel() + parallaxResumeWorkItem = nil + isParallaxSuspended = false + } + .onReceive(NotificationCenter.default.publisher(for: .atollArtworkWallpaperDismissed)) { _ in + withAnimation(.easeInOut(duration: 0.28)) { + isArtworkFullscreen = false + } + } + .onChange(of: isExpanded) { _, expanded in + updatePanelSize() + } + .onChange(of: showMediaOutputControl) { _, enabled in + if !enabled { + withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { + isVolumeSliderVisible = false + } + } + updatePanelSize() + } + .onChange(of: isVolumeSliderVisible) { _, visible in + if useMergedAirPlayOutput { + if visible && isAppleMusicActive { + Task { await airPlayManager.refreshDevices() } + } + } + updatePanelSize() + } + .onChange(of: airPlayManager.devices) { _, _ in + if useMergedAirPlayOutput { + updatePanelSize() + } + } + .onChange(of: routeManager.devices) { _, _ in + if !useMergedAirPlayOutput { + updatePanelSize() + } + } + .onChange(of: enableLyrics) { _, _ in + withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { + updatePanelSize() + } + } + .onChange(of: lockScreenGlassStyle) { _, _ in + logGlassState(reason: "Glass style updated") + } + .onChange(of: glassCustomizationMode) { _, _ in + logGlassState(reason: "Glass mode updated") + } + .onChange(of: musicGlassVariant) { _, _ in + if usesCustomLiquidGlass { + logGlassState(reason: "Liquid variant updated") + } + } + .scaleEffect(animator.isPresented ? 1 : 0.9, anchor: .center) + .opacity(animator.isPresented ? 1 : 0) + .animation(.spring(response: 0.52, dampingFraction: 0.8), value: animator.isPresented) + } + + @ViewBuilder + private var panelForeground: some View { + Group { + if isExpanded { + expandedLayout + } else { + collapsedLayout + } + } + .padding(.horizontal, usesSpotifyCanvasFallbackContentPresentation ? (isExpanded ? 24 : 22) : (isExpanded ? 24 : 20)) + .padding(.vertical, usesSpotifyCanvasFallbackContentPresentation ? (isExpanded ? 18 : 14) : (isExpanded ? 22 : 16)) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: usesSpotifyCanvasFallbackContentPresentation ? .center : .topLeading + ) + } + + private var collapsedLayout: some View { + VStack(spacing: 12) { + collapsedHeader + progressBar + .padding(.top, 4) + .frame(maxWidth: .infinity) + playbackControls(alignment: .center) + .padding(.top, 4) + } + } + + private var expandedLayout: some View { + Group { + if hidesInlineArtworkForSpotifyCanvasFallback { + VStack(alignment: .leading, spacing: 20) { + expandedHeader + progressBar + .padding(.top, 10) + .frame(maxWidth: .infinity) + playbackControls(alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + HStack(alignment: .center, spacing: expandedContentSpacing) { + albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius) + .frame(width: 230, height: 230) + + VStack(alignment: .leading, spacing: 20) { + expandedHeader + progressBar + .padding(.top, 10) + .frame(maxWidth: .infinity) + playbackControls(alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .frame(maxHeight: .infinity, alignment: .center) + } + + private var collapsedHeader: some View { + HStack(alignment: .center, spacing: hidesInlineArtworkForSpotifyCanvasFallback ? 10 : 16) { + if !hidesInlineArtworkForSpotifyCanvasFallback { + albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius) + } + + GeometryReader { geo in + VStack(alignment: .leading, spacing: 1) { + MusicTitleMarqueeView( + text: musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle, + isExplicit: !musicManager.songTitle.isEmpty && musicManager.isCurrentTrackExplicit, + font: .system(size: 12, weight: .semibold), + nsFont: .subheadline, + textColor: .white, + minDuration: 0.45, + frameWidth: geo.size.width, + badgeSpacing: 5, + badgeLabel: "E", + badgeHeight: 13, + badgeForegroundColor: Color.black.opacity(0.72), + badgeBackgroundColor: Color.white.opacity(0.46), + badgeHorizontalPadding: 4, + badgeMinWidth: 14, + badgeCornerRadius: 4 + ) + + Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray) + .lineLimit(1) + } + .frame(width: geo.size.width, height: geo.size.height, alignment: .leading) + } + .frame(maxWidth: .infinity, minHeight: 30, maxHeight: 30, alignment: .leading) + + visualizer(width: 20, height: 16) + } + .frame(height: 60) + } + + private var expandedHeader: some View { + HStack(alignment: .top, spacing: 16) { + GeometryReader { geo in + VStack(alignment: .leading, spacing: 6) { + MusicTitleMarqueeView( + text: musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle, + isExplicit: !musicManager.songTitle.isEmpty && musicManager.isCurrentTrackExplicit, + font: .system(size: 18, weight: .semibold), + nsFont: .title3, + textColor: .white, + minDuration: 0.55, + frameWidth: geo.size.width, + badgeSpacing: 6, + badgeLabel: "E", + badgeHeight: 16, + badgeForegroundColor: Color.black.opacity(0.74), + badgeBackgroundColor: Color.white.opacity(0.5), + badgeHorizontalPadding: 5, + badgeMinWidth: 17, + badgeCornerRadius: 5 + ) + + Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.7) : .gray) + .lineLimit(2) + } + .frame(width: geo.size.width, height: geo.size.height, alignment: .leading) + } + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + + visualizer(width: 24, height: 20) + } + } + + private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View { + let artworkCornerRadius = resolvedArtworkCornerRadius(from: cornerRadius) + + return ZStack(alignment: .bottomTrailing) { + if isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork { + RoundedRectangle(cornerRadius: artworkCornerRadius, style: .continuous) + .fill(Color.white.opacity(0.06)) + .overlay( + Image(systemName: "photo.fill") + .font(.system(size: size * 0.3, weight: .light)) + .foregroundColor(.white.opacity(0.2)) + ) + } else { + albumArtImage(size: size, cornerRadius: cornerRadius) + if showAppIcon, let icon = lockScreenAppIcon { + icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: appIconSize, height: appIconSize) + .shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4) + .offset(x: appIconOffset, y: appIconOffset) + .transition(.scale.combined(with: .opacity)) + } + } + } + .albumArtFlip(angle: isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork ? 0 : musicManager.flipAngle) + .parallax3D( + enableOverride: lockScreenParallaxEnabled && (!isArtworkFullscreen || keepAlbumArtVisibleDuringFullscreenArtwork), + suspended: isParallaxSuspended + ) + .frame(width: size) + .background(albumArtBackground(cornerRadius: artworkCornerRadius)) + .clipShape(RoundedRectangle(cornerRadius: artworkCornerRadius, style: .continuous)) + .opacity(musicManager.isPlaying ? 1 : 0.4) + .scaleEffect(musicManager.isPlaying ? 1 : 0.85) + .animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying) + .animation(.easeInOut(duration: 0.28), value: isArtworkFullscreen) + .onTapGesture { + toggleExpanded() + } + .onRightClick { + expandArtworkToFullscreen() + } + } + + @ViewBuilder + private func visualizer(width: CGFloat, height: CGFloat) -> some View { + if Defaults[.useMusicVisualizer] { + Rectangle() + .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient) + .mask { + AudioVisualizerView(isPlaying: .constant(musicManager.isPlaying)) + .frame(width: width, height: height) + } + .frame(width: width, height: height) + } + } + + private func toggleExpanded() { + let newState = !isExpanded + suspendParallaxInteraction() + withAnimation(.easeInOut(duration: 0.28)) { + isExpanded = newState + } + + if newState { + registerInteraction() + logPanelAppearance(event: "🔍 Expanded") + } else { + logPanelAppearance(event: "⬇️ Collapsed") + cancelCollapseTimer() + } + } + + private func expandArtworkToFullscreen() { + guard fullscreenArtworkEnabled else { return } + + if FullScreenArtworkWindowManager.shared.isShowing { + FullScreenArtworkWindowManager.shared.hide() + withAnimation(.easeInOut(duration: 0.28)) { + isArtworkFullscreen = false + } + return + } + + guard musicManager.hasActiveSession else { return } + + let artwork = musicManager.albumArt + let supportsCanvas = musicManager.bundleIdentifier == "com.apple.Music" + || musicManager.bundleIdentifier == SpotifyController.bundleIdentifier + let canvasURL = (fullscreenVideoArtwork && supportsCanvas) ? musicManager.videoArtworkURL : nil + + withAnimation(.easeInOut(duration: 0.28)) { + isArtworkFullscreen = true + } + + FullScreenArtworkWindowManager.shared.onDismiss = { + Task { @MainActor in + withAnimation(.easeInOut(duration: 0.28)) { + NotificationCenter.default.post(name: .atollArtworkWallpaperDismissed, object: nil) + } + } + } + + FullScreenArtworkWindowManager.shared.show( + artwork: artwork, + videoURL: canvasURL, + allowLiveWallpaper: fullscreenVideoArtwork && supportsCanvas + ) + } + + private func registerInteraction() { + cancelCollapseTimer() + guard isExpanded else { return } + + let workItem = DispatchWorkItem { + suspendParallaxInteraction() + withAnimation(.easeInOut(duration: 0.28)) { + isExpanded = false + } + logPanelAppearance(event: "⏱️ Auto-collapsed") + } + + collapseWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + collapseTimeout, execute: workItem) + } + + private func suspendParallaxInteraction(for duration: TimeInterval = 0.65) { + parallaxResumeWorkItem?.cancel() + isParallaxSuspended = true + + let workItem = DispatchWorkItem { + isParallaxSuspended = false + } + parallaxResumeWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: workItem) + } + + private func cancelCollapseTimer() { + collapseWorkItem?.cancel() + collapseWorkItem = nil + } + + private var isProgressTimelinePaused: Bool { + !musicManager.isPlaying || musicManager.isLiveStream || musicManager.playbackRate <= 0 + } + + // MARK: - Progress Bar + + private var progressBar: some View { + TimelineView( + .animation( + minimumInterval: 1.0, + paused: isProgressTimelinePaused + ) + ) { timeline in + MusicSliderView( + sliderValue: $sliderValue, + duration: Binding( + get: { musicManager.songDuration }, + set: { musicManager.songDuration = $0 } + ), + lastDragged: $lastDragged, + color: musicManager.avgColor, + dragging: $dragging, + currentDate: timeline.date, + timestampDate: musicManager.timestampDate, + elapsedTime: musicManager.elapsedTime, + playbackRate: musicManager.playbackRate, + isPlaying: musicManager.isPlaying, + isLiveStream: musicManager.isLiveStream, + onValueChange: { newValue in + registerInteraction() + musicManager.seek(to: newValue) + }, + labelLayout: .inline, + trailingLabel: .remaining, + restingTrackHeight: 7, + draggingTrackHeight: 11 + ) + } + .onAppear { + sliderValue = musicManager.elapsedTime + } + .onChange(of: musicManager.isLiveStream) { _, isLive in + if isLive { + dragging = false + sliderValue = 0 + } + } + } + + private var sliderColor: Color { + switch Defaults[.sliderColor] { + case .white: + return .white + case .albumArt: + return Color(nsColor: musicManager.avgColor) + case .accent: + return .accentColor + } + } + + private var brandAccentColor: Color { + musicManager.brandAccentColor + } + + // MARK: - Playback Controls + + private func playbackControls(alignment: Alignment) -> some View { + let spacing: CGFloat = isExpanded ? 24 : 20 + let verticalSpacing: CGFloat = (shouldShowVolumeSlider || shouldShowInlineLyrics) ? 14 : 10 + + return VStack(spacing: verticalSpacing) { + controlsRow(alignment: alignment, spacing: spacing) + + if shouldShowVolumeSlider { + volumeSlider + .frame(maxWidth: .infinity, alignment: alignment) + .transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity)) + + if shouldShowAirPlay { + airPlaySection + .frame(maxWidth: .infinity, alignment: alignment) + .transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity)) + } else if shouldShowRouteSelector { + mediaOutputDevicesSection + .frame(maxWidth: .infinity, alignment: alignment) + .transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity)) + } + } + + if shouldShowInlineLyrics { + lyricsSection(alignment: alignment) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .frame(maxWidth: .infinity, alignment: alignment) + .padding(.top, isExpanded ? 6 : 2) + .animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider) + .animation(.easeInOut(duration: 0.24), value: shouldShowAirPlay) + .animation(.easeInOut(duration: 0.24), value: shouldShowRouteSelector) + .animation(.easeInOut(duration: 0.24), value: enableLyrics) + } + + private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View { + let skipNudge: CGFloat = isExpanded ? 14 : 9 + + return HStack(spacing: spacing) { + ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in + slotView(for: slot, skipNudge: skipNudge) + } + } + .frame(maxWidth: .infinity, alignment: alignment) + } + + private var displayedSlots: [MusicControlButton] { + guard showShuffleAndRepeat else { + return fallbackSlots + } + + let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive) + return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout + } + + private var fallbackSlots: [MusicControlButton] { + switch musicSkipBehavior { + case .track: + return MusicControlButton.minimalLayout + case .tenSecond: + return [.none, .seekBackward, .playPause, .seekForward, .none] + } + } + + @ViewBuilder + private func slotView(for control: MusicControlButton, skipNudge: CGFloat) -> some View { + let seekInterval: TimeInterval = 10 + + switch control { + case .none: + Spacer(minLength: 0) + case .playPause: + playPauseButton + case .trackBackward: + controlButton( + icon: "backward.fill", + size: 18, + interaction: .nudge(-skipNudge), + symbolEffect: .replace + ) { + musicManager.previousTrack() + } + case .trackForward: + controlButton( + icon: "forward.fill", + size: 18, + interaction: .nudge(skipNudge), + symbolEffect: .replace + ) { + musicManager.nextTrack() + } + case .seekBackward: + controlButton( + icon: "gobackward.10", + size: 18, + interaction: .wiggle(.counterClockwise), + symbolEffect: .wiggle + ) { + musicManager.seek(by: -seekInterval) + } + case .seekForward: + controlButton( + icon: "goforward.10", + size: 18, + interaction: .wiggle(.clockwise), + symbolEffect: .wiggle + ) { + musicManager.seek(by: seekInterval) + } + case .shuffle: + controlButton( + icon: "shuffle", + size: 18, + isActive: musicManager.isShuffled, + activeColor: brandAccentColor + ) { + musicManager.toggleShuffle() + } + case .repeatMode: + controlButton( + icon: repeatIcon, + size: 18, + isActive: musicManager.repeatMode != .off, + activeColor: brandAccentColor, + symbolEffect: .replace + ) { + musicManager.toggleRepeat() + } + case .mediaOutput: + mediaOutputControlButton + case .airPlay: + if useMergedAirPlayOutput { + mediaOutputControlButton + } else { + standaloneAirPlayButton + } + case .lyrics: + controlButton( + icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", + size: 18, + isActive: enableLyrics, + activeColor: brandAccentColor, + symbolEffect: .replace + ) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) { + enableLyrics.toggle() + } + } + } + } + + private var playPauseButton: some View { + let frameSize: CGFloat = isExpanded ? 80 : 54 + let iconName = musicManager.isPlaying ? "pause.fill" : "play.fill" + + return HoverButton( + icon: iconName, + iconColor: .white, + scale: .large, + pressEffect: nil + ) { + registerInteraction() + musicManager.togglePlay() + } + .frame(width: frameSize, height: frameSize) + } + + private func controlButton( + icon: String, + size: CGFloat = 18, + isActive: Bool = false, + activeColor: Color? = nil, + interaction: PanelControlButton.Interaction = .none, + symbolEffect: PanelControlButton.SymbolEffectStyle = .replace, + action: @escaping () -> Void + ) -> some View { + let resolvedActiveColor = activeColor ?? brandAccentColor + let frameSize: CGFloat = isExpanded ? 56 : 32 + let iconSize: CGFloat = isExpanded ? max(size, 24) : size + let iconColor = isActive ? resolvedActiveColor : .white.opacity(0.8) + let backgroundOpacity: Double = isActive ? 0.22 : 0.0 + + return PanelControlButton( + icon: icon, + frameSize: frameSize, + iconSize: iconSize, + iconColor: iconColor, + backgroundOpacity: backgroundOpacity, + interaction: interaction, + symbolEffect: symbolEffect + ) { + registerInteraction() + action() + } + } + + private var mediaOutputControlButton: some View { + let frameSize: CGFloat = isExpanded ? 56 : 32 + let iconSize: CGFloat = isExpanded ? 26 : 18 + + return PanelControlButton( + icon: mediaOutputIcon, + frameSize: frameSize, + iconSize: iconSize, + iconColor: shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8), + backgroundOpacity: shouldShowVolumeSlider ? 0.22 : 0.0, + interaction: .none, + symbolEffect: .replace, + action: toggleVolumeSlider + ) + .accessibilityLabel("Media output") + } + + private var standaloneAirPlayButton: some View { + let frameSize: CGFloat = isExpanded ? 56 : 32 + let iconSize: CGFloat = isExpanded ? 26 : 18 + + return PanelControlButton( + icon: "airplayaudio", + frameSize: frameSize, + iconSize: iconSize, + iconColor: isAirPlayPopoverPresented ? .accentColor : .white.opacity(0.8), + backgroundOpacity: isAirPlayPopoverPresented ? 0.22 : 0.0, + interaction: .none, + symbolEffect: .replace + ) { + registerInteraction() + isAirPlayPopoverPresented.toggle() + if isAirPlayPopoverPresented { + Task { await airPlayManager.refreshDevices() } + } + } + .accessibilityLabel("AirPlay") + .popover(isPresented: $isAirPlayPopoverPresented, arrowEdge: .bottom) { + AirPlaySelectorPopover( + airPlayManager: airPlayManager, + onHoverChanged: { _ in }, + dismiss: { isAirPlayPopoverPresented = false } + ) + } + } + + private func lyricsSection(alignment: Alignment) -> some View { + let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines) + let transition: AnyTransition = .asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + ) + + return HStack(spacing: 8) { + if !line.isEmpty { + Image(systemName: "music.note") + .font(.system(size: isExpanded ? 14 : 12, weight: .semibold)) + .foregroundColor(.white.opacity(0.7)) + .symbolRenderingMode(.monochrome) + + Text(line) + .font(.system(size: isExpanded ? 14 : 12, weight: .semibold)) + .foregroundColor(.white.opacity(0.88)) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, 6) + .id(line) + .transition(transition) + } + } + .padding(.horizontal, isExpanded ? 10 : 8) + .padding(.top, isExpanded ? 12 : 8) + .frame(maxWidth: .infinity, alignment: alignment) + .animation(.smooth(duration: 0.32), value: line) + } + + private var repeatIcon: String { + switch musicManager.repeatMode { + case .off: return "repeat" + case .all: return "repeat" + case .one: return "repeat.1" + } + } + + 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(sliderBackgroundFill) + ) + } + + private var airPlaySection: some View { + accessorySectionContainer { + if airPlayManager.devices.isEmpty { + Text("No AirPlay outputs available") + .font(.system(size: isExpanded ? 13 : 11, weight: .medium)) + .foregroundColor(.white.opacity(0.68)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + .padding(.horizontal, 12) + } else { + ScrollView(.vertical, showsIndicators: true) { + VStack(spacing: 6) { + ForEach(airPlayManager.devices) { device in + VStack(spacing: 4) { + Button { + registerInteraction() + Task { await airPlayManager.toggleDevice(device) } + } label: { + HStack(spacing: 8) { + Image(systemName: device.iconName) + .font(.system(size: isExpanded ? 14 : 12, weight: .medium)) + .foregroundColor(.white.opacity(0.8)) + Text(device.name) + .font(.system(size: isExpanded ? 13 : 11, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + Spacer() + if device.isSelected { + Image(systemName: "checkmark") + .font(.system(size: isExpanded ? 12 : 10, weight: .bold)) + .foregroundColor(.accentColor) + } + } + .padding(.vertical, 4) + .padding(.horizontal, 12) + } + .buttonStyle(.plain) + + if device.isSelected { + AirPlayVolumeSlider( + airPlayManager: airPlayManager, + deviceID: device.id + ) + .padding(.horizontal, 12) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: accessorySectionScrollMaxHeight) + } + } + .padding(.vertical, 8) + } + + private var mediaOutputDevicesSection: some View { + accessorySectionContainer { + if routeManager.devices.isEmpty { + Text("No audio outputs available") + .font(.system(size: isExpanded ? 13 : 11, weight: .medium)) + .foregroundColor(.white.opacity(0.68)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + .padding(.horizontal, 12) + } else { + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 6) { + ForEach(routeManager.devices) { device in + Button { + registerInteraction() + routeManager.select(device: device) + } label: { + HStack(spacing: 8) { + Image(systemName: device.iconName) + .font(.system(size: isExpanded ? 14 : 12, weight: .medium)) + .foregroundColor(.white.opacity(0.82)) + Text(device.name) + .font(.system(size: isExpanded ? 13 : 11, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + Spacer() + if device.id == routeManager.activeDeviceID { + Image(systemName: "checkmark") + .font(.system(size: isExpanded ? 12 : 10, weight: .bold)) + .foregroundColor(.accentColor) + } + } + .padding(.vertical, 5) + .padding(.horizontal, 12) + } + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: accessorySectionScrollMaxHeight) + } + } + .padding(.vertical, 8) + } + + private func accessorySectionContainer(@ViewBuilder content: () -> Content) -> some View { + content() + .background( + RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous) + .fill(sliderBackgroundFill) + ) + } + + private var sliderBackgroundFill: Color { + if usesLiquidGlass { + return Color.white.opacity(0.05) + } + return Color.white.opacity(0.08) + } + + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + + private var useMergedAirPlayOutput: Bool { + mergedAirPlayOutput && isAppleMusicActive && slotConfig.contains(where: { $0 == .mediaOutput || $0 == .airPlay }) + } + + private var shouldShowAirPlay: Bool { + useMergedAirPlayOutput && shouldShowVolumeSlider && !airPlayManager.devices.isEmpty + } + + private var shouldShowRouteSelector: Bool { + !useMergedAirPlayOutput && shouldShowVolumeSlider + } + + private var shouldShowAccessorySection: Bool { + shouldShowAirPlay || shouldShowRouteSelector + } + + private var shouldShowVolumeSlider: Bool { + showMediaOutputControl && isVolumeSliderVisible + } + + private var sliderExtraHeight: CGFloat { + sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider) + } + + private var accessorySectionScrollMaxHeight: CGFloat { + isExpanded ? 170 : 130 + } + + private var lyricsExtraHeight: CGFloat { + lyricsHeight(forExpanded: isExpanded, enabled: shouldShowInlineLyrics) + } + + private var accessorySectionExtraHeight: CGFloat { + guard shouldShowAccessorySection else { return 0 } + if shouldShowAirPlay { + let selectedCount = airPlayManager.devices.filter(\.isSelected).count + let totalCount = airPlayManager.devices.count + let deviceRows: CGFloat = CGFloat(totalCount) * 30 + CGFloat(max(totalCount - 1, 0)) * 6 + let sliders: CGFloat = CGFloat(selectedCount) * 34 + return min(deviceRows + sliders + 24, accessorySectionScrollMaxHeight + 16) + } + + let totalCount = max(routeManager.devices.count, 1) + let deviceRows: CGFloat = CGFloat(totalCount) * 30 + CGFloat(max(totalCount - 1, 0)) * 6 + return min(deviceRows + 24, accessorySectionScrollMaxHeight + 16) + } + + private var totalExtraHeight: CGFloat { + sliderExtraHeight + accessorySectionExtraHeight + lyricsExtraHeight + } + + private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat { + guard enabled else { return 0 } + return expanded ? expandedLyricsExtraHeight : collapsedLyricsExtraHeight + } + + private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat { + sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) + + accessorySectionExtraHeight + + lyricsHeight(forExpanded: expanded, enabled: shouldShowInlineLyrics) + } + + private func updatePanelSize(animated: Bool = true) { + LockScreenPanelManager.shared.updatePanelSize( + expanded: isExpanded, + additionalHeight: panelAdditionalHeight(forExpanded: isExpanded), + animated: animated + ) + } + + 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() { + guard showMediaOutputControl else { + isVolumeSliderVisible = false + return + } + + registerInteraction() + let newState = !isVolumeSliderVisible + if newState { + routeManager.refreshDevices() + if useMergedAirPlayOutput { + Task { await airPlayManager.refreshDevices() } + } + } + + withAnimation(.easeInOut(duration: 0.24)) { + isVolumeSliderVisible = newState + } + + updatePanelSize() + } + + private func sliderHeight(forExpanded expanded: Bool, visible: Bool) -> CGFloat { + guard visible else { return 0 } + return expanded ? expandedSliderExtraHeight : collapsedSliderExtraHeight + } + + @ViewBuilder + private var panelBackgroundLayer: some View { + if usesSpotifyCanvasFallbackContentPresentation { + canvasFallbackScrim + } else if usesCustomLiquidGlass { + customLiquidPanelBackdrop + } else if usesStandardLiquidGlass { + standardLiquidPanelBackdrop + } else if shouldUseFrostedBlur { + frostedPanelBackground + } else { + opaquePanelBackground + } + } + + private var canvasFallbackScrim: some View { + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .fill(.ultraThinMaterial) + .environment(\.colorScheme, .dark) + .overlay { + LinearGradient( + colors: [ + Color.black.opacity(0.32), + Color.black.opacity(0.18), + Color.black.opacity(0.32) + ], + startPoint: .top, + endPoint: .bottom + ) + .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) + } + .overlay { + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.10), lineWidth: 0.6) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + @ViewBuilder + private var standardLiquidPanelBackdrop: some View { + if #available(macOS 26.0, *) { + GlassTextBackdrop(cornerRadius: panelCornerRadius) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + + @ViewBuilder + private var customLiquidPanelBackdrop: some View { + LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: panelCornerRadius) { + Color.white.opacity(0.04) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + private var frostedPanelBackground: some View { + RoundedRectangle(cornerRadius: panelCornerRadius) + .fill(.ultraThinMaterial) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + private var opaquePanelBackground: some View { + RoundedRectangle(cornerRadius: panelCornerRadius) + .fill(Color.black.opacity(0.45)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + @ViewBuilder + private var panelBorderOverlay: some View { + if usesEnhancedCustomLiquidBorder { + ZStack { + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .stroke( + Color.white.opacity(0.22), + lineWidth: 1.05 + ) + + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .inset(by: 0.85) + .stroke( + LinearGradient( + colors: [ + Color.white.opacity(0.28), + Color.white.opacity(0.1), + Color.black.opacity(0.18) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 0.9 + ) + + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .inset(by: 1.55) + .stroke( + Color.black.opacity(0.16), + lineWidth: 0.55 + ) + } + .allowsHitTesting(false) + } else if usesLiquidGlass { + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .stroke( + Color.white.opacity(usesCustomLiquidGlass ? 0.15 : 0.14), + lineWidth: usesCustomLiquidGlass ? 0.95 : 0.9 + ) + .allowsHitTesting(false) + } else { + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.35), lineWidth: 1.4) + .allowsHitTesting(false) + } + } + + private func albumArtImage(size: CGFloat, cornerRadius: CGFloat) -> some View { + Image(nsImage: musicManager.albumArt) + .resizable() + .aspectRatio(contentMode: .fit) + .compositingGroup() + .clipShape(RoundedRectangle(cornerRadius: resolvedArtworkCornerRadius(from: cornerRadius), style: .continuous)) + } + + @ViewBuilder + private func albumArtBackground(cornerRadius: CGFloat) -> some View { + if usesCustomLiquidGlass { + customLiquidAlbumArtBackground(cornerRadius: cornerRadius) + } else if usesStandardLiquidGlass { + if #available(macOS 26.0, *) { + clearLiquidGlassSurface(cornerRadius: cornerRadius) + } + } else if shouldUseFrostedBlur { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.ultraThinMaterial) + } else { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color.black.opacity(0.35)) + } + } + + private var lockScreenAppIcon: Image? { + guard showAppIcon, !musicManager.usingAppIconForArtwork else { return nil } + let bundleIdentifier = musicManager.bundleIdentifier ?? "com.apple.Music" + return AppIcon(for: bundleIdentifier) + } + + private var appIconSize: CGFloat { + isExpanded ? 58 : 34 + } + + private var appIconCornerRadius: CGFloat { + isExpanded ? 18 : 12 + } + + private var appIconOffset: CGFloat { + isExpanded ? 18 : 12 + } + + private func resolvedArtworkCornerRadius(from baseCornerRadius: CGFloat) -> CGFloat { + let aspectRatio = musicManager.albumArt.size.height > 0 + ? musicManager.albumArt.size.width / musicManager.albumArt.size.height + : 1 + return aspectRatio > 1.0 ? baseCornerRadius / 3 : baseCornerRadius + } + + @available(macOS 26.0, *) + private func clearLiquidGlassSurface(cornerRadius: CGFloat) -> some View { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(.clear) + .glassEffect( + .clear.interactive(), + in: .rect(cornerRadius: cornerRadius) + ) + } + + @ViewBuilder + private func customLiquidAlbumArtBackground(cornerRadius: CGFloat) -> some View { + LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: cornerRadius) { + Color.clear + } + } + + private func logGlassState(reason: String) { + let snapshot = GlassLogSnapshot( + style: lockScreenGlassStyle, + customizationMode: glassCustomizationMode, + variantRawValue: musicGlassVariant.rawValue, + usesLiquidGlass: usesLiquidGlass + ) + guard snapshot != lastLoggedGlassSnapshot else { return } + lastLoggedGlassSnapshot = snapshot + + struct ComponentState { + let name: String + let isLiquid: Bool + } + + let states = [ + ComponentState(name: "Panel Shell", isLiquid: usesLiquidGlass), + ComponentState(name: "Control Capsules", isLiquid: usesLiquidGlass), + ComponentState(name: "Volume Slider", isLiquid: usesLiquidGlass), + ComponentState(name: "Album Art Plate", isLiquid: usesLiquidGlass) + ] + + let componentSummary = states.map { entry -> String in + let mode = entry.isLiquid ? "Liquid" : "Frosted" + return "\(entry.name)=\(mode)" + }.joined(separator: ", ") + + let modeDescription: String + if usesCustomLiquidGlass { + modeDescription = "Custom Liquid (variant \(musicGlassVariant.rawValue))" + } else if usesStandardLiquidGlass { + modeDescription = "Standard Liquid" + } else { + modeDescription = lockScreenGlassStyle.rawValue + } + + print("[LockScreenMusicPanel] \(reason) – customization=\(glassCustomizationMode.rawValue), mode=\(modeDescription), components[\(componentSummary)], macOS \(currentOSVersionDescription())") + + if glassCustomizationMode == .standard && lockScreenGlassStyle == .liquid && !usesStandardLiquidGlass { + print("[LockScreenMusicPanel] Liquid Glass requested but unavailable on this macOS build. Falling back to frosted visuals.") + } + } + + private func currentOSVersionDescription() -> String { + let version = ProcessInfo.processInfo.operatingSystemVersion + return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + } + + private func logPanelAppearance(event: String = "✅ View appeared") { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + let styleDescriptor = usesLiquidGlass ? "Liquid Glass" : "Frosted" + print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: \(event) – \(styleDescriptor)") + } +} + +private struct PanelControlButton: View { + let icon: String + let frameSize: CGFloat + let iconSize: CGFloat + let iconColor: Color + let backgroundOpacity: Double + let interaction: Interaction + let symbolEffect: SymbolEffectStyle + let action: () -> Void + + @State private var isHovering = false + @State private var pressOffset: CGFloat = 0 + @State private var rotationAngle: Double = 0 + @State private var wiggleToken: Int = 0 + + var body: some View { + Button(action: { + triggerPressEffect() + action() + }) { + RoundedRectangle(cornerRadius: frameSize / 2, style: .continuous) + .fill(backgroundColor) + .overlay( + iconView + ) + } + .frame(width: frameSize, height: frameSize) + .buttonStyle(PlainButtonStyle()) + .offset(x: pressOffset) + .rotationEffect(.degrees(rotationAngle)) + .onHover { hovering in + withAnimation(.smooth(duration: 0.24)) { + isHovering = hovering + } + } + } + + private var backgroundColor: Color { + let hoveredOpacity = max(backgroundOpacity + 0.08, 0.18) + let appliedOpacity = isHovering ? hoveredOpacity : backgroundOpacity + return Color.white.opacity(min(appliedOpacity, 0.32)) + } + + private func triggerPressEffect() { + switch interaction { + case .none: + return + case .nudge(let amount): + withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) { + pressOffset = amount + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { + withAnimation(.spring(response: 0.28, dampingFraction: 0.72)) { + pressOffset = 0 + } + } + case .wiggle(let direction): + guard #available(macOS 14.0, *) else { return } + wiggleToken += 1 + let angle: Double = direction == .clockwise ? 10 : -10 + + withAnimation(.spring(response: 0.18, dampingFraction: 0.5)) { + rotationAngle = angle + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + withAnimation(.spring(response: 0.32, dampingFraction: 0.75)) { + rotationAngle = 0 + } + } + } + } + + @ViewBuilder + private var iconView: some View { + let base = Image(systemName: icon) + .font(.system(size: iconSize, weight: .medium)) + .foregroundStyle(iconColor) + + switch symbolEffect { + case .replace: + base.contentTransition(.symbolEffect(.replace)) + case .replaceAndBounce: + if #available(macOS 14.0, *) { + base + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.bounce, value: icon) + } else { + base.contentTransition(.symbolEffect(.replace)) + } + case .wiggle: + if #available(macOS 15.0, *) { + base + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.wiggle.byLayer, options: .nonRepeating, value: wiggleToken) + } else { + base.contentTransition(.symbolEffect(.replace)) + } + } + } + + enum Interaction { + case none + case nudge(CGFloat) + case wiggle(WiggleDirection) + } + + enum SymbolEffectStyle { + case replace + case replaceAndBounce + case wiggle + } + + enum WiggleDirection { + case clockwise + case counterClockwise + } +} + +@available(macOS 26.0, *) +private struct GlassTextBackdrop: View { + let cornerRadius: CGFloat + + var body: some View { + GeometryReader { proxy in + let dynamicFontSize = max(min(proxy.size.width, proxy.size.height) / 8, 42) + + Text("Lock Screen Liquid Glass") + .font(.system(size: dynamicFontSize, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.clear) + .frame(width: proxy.size.width, height: proxy.size.height) + .glassEffect( + .clear.interactive(), + in: .rect(cornerRadius: cornerRadius) + ) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +// MARK: - Right Click Gesture + +private struct RightClickOverlay: ViewModifier { + let action: () -> Void + + func body(content: Content) -> some View { + content.overlay( + RightClickReceiverView(action: action) + .frame(maxWidth: .infinity, maxHeight: .infinity) + ) + } +} + +private struct RightClickReceiverView: NSViewRepresentable { + let action: () -> Void + + func makeNSView(context: Context) -> RightClickNSView { + let view = RightClickNSView() + view.onRightClick = action + return view + } + + func updateNSView(_ nsView: RightClickNSView, context: Context) { + nsView.onRightClick = action + } +} + +private extension View { + func onRightClick(perform action: @escaping () -> Void) -> some View { + modifier(RightClickOverlay(action: action)) + } +} + +final class RightClickNSView: NSView { + var onRightClick: (() -> Void)? + private var eventMonitor: Any? + + deinit { + removeEventMonitor() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + + if window == nil { + removeEventMonitor() + } else { + installEventMonitorIfNeeded() + } + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + private func installEventMonitorIfNeeded() { + guard eventMonitor == nil else { return } + + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] event in + guard let self, let window = self.window, event.window === window else { + return event + } + + let pointInView = self.convert(event.locationInWindow, from: nil) + guard self.bounds.contains(pointInView) else { + return event + } + + self.onRightClick?() + return nil + } + } + + private func removeEventMonitor() { + guard let eventMonitor else { return } + NSEvent.removeMonitor(eventMonitor) + self.eventMonitor = nil + } +} + +extension Notification.Name { + static let atollArtworkWallpaperDismissed = Notification.Name("atollArtworkWallpaperDismissed") +} diff --git a/DynamicIsland/components/LockScreen/LockScreenReminderWidget.swift b/DynamicIsland/components/LockScreen/LockScreenReminderWidget.swift new file mode 100644 index 00000000..f661664b --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenReminderWidget.swift @@ -0,0 +1,124 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct LockScreenReminderWidget: View { + let snapshot: LockScreenReminderWidgetSnapshot + + private let primaryFont = Font.system(size: 19, weight: .semibold, design: .rounded) + private let iconSize: CGFloat = 20 + private let chipWidth: CGFloat = 9 + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: snapshot.iconName) + .font(.system(size: iconSize, weight: .semibold)) + .foregroundStyle(Color.white) + + RoundedRectangle(cornerRadius: 2) + .fill(chipColor) + .frame(width: chipWidth, height: iconSize) + + Text(snapshot.title) + .font(primaryFont) + .foregroundStyle(primaryTextColor) + .lineLimit(1) + .minimumScaleFactor(0.85) + .allowsTightening(true) + .layoutPriority(1) + .frame(height: iconSize, alignment: .center) + + if let relative = snapshot.relativeDescription { + Text("•") + .font(primaryFont) + .foregroundStyle(separatorColor) + .frame(height: iconSize, alignment: .center) + + Text(relative) + .font(primaryFont) + .foregroundStyle(secondaryTextColor) + .lineLimit(1) + .minimumScaleFactor(0.85) + .allowsTightening(true) + .layoutPriority(0.75) + .frame(height: iconSize, alignment: .center) + } + + Text("•") + .font(primaryFont) + .foregroundStyle(separatorColor) + .frame(height: iconSize, alignment: .center) + + Text(snapshot.eventTimeText) + .font(primaryFont) + .foregroundStyle(secondaryTextColor) + .lineLimit(1) + .minimumScaleFactor(0.85) + .allowsTightening(true) + .layoutPriority(0.75) + .frame(height: iconSize, alignment: .center) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(minWidth: 240, idealWidth: 360, maxWidth: 520, alignment: .leading) + .foregroundStyle(Color.white) + .background(Color.clear) + .shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 3) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + } + + private var accentColor: Color { + snapshot.isCritical ? .red : snapshot.accent.color + } + + private var chipColor: Color { + if snapshot.isCritical { + return .red + } + switch snapshot.chipStyle { + case .eventColor: + return accentColor.ensureMinimumBrightness(factor: 0.7) + case .monochrome: + return Color.white.opacity(0.85) + } + } + + private var primaryTextColor: Color { + Color.white.opacity(0.92) + } + + private var secondaryTextColor: Color { + snapshot.isCritical ? Color.white : Color.white.opacity(0.78) + } + + private var separatorColor: Color { + Color.white.opacity(0.65) + } + + private var accessibilityLabel: String { + var components: [String] = [snapshot.title] + if let relative = snapshot.relativeDescription { + components.append(relative) + } + components.append(snapshot.eventTimeText) + return components.joined(separator: ", ") + } +} + diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift new file mode 100644 index 00000000..0d6ce7c4 --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift @@ -0,0 +1,329 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct LockScreenTimerWidget: View { + static let defaultWidth: Double = 420 + static let preferredHeight: CGFloat = 96 + static var preferredSize: CGSize { + CGSize(width: CGFloat(Defaults[.lockScreenTimerWidgetWidth]), height: preferredHeight) + } + static let cornerRadius: CGFloat = 26 + + @ObservedObject private var animator: LockScreenTimerWidgetAnimator + @ObservedObject private var timerManager = TimerManager.shared + @Default(.lockScreenTimerGlassStyle) private var timerGlassStyle + @Default(.lockScreenTimerGlassCustomizationMode) private var timerGlassCustomizationMode + @Default(.lockScreenTimerLiquidGlassVariant) private var timerGlassVariant + @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass + @Default(.timerPresets) private var timerPresets + @Default(.lockScreenTimerWidgetWidth) private var widgetWidth + + @MainActor + init(animator: LockScreenTimerWidgetAnimator? = nil) { + if let animator { + _animator = ObservedObject(wrappedValue: animator) + } else { + _animator = ObservedObject(wrappedValue: LockScreenTimerWidgetAnimator(isPresented: true)) + } + } + + private var hasHoursComponent: Bool { + abs(timerManager.remainingTime) >= 3600 + } + + private var hasDoubleDigitHours: Bool { + abs(timerManager.remainingTime) >= 36_000 // 10 hours or more + } + + private var countdownFrameWidth: CGFloat { + if hasDoubleDigitHours { return 200 } + if hasHoursComponent { return 185 } + return 160 + } + + private var countdownFont: Font { + let baseSize: CGFloat = hasDoubleDigitHours ? 42 : 46 + return .system(size: baseSize, weight: .bold, design: .rounded) + } + + private var countdownText: String { + timerManager.formattedRemainingTime() + } + + private var activePresetColor: Color? { + guard let presetId = timerManager.activePresetId else { return nil } + return timerPresets.first { $0.id == presetId }?.color + } + + private var accentColor: Color { + (activePresetColor ?? timerManager.timerColor) + .ensureMinimumBrightness(factor: 0.75) + } + + private var accentGradient: LinearGradient { + LinearGradient( + colors: [ + accentColor.opacity(0.35), + accentColor.ensureMinimumBrightness(factor: 0.5) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + private var timerSurfaceMode: LockScreenTimerSurfaceMode { + timerGlassModeIsGlass ? .glass : .classic + } + + private var usesGlassBackground: Bool { + timerSurfaceMode == .glass + } + + private var usesCustomLiquidGlass: Bool { + guard usesGlassBackground else { return false } + return timerGlassStyle == .liquid && timerGlassCustomizationMode == .customLiquid + } + + private var usesStandardLiquidGlass: Bool { + guard usesGlassBackground else { return false } + guard timerGlassStyle == .liquid else { return false } + if #available(macOS 26.0, *) { + return timerGlassCustomizationMode == .standard + } + return false + } + + @ViewBuilder + private var widgetBackground: some View { + if usesGlassBackground { + if usesCustomLiquidGlass { + customLiquidBackground + } else if timerGlassStyle == .liquid { + standardLiquidBackground + } else { + frostedBackground + } + } else { + classicBackground + } + } + + @ViewBuilder + private var standardLiquidBackground: some View { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + .glassEffect( + .clear.tint(accentColor.opacity(0.18)).interactive(), + in: .rect(cornerRadius: Self.cornerRadius) + ) + } else { + frostedBackground + } + } + + private var customLiquidBackground: some View { + LiquidGlassBackground(variant: timerGlassVariant, cornerRadius: Self.cornerRadius) { + Color.black.opacity(0.12) + } + } + + private var frostedBackground: some View { + RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + .fill(.ultraThinMaterial) + } + + private var classicBackground: some View { + RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + .fill(Color.black.opacity(0.65)) + } + + private var widgetSize: CGSize { + CGSize(width: CGFloat(widgetWidth), height: Self.preferredHeight) + } + + private var pauseIcon: String { + timerManager.isPaused ? "play.fill" : "pause.fill" + } + + private var pauseLabel: String { + timerManager.isPaused ? "Resume" : "Pause" + } + + private var secondaryIcon: String { + timerManager.isOvertime ? "stop.fill" : "xmark" + } + + private var secondaryLabel: String { + timerManager.isOvertime ? "Stop" : "Cancel" + } + + var body: some View { + VStack(spacing: 16) { + HStack(alignment: .center, spacing: 0) { + controlButtons + .padding(.trailing, 12) + + Spacer() + + countdownSection + .padding(.leading, 12) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .frame(width: widgetSize.width, height: widgetSize.height) + .background(widgetBackground) + .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + } + .shadow(color: Color.black.opacity(0.25), radius: 18, x: 0, y: 16) + .overlay(alignment: .topLeading) { + accentRibbon + } + .scaleEffect(animator.isPresented ? 1 : 0.9) + .opacity(animator.isPresented ? 1 : 0) + .animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.22), value: animator.isPresented) + } + + private var controlButtons: some View { + HStack(spacing: 10) { + if !timerManager.isOvertime { + CircleButton( + icon: pauseIcon, + foreground: Color.white.opacity(0.95), + background: accentColor.opacity(0.32), + action: togglePause, + isEnabled: timerManager.allowsManualInteraction, + helpText: pauseLabel + ) + } + + CircleButton( + icon: secondaryIcon, + foreground: Color.white.opacity(0.95), + background: Color.black.opacity(0.35), + action: stopTimer, + isEnabled: timerManager.allowsManualInteraction, + helpText: secondaryLabel + ) + } + } + + private var countdownSection: some View { + VStack(alignment: .trailing, spacing: 0) { + Text(countdownText) + .font(countdownFont) + .monospacedDigit() + .foregroundStyle(timerManager.isOvertime ? Color.red : accentColor) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: timerManager.remainingTime) + .lineLimit(1) + .minimumScaleFactor(0.68) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .frame(width: countdownFrameWidth, alignment: .center) + .padding(.trailing, 2) + .layoutPriority(2) + } + + private var accentRibbon: some View { + Capsule() + .fill(accentGradient) + .frame(width: 110, height: 26) + .blur(radius: 12) + .offset(x: 18, y: -6) + .opacity(0.45) + } + + private func togglePause() { + guard timerManager.allowsManualInteraction else { return } + if timerManager.isPaused { + timerManager.resumeTimer() + } else { + timerManager.pauseTimer() + } + } + + private func stopTimer() { + let allowsManualInteraction = timerManager.allowsManualInteraction + + LockScreenTimerWidgetPanelManager.shared.hide() + + Task.detached(priority: .userInitiated) { [allowsManualInteraction] in + try? await Task.sleep(nanoseconds: LockScreenTimerWidgetPanelManager.hideAnimationDurationNanoseconds) + + guard !Task.isCancelled else { return } + + await MainActor.run { + if allowsManualInteraction { + TimerManager.shared.stopTimer() + } else { + TimerManager.shared.endExternalTimer(triggerSmoothClose: false) + } + } + } + } + + private struct CircleButton: View { + let icon: String + let foreground: Color + let background: Color + let action: () -> Void + let isEnabled: Bool + let helpText: String + + @State private var isHovering = false + + private var effectiveBackground: Color { + background.opacity(isHovering ? 0.9 : 0.7) + } + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(foreground) + .frame(width: 48, height: 48) + .background(effectiveBackground.opacity(isEnabled ? 1 : 0.25)) + .clipShape(Circle()) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + .contentShape(Circle()) + .help(helpText) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.4) + .onHover { hovering in isHovering = hovering } + } + } +} + +#Preview { + LockScreenTimerWidget() + .frame(width: LockScreenTimerWidget.preferredSize.width, height: LockScreenTimerWidget.preferredSize.height) + .padding() + .background(Color.black) + .onAppear { + TimerManager.shared.startDemoTimer(duration: 1783) + } +} diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift new file mode 100644 index 00000000..60e2ec19 --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -0,0 +1,1533 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults +import AppKit +import CoreGraphics + + +struct LockScreenWeatherWidget: View { + let snapshot: LockScreenWeatherSnapshot + @ObservedObject private var focusManager = DoNotDisturbManager.shared + @ObservedObject private var calendarManager = CalendarManager.shared + @Default(.enableDoNotDisturbDetection) private var focusDetectionEnabled + @Default(.showDoNotDisturbIndicator) private var focusIndicatorEnabled + @Default(.enableLockScreenFocusWidget) private var lockScreenFocusWidgetEnabled + @Default(.lockScreenShowCalendarCountdown) private var lockScreenShowCalendarCountdown + @Default(.lockScreenShowCalendarEvent) private var lockScreenShowCalendarEvent + @Default(.lockScreenShowCalendarEventEntireDuration) private var lockScreenShowCalendarEventEntireDuration + @Default(.lockScreenShowCalendarEventAfterStartWindow) private var lockScreenShowCalendarEventAfterStartWindow + @Default(.lockScreenShowCalendarTimeRemaining) private var lockScreenShowCalendarTimeRemaining + @Default(.lockScreenShowCalendarStartTimeAfterBegins) private var lockScreenShowCalendarStartTimeAfterBegins + @Default(.lockScreenCalendarEventLookaheadWindow) private var lockScreenCalendarEventLookaheadWindow + @Default(.lockScreenWeatherWidgetRowOrder) private var lockScreenWeatherWidgetRowOrder + @Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode + @Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs + @Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled + @Default(.lockScreenReminderChipStyle) private var lockScreenReminderChipStyle + @Default(.reminderSneakPeekDuration) private var reminderSneakPeekDuration + + @State private var currentTime = Date() + @State private var calendarRowVisible: Bool = false + @State private var lastCalendarLine: String = "" + @State private var calendarRowRenderToken: Int = 0 + @State private var widgetWidthRemeasureToken: Int = 0 + @State private var lastBadgeLogKey: String = "" + private var currentCalendarEventID: String { + nextCalendarEvent?.id ?? "no_event" + } + + 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 static let sunriseFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + formatter.locale = .current + return formatter + }() + + // MARK: - Refresh + /// Refreshes every 15 seconds (general refresh), and optionally also ticks exactly on minute boundaries + /// *only when the screen is locked* and the calendar widget is enabled. + private final class LockAwareTicker: ObservableObject { + @Published var now: Date = Date() + + private var refreshTimer: Timer? + private var minuteTimer: Timer? + + private var lockObserver: NSObjectProtocol? + private var unlockObserver: NSObjectProtocol? + + private var isLocked: Bool = false + private var minuteAlignedEnabled: Bool = false + + func start() { + stop() + installLockObservers() + refreshLockStateFromSystem() + startGeneralRefresh() + fireNow() + updateMinuteAlignedTimer() + } + + func stop() { + refreshTimer?.invalidate() + refreshTimer = nil + + minuteTimer?.invalidate() + minuteTimer = nil + + if let lockObserver { DistributedNotificationCenter.default().removeObserver(lockObserver) } + if let unlockObserver { DistributedNotificationCenter.default().removeObserver(unlockObserver) } + lockObserver = nil + unlockObserver = nil + } + + func fireNow() { + now = Date() + } + + /// Enable/disable the minute-aligned ticker (minute boundary ticks). + /// It will only actually run when the screen is locked. + func setMinuteAlignedEnabled(_ enabled: Bool) { + minuteAlignedEnabled = enabled + updateMinuteAlignedTimer() + } + + private func startGeneralRefresh() { + refreshTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) { [weak self] _ in + self?.fireNow() + } + refreshTimer?.tolerance = 0 + if let refreshTimer { + RunLoop.main.add(refreshTimer, forMode: .common) + } + } + + private func refreshLockStateFromSystem() { + if let dict = CGSessionCopyCurrentDictionary() as? [String: Any] { + if let locked = dict["CGSSessionScreenIsLocked"] as? Bool { + isLocked = locked + } + } + } + + private func installLockObservers() { + lockObserver = DistributedNotificationCenter.default().addObserver( + forName: NSNotification.Name("com.apple.screenIsLocked"), + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + self.isLocked = true + self.fireNow() + self.updateMinuteAlignedTimer() + } + + unlockObserver = DistributedNotificationCenter.default().addObserver( + forName: NSNotification.Name("com.apple.screenIsUnlocked"), + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + self.isLocked = false + self.fireNow() + self.updateMinuteAlignedTimer() + } + } + + private func updateMinuteAlignedTimer() { + let shouldRun = isLocked && minuteAlignedEnabled + if !shouldRun { + minuteTimer?.invalidate() + minuteTimer = nil + return + } + + if minuteTimer != nil { + return + } + + scheduleNextBoundaryAndRepeat() + } + + private func scheduleNextBoundaryAndRepeat() { + let current = Date() + let calendar = Calendar.current + + var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: current) + components.second = 0 + let thisMinute = calendar.date(from: components) ?? current + let nextMinute = calendar.date(byAdding: .minute, value: 1, to: thisMinute) ?? current.addingTimeInterval(60) + + let initialDelay = max(0.0, nextMinute.timeIntervalSince(current)) + minuteTimer = Timer.scheduledTimer(withTimeInterval: initialDelay, repeats: false) { [weak self] _ in + guard let self else { return } + self.fireNow() + + self.minuteTimer?.invalidate() + self.minuteTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in + self?.fireNow() + } + self.minuteTimer?.tolerance = 0 + if let minuteTimer { + RunLoop.main.add(minuteTimer, forMode: .common) + } + } + minuteTimer?.tolerance = 0 + if let minuteTimer { + RunLoop.main.add(minuteTimer, forMode: .common) + } + } + + deinit { + stop() + } + } + + @StateObject private var minuteTicker = LockAwareTicker() + + private enum CalendarLookaheadWindow: String { + case mins15 = "15m" + case mins30 = "30m" + case hour1 = "1h" + case hours3 = "3h" + case hours6 = "6h" + case hours12 = "12h" + case restOfDay = "rest_of_day" + case allTime = "all_time" + + var minutes: Int? { + switch self { + case .mins15: return 15 + case .mins30: return 30 + case .hour1: return 60 + case .hours3: return 180 + case .hours6: return 360 + case .hours12: return 720 + case .restOfDay, .allTime: return nil + } + } + } + + private enum CalendarAfterStartWindow: String { + case min1 = "1m" + case mins5 = "5m" + case mins10 = "10m" + case mins15 = "15m" + case mins30 = "30m" + case mins45 = "45m" + case hour1 = "1h" + case hours2 = "2h" + + var minutes: Int { + switch self { + case .min1: return 1 + case .mins5: return 5 + case .mins10: return 10 + case .mins15: return 15 + case .mins30: return 30 + case .mins45: return 45 + case .hour1: return 60 + case .hours2: return 120 + } + } + } + + private var selectedLookaheadWindow: CalendarLookaheadWindow { + let raw = lockScreenCalendarEventLookaheadWindow + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + switch raw { + case "all time", "all_time", "alltime": + return .allTime + case "rest of the day", "rest_of_day", "restofday": + return .restOfDay + case "15m", "15 mins", "15min", "15mins": + return .mins15 + case "30m", "30 mins", "30min", "30mins": + return .mins30 + case "1h", "1 hour", "1hour": + return .hour1 + case "3h", "3 hours", "3hours": + return .hours3 + case "6h", "6 hours", "6hours": + return .hours6 + case "12h", "12 hours", "12hours": + return .hours12 + default: + return CalendarLookaheadWindow(rawValue: lockScreenCalendarEventLookaheadWindow) ?? .hours3 + } + } + + private var selectedAfterStartWindow: CalendarAfterStartWindow { + let raw = lockScreenShowCalendarEventAfterStartWindow + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + switch raw { + case "1m", "1 min", "1 minute", "1min": + return .min1 + case "5m", "5 min", "5 mins", "5 minutes", "5min": + return .mins5 + case "10m", "10 min", "10 mins", "10 minutes", "10min": + return .mins10 + case "15m", "15 min", "15 mins", "15 minutes", "15min": + return .mins15 + case "30m", "30 min", "30 mins", "30 minutes", "30min": + return .mins30 + case "45m", "45 min", "45 mins", "45 minutes", "45min": + return .mins45 + case "1h", "1 hr", "1 hour", "1hour": + return .hour1 + case "2h", "2 hrs", "2 hours", "2hours", "2hour": + return .hours2 + default: + return CalendarAfterStartWindow(rawValue: lockScreenShowCalendarEventAfterStartWindow) ?? .mins5 + } + } + + private func lookaheadEndDate(from now: Date) -> Date? { + switch selectedLookaheadWindow { + case .allTime: + return nil + case .restOfDay: + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: now) + return calendar.date(byAdding: .day, value: 1, to: startOfToday) + default: + if let minutes = selectedLookaheadWindow.minutes { + return Calendar.current.date(byAdding: .minute, value: minutes, to: now) + } + return nil + } + } + + private func isEventEligibleForLookahead(_ event: EventModel, now: Date) -> Bool { + if event.isAllDay { + return false + } + if lockScreenShowCalendarEventEntireDuration, event.start <= now, event.end >= now { + return true + } + guard event.start > now else { return false } + guard let endDate = lookaheadEndDate(from: now) else { + return true + } + return event.start <= endDate + } + + private var lockScreenAllowedCalendarIDs: Set? { + guard lockScreenCalendarSelectionMode != "all" else { return nil } + return lockScreenSelectedCalendarIDs + } + + private func passesLockScreenCalendarFilter(_ event: EventModel) -> Bool { + guard let allowed = lockScreenAllowedCalendarIDs else { return true } + guard !allowed.isEmpty else { return false } + return allowed.contains(event.calendar.id) + } + + private var nextCalendarEvent: EventModel? { + let now = currentTime + + if lockScreenShowCalendarEventEntireDuration { + let candidates = calendarManager.lockScreenEvents + .filter { $0.end >= now } + .filter(passesLockScreenCalendarFilter) + .filter { isEventEligibleForLookahead($0, now: now) } + .sorted { $0.start < $1.start } + return candidates.first + } + + let graceMinutes = lockScreenShowCalendarEventAfterStartEnabled + ? selectedAfterStartWindow.minutes + : 0 + + if graceMinutes > 0 { + let graceEndForEvent: (EventModel) -> Date = { event in + Calendar.current.date(byAdding: .minute, value: graceMinutes, to: event.start) ?? event.start + } + + let activeCandidates = calendarManager.lockScreenEvents + .filter { !$0.isAllDay } + .filter(passesLockScreenCalendarFilter) + .filter { $0.start <= now && $0.end >= now } + .filter { now <= graceEndForEvent($0) } + .sorted { $0.start < $1.start } + + if let active = activeCandidates.first { + return active + } + } + + let upcoming = calendarManager.lockScreenEvents + .filter { $0.start > now } + .filter(passesLockScreenCalendarFilter) + .filter { isEventEligibleForLookahead($0, now: now) } + .sorted { $0.start < $1.start } + + return upcoming.first + } + + private var shouldShowCalendarRow: Bool { + lockScreenShowCalendarEvent && nextCalendarEvent != nil + } + + private var shouldShowFocusInCalendarSlot: Bool { + shouldShowFocusWidget && !shouldShowCalendarRow + } + + private var shouldCombineFocusAndCalendar: Bool { + shouldShowFocusWidget && shouldShowCalendarRow + } + + private enum RowKind { + case weather + case focus + case calendar + } + + private var orderedRowKinds: [RowKind] { + switch lockScreenWeatherWidgetRowOrder { + case "weather_focus_calendar": + return [.weather, .focus, .calendar] + case "weather_calendar_focus": + return [.weather, .calendar, .focus] + case "focus_weather_calendar": + return [.focus, .weather, .calendar] + case "focus_calendar_weather": + return [.focus, .calendar, .weather] + case "calendar_weather_focus": + return [.calendar, .weather, .focus] + case "calendar_focus_weather": + return [.calendar, .focus, .weather] + default: + return [.weather, .calendar, .focus] + } + } + + private var enabledRowKinds: [RowKind] { + var enabled: Set = [.weather] + if lockScreenShowCalendarEvent { enabled.insert(.calendar) } + if shouldShowFocusWidget { enabled.insert(.focus) } + if shouldShowFocusInCalendarSlot { enabled.insert(.calendar); enabled.remove(.focus) } + if shouldCombineFocusAndCalendar { enabled.remove(.focus) } + return orderedRowKinds.filter { enabled.contains($0) } + } + + private var fullRowOrder: [RowKind] { + orderedRowKinds + } + + private func isRowActive(_ kind: RowKind) -> Bool { + switch kind { + case .weather: + return true + case .focus: + return shouldShowFocusWidget + case .calendar: + return nextCalendarEvent != nil + } + } + + private var shouldCollapseGap: Bool { + fullRowOrder.count == 3 + } + + private var collapseDistance: CGFloat { + 26 + focusWidgetSpacing + } + + private var collapseOffsetForBottomRow: CGFloat { + guard shouldCollapseGap else { return 0 } + let middleKind = fullRowOrder[1] + let bottomKind = fullRowOrder[2] + + let middleActive = isRowActive(middleKind) + let bottomActive = isRowActive(bottomKind) + + return (!middleActive && bottomActive) ? -collapseDistance : 0 + } + + private var isInline: Bool { snapshot.widgetStyle == .inline } + private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top } + private var stackSpacing: CGFloat { isInline ? 14 : 22 } + private var mainRowAlignment: Alignment { + if isInline { + return shouldCombineFocusAndCalendar ? .center : .leading + } + return .center + } + private var secondaryRowAlignment: Alignment { .center } + private var focusRowAlignment: Alignment { .center } + private var gaugeDiameter: CGFloat { 64 } + private var topPadding: CGFloat { isInline ? 6 : 22 } + private var bottomPadding: CGFloat { isInline ? 6 : 10 } + + private var monochromeGaugeTint: Color { + Color.white.opacity(0.9) + } + + var body: some View { + VStack(alignment: .leading, spacing: focusWidgetSpacing) { + ForEach(Array(enabledRowKinds.enumerated()), id: \.offset) { index, kind in + rowView(for: kind) + .offset(y: offsetForRow(index: index)) + .animation(.easeInOut(duration: 0.3), value: collapseOffsetForBottomRow) + } + } + .id(widgetWidthRemeasureToken) + .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) + .onReceive(minuteTicker.$now) { currentTime = $0 } + .onAppear { + minuteTicker.start() + minuteTicker.setMinuteAlignedEnabled(lockScreenShowCalendarEvent) + currentTime = Date() + calendarRowVisible = shouldShowCalendarRow + + Task { + await calendarManager.checkCalendarAuthorization() + await calendarManager.checkReminderAuthorization() + await calendarManager.updateLockScreenEvents(force: true) + } + + if let event = nextCalendarEvent { + lastCalendarLine = eventLineText(for: event) + logBadgeColor(event: event, reason: "on-appear") + } + } + .onChange(of: lockScreenShowCalendarEvent) { _, enabled in + minuteTicker.setMinuteAlignedEnabled(enabled) + } + .onChange(of: lockScreenCalendarEventLookaheadWindow) { _, _ in + Task { await calendarManager.updateLockScreenEvents(force: true) } + } + .onChange(of: lockScreenCalendarSelectionMode) { _, _ in + Task { await calendarManager.updateLockScreenEvents(force: true) } + } + .onChange(of: lockScreenSelectedCalendarIDs) { _, _ in + Task { await calendarManager.updateLockScreenEvents(force: true) } + } + .onDisappear { minuteTicker.stop() } + .onChange(of: currentCalendarEventID) { _, _ in + calendarRowRenderToken &+= 1 + if nextCalendarEvent != nil { + widgetWidthRemeasureToken &+= 1 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.20) { + calendarRowRenderToken &+= 1 + if nextCalendarEvent != nil { + widgetWidthRemeasureToken &+= 1 + } + } + } + .onChange(of: currentCalendarEventID) { _, _ in + if let event = nextCalendarEvent { + let newLine = eventLineText(for: event) + if !newLine.isEmpty { + lastCalendarLine = newLine + } + logBadgeColor(event: event, reason: "event-change") + } + } + .onChange(of: lockScreenReminderChipStyle) { _, _ in + if let event = nextCalendarEvent { + logBadgeColor(event: event, reason: "chip-style-change") + } + } + .onChange(of: shouldShowCalendarRow) { _, newValue in + withAnimation(.easeInOut(duration: 0.25)) { + calendarRowVisible = newValue + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + } + + private func offsetForRow(index: Int) -> CGFloat { + if enabledRowKinds.count != 3 { return 0 } + if index == 2 { + return collapseOffsetForBottomRow + } + return 0 + } + + @ViewBuilder + private func rowView(for kind: RowKind) -> some View { + switch kind { + case .weather: + mainWidgetRow + case .calendar: + if shouldCombineFocusAndCalendar { + combinedFocusCalendarRow + } else if shouldShowFocusInCalendarSlot { + focusRowInCalendarSlot + } else { + nextEventRow + } + case .focus: + if shouldCombineFocusAndCalendar { + EmptyView() + } else { + focusWidget + .opacity(shouldShowFocusWidget ? 1 : 0) + .accessibilityHidden(!shouldShowFocusWidget) + .allowsHitTesting(false) + } + } + } + + private var focusRowInCalendarSlot: some View { + HStack(alignment: .center, spacing: 8) { + focusIcon + .font(.system(size: 20, weight: .semibold)) + .frame(width: 26, height: 26) + + Text(focusDisplayName) + .font(inlinePrimaryFont) + .lineLimit(1) + .minimumScaleFactor(0.85) + .allowsTightening(true) + } + .frame(maxWidth: .infinity, alignment: secondaryRowAlignment) + .padding(.horizontal, 2) + .opacity(shouldShowFocusWidget ? 1 : 0) + .accessibilityLabel("Focus active: \(focusDisplayName)") + .allowsHitTesting(false) + } + + private var mainWidgetRow: some View { + HStack(alignment: stackAlignment, spacing: stackSpacing) { + if let charging = snapshot.charging { + chargingSegment(for: charging) + } + + if 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 snapshot.showsSunrise, let sunriseText = sunriseTimeText { + sunriseSegment(text: sunriseText) + } + + if shouldShowLocation { + locationSegment + } + } + .frame(maxWidth: .infinity, alignment: mainRowAlignment) + } + + private var nextEventRow: some View { + let event = nextCalendarEvent + let line = shouldShowCalendarRow ? eventLineText(for: event) : lastCalendarLine + let iconConfig = calendarRowIconConfig(for: event) + + return LockScreenCalendarEventRow( + line: line, + isVisible: calendarRowVisible, + renderToken: calendarRowRenderToken, + font: inlinePrimaryFont, + alignment: secondaryRowAlignment, + iconName: iconConfig.name, + iconRenderingMode: iconConfig.renderingMode, + iconColors: iconConfig.colors + ) + } + + private var combinedFocusCalendarRow: some View { + let event = nextCalendarEvent + let line = shouldShowCalendarRow ? eventLineText(for: event) : lastCalendarLine + let iconConfig = calendarRowIconConfig(for: event) + + return HStack(alignment: .center, spacing: 10) { + HStack(alignment: .center, spacing: 6) { + calendarIconView(iconConfig) + .frame(width: 26, height: 26) + + Text(line) + .font(inlinePrimaryFont) + .lineLimit(1) + .minimumScaleFactor(0.85) + .allowsTightening(true) + } + .layoutPriority(2) + + Text("•") + .font(inlinePrimaryFont) + .foregroundStyle(secondaryLabelColor) + .accessibilityHidden(true) + + HStack(alignment: .center, spacing: 8) { + focusIcon + .font(.system(size: 20, weight: .semibold)) + .frame(width: 26, height: 26) + + Text(focusDisplayName) + .font(inlinePrimaryFont) + .lineLimit(1) + .minimumScaleFactor(0.85) + .allowsTightening(true) + } + .layoutPriority(1) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 2) + .id(calendarRowRenderToken) + .opacity(shouldShowFocusWidget && shouldShowCalendarRow ? 1 : 0) + .accessibilityLabel("\(line). Focus active: \(focusDisplayName)") + .allowsHitTesting(false) + } + + @ViewBuilder + private func calendarIconView(_ iconConfig: (name: String, renderingMode: SymbolRenderingMode, colors: [Color])) -> some View { + let image = Image(systemName: iconConfig.name) + .font(.system(size: 20, weight: .semibold)) + + let isPalette = String(describing: iconConfig.renderingMode) + .lowercased() + .contains("palette") + + if isPalette, iconConfig.colors.count >= 2 { + image + .symbolRenderingMode(.palette) + .foregroundStyle(iconConfig.colors[0], iconConfig.colors[1]) + } else if iconConfig.colors.count >= 2 { + image + .symbolRenderingMode(iconConfig.renderingMode) + .foregroundStyle(iconConfig.colors[0], iconConfig.colors[1]) + } else if iconConfig.colors.count == 1 { + image + .symbolRenderingMode(iconConfig.renderingMode) + .foregroundStyle(iconConfig.colors[0]) + } else { + image + .symbolRenderingMode(iconConfig.renderingMode) + } + } + + private func calendarRowIconConfig(for event: EventModel?) -> (name: String, renderingMode: SymbolRenderingMode, colors: [Color]) { + guard let event else { + return (name: "calendar.badge", renderingMode: .hierarchical, colors: [Color.white.opacity(0.9)]) + } + + let badgeColor = reminderChipColor(for: event) + logBadgeColor(event: event, reason: "icon-config") + + if event.type.isReminder || event.calendar.isReminder { + if isReminderCritical(event) { + return (name: "exclamationmark.triangle.fill", renderingMode: .multicolor, colors: []) + } + return ( + name: "calendar.badge", + renderingMode: .palette, + colors: [badgeColor, Color.white.opacity(0.6)] + ) + } + + let eventBadgeColor = eventAccentColor(for: event) + return ( + name: "calendar.badge", + renderingMode: .palette, + colors: [eventBadgeColor, Color.white.opacity(0.6)] + ) + } + + private func logBadgeColor(event: EventModel, reason: String) { + let key = "\(event.id)|\(lockScreenReminderChipStyle.rawValue)|\(isReminderCritical(event))|\(reason)" + lastBadgeLogKey = key + + let color = reminderChipColor(for: event) + let nsColor = NSColor(color).usingColorSpace(.deviceRGB) + if let nsColor { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + nsColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let hex = String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255)) + let alphaText = String(format: "%.2f", alpha) + NSLog("LockScreenCalendarRow badge color: %@ alpha=%@ style=%@ critical=%@ reason=%@", hex, alphaText, lockScreenReminderChipStyle.rawValue, String(isReminderCritical(event)), reason) + } else { + NSLog("LockScreenCalendarRow badge color: style=%@ critical=%@ reason=%@", lockScreenReminderChipStyle.rawValue, String(isReminderCritical(event)), reason) + } + } + + private func reminderChipColor(for event: EventModel) -> Color { + if isReminderCritical(event) { return .red } + switch lockScreenReminderChipStyle { + case .monochrome: + return Color.white.opacity(0.85) + case .eventColor: + let color = reminderAccentColor(for: event) + return color.ensureMinimumBrightness(factor: 0.7) + } + } + + private func reminderAccentColor(for event: EventModel) -> Color { + Color(event.calendar.color) + } + + private func eventAccentColor(for event: EventModel) -> Color { + Color(event.calendar.color).ensureMinimumBrightness(factor: 0.7) + } + + private func isReminderCritical(_ event: EventModel) -> Bool { + guard event.type.isReminder || event.calendar.isReminder else { return false } + let window = TimeInterval(reminderSneakPeekDuration) + guard window > 0 else { return false } + let remaining = event.start.timeIntervalSince(currentTime) + return remaining > 0 && remaining <= window + } + + private func eventLineText(for event: EventModel?) -> String { + guard let event else { return "" } + let now = currentTime + + if event.isAllDay { + return "\(String(format: String(localized: "All-day"))) • \(event.title)" + } + + let timeString = event.start.formatted(date: .omitted, time: .shortened) + + if event.start <= now && event.end >= now { + let leading = lockScreenShowCalendarStartTimeAfterBegins ? "\(timeString) • " : "" + let suffix: String + if lockScreenShowCalendarTimeRemaining { + let secondsLeft = event.end.timeIntervalSince(now) + let minutesLeft = max(0, Int(ceil(secondsLeft / 60.0))) + suffix = " • \(String(format: String(localized: "%@ left"), countdownText(fromMinutes: minutesLeft)))" + } else { + suffix = "" + } + return "\(leading)\(event.title)\(suffix)" + } + + guard lockScreenShowCalendarCountdown else { + return "\(timeString) • \(event.title)" + } + + let secondsUntilStart = event.start.timeIntervalSince(now) + let totalMinutes = max(0, Int(ceil(secondsUntilStart / 60.0))) + return "\(timeString) • \(event.title) • \(String(format: String(localized: "in %@"), (countdownText(fromMinutes: totalMinutes))))" + } + + private func countdownText(fromMinutes minutes: Int) -> String { + if minutes < 60 { + return "\(minutes)m" + } + + let hours = String(format: String(localized: "%lldh"), minutes / 60) + let mins = minutes % 60 + let minStr = String(format: String(localized: "%lldm"), minutes % 60) + if mins == 0 { + return "\(hours)" + } + return "\(hours) \(minStr)" + } + + private var sunriseTimeText: String? { + guard let sunrise = snapshot.sunCycle?.sunrise else { return nil } + return Self.sunriseFormatter.string(from: sunrise) + } + + @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: + inlineBatterySegment(for: info) + 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) + let symbolName = info.usesLaptopSymbol ? "laptopcomputer" : batteryIconName(for: level) + + return VStack(spacing: 6) { + Gauge(value: Double(level), in: 0...100) { + EmptyView() + } currentValueLabel: { + Image(systemName: symbolName) + .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 inlineBatterySegment(for info: LockScreenWeatherSnapshot.BatteryInfo) -> some View { + let level = clampedBatteryLevel(info.batteryLevel) + + return HStack(alignment: .firstTextBaseline, spacing: 6) { + // Optional icon (laptop vs battery glyph) + Image(systemName: info.usesLaptopSymbol ? "laptopcomputer" : batteryIconName(for: level)) + .font(.system(size: 20, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + + // ✅ The actual percentage text you want in inline style + Text("\(level)%") + .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: "\(info.scale.compactLabel) \(info.index)", secondary: info.category.displayName) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .layoutPriority(1) + } + + private func circularAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View { + let range = info.scale.gaugeRange + let clampedValue = min(max(Double(info.index), range.lowerBound), range.upperBound) + + return VStack(spacing: 6) { + Gauge(value: clampedValue, in: range) { + EmptyView() + } currentValueLabel: { + Text("\(info.index)") + .font(.system(size: 20, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.white) + } + .gaugeStyle(.accessoryCircular) + .tint(aqiTint(for: info)) + .frame(width: gaugeDiameter, height: gaugeDiameter) + + Text("\(info.scale.compactLabel) · \(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 func sunriseSegment(text: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemName: "sunrise.fill") + .font(.system(size: 20, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + Text(text) + .font(inlinePrimaryFont) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .layoutPriority(0.8) + .accessibilityLabel("Sunrise at \(text)") + } + + private var shouldShowLocation: Bool { + snapshot.showsLocation && (snapshot.locationName?.isEmpty == false) + } + + private var focusWidgetSpacing: CGFloat { + guard lockScreenShowCalendarEvent || lockScreenFocusWidgetEnabled else { return 0 } + return isInline ? 14 : 20 + } + + private var shouldShowFocusWidget: Bool { + lockScreenFocusWidgetEnabled && + focusDetectionEnabled && + focusIndicatorEnabled && + focusManager.isDoNotDisturbActive && + !focusDisplayName.isEmpty + } + + private var focusDisplayName: String { + let trimmed = focusManager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + if focusMode == .doNotDisturb { + return "Do Not Disturb" + } + let fallback = focusMode.displayName + return fallback.isEmpty ? "Focus" : fallback + } + + private var focusMode: FocusModeType { + FocusModeType.resolve( + identifier: focusManager.currentFocusModeIdentifier, + name: focusManager.currentFocusModeName + ) + } + + private var focusIcon: Image { + focusMode + .resolvedActiveIcon(usePrivateSymbol: true) + .renderingMode(.template) + } + + private var focusWidget: some View { + HStack(alignment: .center, spacing: 8) { + focusIcon + .font(.system(size: 20, weight: .semibold)) + .frame(width: 26, height: 26) + + Text(focusDisplayName) + .font(inlinePrimaryFont) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .frame(maxWidth: .infinity, alignment: focusRowAlignment) + .padding(.horizontal, 2) + .accessibilityLabel("Focus active: \(focusDisplayName)") + } + + 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 { + let level = info.batteryLevel.map(clampedBatteryLevel) + if level == 100 { + return NSLocalizedString("Fully charged", comment: "Charging fallback label when already charged") + } + if let level { + let onHold = NSLocalizedString("On hold", comment: "Plugged in but charging is paused by the system") + return "\(level)% • \(onHold)" + } + return NSLocalizedString("On hold", comment: "Plugged in but charging is paused by the system") + } + 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 info: LockScreenWeatherSnapshot.AirQualityInfo) -> Color { + guard snapshot.usesGaugeTint else { return monochromeGaugeTint } + switch info.category { + case .good: + return Color(red: 0.20, green: 0.79, blue: 0.39) + case .fair: + return Color(red: 0.55, green: 0.85, blue: 0.32) + 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 .poor: + return Color(red: 0.98, green: 0.57, blue: 0.24) + case .veryPoor: + 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 .extremelyPoor: + return Color(red: 0.50, green: 0.13, blue: 0.28) + 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)) + } + + if shouldShowFocusWidget { + components.append("Focus active: \(focusDisplayName)") + } + + if lockScreenShowCalendarEvent, let event = nextCalendarEvent { + let line = eventLineText(for: event) + if !line.isEmpty { + components.append("Calendar: \(line)") + } + } + + 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 { + let level = charging.batteryLevel.map(clampedBatteryLevel) + if level == 100 { + return NSLocalizedString("Battery fully charged", comment: "Battery is full") + } + if let level { + return String( + format: NSLocalizedString("Battery at %d percent, charging on hold", comment: "Plugged in but charging is paused by the system"), + level + ) + } + return NSLocalizedString("Charging on hold", comment: "Plugged in but charging is paused by the system") + } + + 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.scale.accessibilityLabel) \(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 new file mode 100644 index 00000000..165dea8a --- /dev/null +++ b/DynamicIsland/components/MinimalisticMusicView.swift @@ -0,0 +1,157 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +// Note: lyrics display is inlined into the main minimalistic view below and is controlled by Defaults[.enableLyrics] + +struct MinimalisticMusicView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var musicManager = MusicManager.shared + @Default(.enableLyrics) var enableLyrics + @State private var isHovering: Bool = false + + var body: some View { + VStack(spacing: 2) { + // Main content row + HStack(spacing: 0) { + // Left: Album Art + albumArtView + + // Middle: Song Title, Artist and Lyrics (lyrics shown under artist when enabled) + Rectangle() + .fill(.black) + .overlay( + GeometryReader { geo in + VStack(alignment: .center, spacing: 2) { + if !musicManager.songTitle.isEmpty { + MusicTitleMarqueeView( + text: musicManager.songTitle, + isExplicit: musicManager.isCurrentTrackExplicit, + 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), + alignment: .center, + badgeHeight: 13 + ) + } + + // Artist name + if !musicManager.artistName.isEmpty { + Text(musicManager.artistName) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .center) + } + + // Lyrics under the author name (same font size as author) + if enableLyrics { + lyricsLineView + .font(.system(size: 11, weight: .regular)) + } + } + .frame(width: geo.size.width, height: geo.size.height, alignment: .center) + } + ) + .frame(width: vm.closedNotchSize.width) + + // Right: Music Visualizer + visualizerView + } + + // (lyrics are displayed inline under the artist name) + } + // reserve extra height when lyrics are enabled + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) + .onHover { hovering in + isHovering = hovering + } + } + + // MARK: - Album Art + + private var albumArtView: some View { + HStack { + Color.clear + .aspectRatio(1, contentMode: .fit) + .background( + Image(nsImage: musicManager.albumArt) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? 4 : 12)) + + ) + .clipped() + .albumArtFlip(angle: musicManager.flipAngle) + .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) + } + .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))) + } + + // MARK: - Visualizer + + private var visualizerView: some View { + HStack { + Rectangle() + .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient) + .frame(width: 50, alignment: .center) + .mask { + AudioVisualizerView(isPlaying: $musicManager.isPlaying) + .frame(width: 16, height: 12) + } + .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), + height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) + } + .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), + height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) + } +} + +private extension MinimalisticMusicView { + var lyricsLineView: some View { + let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines) + + return HStack(spacing: 6) { + if !line.isEmpty { + Image(systemName: "music.note") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.white.opacity(0.7)) + .symbolRenderingMode(.monochrome) + + Text(line) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.white.opacity(0.88)) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 6) + .id(line) + .transition(.asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + )) + } + } + .frame(maxWidth: .infinity, alignment: .center) + .animation(.smooth(duration: 0.32), value: line) + } +} diff --git a/DynamicIsland/components/Music/LiveStreamProgressIndicator.swift b/DynamicIsland/components/Music/LiveStreamProgressIndicator.swift new file mode 100644 index 00000000..658cdaeb --- /dev/null +++ b/DynamicIsland/components/Music/LiveStreamProgressIndicator.swift @@ -0,0 +1,79 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct LiveStreamProgressIndicator: View { + let tint: Color + + private var baseGradient: LinearGradient { + LinearGradient( + colors: [ + tint.opacity(0.18), + tint.opacity(0.42), + tint.opacity(0.18) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + + private var centerShade: LinearGradient { + LinearGradient( + stops: [ + .init(color: .black.opacity(0.0), location: 0.0), + .init(color: .black.opacity(0.38), location: 0.5), + .init(color: .black.opacity(0.0), location: 1.0) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + + var body: some View { + Capsule() + .fill(Color.white.opacity(0.07)) + .overlay( + Capsule() + .fill(baseGradient) + .opacity(0.9) + .blendMode(.plusLighter) + ) + .overlay( + Capsule() + .fill(centerShade) + .blendMode(.multiply) + ) + .overlay( + Capsule() + .stroke(tint.opacity(0.6), lineWidth: 0.7) + .blendMode(.screen) + ) + .frame(height: 10) + .overlay { + Text("LIVE") + .font(.system(size: 12, weight: .black, design: .rounded)) + .tracking(1.4) + .foregroundStyle(Color.white) + .shadow(color: .black.opacity(0.65), radius: 4, y: 1) + } + .allowsHitTesting(false) + .accessibilityLabel("Live stream indicator") + .opacity(0.95) + } +} diff --git a/DynamicIsland/components/Music/LottieAnimationView.swift b/DynamicIsland/components/Music/LottieAnimationView.swift index 8388416e..7bf72b66 100644 --- a/DynamicIsland/components/Music/LottieAnimationView.swift +++ b/DynamicIsland/components/Music/LottieAnimationView.swift @@ -1,9 +1,24 @@ -// -// LottieAnimationView.swift -// DynamicIsland -// -// Created by Richard Kunkli on 2024. 10. 29.. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import Lottie diff --git a/DynamicIsland/components/Music/MusicControlOverlay.swift b/DynamicIsland/components/Music/MusicControlOverlay.swift new file mode 100644 index 00000000..1acde11b --- /dev/null +++ b/DynamicIsland/components/Music/MusicControlOverlay.swift @@ -0,0 +1,341 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct MusicControlOverlay: View { + let notchHeight: CGFloat + let cornerRadius: CGFloat + + @ObservedObject private var musicManager = MusicManager.shared + @Default(.musicSkipBehavior) private var musicSkipBehavior + + private let seekInterval: TimeInterval = MusicManager.skipGestureSeekInterval + private let skipPressMagnitude: CGFloat = 8 + + private var trackBackwardPressEffect: FloatingMediaButton.PressEffect { .nudge(-skipPressMagnitude) } + private var trackForwardPressEffect: FloatingMediaButton.PressEffect { .nudge(skipPressMagnitude) } + private var tenSecondBackwardPressEffect: FloatingMediaButton.PressEffect { .wiggle(.counterClockwise) } + private var tenSecondForwardPressEffect: FloatingMediaButton.PressEffect { .wiggle(.clockwise) } + + private var controlsEnabled: Bool { + !musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil + } + + private var buttonSide: CGFloat { + let preferred = notchHeight * 0.94 + let lowerBound = max(30, notchHeight * 0.8) + let upperBound = max(notchHeight + 2, 32) + return min(max(preferred, lowerBound), upperBound) + } + + private var playPauseSide: CGFloat { + min(buttonSide + 8, notchHeight + 4) + } + + private var buttonCornerRadius: CGFloat { + max(cornerRadius - 8, buttonSide * 0.28) + } + + private var windowCornerRadius: CGFloat { + max(cornerRadius - 6, 12) + } + + private var backwardConfig: ButtonConfig { + switch musicSkipBehavior { + case .track: + return ButtonConfig( + icon: "backward.fill", + pressEffect: trackBackwardPressEffect, + symbolEffect: .replace, + action: { MusicManager.shared.previousTrack() } + ) + case .tenSecond: + return ButtonConfig( + icon: "gobackward.10", + pressEffect: tenSecondBackwardPressEffect, + symbolEffect: .wiggle, + action: { MusicManager.shared.seek(by: -seekInterval) } + ) + } + } + + private var forwardConfig: ButtonConfig { + switch musicSkipBehavior { + case .track: + return ButtonConfig( + icon: "forward.fill", + pressEffect: trackForwardPressEffect, + symbolEffect: .replace, + action: { MusicManager.shared.nextTrack() } + ) + case .tenSecond: + return ButtonConfig( + icon: "goforward.10", + pressEffect: tenSecondForwardPressEffect, + symbolEffect: .wiggle, + action: { MusicManager.shared.seek(by: seekInterval) } + ) + } + } + + private var playPauseConfig: ButtonConfig { + ButtonConfig( + icon: musicManager.isPlaying ? "pause.fill" : "play.fill", + pressEffect: .none, + symbolEffect: .replace, + action: { MusicManager.shared.togglePlay() } + ) + } + + var body: some View { + let verticalPadding = max(8, notchHeight * 0.12) + + let backwardGestureTrigger = skipGestureTrigger(for: .backward) + let forwardGestureTrigger = skipGestureTrigger(for: .forward) + + HStack(spacing: 18) { + FloatingMediaButton( + icon: backwardConfig.icon, + frameSize: CGSize(width: buttonSide, height: buttonSide), + cornerRadius: buttonCornerRadius, + foregroundColor: .white.opacity(controlsEnabled ? 0.9 : 0.35), + pressEffect: backwardConfig.pressEffect, + symbolEffectStyle: backwardConfig.symbolEffect, + externalTriggerToken: backwardGestureTrigger?.token, + externalTriggerEffect: backwardGestureTrigger?.pressEffect, + isEnabled: controlsEnabled, + action: backwardConfig.action + ) + + FloatingMediaButton( + icon: playPauseConfig.icon, + frameSize: CGSize(width: playPauseSide, height: playPauseSide), + cornerRadius: max(buttonCornerRadius + 2, playPauseSide * 0.32), + foregroundColor: .white, + pressEffect: playPauseConfig.pressEffect, + symbolEffectStyle: playPauseConfig.symbolEffect, + externalTriggerToken: nil, + externalTriggerEffect: nil, + isEnabled: controlsEnabled, + action: playPauseConfig.action + ) + + FloatingMediaButton( + icon: forwardConfig.icon, + frameSize: CGSize(width: buttonSide, height: buttonSide), + cornerRadius: buttonCornerRadius, + foregroundColor: .white.opacity(controlsEnabled ? 0.9 : 0.35), + pressEffect: forwardConfig.pressEffect, + symbolEffectStyle: forwardConfig.symbolEffect, + externalTriggerToken: forwardGestureTrigger?.token, + externalTriggerEffect: forwardGestureTrigger?.pressEffect, + isEnabled: controlsEnabled, + action: forwardConfig.action + ) + } + .padding(.vertical, verticalPadding) + .padding(.horizontal, 18) + .frame(height: notchHeight) + .frame(minWidth: buttonSide * 3.2) + .background { + RoundedRectangle(cornerRadius: windowCornerRadius, style: .continuous) + .fill(Color.black) + } + .compositingGroup() + .animation(.smooth(duration: 0.2), value: musicManager.isPlaying) + .animation(.smooth(duration: 0.2), value: musicSkipBehavior) + .animation(.smooth(duration: 0.2), value: controlsEnabled) + } + + private struct ButtonConfig { + let icon: String + let pressEffect: FloatingMediaButton.PressEffect + let symbolEffect: FloatingMediaButton.SymbolEffectStyle + let action: () -> Void + } +} + +private struct FloatingMediaButton: View { + let icon: String + let frameSize: CGSize + let cornerRadius: CGFloat + let foregroundColor: Color + let pressEffect: PressEffect + let symbolEffectStyle: SymbolEffectStyle + let externalTriggerToken: Int? + let externalTriggerEffect: PressEffect? + let isEnabled: Bool + let action: () -> Void + + @State private var isHovering = false + @State private var pressOffset: CGFloat = 0 + @State private var rotationAngle: Double = 0 + @State private var wiggleToken: Int = 0 + @State private var lastExternalTriggerToken: Int? + + var body: some View { + Button { + guard isEnabled else { return } + triggerPressEffect() + action() + } label: { + iconView + .frame(width: frameSize.width, height: frameSize.height) + .background( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(isHovering && isEnabled ? Color.white.opacity(0.18) : .clear) + ) + .contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } + .buttonStyle(PlainButtonStyle()) + .offset(x: pressOffset) + .rotationEffect(.degrees(rotationAngle)) + .disabled(!isEnabled) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.18)) { + isHovering = hovering + } + } + .onChange(of: externalTriggerToken) { _, newToken in + guard isEnabled, let newToken else { return } + guard newToken != lastExternalTriggerToken else { return } + lastExternalTriggerToken = newToken + triggerPressEffect(override: externalTriggerEffect) + } + } + + private var iconView: some View { + let image = Image(systemName: icon) + .font(.system(size: min(frameSize.width, frameSize.height) * 0.48, weight: .semibold)) + .foregroundColor(foregroundColor) + + switch symbolEffectStyle { + case .none: + return AnyView(image) + case .replace: + if #available(macOS 14.0, *) { + return AnyView(image.contentTransition(.symbolEffect(.replace))) + } else { + return AnyView(image) + } + case .replaceAndBounce: + if #available(macOS 14.0, *) { + return AnyView( + image + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.bounce, value: icon) + ) + } else { + return AnyView(image) + } + case .wiggle: + if #available(macOS 15.0, *) { + return AnyView( + image.symbolEffect( + .wiggle.byLayer, + options: .nonRepeating, + value: wiggleToken + ) + ) + } else { + return AnyView(image) + } + } + } + + private func triggerPressEffect(override: PressEffect? = nil) { + let activeEffect = override ?? pressEffect + + switch activeEffect { + case .none: + return + case .nudge(let amount): + withAnimation(.spring(response: 0.16, dampingFraction: 0.72)) { + pressOffset = amount + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { + pressOffset = 0 + } + } + case .wiggle(let direction): + guard #available(macOS 14.0, *) else { return } + wiggleToken += 1 + let angle: Double = direction == .clockwise ? 11 : -11 + + withAnimation(.spring(response: 0.18, dampingFraction: 0.52)) { + rotationAngle = angle + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { + withAnimation(.spring(response: 0.32, dampingFraction: 0.76)) { + rotationAngle = 0 + } + } + } + } + + enum PressEffect { + case none + case nudge(CGFloat) + case wiggle(WiggleDirection) + } + + enum SymbolEffectStyle { + case none + case replace + case replaceAndBounce + case wiggle + } + + enum WiggleDirection { + case clockwise + case counterClockwise + } +} + +private extension MusicControlOverlay { + func skipGestureTrigger(for direction: MusicManager.SkipDirection) -> (token: Int, pressEffect: FloatingMediaButton.PressEffect)? { + guard let pulse = musicManager.skipGesturePulse, pulse.direction == direction else { + return nil + } + + let effect: FloatingMediaButton.PressEffect + switch (pulse.behavior, direction) { + case (.track, .backward): + effect = trackBackwardPressEffect + case (.track, .forward): + effect = trackForwardPressEffect + case (.tenSecond, .backward): + effect = tenSecondBackwardPressEffect + case (.tenSecond, .forward): + effect = tenSecondForwardPressEffect + } + + return (token: pulse.token, pressEffect: effect) + } +} + +#Preview { + MusicControlOverlay(notchHeight: 34, cornerRadius: 14) + .padding() + .background(Color.gray.opacity(0.2)) +} diff --git a/DynamicIsland/components/Music/MusicVisualizer.swift b/DynamicIsland/components/Music/MusicVisualizer.swift index ffb769f6..104079d1 100644 --- a/DynamicIsland/components/Music/MusicVisualizer.swift +++ b/DynamicIsland/components/Music/MusicVisualizer.swift @@ -1,9 +1,25 @@ -// -// MusicVisualizer.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 02/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + import AppKit import Cocoa import SwiftUI @@ -18,6 +34,10 @@ class AudioSpectrum: NSView { wantsLayer = true setupBars() } + + deinit { + animationTimer?.invalidate() + } required init?(coder: NSCoder) { super.init(coder: coder) @@ -50,6 +70,15 @@ class AudioSpectrum: NSView { } } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + stopAnimating() + } else if isPlaying { + startAnimating() + } + } + private func startAnimating() { guard animationTimer == nil else { return } animationTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] _ in @@ -72,7 +101,9 @@ class AudioSpectrum: NSView { animation.autoreverses = true animation.fillMode = .forwards animation.isRemovedOnCompletion = false - + if #available(macOS 13.0, *) { + animation.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 24, preferred: 24) + } barLayer.add(animation, forKey: "scaleY") } } @@ -106,6 +137,10 @@ struct AudioSpectrumView: NSViewRepresentable { func updateNSView(_ nsView: AudioSpectrum, context: Context) { nsView.setPlaying(isPlaying) } + + static func dismantleNSView(_ nsView: AudioSpectrum, coordinator: ()) { + nsView.setPlaying(false) + } } #Preview { diff --git a/DynamicIsland/components/Notch/ClipboardHistoryPopover.swift b/DynamicIsland/components/Notch/ClipboardHistoryPopover.swift new file mode 100644 index 00000000..530f6723 --- /dev/null +++ b/DynamicIsland/components/Notch/ClipboardHistoryPopover.swift @@ -0,0 +1,214 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct ClipboardHistoryPopover: View { + @ObservedObject var clipboardManager = ClipboardManager.shared + @Binding var isPresented: Bool + @EnvironmentObject var vm: DynamicIslandViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack { + Image(systemName: "doc.on.clipboard") + .foregroundColor(.white) + .font(.system(size: 16, weight: .medium)) + Text("Clipboard History") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + + Spacer() + + Button(action: { + clipboardManager.clearHistory() + }) { + Image(systemName: "trash") + .foregroundColor(.red) + .font(.system(size: 12)) + } + .buttonStyle(PlainButtonStyle()) + .disabled(clipboardManager.clipboardHistory.isEmpty) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Content + if clipboardManager.clipboardHistory.isEmpty { + EmptyClipboardView() + } else { + ClipboardItemsList() + } + } + .frame(width: 280) + .frame(maxHeight: 200) + .background(Color.black.opacity(0.95)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5) + .onHover { hovering in + // Close popover and notch when mouse exits + if !hovering { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isPresented = false + // Also close the notch if no other popovers are active + if vm.notchState == .open && !vm.isBatteryPopoverActive { + vm.close() + } + } + } + } + } + + @ViewBuilder + private func EmptyClipboardView() -> some View { + VStack(spacing: 8) { + Image(systemName: "clipboard") + .font(.system(size: 24)) + .foregroundColor(.gray) + Text("No clipboard history") + .font(.system(size: 12)) + .foregroundColor(.gray) + Text("Copy something to get started") + .font(.system(size: 10)) + .foregroundColor(.gray.opacity(0.7)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 20) + } + + @ViewBuilder + private func ClipboardItemsList() -> some View { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(clipboardManager.clipboardHistory) { item in + ClipboardItemRow(item: item) { + isPresented = false + } + } + } + } + .frame(maxHeight: 150) + } +} + +struct ClipboardItemRow: View { + let item: ClipboardItem + let onCopy: () -> Void + @ObservedObject var clipboardManager = ClipboardManager.shared + @State private var isHovering = false + + var body: some View { + HStack(alignment: .top, spacing: 8) { + // Type icon + Image(systemName: item.type.icon) + .font(.system(size: 12)) + .foregroundColor(.blue) + .frame(width: 16) + + // Content + VStack(alignment: .leading, spacing: 2) { + Text(item.preview) + .font(.system(size: 11)) + .foregroundColor(.white) + .lineLimit(2) + .multilineTextAlignment(.leading) + + HStack { + Text(item.type.displayName) + .font(.system(size: 9)) + .foregroundColor(.gray) + + Spacer() + + Text(timeAgoString(from: item.timestamp)) + .font(.system(size: 9)) + .foregroundColor(.gray) + } + } + + Spacer() + + // Action buttons (shown on hover) + if isHovering { + HStack(spacing: 4) { + Button(action: { + clipboardManager.copyToClipboard(item) + onCopy() + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 10)) + .foregroundColor(.green) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: { + clipboardManager.deleteItem(item) + }) { + Image(systemName: "trash") + .font(.system(size: 10)) + .foregroundColor(.red) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isHovering ? Color.white.opacity(0.1) : Color.clear) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = hovering + } + } + .onTapGesture { + clipboardManager.copyToClipboard(item) + onCopy() + } + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return String(localized: "Just now") + } else if interval < 3600 { + let minutes = Int(interval / 60) + return String(localized: "\(minutes)m ago") + } else if interval < 86400 { + let hours = Int(interval / 3600) + return String(localized: "\(hours)h ago") + } else { + let days = Int(interval / 86400) + return String(localized: "\(days)d ago") + } + } +} + +#Preview { + ClipboardHistoryPopover(isPresented: .constant(true)) + .padding() + .background(Color.gray) +} diff --git a/DynamicIsland/components/Notch/DynamicIslandExtrasMenu.swift b/DynamicIsland/components/Notch/DynamicIslandExtrasMenu.swift index b5462079..aca5f5f1 100644 --- a/DynamicIsland/components/Notch/DynamicIslandExtrasMenu.swift +++ b/DynamicIsland/components/Notch/DynamicIslandExtrasMenu.swift @@ -1,9 +1,20 @@ -// -// DynamicIslandExtrasMenu.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 04/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index ed6fd557..29235f0d 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -1,9 +1,20 @@ -// -// DynamicIslandHeader.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 04/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Defaults import SwiftUI @@ -13,14 +24,30 @@ struct DynamicIslandHeader: View { @EnvironmentObject var webcamManager: WebcamManager @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared - @StateObject var tvm = TrayDrop.shared + @ObservedObject var clipboardManager = ClipboardManager.shared + @ObservedObject var shelfState = ShelfStateViewModel.shared + @ObservedObject var timerManager = TimerManager.shared + @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared + @State private var showClipboardPopover = false + @State private var showColorPickerPopover = false + @State private var showTimerPopover = false + @Default(.enableTimerFeature) var enableTimerFeature + @Default(.timerDisplayMode) var timerDisplayMode + @Default(.showClipboardIcon) var showClipboardIcon + @Default(.showColorPickerIcon) var showColorPickerIcon + @Default(.clipboardDisplayMode) var clipboardDisplayMode + @Default(.showBatteryIndicator) var showBatteryIndicator + @Default(.showBatteryPercentInside) var showBatteryPercentInside + @Default(.enableMinimalisticUI) var enableMinimalisticUI + var body: some View { HStack(spacing: 0) { HStack { - if (!tvm.isEmpty || coordinator.alwaysShowTabs) && Defaults[.dynamicShelf] { - TabSelectionView() - } else if vm.notchState == .open { - EmptyView() + if !enableMinimalisticUI { + let shouldShowTabs = coordinator.alwaysShowTabs || vm.notchState == .open || !shelfState.items.isEmpty + if shouldShowTabs { + TabSelectionView() + } } } .frame(maxWidth: .infinity, alignment: .leading) @@ -28,26 +55,24 @@ struct DynamicIslandHeader: View { .blur(radius: vm.notchState == .closed ? 20 : 0) .animation(.smooth.delay(0.1), value: vm.notchState) .zIndex(2) + .padding(8) if vm.notchState == .open { + let spacerWidth = min(vm.closedNotchSize.width, 300) Rectangle() - .fill(NSScreen.screens - .first(where: { $0.localizedName == coordinator.selectedScreen })?.safeAreaInsets.top ?? 0 > 0 ? .black : .clear) - .frame(width: vm.closedNotchSize.width) + .fill(enableMinimalisticUI ? .clear : (NSScreen.screens + .first(where: { $0.localizedName == coordinator.selectedScreen })?.safeAreaInsets.top ?? 0 > 0 ? .black : .clear)) + .frame(width: spacerWidth) .mask { NotchShape() } } HStack(spacing: 4) { - if vm.notchState == .open { + if vm.notchState == .open && !enableMinimalisticUI { if Defaults[.showMirror] { Button(action: { - if webcamManager.isSessionRunning { - webcamManager.stopSession() - } else { - webcamManager.startSession() - } + vm.toggleCameraPreview() }) { Capsule() .fill(.black) @@ -61,6 +86,118 @@ struct DynamicIslandHeader: View { } .buttonStyle(PlainButtonStyle()) } + + if Defaults[.enableClipboardManager] + && showClipboardIcon + && clipboardDisplayMode != .separateTab { + Button(action: { + // Switch behavior based on display mode + switch clipboardDisplayMode { + case .panel: + ClipboardPanelManager.shared.toggleClipboardPanel() + case .popover: + showClipboardPopover.toggle() + case .separateTab: + coordinator.currentView = .notes + } + }) { + Capsule() + .fill(.black) + .frame(width: 30, height: 30) + .overlay { + Image(systemName: "doc.on.clipboard") + .foregroundColor(.white) + .padding() + .imageScale(.medium) + } + } + .buttonStyle(PlainButtonStyle()) + .popover(isPresented: $showClipboardPopover, arrowEdge: .bottom) { + ClipboardPopover() + } + .onChange(of: showClipboardPopover) { isActive in + vm.isClipboardPopoverActive = isActive + + // If popover was closed, trigger a hover recheck + if !isActive { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + vm.shouldRecheckHover.toggle() + } + } + } + .onAppear { + if Defaults[.enableClipboardManager] && !clipboardManager.isMonitoring { + clipboardManager.startMonitoring() + } + } + } + + // ColorPicker button + if Defaults[.enableColorPickerFeature] && showColorPickerIcon{ + Button(action: { + switch Defaults[.colorPickerDisplayMode] { + case .panel: + ColorPickerPanelManager.shared.toggleColorPickerPanel() + case .popover: + showColorPickerPopover.toggle() + } + }) { + Capsule() + .fill(.black) + .frame(width: 30, height: 30) + .overlay { + Image(systemName: "eyedropper") + .foregroundColor(.white) + .padding() + .imageScale(.medium) + } + } + .buttonStyle(PlainButtonStyle()) + .popover(isPresented: $showColorPickerPopover, arrowEdge: .bottom) { + ColorPickerPopover() + } + .onChange(of: showColorPickerPopover) { isActive in + vm.isColorPickerPopoverActive = isActive + + // If popover was closed, trigger a hover recheck + if !isActive { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + vm.shouldRecheckHover.toggle() + } + } + } + } + + if Defaults[.enableTimerFeature] && timerDisplayMode == .popover { + 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() @@ -77,7 +214,37 @@ struct DynamicIslandHeader: View { } .buttonStyle(PlainButtonStyle()) } - if Defaults[.showBatteryIndicator] { + + // Screen Recording Indicator + if Defaults[.enableScreenRecordingDetection] && Defaults[.showRecordingIndicator] && !shouldSuppressStatusIndicators { + RecordingIndicator() + .frame(width: 30, height: 30) // Same size as other header elements + } + + if Defaults[.enableDoNotDisturbDetection] + && Defaults[.showDoNotDisturbIndicator] + && doNotDisturbManager.isDoNotDisturbActive + && !shouldSuppressStatusIndicators { + FocusIndicator() + .frame(width: 30, height: 30) + .transition(.opacity) + } + } + + if vm.notchState == .open && showBatteryIndicator { + if enableMinimalisticUI { + MinimalisticBatteryView( + levelBattery: batteryModel.levelBattery, + isPluggedIn: batteryModel.isPluggedIn, + isCharging: batteryModel.isCharging, + isInLowPowerMode: batteryModel.isInLowPowerMode, + bodyWidth: 28, + bodyHeight: 14, + isForNotification: false, + showPercentInside: showBatteryPercentInside + ) + .padding(.trailing, 4) + } else { DynamicIslandBatteryView( batteryWidth: 30, isCharging: batteryModel.isCharging, @@ -100,6 +267,51 @@ struct DynamicIslandHeader: View { } .foregroundColor(.gray) .environmentObject(vm) + .onChange(of: coordinator.shouldToggleClipboardPopover) { _ in + // Only toggle if clipboard is enabled + if Defaults[.enableClipboardManager] { + switch clipboardDisplayMode { + case .panel: + ClipboardPanelManager.shared.toggleClipboardPanel() + case .popover: + showClipboardPopover.toggle() + case .separateTab: + if coordinator.currentView == .notes { + coordinator.currentView = .home + } else { + coordinator.currentView = .notes + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ToggleClipboardPopover"))) { _ in + // Handle keyboard shortcut for popover mode + if Defaults[.enableClipboardManager] && clipboardDisplayMode == .popover { + showClipboardPopover.toggle() + } + } + .onChange(of: enableTimerFeature) { _, newValue in + if !newValue { + showTimerPopover = false + vm.isTimerPopoverActive = false + } + } + .onChange(of: timerDisplayMode) { _, mode in + if mode == .tab { + showTimerPopover = false + vm.isTimerPopoverActive = false + } + } + } +} + +private extension DynamicIslandHeader { + var shouldSuppressStatusIndicators: Bool { + Defaults[.settingsIconInNotch] + && Defaults[.enableClipboardManager] + && Defaults[.showClipboardIcon] + && Defaults[.showColorPickerIcon] + && Defaults[.enableTimerFeature] } } diff --git a/DynamicIsland/components/Notch/DynamicIslandPillShape.swift b/DynamicIsland/components/Notch/DynamicIslandPillShape.swift new file mode 100644 index 00000000..18d88995 --- /dev/null +++ b/DynamicIsland/components/Notch/DynamicIslandPillShape.swift @@ -0,0 +1,50 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +/// A pill-shaped (capsule-like) clip shape used for the Dynamic Island mode +/// on external and non-notched displays. Uses `.continuous` rounded corners +/// for an Apple-style squircle appearance, inspired by DynamicNotchKit's +/// floating style. +struct DynamicIslandPillShape: Shape { + var cornerRadius: CGFloat + + var animatableData: CGFloat { + get { cornerRadius } + set { cornerRadius = newValue } + } + + func path(in rect: CGRect) -> Path { + let resolvedRadius = min(cornerRadius, min(rect.width, rect.height) / 2) + return Path(roundedRect: rect, cornerRadius: resolvedRadius, style: .continuous) + } +} + +#Preview { + VStack(spacing: 20) { + DynamicIslandPillShape(cornerRadius: 16) + .fill(.black) + .frame(width: 185, height: 32) + + DynamicIslandPillShape(cornerRadius: 24) + .fill(.black) + .frame(width: 640, height: 200) + } + .padding(20) +} diff --git a/DynamicIsland/components/Notch/DynamicIslandWindow.swift b/DynamicIsland/components/Notch/DynamicIslandWindow.swift index df55f028..ffcb9fb4 100644 --- a/DynamicIsland/components/Notch/DynamicIslandWindow.swift +++ b/DynamicIsland/components/Notch/DynamicIslandWindow.swift @@ -1,9 +1,24 @@ -// -// DynamicIslandWindow.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 06/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Cocoa @@ -38,13 +53,16 @@ class DynamicIslandWindow: NSPanel { isReleasedWhenClosed = false level = .mainMenu + 3 hasShadow = false + + ScreenCaptureVisibilityManager.shared.register(self, scope: .entireInterface) } override var canBecomeKey: Bool { - false + true } override var canBecomeMain: Bool { - false + true } + } diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift new file mode 100644 index 00000000..434ea407 --- /dev/null +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -0,0 +1,1355 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +#if canImport(AppKit) +import AppKit +#endif + +// Lyrics are shown/hidden only via Defaults[.enableLyrics] in settings. Inline display is used in the player views. + +struct MinimalisticMusicPlayerView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + let albumArtNamespace: Namespace.ID + @Default(.showShuffleAndRepeat) private var showCustomControls + @Default(.musicControlSlots) private var slotConfig + @Default(.showMediaOutputControl) private var showMediaOutputControl + @Default(.musicSkipBehavior) private var musicSkipBehavior + @ObservedObject private var reminderManager = ReminderLiveActivityManager.shared + @ObservedObject private var timerManager = TimerManager.shared + @ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared + @State private var hudValue: Double = 0 + @State private var hudDragging: Bool = false + @State private var hudLastDragged: Date = .distantPast + @Default(.enableReminderLiveActivity) private var enableReminderLiveActivity + @Default(.enableLyrics) private var enableLyrics + @Default(.timerPresets) private var timerPresets + private let seekInterval: TimeInterval = 10 + private let skipMagnitude: CGFloat = 8 + + var body: some View { + if !musicManager.hasActiveSession { + // Nothing playing state + VStack(spacing: 0) { + Spacer(minLength: 0) + + VStack(spacing: 8) { + Image(systemName: "music.note.slash") + .font(.system(size: 24, weight: .light)) + .foregroundColor(.gray) + Text("Nothing Playing") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.gray) + } + + Spacer(minLength: 0) + + timerCountdownSection + + reminderList + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity) + .frame(height: calculateDynamicHeight()) + .animation(.smooth(duration: 0.3), value: dynamicHeightSignature) + } else { + VStack(spacing: 0) { + // Header area with album art (matching DynamicIslandHeader height of 24pt) + 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 { + MusicTitleMarqueeView( + text: musicManager.songTitle, + isExplicit: musicManager.isCurrentTrackExplicit, + font: .system(size: 12, weight: .semibold), + nsFont: .subheadline, + textColor: .white, + frameWidth: textWidth, + badgeHeight: 13 + ) + } + + 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) + + // Compact progress bar + progressBar + .padding(.top, 6) + .clipped() + + // Compact playback controls + if shouldShowControlHUDRow { + controlHUDRow + .padding(.top, 4) + } else { + playbackControls + .padding(.top, 4) + } + + if enableLyrics { + lyricsView + .padding(.top, 10) + } + + timerCountdownSection + + reminderList + } + .padding(.horizontal, 12) + .padding(.top, 6) + .padding(.bottom, ReminderLiveActivityManager.baselineMinimalisticBottomPadding) + .frame(maxWidth: .infinity) + .frame(height: calculateDynamicHeight(), alignment: .top) + .animation(.smooth(duration: 0.3), value: dynamicHeightSignature) + } + } + + // MARK: - TypingLyricView + + struct TypingLyricView: View { + let text: String + let color: Color + let id: Int + let playbackRate: Double + let isPlaying: Bool + @State private var displayed: String = "" + @State private var lastText: String = "" + @State private var animationTask: Task? + + var body: some View { + Text(displayed) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(color) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + .padding(.top, 4) + .id(id) + .onChange(of: text) { _, newText in + animateTyping(newText) + } + .onChange(of: isPlaying) { _, playing in + if !playing { + animationTask?.cancel() + } else if displayed != text { + animateTyping(text) + } + } + .onAppear { + animateTyping(text) + } + .onDisappear { + animationTask?.cancel() + } + } + + private func animateTyping(_ newText: String) { + animationTask?.cancel() + displayed = "" + lastText = newText + let chars = Array(newText) + + animationTask = Task { + for (i, c) in chars.enumerated() { + if Task.isCancelled { return } + try? await Task.sleep(for: .milliseconds(Int(30 / max(playbackRate, 0.1)))) + if Task.isCancelled { return } + if lastText == newText { + displayed += String(c) + } + } + } + } + } + + private var reminderEntries: [ReminderLiveActivityManager.ReminderEntry] { + reminderManager.activeWindowReminders + } + + private var shouldShowReminderList: Bool { + enableReminderLiveActivity && !reminderEntries.isEmpty + } + + private var reminderListHeight: CGFloat { + ReminderLiveActivityManager.additionalHeight(forRowCount: reminderEntries.count) + } + + private var shouldShowTimerCountdown: Bool { + coordinator.timerLiveActivityEnabled && timerManager.isExternalTimerActive + } + + private var brandAccentColor: Color { + musicManager.brandAccentColor + } + + private var timerCountdownColor: Color { + let baseColor: Color + if let presetId = timerManager.activePresetId, + let preset = timerPresets.first(where: { $0.id == presetId }) { + baseColor = preset.color + } else { + baseColor = timerManager.timerColor + } + return baseColor.ensureMinimumBrightness(factor: 0.75) + } + + private var timerCountdownText: String { + timerManager.formattedRemainingTime() + } + + private var timerDisplayName: String { + let trimmed = timerManager.timerName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Timer" : trimmed + } + + private var dynamicHeightSignature: Int { + var signature = reminderEntries.count * 10 + if enableLyrics { signature += 1 } + if shouldShowTimerCountdown { signature += 100 } + return signature + } + + private func calculateDynamicHeight() -> CGFloat { + var height: CGFloat = 50 // Base height for header + + // Add progress bar height + height += 6 + 4 // progress bar + top padding + + // Add playback controls height + height += 54 + 2 // controls + top padding + + // Add lyrics height if enabled in settings (reserve space even while loading) + if enableLyrics { + let lyricsTopPadding: CGFloat = 10 + let lyricsEstimatedHeight: CGFloat = 34 + height += lyricsTopPadding + lyricsEstimatedHeight + } + + if shouldShowTimerCountdown { + height += minimalisticTimerCountdownBlockHeight + } + + // Add reminder list height if showing + if shouldShowReminderList { + height += reminderListHeight + } + + // Add padding + height += 15 // top padding + height += ReminderLiveActivityManager.baselineMinimalisticBottomPadding + + return height + } + + private var timerCountdownSection: some View { + VStack(spacing: 0) { + if shouldShowTimerCountdown { + timerCountdownView + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.top, shouldShowTimerCountdown ? minimalisticTimerCountdownTopPadding : 0) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: shouldShowTimerCountdown ? minimalisticTimerCountdownBlockHeight : 0, alignment: .top) + .animation(.smooth(duration: 0.25), value: shouldShowTimerCountdown) + } + + private func displayFont(size: CGFloat) -> Font { + .custom("SF Pro Display", size: size) + } + + private var timerCountdownView: some View { + GeometryReader { geometry in + let availableWidth = geometry.size.width + let preferredCountdownWidth = max(availableWidth * 0.42, 150) + let maxCountdownWidth = max(availableWidth - 60, 0) + let countdownWidth = min(preferredCountdownWidth, maxCountdownWidth) + let marqueeWidth = max(availableWidth - countdownWidth - 12, 0) + + HStack(alignment: .lastTextBaseline, spacing: 12) { + MarqueeText( + .init(get: { timerDisplayName }, set: { _ in }), + font: .system(size: 16, weight: .semibold), + nsFont: .title3, + textColor: timerCountdownColor.opacity(0.85), + frameWidth: max(marqueeWidth, 1) + ) + .alignmentGuide(.lastTextBaseline) { dimensions in + dimensions[VerticalAlignment.bottom] + } + + Spacer(minLength: 8) + + Text(timerCountdownText) + .font(displayFont(size: 56)) + .monospacedDigit() + .foregroundStyle(timerManager.isOvertime ? Color.red : timerCountdownColor) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: timerManager.remainingTime) + .lineLimit(1) + .frame(width: countdownWidth, alignment: .trailing) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 4) + .frame(height: minimalisticTimerCountdownContentHeight, alignment: .top) + } + + private var reminderList: some View { + MinimalisticReminderEventListView(reminders: reminderEntries) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: reminderListHeight, alignment: .top) + .opacity(shouldShowReminderList ? 1 : 0) + .animation(.easeInOut(duration: 0.18), value: shouldShowReminderList) + .environmentObject(vm) + } + + private var lyricsView: some View { + let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines) + let transition: AnyTransition = .asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + ) + + return HStack(spacing: 6) { + if !line.isEmpty { + Image(systemName: "music.note") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white.opacity(0.7)) + .symbolRenderingMode(.monochrome) + + Text(line) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white.opacity(0.88)) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, 6) + .id(line) + .transition(transition) + } + } + .padding(.horizontal, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .animation(.smooth(duration: 0.32), value: line) + } + + +private struct MinimalisticReminderEventListView: View { + let reminders: [ReminderLiveActivityManager.ReminderEntry] + + private let textFont = Font.system(size: 13, weight: .regular) + private let separatorSpacing: CGFloat = 10 + + var body: some View { + VStack(alignment: .leading, spacing: ReminderLiveActivityManager.listRowSpacing) { + ForEach(reminders) { entry in + MinimalisticReminderEventRow(entry: entry, textFont: textFont, separatorSpacing: separatorSpacing) + } + } + .padding(.top, ReminderLiveActivityManager.listTopPadding) + } +} + +private struct MinimalisticReminderEventRow: View { + let entry: ReminderLiveActivityManager.ReminderEntry + let textFont: Font + let separatorSpacing: CGFloat + + @EnvironmentObject private var vm: DynamicIslandViewModel + @State private var didCopyLink = false + @State private var copyResetToken: UUID? + @State private var isDetailsPopoverPresented = false + @State private var isHoveringDetailsPopover = false + + private let indicatorHeight: CGFloat = 20 + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + + var body: some View { + HStack(spacing: separatorSpacing) { + RoundedRectangle(cornerRadius: 3) + .fill(eventColor) + .frame(width: 8, height: indicatorHeight) + + HStack(spacing: 6) { + Text(entry.event.title) + .font(textFont) + .foregroundStyle(Color.white) + .lineLimit(1) + + if let timeText { + Text(timeText) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.6)) + .lineLimit(1) + } + } + .layoutPriority(1) + + Spacer(minLength: 12) + + HStack(spacing: separatorSpacing) { + if let url = linkURL { + Button { + copyToClipboard(url: url) + triggerCopyFeedback() + } label: { + Image(systemName: didCopyLink ? "checkmark.circle.fill" : "link") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(didCopyLink ? Color.green : Color.white.opacity(0.85)) + .symbolRenderingMode(.monochrome) + .animation(.easeInOut(duration: 0.2), value: didCopyLink) + } + .buttonStyle(.plain) + .help("Copy event link") + } + + if hasDetails { + Button { + isDetailsPopoverPresented.toggle() + } label: { + Image(systemName: "info.circle") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.85)) + } + .buttonStyle(.plain) + .popover(isPresented: $isDetailsPopoverPresented, arrowEdge: .top) { + MinimalisticReminderDetailsView( + entry: entry, + linkURL: linkURL, + onHoverChanged: { hovering in + isHoveringDetailsPopover = hovering + updatePopoverActivity() + } + ) + .onDisappear { + isHoveringDetailsPopover = false + updatePopoverActivity() + } + } + .onChange(of: isDetailsPopoverPresented) { _, presented in + if !presented { + isHoveringDetailsPopover = false + updatePopoverActivity() + } + } + } + } + } + .frame(height: ReminderLiveActivityManager.listRowHeight) + .contentShape(Rectangle()) + .onTapGesture { + openInCalendar() + } + .onDisappear { + copyResetToken = nil + didCopyLink = false + vm.isReminderPopoverActive = false + } + } + + private var eventColor: Color { + Color(nsColor: entry.event.calendar.color).ensureMinimumBrightness(factor: 0.7) + } + + private var hasDetails: Bool { + let location = entry.event.location?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let notes = entry.event.notes?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !location.isEmpty || !notes.isEmpty + } + + private var linkURL: URL? { + entry.event.url ?? entry.event.calendarAppURL() + } + + private var timeText: String? { + Self.timeFormatter.string(from: entry.event.start) + } + + private func updatePopoverActivity() { + vm.isReminderPopoverActive = isDetailsPopoverPresented && isHoveringDetailsPopover + } + + private func triggerCopyFeedback() { + withAnimation(.spring(response: 0.35, dampingFraction: 0.65)) { + didCopyLink = true + } + + let token = UUID() + copyResetToken = token + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [token] in + guard copyResetToken == token else { return } + + withAnimation(.easeInOut(duration: 0.2)) { + didCopyLink = false + } + + if copyResetToken == token { + copyResetToken = nil + } + } + } + + private func copyToClipboard(url: URL) { +#if canImport(AppKit) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(url.absoluteString, forType: .string) +#endif + } + + private func openInCalendar() { +#if canImport(AppKit) + guard let url = linkURL else { return } + NSWorkspace.shared.open(url) +#endif + } +} + +private struct MinimalisticReminderDetailsView: View { + let entry: ReminderLiveActivityManager.ReminderEntry + let linkURL: URL? + var onHoverChanged: (Bool) -> Void = { _ in } + + private let detailFont = Font.system(size: 13, weight: .regular) + private let smallLabelFont = Font.system(size: 12, weight: .semibold) + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(entry.event.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.white) + .lineLimit(2) + + VStack(alignment: .leading, spacing: 6) { + if let timeRange = timeRangeText { + detailRow(icon: "clock", label: "Time", value: timeRange) + } + + if let location = entry.event.location, !location.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + detailRow(icon: "mappin.and.ellipse", label: "Location", value: location) + } + + if let notes = entry.event.notes, !notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + detailRow(icon: "note.text", label: "Notes", value: notes) + } + } + + if let url = linkURL { + Button { + open(url: url) + } label: { + Label("Open in Calendar", systemImage: "arrow.up.right.square") + .font(.system(size: 12, weight: .semibold)) + } + .buttonStyle(.link) + .foregroundStyle(Color.accentColor) + } + } + .padding(16) + .frame(minWidth: 220) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.black.opacity(0.92)) + ) + .onHover { hovering in + onHoverChanged(hovering) + } + .onDisappear { + onHoverChanged(false) + } + } + + private func detailRow(icon: String, label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Label(label, systemImage: icon) + .font(smallLabelFont) + .foregroundStyle(Color.white.opacity(0.8)) + Text(value) + .font(detailFont) + .foregroundStyle(Color.white) + } + } + + private var timeRangeText: String? { + let startText = Self.timeFormatter.string(from: entry.event.start) + let endText = Self.timeFormatter.string(from: entry.event.end) + return startText == endText ? startText : "\(startText) – \(endText)" + } + + private func open(url: URL) { +#if canImport(AppKit) + NSWorkspace.shared.open(url) +#endif + } +} + // MARK: - Visualizer + + @Default(.useMusicVisualizer) var useMusicVisualizer + + private var visualizer: some View { + Rectangle() + .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: MusicManager.shared.avgColor).gradient : Color.gray.gradient) + .mask { + AudioVisualizerView(isPlaying: .constant(MusicManager.shared.isPlaying)) + .frame(width: 20, height: 16) + } + .frame(width: 20, height: 16) + .matchedGeometryEffect(id: "spectrum", in: albumArtNamespace) + } + + // MARK: - Progress Bar (Full Width) + + @ObservedObject var musicManager = MusicManager.shared + @State private var sliderValue: Double = MusicManager.shared.estimatedPlaybackPosition() + @State private var dragging: Bool = false + @State private var lastDragged: Date = .distantPast + + /// Whether the progress timeline should be paused (no ticks). + private var isProgressTimelinePaused: Bool { + !musicManager.isPlaying || musicManager.isLiveStream || musicManager.playbackRate <= 0 + } + + private var progressBar: some View { + TimelineView( + .animation( + minimumInterval: 1.0, + paused: isProgressTimelinePaused + ) + ) { timeline in + MusicSliderView( + sliderValue: $sliderValue, + duration: Binding( + get: { musicManager.songDuration }, + set: { musicManager.songDuration = $0 } + ), + lastDragged: $lastDragged, + color: musicManager.avgColor, + dragging: $dragging, + currentDate: timeline.date, + timestampDate: musicManager.timestampDate, + elapsedTime: musicManager.elapsedTime, + playbackRate: musicManager.playbackRate, + isPlaying: musicManager.isPlaying, + isLiveStream: musicManager.isLiveStream, + onValueChange: { newValue in + musicManager.seek(to: newValue) + }, + labelLayout: .inline, + trailingLabel: .remaining, + restingTrackHeight: 7, + draggingTrackHeight: 11 + ) + } + .onAppear { + sliderValue = musicManager.elapsedTime + } + .onChange(of: musicManager.isLiveStream) { _, isLive in + if isLive { + dragging = false + sliderValue = 0 + } + } + } + + // MARK: - Playback Controls (Larger) + + private var playbackControls: some View { + HStack(spacing: 10) { + ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in + slotView(for: slot) + } + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 2) + } + + private var shouldShowControlHUDRow: Bool { + guard vm.notchState == .open else { return false } + guard coordinator.sneakPeek.show else { return false } + guard Defaults[.enableSystemHUD] else { return false } + guard !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] else { return false } + + switch coordinator.sneakPeek.type { + case .volume: + return Defaults[.enableVolumeHUD] + case .brightness: + return Defaults[.enableBrightnessHUD] + case .backlight: + return Defaults[.enableKeyboardBacklightHUD] + default: + return false + } + } + + private var controlHUDRow: some View { + HStack(alignment: .center, spacing: 10) { + if !controlLeftIconName.isEmpty { + Image(systemName: controlLeftIconName) + .font(.system(size: 16, weight: .semibold)) + .frame(width: 22, height: 22, alignment: .center) + } + + controlHUDSlider + + if !controlRightIconName.isEmpty { + Image(systemName: controlRightIconName) + .font(.system(size: 16, weight: .semibold)) + .frame(width: 22, height: 22, alignment: .center) + } + } + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 60, alignment: .center) + .onAppear { syncHUDValueIfNeeded(force: true) } + .onChange(of: coordinator.sneakPeek.value) { _, _ in + syncHUDValueIfNeeded(force: false) + } + } + + private var controlHUDSlider: some View { + VStack(spacing: 0) { + Spacer(minLength: 0) + CustomSlider( + value: Binding( + get: { hudValue }, + set: { newValue in + hudValue = newValue + updateControlHUDValue(newValue) + } + ), + range: 0...1, + color: .white, + dragging: $hudDragging, + lastDragged: $hudLastDragged, + onValueChange: { newValue in + updateControlHUDValue(newValue) + }, + thumbSize: 10, + restingTrackHeight: 4, + draggingTrackHeight: 7 + ) + .frame(height: 7) + Spacer(minLength: 0) + } + .frame(height: 22) + } + + private func syncHUDValueIfNeeded(force: Bool) { + guard shouldShowControlHUDRow else { return } + guard force || !hudDragging else { return } + hudValue = Double(coordinator.sneakPeek.value) + } + + private func updateControlHUDValue(_ newValue: Double) { + let clamped = max(0, min(1, newValue)) + switch coordinator.sneakPeek.type { + case .volume: + SystemVolumeController.shared.setVolume(Float(clamped)) + case .brightness: + SystemBrightnessController.shared.setBrightness(Float(clamped)) + case .backlight: + SystemKeyboardBacklightController.shared.setLevel(Float(clamped)) + default: + break + } + } + + private var controlLeftIconName: String { + switch coordinator.sneakPeek.type { + case .volume: + return SystemVolumeController.shared.isMuted ? "speaker.slash" : "speaker.wave.1" + case .brightness: + return "sun.min.fill" + case .backlight: + return "light.min" + default: + return "" + } + } + + private var controlRightIconName: String { + switch coordinator.sneakPeek.type { + case .volume: + return SystemVolumeController.shared.isMuted ? "" : "speaker.wave.3" + case .brightness: + return "sun.max.fill" + case .backlight: + return "light.max" + default: + return "" + } + } + + private var playPauseButton: some View { + MinimalisticSquircircleButton( + icon: musicManager.isPlaying ? (musicManager.isLiveStream ? "stop.fill" : "pause.fill") : "play.fill", + fontSize: 26, + fontWeight: .semibold, + frameSize: CGSize(width: 54, height: 54), + cornerRadius: 22, + foregroundColor: .white, + pressEffect: .none, + symbolEffectStyle: .replace, + action: { + musicManager.togglePlay() + } + ) + } + + private struct SkipTrigger { + let token: Int + let pressEffect: MinimalisticSquircircleButton.PressEffect + } + + private func controlButton( + icon: String, + size: CGFloat = 18, + isActive: Bool = false, + activeColor: Color? = nil, + pressEffect: MinimalisticSquircircleButton.PressEffect = .none, + symbolEffect: MinimalisticSquircircleButton.SymbolEffectStyle = .none, + trigger: SkipTrigger? = nil, + action: @escaping () -> Void + ) -> some View { + let resolvedActiveColor = activeColor ?? brandAccentColor + return MinimalisticSquircircleButton( + icon: icon, + fontSize: size, + fontWeight: .medium, + frameSize: CGSize(width: 36, height: 36), + cornerRadius: 14, + foregroundColor: isActive ? resolvedActiveColor : .white.opacity(0.85), + pressEffect: pressEffect, + symbolEffectStyle: symbolEffect, + externalTriggerToken: trigger?.token, + externalTriggerEffect: trigger?.pressEffect, + action: action + ) + } + + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + + private var displayedSlots: [MusicControlButton] { + if showCustomControls { + let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive) + return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout + } + + switch musicSkipBehavior { + case .track: + return MusicControlButton.minimalLayout + case .tenSecond: + return [.none, .seekBackward, .playPause, .seekForward, .none] + } + } + + @ViewBuilder + private func slotView(for control: MusicControlButton) -> some View { + switch control { + case .none: + Spacer(minLength: 0) + case .playPause: + playPauseButton + case .trackBackward: + controlButton( + icon: "backward.fill", + size: 18, + pressEffect: .nudge(-skipMagnitude), + symbolEffect: .replace, + trigger: skipGestureTrigger(for: .trackBackward), + action: { musicManager.previousTrack() } + ) + case .trackForward: + controlButton( + icon: "forward.fill", + size: 18, + pressEffect: .nudge(skipMagnitude), + symbolEffect: .replace, + trigger: skipGestureTrigger(for: .trackForward), + action: { musicManager.nextTrack() } + ) + case .seekBackward: + controlButton( + icon: "gobackward.10", + size: 18, + pressEffect: .wiggle(.counterClockwise), + symbolEffect: .wiggle, + trigger: skipGestureTrigger(for: .seekBackward), + action: { musicManager.seek(by: -seekInterval) } + ) + case .seekForward: + controlButton( + icon: "goforward.10", + size: 18, + pressEffect: .wiggle(.clockwise), + symbolEffect: .wiggle, + trigger: skipGestureTrigger(for: .seekForward), + action: { musicManager.seek(by: seekInterval) } + ) + case .shuffle: + controlButton(icon: "shuffle", isActive: musicManager.isShuffled) { + Task { await musicManager.toggleShuffle() } + } + case .repeatMode: + controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off, symbolEffect: .replace) { + Task { await musicManager.toggleRepeat() } + } + case .mediaOutput: + MinimalisticMediaOutputButton() + case .airPlay: + MinimalisticAirPlayButton() + case .lyrics: + controlButton( + icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", + isActive: enableLyrics, + activeColor: brandAccentColor, + symbolEffect: .replace + ) { + enableLyrics.toggle() + } + } + } + + private func skipGestureTrigger(for control: MusicControlButton) -> SkipTrigger? { + guard let pulse = musicManager.skipGesturePulse else { return nil } + + switch control { + case .trackBackward where pulse.behavior == .track && pulse.direction == .backward: + return SkipTrigger(token: pulse.token, pressEffect: .nudge(-skipMagnitude)) + case .trackForward where pulse.behavior == .track && pulse.direction == .forward: + return SkipTrigger(token: pulse.token, pressEffect: .nudge(skipMagnitude)) + case .seekBackward where pulse.behavior == .tenSecond && pulse.direction == .backward: + return SkipTrigger(token: pulse.token, pressEffect: .wiggle(.counterClockwise)) + case .seekForward where pulse.behavior == .tenSecond && pulse.direction == .forward: + return SkipTrigger(token: pulse.token, pressEffect: .wiggle(.clockwise)) + default: + return nil + } + } + 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: 36, height: 36), + cornerRadius: 14, + foregroundColor: .white.opacity(0.85), + symbolEffectStyle: .replace + ) { + 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 struct MinimalisticAirPlayButton: View { + @ObservedObject private var musicManager = MusicManager.shared + @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared + @EnvironmentObject private var vm: DynamicIslandViewModel + @State private var isPopoverPresented = false + @State private var isHoveringPopover = false + + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + + var body: some View { + MinimalisticSquircircleButton( + icon: "airplayaudio", + fontSize: 18, + fontWeight: .medium, + frameSize: CGSize(width: 36, height: 36), + cornerRadius: 14, + foregroundColor: .white.opacity(0.85), + symbolEffectStyle: .replace + ) { + isPopoverPresented.toggle() + if isPopoverPresented { + Task { await airPlayManager.refreshDevices() } + } + } + .accessibilityLabel("AirPlay") + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + AirPlaySelectorPopover( + airPlayManager: airPlayManager, + onHoverChanged: { hovering in + isHoveringPopover = hovering + updateActivity() + } + ) { + isPopoverPresented = false + isHoveringPopover = false + updateActivity() + } + } + .onChange(of: isPopoverPresented) { _, presented in + if !presented { isHoveringPopover = false } + updateActivity() + } + .onAppear { + if isAppleMusicActive { + Task { await airPlayManager.refreshDevices() } + } + } + .onChange(of: musicManager.bundleIdentifier) { _, newBundle in + if newBundle == "com.apple.Music" { + Task { await airPlayManager.refreshDevices() } + } + } + .onDisappear { + vm.isMediaOutputPopoverActive = false + } + } + + private func updateActivity() { + vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover + } + } + + private var repeatIcon: String { + switch musicManager.repeatMode { + case .off: return "repeat" + case .all: return "repeat" + case .one: return "repeat.1" + } + } +} + +// MARK: - Minimalistic Album Art + +struct MinimalisticAlbumArtView: View { + @ObservedObject var musicManager = MusicManager.shared + @ObservedObject var vm: DynamicIslandViewModel + @Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland + let albumArtNamespace: Namespace.ID + + private var usesLiveCanvasArtwork: Bool { + showLiveCanvasInDynamicIsland && musicManager.videoArtworkURL != nil + } + + private var albumArtCornerRadius: CGFloat { + musicManager.albumArt.size.width / musicManager.albumArt.size.height > 1.0 ? 4 : 12 + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + if Defaults[.lightingEffect] { + albumArtBackground + } + albumArtButton + } + } + + private var albumArtBackground: some View { + Color.clear + .aspectRatio(1, contentMode: .fit) + .background( + DynamicIslandArtworkSourceView( + cornerRadius: albumArtCornerRadius, + contentMode: .fill + ) + ) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: albumArtCornerRadius)) + .scaleEffect(x: 1.04, y: 1.05) + .rotationEffect(.degrees(92)) + .blur(radius: 14) + .opacity( + usesLiveCanvasArtwork + ? (musicManager.isPlaying ? 0.35 : 0.12) + : min(0.28, 1 - max(musicManager.albumArt.getBrightness(), 0.3)) + ) + .shadow( + color: Color(nsColor: musicManager.avgColor).opacity(usesLiveCanvasArtwork ? 0.14 : 0.08), + radius: usesLiveCanvasArtwork ? 6 : 4, + x: 0, + y: 0 + ) + } + + private var albumArtButton: some View { + Button { + musicManager.openMusicApp() + } label: { + Color.clear + .aspectRatio(1, contentMode: .fit) + .background( + DynamicIslandArtworkSourceView( + cornerRadius: albumArtCornerRadius, + contentMode: .fit + ) + ) + .clipped() + .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) + .albumArtFlip(angle: musicManager.flipAngle) + .parallax3D() + } + .buttonStyle(PlainButtonStyle()) + .opacity(musicManager.isPlaying ? 1 : 0.4) + .scaleEffect(musicManager.isPlaying ? 1 : 0.85) + } +} + +// MARK: - Hover-highlighted control button + +private struct MinimalisticSquircircleButton: View { + let icon: String + let fontSize: CGFloat + let fontWeight: Font.Weight + let frameSize: CGSize + let cornerRadius: CGFloat + let foregroundColor: Color + let pressEffect: PressEffect + let symbolEffectStyle: SymbolEffectStyle + let externalTriggerToken: Int? + let externalTriggerEffect: PressEffect? + let action: () -> Void + + @State private var isHovering = false + @State private var pressOffset: CGFloat = 0 + @State private var rotationAngle: Double = 0 + @State private var wiggleToken: Int = 0 + @State private var lastExternalTriggerToken: Int? + + init( + icon: String, + fontSize: CGFloat, + fontWeight: Font.Weight, + frameSize: CGSize, + cornerRadius: CGFloat, + foregroundColor: Color, + pressEffect: PressEffect = .none, + symbolEffectStyle: SymbolEffectStyle = .none, + externalTriggerToken: Int? = nil, + externalTriggerEffect: PressEffect? = nil, + action: @escaping () -> Void + ) { + self.icon = icon + self.fontSize = fontSize + self.fontWeight = fontWeight + self.frameSize = frameSize + self.cornerRadius = cornerRadius + self.foregroundColor = foregroundColor + self.pressEffect = pressEffect + self.symbolEffectStyle = symbolEffectStyle + self.externalTriggerToken = externalTriggerToken + self.externalTriggerEffect = externalTriggerEffect + self.action = action + } + + var body: some View { + Button { + triggerPressEffect() + action() + } label: { + iconView() + .frame(width: frameSize.width, height: frameSize.height) + .background( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(isHovering ? Color.white.opacity(0.18) : .clear) + ) + .contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } + .buttonStyle(PlainButtonStyle()) + .offset(x: pressOffset) + .rotationEffect(.degrees(rotationAngle)) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.18)) { + isHovering = hovering + } + } + .onChange(of: externalTriggerToken) { _, newToken in + guard let newToken, newToken != lastExternalTriggerToken else { return } + lastExternalTriggerToken = newToken + triggerPressEffect(override: externalTriggerEffect) + } + } + + private func triggerPressEffect(override: PressEffect? = nil) { + let effect = override ?? pressEffect + + switch effect { + case .none: + return + case .nudge(let amount): + withAnimation(.spring(response: 0.16, dampingFraction: 0.72)) { + pressOffset = amount + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { + pressOffset = 0 + } + } + case .wiggle(let direction): + guard #available(macOS 14.0, *) else { return } + wiggleToken += 1 + let angle: Double = direction == .clockwise ? 11 : -11 + + withAnimation(.spring(response: 0.18, dampingFraction: 0.52)) { + rotationAngle = angle + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { + withAnimation(.spring(response: 0.32, dampingFraction: 0.76)) { + rotationAngle = 0 + } + } + } + } + + @ViewBuilder + private func iconView() -> some View { + let image = Image(systemName: icon) + .font(.system(size: fontSize, weight: fontWeight)) + .foregroundColor(foregroundColor) + + switch symbolEffectStyle { + case .none: + image + case .replace: + if #available(macOS 14.0, *) { + image.contentTransition(.symbolEffect(.replace)) + } else { + image + } + case .bounce: + if #available(macOS 14.0, *) { + image.symbolEffect(.bounce, value: icon) + } else { + image + } + case .replaceAndBounce: + if #available(macOS 14.0, *) { + image + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.bounce, value: icon) + } else { + image + } + case .wiggle: + if #available(macOS 15.0, *) { + image.symbolEffect( + .wiggle.byLayer, + options: .nonRepeating, + value: wiggleToken + ) + } else { + image + } + } + } + + enum PressEffect { + case none + case nudge(CGFloat) + case wiggle(WiggleDirection) + } + + enum SymbolEffectStyle { + case none + case replace + case bounce + case replaceAndBounce + case wiggle + } + + enum WiggleDirection { + case clockwise + case counterClockwise + } +} diff --git a/DynamicIsland/components/Notch/NotchColorPickerView.swift b/DynamicIsland/components/Notch/NotchColorPickerView.swift new file mode 100644 index 00000000..674878e8 --- /dev/null +++ b/DynamicIsland/components/Notch/NotchColorPickerView.swift @@ -0,0 +1,362 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct NotchColorPickerView: View { + @ObservedObject var colorPickerManager = ColorPickerManager.shared + @Default(.enableColorPickerFeature) var enableColorPickerFeature + @State private var hoveredColorId: UUID? + @State private var showColorInfo: Bool = false + @State private var selectedColor: PickedColor? + + var body: some View { + if !enableColorPickerFeature { + disabledStateView + } else { + colorPickerContent + } + } + + private var disabledStateView: some View { + VStack(spacing: 12) { + Image(systemName: "eyedropper.halffull") + .font(.system(size: 32)) + .foregroundColor(.secondary) + + Text("ColorPicker Disabled") + .font(.headline) + .foregroundColor(.primary) + + Text("Enable ColorPicker in Settings → ColorPicker") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var colorPickerContent: some View { + VStack(spacing: 16) { + headerSection + + if colorPickerManager.colorHistory.isEmpty { + emptyStateView + } else { + colorHistoryGrid + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay( + colorInfoOverlay, + alignment: .topTrailing + ) + } + + private var headerSection: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Image(systemName: "eyedropper.halffull") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + + Text("Recent Colors") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary) + } + + Text("\(colorPickerManager.colorHistory.count) colors") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + + Spacer() + + // Pick Color Button + Button(action: { + colorPickerManager.startColorPicking() + }) { + HStack(spacing: 6) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 12, weight: .medium)) + Text("Pick Color") + .font(.system(size: 12, weight: .medium)) + } + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + .onHover { isHovering in + if isHovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + } + + private var emptyStateView: some View { + VStack(spacing: 12) { + Image(systemName: "eyedropper") + .font(.system(size: 28)) + .foregroundColor(.secondary) + + Text("No Colors Yet") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + + Text("Click 'Pick Color' or use Cmd+Shift+P to start picking colors") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + + private var colorHistoryGrid: some View { + LazyVGrid(columns: gridColumns, spacing: 12) { + ForEach(colorPickerManager.colorHistory.prefix(15)) { color in + ColorCircleView( + color: color, + isHovered: hoveredColorId == color.id + ) + .onHover { isHovering in + withAnimation(.easeInOut(duration: 0.2)) { + if isHovering { + hoveredColorId = color.id + selectedColor = color + showColorInfo = true + } else { + hoveredColorId = nil + if selectedColor?.id == color.id { + showColorInfo = false + selectedColor = nil + } + } + } + } + .onTapGesture { + // Copy hex color to clipboard + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(color.hexString, forType: .string) + + // Provide haptic feedback + if Defaults[.enableHaptics] { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } + } + } + } + .padding(.horizontal, 8) + } + + private var gridColumns: [GridItem] { + let availableWidth: CGFloat = 400 // Approximate notch content width + let circleSize: CGFloat = 32 + let spacing: CGFloat = 12 + let maxColumns = Int((availableWidth + spacing) / (circleSize + spacing)) + let columns = min(maxColumns, 8) // Maximum 8 columns + + return Array(repeating: GridItem(.fixed(circleSize), spacing: spacing), count: columns) + } + + private var colorInfoOverlay: some View { + Group { + if showColorInfo, let color = selectedColor { + ColorInfoPopup(color: color) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .opacity + )) + } + } + } +} + +struct ColorCircleView: View { + let color: PickedColor + let isHovered: Bool + + var body: some View { + ZStack { + // Color circle + Circle() + .fill(color.color) + .frame(width: 32, height: 32) + .overlay( + Circle() + .stroke(Color.white.opacity(0.8), lineWidth: isHovered ? 2 : 1) + ) + .overlay( + Circle() + .stroke(Color.black.opacity(0.2), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.3), radius: isHovered ? 4 : 2, x: 0, y: isHovered ? 2 : 1) + .scaleEffect(isHovered ? 1.1 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isHovered) + } + .frame(width: 32, height: 32) + } +} + +struct ColorInfoPopup: View { + let color: PickedColor + @Default(.showColorFormats) var showColorFormats + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Header with color preview + HStack(spacing: 8) { + Circle() + .fill(color.color) + .frame(width: 24, height: 24) + .overlay( + Circle() + .stroke(Color.white.opacity(0.8), lineWidth: 1) + ) + + VStack(alignment: .leading, spacing: 2) { + Text("Picked Color") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.primary) + + Text(timeAgoString(from: color.timestamp)) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + + Spacer() + } + + Divider() + .background(Color.gray.opacity(0.3)) + + // Color formats + if showColorFormats { + VStack(alignment: .leading, spacing: 6) { + ForEach(color.allFormats.prefix(4)) { format in + ColorFormatRow(format: format, color: color) + } + } + } else { + ColorFormatRow( + format: ColorFormat(name: "HEX", value: color.hexString, copyValue: color.hexString), + color: color + ) + } + } + .padding(12) + .frame(width: 220) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.9)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.4), radius: 15, x: 0, y: 8) + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return String(localized: "Just now") + } else if interval < 3600 { + let minutes = Int(interval / 60) + return String(localized: "\(minutes)m ago") + } else if interval < 86400 { + let hours = Int(interval / 3600) + return String(localized: "\(hours)h ago") + } else { + let days = Int(interval / 86400) + return String(localized: "\(days)d ago") + } + } +} + +struct ColorFormatRow: View { + let format: ColorFormat + let color: PickedColor + @State private var isHovered: Bool = false + + var body: some View { + HStack(spacing: 8) { + Text(format.name) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + .frame(width: 40, alignment: .leading) + + Text(format.value) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.primary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + if isHovered { + Button(action: { + ColorPickerManager.shared.copyColorToClipboard(color, format: format) + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 10)) + .foregroundColor(.blue) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovered ? Color.white.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovered = hovering + } + } + .onTapGesture { + ColorPickerManager.shared.copyColorToClipboard(color, format: format) + } + } +} + +#Preview { + NotchColorPickerView() + .frame(width: 500, height: 200) + .background(Color.black) + .onAppear { + // Add some sample colors for preview + let manager = ColorPickerManager.shared + manager.colorHistory = PickedColor.sampleColors + } +} diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index aeeaf64a..da388b22 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -1,14 +1,151 @@ -// -// NotchHomeView.swift -// DynamicIsland -// -// Created by Hugo Persson on 2024-08-18. -// Modified by Harsh Vardhan Goswami & Richard Kunkli & Mustafa Ramadan -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Combine import Defaults import SwiftUI +import AppKit +import AVFoundation + +private final class DynamicIslandArtworkLoopController { + let player: AVQueuePlayer + private var looper: AVPlayerLooper? + private var playbackStateCancellable: AnyCancellable? + + init(url: URL) { + let item = AVPlayerItem(url: url) + player = AVQueuePlayer() + player.isMuted = true + player.actionAtItemEnd = .none + looper = AVPlayerLooper(player: player, templateItem: item) + + if MusicManager.shared.isPlaying { + player.play() + } + + playbackStateCancellable = MusicManager.shared.$isPlaying + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] isPlaying in + guard let self else { return } + if isPlaying { + self.player.play() + } else { + self.player.pause() + } + } + } + + deinit { + player.pause() + looper = nil + playbackStateCancellable = nil + } +} + +private final class DynamicIslandArtworkVideoContainerView: NSView { + let playerLayer = AVPlayerLayer() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + playerLayer.videoGravity = .resizeAspectFill + playerLayer.backgroundColor = NSColor.clear.cgColor + layer?.addSublayer(playerLayer) + } + + required init?(coder: NSCoder) { + nil + } + + override func layout() { + super.layout() + playerLayer.frame = bounds + } +} + +private struct DynamicIslandArtworkVideoView: NSViewRepresentable { + let url: URL + let videoGravity: AVLayerVideoGravity + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> DynamicIslandArtworkVideoContainerView { + let view = DynamicIslandArtworkVideoContainerView(frame: .zero) + context.coordinator.attach(layer: view.playerLayer, url: url, gravity: videoGravity) + return view + } + + func updateNSView(_ nsView: DynamicIslandArtworkVideoContainerView, context: Context) { + context.coordinator.attach(layer: nsView.playerLayer, url: url, gravity: videoGravity) + } + + final class Coordinator { + private var controller: DynamicIslandArtworkLoopController? + private var currentURL: URL? + + func attach(layer: AVPlayerLayer, url: URL, gravity: AVLayerVideoGravity) { + layer.videoGravity = gravity + + if currentURL != url || controller == nil { + currentURL = url + controller = DynamicIslandArtworkLoopController(url: url) + } + + if layer.player !== controller?.player { + layer.player = controller?.player + } + } + } +} + +struct DynamicIslandArtworkSourceView: View { + @ObservedObject private var musicManager = MusicManager.shared + @Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland + + let cornerRadius: CGFloat + let contentMode: ContentMode + + private var liveCanvasURL: URL? { + guard showLiveCanvasInDynamicIsland else { return nil } + return musicManager.videoArtworkURL + } + + var body: some View { + Group { + if let liveCanvasURL { + DynamicIslandArtworkVideoView(url: liveCanvasURL, videoGravity: .resizeAspectFill) + } else { + Image(nsImage: musicManager.albumArt) + .resizable() + .aspectRatio(contentMode: contentMode) + } + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } +} // MARK: - Music Player Components @@ -17,18 +154,35 @@ struct MusicPlayerView: View { let albumArtNamespace: Namespace.ID var body: some View { - HStack { + HStack(alignment: .top, spacing: 12) { AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace) - MusicControlsView().drawingGroup().compositingGroup() + MusicControlsView() + .frame(maxWidth: .infinity, alignment: .leading) } + .frame(maxWidth: .infinity, alignment: .leading) } } struct AlbumArtView: View { @ObservedObject var musicManager = MusicManager.shared @ObservedObject var vm: DynamicIslandViewModel + @Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland let albumArtNamespace: Namespace.ID + private var usesLiveCanvasArtwork: Bool { + showLiveCanvasInDynamicIsland && musicManager.videoArtworkURL != nil + } + + private var albumArtCornerRadius: CGFloat { + Defaults[.cornerRadiusScaling] + ? musicManager.albumArt.size.width / musicManager.albumArt.size.height > 1.0 + ? MusicPlayerImageSizes.cornerRadiusInset.opened / 3 + : MusicPlayerImageSizes.cornerRadiusInset.opened + : musicManager.albumArt.size.width / musicManager.albumArt.size.height > 1.0 + ? MusicPlayerImageSizes.cornerRadiusInset.closed / 3 + : MusicPlayerImageSizes.cornerRadiusInset.closed + } + var body: some View { ZStack(alignment: .bottomTrailing) { if Defaults[.lightingEffect] { @@ -42,44 +196,68 @@ struct AlbumArtView: View { Color.clear .aspectRatio(1, contentMode: .fit) .background( - Image(nsImage: musicManager.albumArt) - .resizable() - .aspectRatio(contentMode: .fill) + DynamicIslandArtworkSourceView( + cornerRadius: albumArtCornerRadius, + contentMode: .fill + ) ) .clipped() - .clipShape(RoundedRectangle(cornerRadius: Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.opened : MusicPlayerImageSizes.cornerRadiusInset.closed)) .scaleEffect(x: 1.3, y: 1.4) .rotationEffect(.degrees(92)) - .blur(radius: 35) - .opacity(min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3))) + .blur(radius: 40) + .opacity( + usesLiveCanvasArtwork + ? (musicManager.isPlaying ? 0.62 : 0.18) + : (musicManager.isPlaying ? 0.5 : 0) + ) + .shadow( + color: Color(nsColor: musicManager.avgColor).opacity(usesLiveCanvasArtwork ? 0.24 : 0.16), + radius: usesLiveCanvasArtwork ? 22 : 14, + x: 0, + y: 0 + ) } private var albumArtButton: some View { - Button { - musicManager.openMusicApp() - } label: { - ZStack(alignment: .bottomTrailing) { - albumArtImage - appIconOverlay + ZStack { + Button { + musicManager.openMusicApp() + } label: { + ZStack(alignment:.bottomTrailing) { + albumArtImage + appIconOverlay + } + .albumArtFlip(angle: musicManager.flipAngle) + .parallax3D() + .padding(.bottom, -5) + } + .buttonStyle(PlainButtonStyle()) + .scaleEffect(musicManager.isPlaying ? 1 : 0.85) + + albumArtDarkOverlay } - .buttonStyle(PlainButtonStyle()) - .opacity(musicManager.isPlaying ? 1 : 0.4) - .scaleEffect(musicManager.isPlaying ? 1 : 0.85) + } + + private var albumArtDarkOverlay: some View { + Rectangle() + .aspectRatio(1, contentMode: .fit) + .foregroundColor(Color.black) + .opacity(musicManager.isPlaying ? 0 : 0.8) + .blur(radius: 50) } private var albumArtImage: some View { Color.clear .aspectRatio(1, contentMode: .fit) - .background( - Image(nsImage: musicManager.albumArt) - .resizable() - .aspectRatio(contentMode: .fill) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: musicManager.isFlipping) - ) - .clipped() - .clipShape(RoundedRectangle(cornerRadius: Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.opened : MusicPlayerImageSizes.cornerRadiusInset.closed)) + .overlay { + DynamicIslandArtworkSourceView( + cornerRadius: albumArtCornerRadius, + contentMode: .fit + ) + } .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) + .clipped() } @ViewBuilder @@ -87,27 +265,44 @@ struct AlbumArtView: View { if vm.notchState == .open && !musicManager.usingAppIconForArtwork { AppIcon(for: musicManager.bundleIdentifier ?? "com.apple.Music") .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 30, height: 30) + .scaledToFill() + .frame(width: 36, height: 36) .offset(x: 10, y: 10) .transition(.scale.combined(with: .opacity).animation(.bouncy.delay(0.3))) + .zIndex(2) } } } struct MusicControlsView: View { + @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var musicManager = MusicManager.shared - @State private var sliderValue: Double = 0 + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @State private var sliderValue: Double = MusicManager.shared.estimatedPlaybackPosition() @State private var dragging: Bool = false @State private var lastDragged: Date = .distantPast - + @State private var hudValue: Double = 0 + @State private var hudDragging: Bool = false + @State private var hudLastDragged: Date = .distantPast + @Default(.showShuffleAndRepeat) private var showCustomControls + @Default(.musicControlSlots) private var slotConfig + @Default(.showMediaOutputControl) private var showMediaOutputControl + @Default(.musicSkipBehavior) private var musicSkipBehavior + @Default(.enableLyrics) private var enableLyrics + private let seekInterval: TimeInterval = 10 + private let skipMagnitude: CGFloat = 6 + var body: some View { VStack(alignment: .leading) { songInfoAndSlider - playbackControls + if shouldShowControlHUDRow { + controlHUDRow + } else { + playbackControls + } } .buttonStyle(PlainButtonStyle()) - .frame(minWidth: Defaults[.showMirror] && Defaults[.showCalendar] ? 140 : 180) + .frame(maxWidth: .infinity, alignment: .leading) } private var songInfoAndSlider: some View { @@ -119,11 +314,20 @@ struct MusicControlsView: View { } .padding(.top, 10) .padding(.leading, 5) + .frame(maxWidth: .infinity, alignment: .leading) } private func songInfo(width: CGFloat) -> some View { VStack(alignment: .leading, spacing: 0) { - MarqueeText($musicManager.songTitle, font: .headline, nsFont: .headline, textColor: .white, frameWidth: width) + MusicTitleMarqueeView( + text: musicManager.songTitle, + isExplicit: musicManager.isCurrentTrackExplicit, + font: .headline, + nsFont: .headline, + textColor: .white, + frameWidth: width, + badgeHeight: 14 + ) MarqueeText( $musicManager.artistName, font: .headline, @@ -133,11 +337,45 @@ struct MusicControlsView: View { frameWidth: width ) .fontWeight(.medium) + // Lyrics shown under the author name (same font size as author) when enabled in settings + if enableLyrics { + let transition = AnyTransition.asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + ) + + let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines) + + if !line.isEmpty { + let lyricsBinding = Binding( + get: { musicManager.currentLyrics }, + set: { _ in } + ) + + MarqueeText( + lyricsBinding, + font: .system(size: 12, weight: .regular), + nsFont: .headline, + textColor: .white.opacity(0.7), + minDuration: 0.35, + frameWidth: width + ) + .padding(.top, 2) + .id(line) + .transition(transition) + .animation(.easeInOut(duration: 0.32), value: line) + } + } } } + /// Whether the progress timeline should be paused (no ticks). + private var isProgressTimelinePaused: Bool { + !musicManager.isPlaying || musicManager.isLiveStream || musicManager.playbackRate <= 0 + } + private var musicSlider: some View { - TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in + TimelineView(.animation(minimumInterval: 1.0, paused: isProgressTimelinePaused)) { timeline in MusicSliderView( sliderValue: $sliderValue, duration: $musicManager.songDuration, @@ -145,13 +383,13 @@ struct MusicControlsView: View { color: musicManager.avgColor, dragging: $dragging, currentDate: timeline.date, - lastUpdated: musicManager.lastUpdated, - ignoreLastUpdated: musicManager.ignoreLastUpdated, timestampDate: musicManager.timestampDate, elapsedTime: musicManager.elapsedTime, playbackRate: musicManager.playbackRate, - isPlaying: musicManager.isPlaying + isPlaying: musicManager.isPlaying, + isLiveStream: musicManager.isLiveStream ) { newValue in + guard !musicManager.isLiveStream else { return } MusicManager.shared.seek(to: newValue) } .padding(.top, 5) @@ -161,17 +399,282 @@ struct MusicControlsView: View { private var playbackControls: some View { HStack(spacing: 8) { - HoverButton(icon: "backward.fill", scale: .medium) { - MusicManager.shared.previousTrack() + ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in + slotView(for: slot) } - HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) { - MusicManager.shared.togglePlay() + } + .frame(maxWidth: .infinity, alignment: .center) + } + + private var shouldShowControlHUDRow: Bool { + guard vm.notchState == .open else { return false } + guard coordinator.sneakPeek.show else { return false } + guard Defaults[.enableSystemHUD] else { return false } + guard !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] else { return false } + + switch coordinator.sneakPeek.type { + case .volume: + return Defaults[.enableVolumeHUD] + case .brightness: + return Defaults[.enableBrightnessHUD] + case .backlight: + return Defaults[.enableKeyboardBacklightHUD] + default: + return false + } + } + + private var controlHUDRow: some View { + HStack(alignment: .center, spacing: 10) { + if !controlLeftIconName.isEmpty { + Image(systemName: controlLeftIconName) + .font(.system(size: 16, weight: .semibold)) + .frame(width: 22, height: 22, alignment: .center) } - HoverButton(icon: "forward.fill", scale: .medium) { - MusicManager.shared.nextTrack() + + controlHUDSlider + + if !controlRightIconName.isEmpty { + Image(systemName: controlRightIconName) + .font(.system(size: 16, weight: .semibold)) + .frame(width: 22, height: 22, alignment: .center) } } .frame(maxWidth: .infinity, alignment: .center) + .onAppear { syncHUDValueIfNeeded(force: true) } + .onChange(of: coordinator.sneakPeek.value) { _, _ in + syncHUDValueIfNeeded(force: false) + } + } + + private var controlHUDSlider: some View { + VStack(spacing: 0) { + Spacer(minLength: 0) + CustomSlider( + value: Binding( + get: { hudValue }, + set: { newValue in + hudValue = newValue + updateControlHUDValue(newValue) + } + ), + range: 0...1, + color: .white, + dragging: $hudDragging, + lastDragged: $hudLastDragged, + onValueChange: { newValue in + updateControlHUDValue(newValue) + }, + thumbSize: 10, + restingTrackHeight: 4, + draggingTrackHeight: 7 + ) + .frame(height: 7) + Spacer(minLength: 0) + } + .frame(height: 22) + } + + private func syncHUDValueIfNeeded(force: Bool) { + guard shouldShowControlHUDRow else { return } + guard force || !hudDragging else { return } + hudValue = Double(coordinator.sneakPeek.value) + } + + private func updateControlHUDValue(_ newValue: Double) { + let clamped = max(0, min(1, newValue)) + switch coordinator.sneakPeek.type { + case .volume: + SystemVolumeController.shared.setVolume(Float(clamped)) + case .brightness: + SystemBrightnessController.shared.setBrightness(Float(clamped)) + case .backlight: + SystemKeyboardBacklightController.shared.setLevel(Float(clamped)) + default: + break + } + } + + private var controlLeftIconName: String { + switch coordinator.sneakPeek.type { + case .volume: + return SystemVolumeController.shared.isMuted ? "speaker.slash" : "speaker.wave.1" + case .brightness: + return "sun.min.fill" + case .backlight: + return "light.min" + default: + return "" + } + } + + private var controlRightIconName: String { + switch coordinator.sneakPeek.type { + case .volume: + return SystemVolumeController.shared.isMuted ? "" : "speaker.wave.3" + case .brightness: + return "sun.max.fill" + case .backlight: + return "light.max" + default: + return "" + } + } + + private var brandAccentColor: Color { + musicManager.brandAccentColor + } + + private var repeatIcon: String { + switch musicManager.repeatMode { + case .off: + return "repeat" + case .all: + return "repeat" + case .one: + return "repeat.1" + } + } + + private var repeatIconColor: Color { + switch musicManager.repeatMode { + case .off: + return .white + case .all, .one: + return brandAccentColor + } + } + + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + + private var displayedSlots: [MusicControlButton] { + if showCustomControls { + let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive) + return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout + } + + switch musicSkipBehavior { + case .track: + return MusicControlButton.minimalLayout + case .tenSecond: + return [.none, .seekBackward, .playPause, .seekForward, .none] + } + } + + @ViewBuilder + private func slotView(for control: MusicControlButton) -> some View { + switch control { + case .none: + Spacer(minLength: 0) + case .playPause: + HoverButton( + icon: musicManager.isPlaying ? (musicManager.isLiveStream ? "stop.fill" : "pause.fill") : "play.fill", + scale: .large + ) { + MusicManager.shared.togglePlay() + } + case .trackBackward: + playbackButton( + icon: "backward.fill", + press: .nudge(-skipMagnitude), + trigger: skipGestureTrigger(for: .trackBackward) + ) { + musicManager.previousTrack() + } + case .trackForward: + playbackButton( + icon: "forward.fill", + press: .nudge(skipMagnitude), + trigger: skipGestureTrigger(for: .trackForward) + ) { + musicManager.nextTrack() + } + case .seekBackward: + playbackButton( + icon: "gobackward.10", + press: .wiggle(.counterClockwise), + trigger: skipGestureTrigger(for: .seekBackward) + ) { + musicManager.seek(by: -seekInterval) + } + case .seekForward: + playbackButton( + icon: "goforward.10", + press: .wiggle(.clockwise), + trigger: skipGestureTrigger(for: .seekForward) + ) { + musicManager.seek(by: seekInterval) + } + case .shuffle: + HoverButton( + icon: "shuffle", + iconColor: musicManager.isShuffled ? brandAccentColor : .white, + scale: .medium + ) { + MusicManager.shared.toggleShuffle() + } + case .repeatMode: + HoverButton( + icon: repeatIcon, + iconColor: repeatIconColor, + scale: .medium + ) { + MusicManager.shared.toggleRepeat() + } + case .mediaOutput: + MediaOutputPickerButton() + case .airPlay: + AirPlayPickerButton() + case .lyrics: + HoverButton( + icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", + iconColor: enableLyrics ? brandAccentColor : .white, + scale: .medium + ) { + enableLyrics.toggle() + } + } + } + + private struct SkipTrigger { + let token: Int + let pressEffect: HoverButton.PressEffect + } + + private func playbackButton( + icon: String, + press: HoverButton.PressEffect?, + trigger: SkipTrigger?, + action: @escaping () -> Void + ) -> some View { + HoverButton( + icon: icon, + scale: .medium, + pressEffect: press, + externalTriggerToken: trigger?.token, + externalTriggerEffect: trigger?.pressEffect + ) { + action() + } + } + + private func skipGestureTrigger(for control: MusicControlButton) -> SkipTrigger? { + guard let pulse = musicManager.skipGesturePulse else { return nil } + + switch control { + case .trackBackward where pulse.behavior == .track && pulse.direction == .backward: + return SkipTrigger(token: pulse.token, pressEffect: .nudge(-skipMagnitude)) + case .trackForward where pulse.behavior == .track && pulse.direction == .forward: + return SkipTrigger(token: pulse.token, pressEffect: .nudge(skipMagnitude)) + case .seekBackward where pulse.behavior == .tenSecond && pulse.direction == .backward: + return SkipTrigger(token: pulse.token, pressEffect: .wiggle(.counterClockwise)) + case .seekForward where pulse.behavior == .tenSecond && pulse.direction == .forward: + return SkipTrigger(token: pulse.token, pressEffect: .wiggle(.clockwise)) + default: + return nil + } } } @@ -182,7 +685,16 @@ struct NotchHomeView: View { @ObservedObject var webcamManager = WebcamManager.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared + @ObservedObject private var musicManager = MusicManager.shared + @Default(.showStandardMediaControls) private var showStandardMediaControls + @Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer let albumArtNamespace: Namespace.ID + + /// Whether the music player should actively display (enabled AND has real content). + private var shouldShowMusicPlayer: Bool { + showStandardMediaControls && (!autoHideInactiveNotchMediaPlayer || musicManager.hasActiveSession) + } var body: some View { Group { @@ -195,29 +707,56 @@ struct NotchHomeView: View { private var mainContent: some View { HStack(alignment: .top, spacing: 20) { - MusicPlayerView(albumArtNamespace: albumArtNamespace) - - if Defaults[.showCalendar] { - CalendarView() + if Defaults[.enableMinimalisticUI] { + if let overridePayload = minimalisticOverridePayload { + ExtensionMinimalisticExperienceView( + payload: overridePayload, + albumArtNamespace: albumArtNamespace + ) + } else { + MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) + } + } else { + // Normal mode: Show full music player with optional calendar and webcam + if shouldShowMusicPlayer { + MusicPlayerView(albumArtNamespace: albumArtNamespace) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if Defaults[.showCalendar] { + Group { + if shouldShowMusicPlayer { + CalendarView() + } else { + StandaloneCalendarView() + } + } + .frame(maxWidth: .infinity, alignment: .leading) .onHover { isHovering in vm.isHoveringCalendar = isHovering } .environmentObject(vm) - } - - if Defaults[.showMirror], - webcamManager.cameraAvailable, - vm.notchState == .open { - CameraPreviewView(webcamManager: webcamManager) - .scaledToFit() - .opacity(vm.notchState == .closed ? 0 : 1) - .blur(radius: vm.notchState == .closed ? 20 : 0) + } + + if Defaults[.showMirror], + webcamManager.cameraAvailable, + vm.notchState == .open { + CameraPreviewView(webcamManager: webcamManager) + .scaledToFit() + .opacity(vm.notchState == .closed ? 0 : 1) + .blur(radius: vm.notchState == .closed ? 20 : 0) + } } } .transition(.opacity.animation(.smooth.speed(0.9)) .combined(with: .blurReplace.animation(.smooth.speed(0.9))) .combined(with: .move(edge: .top))) .blur(radius: vm.notchState == .closed ? 30 : 0) + .padding(Defaults[.enableMinimalisticUI] ? 0 : 8) //Putting the main padding for home view here for consistency + } + + private var minimalisticOverridePayload: ExtensionNotchExperiencePayload? { + extensionNotchExperienceManager.minimalisticReplacementPayload() } } @@ -228,50 +767,172 @@ struct MusicSliderView: View { var color: NSColor @Binding var dragging: Bool let currentDate: Date - let lastUpdated: Date - let ignoreLastUpdated: Bool let timestampDate: Date let elapsedTime: Double let playbackRate: Double let isPlaying: Bool + let isLiveStream: Bool var onValueChange: (Double) -> Void + var labelLayout: TimeLabelLayout = .stacked + var trailingLabel: TrailingLabel = .duration + var restingTrackHeight: CGFloat = 5 + var draggingTrackHeight: CGFloat = 9 + + enum TimeLabelLayout { + case stacked + case inline + } - var currentElapsedTime: Double { - // A small buffer is needed to ensure a meaningful difference between the two dates - guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -0.0001, (timestampDate > lastUpdated || ignoreLastUpdated) else { return sliderValue } - let timeDifference = isPlaying ? currentDate.timeIntervalSince(timestampDate) : 0 - let elapsed = elapsedTime + (timeDifference * playbackRate) - return min(elapsed, duration) + enum TrailingLabel { + case duration + case remaining } var body: some View { - VStack { - CustomSlider( - value: $sliderValue, - range: 0 ... duration, - color: Defaults[.sliderColor] == SliderColorEnum.albumArt ? Color( - nsColor: color - ).ensureMinimumBrightness(factor: 0.8) : Defaults[.sliderColor] == SliderColorEnum.accent ? .accentColor : .white, - dragging: $dragging, - lastDragged: $lastDragged, - onValueChange: onValueChange - ) - .frame(height: 10, alignment: .center) + Group { + if isLiveStream { + liveStreamView + } else { + switch labelLayout { + case .stacked: + stackedContent + case .inline: + inlineContent + } + } + } + .onChange(of: currentDate) { newDate in + guard !isLiveStream else { return } + guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return } + sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: newDate) + } + .onChange(of: isPlaying) { _, playing in + // Snap slider to the exact position when music pauses so + // the in-flight animation doesn't coast past the true value. + if !playing { + sliderValue = MusicManager.shared.estimatedPlaybackPosition() + } + } + .onChange(of: isLiveStream) { isLive in + if isLive { + sliderValue = 0 + } + } + } + + private var stackedContent: some View { + VStack(spacing: 6) { + sliderCore + .frame(height: sliderFrameHeight) + HStack { Text(timeString(from: sliderValue)) Spacer() - Text(timeString(from: duration)) + Text(trailingTimeText) } .fontWeight(.medium) - .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: color) - .ensureMinimumBrightness(factor: 0.6) : .gray) + .foregroundColor(timeLabelColor) .font(.caption) } - .onChange(of: currentDate) { - sliderValue = currentElapsedTime + } + + private var inlineContent: some View { + HStack(spacing: 10) { + Text(timeString(from: sliderValue)) + .font(inlineLabelFont) + .foregroundColor(timeLabelColor) + .frame(width: 42, alignment: .leading) + + sliderCore + .frame(height: sliderFrameHeight) + .frame(maxWidth: .infinity) + + Text(trailingTimeText) + .font(inlineLabelFont) + .foregroundColor(timeLabelColor) + .frame(width: 48, alignment: .trailing) } } + @ViewBuilder + private var liveStreamView: some View { + switch labelLayout { + case .stacked: + LiveStreamProgressIndicator(tint: sliderTint) + .frame(maxWidth: .infinity) + .frame(height: sliderFrameHeight) + + case .inline: + HStack(spacing: 10) { + Spacer() + .frame(width: 42) + LiveStreamProgressIndicator(tint: sliderTint) + .frame(maxWidth: .infinity) + .frame(height: sliderFrameHeight) + + Spacer() + .frame(width: 48) + } + } + } + + private var sliderCore: some View { + CustomSlider( + value: $sliderValue, + range: 0 ... duration, + color: sliderTint, + dragging: $dragging, + lastDragged: $lastDragged, + onValueChange: onValueChange, + restingTrackHeight: restingTrackHeight, + draggingTrackHeight: draggingTrackHeight + ) + // Smoothly interpolate the filled track between 1-second ticks using + // Core Animation — runs on the GPU with zero CPU polling cost. + // Disabled while dragging or paused so the bar responds instantly. + .animation( + !dragging && isPlaying && !isLiveStream + ? .linear(duration: 1.0) + : nil, + value: sliderValue + ) + } + + private var sliderTint: Color {// + switch Defaults[.sliderColor] { + case .albumArt: + return Color(nsColor: color).ensureMinimumBrightness(factor: 0.6) + case .accent: + return .accentColor + case .white: + return .white + } + } + + private var timeLabelColor: Color { + Defaults[.playerColorTinting] + ? Color(nsColor: color).ensureMinimumBrightness(factor: 0.6) + : .gray + } + + private var trailingTimeText: String { + switch trailingLabel { + case .duration: + return timeString(from: duration) + case .remaining: + let remaining = max(duration - sliderValue, 0) + return "-" + timeString(from: remaining) + } + } + + private var inlineLabelFont: Font { + .system(size: 11, weight: .medium, design: .monospaced) + } + + private var sliderFrameHeight: CGFloat { + max(restingTrackHeight, draggingTrackHeight) + } + func timeString(from seconds: Double) -> String { let totalMinutes = Int(seconds) / 60 let remainingSeconds = Int(seconds) % 60 @@ -284,8 +945,10 @@ struct MusicSliderView: View { return String(format: "%d:%02d", minutes, remainingSeconds) } } + } + struct CustomSlider: View { @Binding var value: Double var range: ClosedRange @@ -294,28 +957,31 @@ struct CustomSlider: View { @Binding var lastDragged: Date var onValueChange: ((Double) -> Void)? var thumbSize: CGFloat = 12 + var restingTrackHeight: CGFloat = 5 + var draggingTrackHeight: CGFloat = 9 var body: some View { GeometryReader { geometry in let width = geometry.size.width - let height = CGFloat(dragging ? 9 : 5) + let trackHeight = CGFloat(dragging ? draggingTrackHeight : restingTrackHeight) let rangeSpan = range.upperBound - range.lowerBound - let filledTrackWidth = min(rangeSpan == .zero ? 0 : ((value - range.lowerBound) / rangeSpan) * width, width) + let progress = rangeSpan == .zero ? 0 : (value - range.lowerBound) / rangeSpan + let filledTrackWidth = min(max(progress, 0), 1) * width ZStack(alignment: .leading) { // Background track Rectangle() .fill(.gray.opacity(0.3)) - .frame(height: height) + .frame(height: trackHeight) // Filled track Rectangle() .fill(color) - .frame(width: filledTrackWidth, height: height) + .frame(width: filledTrackWidth, height: trackHeight) } - .cornerRadius(height / 2) - .frame(height: 10) + .cornerRadius(trackHeight / 2) + .frame(height: max(restingTrackHeight, draggingTrackHeight)) .contentShape(Rectangle()) .highPriorityGesture( DragGesture(minimumDistance: 0) @@ -337,6 +1003,393 @@ 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 + } +} + +private struct AirPlayPickerButton: View { + @ObservedObject private var musicManager = MusicManager.shared + @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared + @State private var isPopoverPresented = false + @State private var isHoveringPopover = false + @EnvironmentObject private var vm: DynamicIslandViewModel + + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + + var body: some View { + HoverButton(icon: "airplayaudio", iconColor: .white, scale: .medium) { + isPopoverPresented.toggle() + if isPopoverPresented { + Task { await airPlayManager.refreshDevices() } + } + } + .accessibilityLabel("AirPlay") + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + AirPlaySelectorPopover( + airPlayManager: airPlayManager, + onHoverChanged: { hovering in + isHoveringPopover = hovering + updatePopoverActivity() + } + ) { + isPopoverPresented = false + isHoveringPopover = false + updatePopoverActivity() + } + } + .onAppear { + if isAppleMusicActive { + Task { await airPlayManager.refreshDevices() } + } + } + .onChange(of: isPopoverPresented) { _, presented in + if !presented { isHoveringPopover = false } + updatePopoverActivity() + } + .onChange(of: musicManager.bundleIdentifier) { _, newBundle in + if newBundle == "com.apple.Music" { + Task { await airPlayManager.refreshDevices() } + } + } + .onDisappear { + vm.isMediaOutputPopoverActive = false + } + } + + 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: 240) + } + } + } + + 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)))%" + } +} + +struct AirPlaySelectorPopover: View { + @ObservedObject var airPlayManager: AppleMusicAirPlayManager + var onHoverChanged: (Bool) -> Void + var dismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("AirPlay") + .font(.caption) + .foregroundColor(.secondary) + + if airPlayManager.devices.isEmpty { + Text("No AirPlay devices found") + .font(.callout) + .foregroundColor(.secondary) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ScrollView { + VStack(spacing: 6) { + ForEach(airPlayManager.devices) { device in + VStack(spacing: 4) { + Button { + Task { await airPlayManager.toggleDevice(device) } + } label: { + HStack(spacing: 8) { + Image(systemName: device.iconName) + .font(.system(size: 14, weight: .medium)) + Text(device.name) + .foregroundColor(.primary) + .lineLimit(1) + Spacer() + if device.isSelected { + 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.isSelected ? Color.primary.opacity(0.12) : .clear) + ) + } + .buttonStyle(.plain) + + if device.isSelected { + AirPlayVolumeSlider( + airPlayManager: airPlayManager, + deviceID: device.id + ) + .padding(.horizontal, 8) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 300) + } + } + .frame(width: 240) + .padding(16) + .onHover { hovering in + onHoverChanged(hovering) + } + .onDisappear { + onHoverChanged(false) + } + } +} + +/// Local @State slider decoupled from the manager's @Published state. +/// This prevents SwiftUI from resetting the slider position when other +/// published properties on the manager change during a drag. +struct AirPlayVolumeSlider: View { + @ObservedObject var airPlayManager: AppleMusicAirPlayManager + let deviceID: String + + @State private var sliderValue: Double = 0 + @State private var isSyncing = false + + var body: some View { + Slider(value: $sliderValue, in: 0...100) + .tint(.accentColor) + .onAppear { + isSyncing = true + sliderValue = Double(airPlayManager.currentVolume(for: deviceID)) + isSyncing = false + } + .onChange(of: sliderValue) { _, newValue in + guard !isSyncing else { return } + airPlayManager.setVolume(Int(newValue), for: deviceID) + } + } +} + +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/NotchNotesView.swift b/DynamicIsland/components/Notch/NotchNotesView.swift new file mode 100644 index 00000000..48a54af7 --- /dev/null +++ b/DynamicIsland/components/Notch/NotchNotesView.swift @@ -0,0 +1,1155 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct NotchNotesView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @FocusState private var isFocused: Bool + @Default(.savedNotes) var savedNotes + @Default(.clipboardDisplayMode) var clipboardDisplayMode + @Default(.enableClipboardManager) var enableClipboardManager + + @State private var selectedNoteId: UUID? + @State private var isEditingNewNote = false + + // Editor State + @State private var editorTitle: String = "" + @State private var editorContent: String = "" + @State private var editorImageData: Data? = nil + @State private var editorColorIndex: Int = 0 + @State private var editorNoteId: UUID? + @State private var autoSaveTask: Task? + + @Default(.enableNotes) var enableNotes + + var showSplitView: Bool { + return enableClipboardManager && clipboardDisplayMode == .separateTab + } + + var body: some View { + HStack(spacing: 0) { + if showSplitView { + NotchClipboardList() + .frame(maxWidth: .infinity) + + if enableNotes { + Divider() + .background(Color.white.opacity(0.15)) + } + } + + if enableNotes { + ZStack { + if isEditingNewNote || selectedNoteId != nil { + NoteEditorView( + title: $editorTitle, + content: $editorContent, + imageData: $editorImageData, + colorIndex: $editorColorIndex, + onSave: saveNote, + onCancel: cancelEdit, + isNew: isEditingNewNote + ) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .trailing))) + } else { + NoteListView( + notes: savedNotes, + onSelect: selectNote, + onCreate: createNote, + onDelete: deleteNote, + onDeleteItem: deleteNoteItem, + onClearAll: clearAllNotes, + onTogglePin: togglePin, + onCreateFromClipboard: createNoteFromClipboard + ) + .transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .leading))) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() // Prevent overflow during transition + .animation(.spring(response: 0.35, dampingFraction: 0.8), value: isEditingNewNote) + .animation(.spring(response: 0.35, dampingFraction: 0.8), value: selectedNoteId) + .overlay { + // Hidden button for Cmd+V paste listener + Button("") { + handlePaste() + } + .keyboardShortcut("v", modifiers: .command) + .opacity(0) + .allowsHitTesting(false) + } + } + } + .frame(maxHeight: .infinity) + .onAppear { + updateLayoutState() + } + .onDisappear { + if isEditingNewNote || selectedNoteId != nil { + persistNote() + } + coordinator.notesLayoutState = .list + } + .onChange(of: isEditingNewNote) { _, _ in + updateLayoutState() + } + .onChange(of: selectedNoteId) { _, _ in + updateLayoutState() + } + .onChange(of: enableClipboardManager) { _, _ in + updateLayoutState() + } + .onChange(of: clipboardDisplayMode) { _, _ in + updateLayoutState() + } + .onChange(of: enableNotes) { _, _ in + updateLayoutState() + } + .onChange(of: editorContent) { _, _ in + scheduleAutoSave() + } + .onChange(of: editorTitle) { _, _ in + scheduleAutoSave() + } + .onChange(of: editorColorIndex) { _, _ in + scheduleAutoSave() + } + } + + // MARK: - Actions + + private func handlePaste() { + let pasteboard = NSPasteboard.general + + // Check for file URLs first (higher quality source than icons) + if let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] { + let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif"] + if let firstImageURL = fileURLs.first(where: { imageExtensions.contains($0.pathExtension.lowercased()) }), + let imageData = try? Data(contentsOf: firstImageURL) { + updateImageData(imageData) + return + } + } + + // Then direct image data + if let tiffData = pasteboard.data(forType: .tiff) { + updateImageData(tiffData) + return + } else if let pngData = pasteboard.data(forType: .png) { + updateImageData(pngData) + return + } + + // Handle text paste + if let text = pasteboard.string(forType: .string) { + if isEditingNewNote || selectedNoteId != nil { + // In editor: insert text at the end of content since we intercepted the shortcut + editorContent.append(text) + } else { + // Not in editor: create a new note with the pasted text + createNoteWithContent(text) + } + } + } + + private func updateImageData(_ data: Data) { + withAnimation { + if isEditingNewNote || selectedNoteId != nil { + editorImageData = data + } else { + // If not editing, create a new note with this image + editorTitle = "" + editorContent = "" + editorImageData = data + editorColorIndex = 0 + editorNoteId = UUID() + isEditingNewNote = true + } + } + } + + private func createNoteWithContent(_ content: String) { + editorTitle = "" + editorContent = content + editorImageData = nil + editorColorIndex = 0 + editorNoteId = UUID() + isEditingNewNote = true + } + + private func createNote() { + editorTitle = "" + editorContent = "" + editorImageData = nil + editorColorIndex = 0 // Default Yellow + editorNoteId = UUID() + isEditingNewNote = true + } + + private func selectNote(_ note: NoteItem) { + editorTitle = note.title + editorContent = note.content + editorImageData = note.getImageData() // Load from disk + editorColorIndex = note.colorIndex + editorNoteId = note.id + selectedNoteId = note.id + isEditingNewNote = false + } + + private func persistNote() { + guard let id = editorNoteId else { return } + + let isExistingNote = savedNotes.contains(where: { $0.id == id }) + if !isExistingNote && editorTitle.isEmpty && editorContent.isEmpty && editorImageData == nil { + return + } + + var notes = savedNotes + let now = Date() + + var fileName: String? = nil + if let data = editorImageData { + let name = "note_image_\(id.uuidString).png" + let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(name) + try? data.write(to: fileURL) + fileName = name + } + + if let index = notes.firstIndex(where: { $0.id == id }) { + // Update + if let oldFileName = notes[index].imageFileName, oldFileName != fileName { + let oldFileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(oldFileName) + try? FileManager.default.removeItem(at: oldFileURL) + } + + notes[index].title = editorTitle + notes[index].content = editorContent + notes[index].imageFileName = fileName + notes[index].colorIndex = editorColorIndex + } else { + // Create + let newNote = NoteItem( + id: id, + title: editorTitle.isEmpty ? "Untitled Note" : editorTitle, + content: editorContent, + creationDate: now, + colorIndex: editorColorIndex, + isPinned: false, + imageFileName: fileName + ) + notes.insert(newNote, at: 0) + } + + savedNotes = notes + } + + private func scheduleAutoSave() { + guard isEditingNewNote || selectedNoteId != nil else { return } + + autoSaveTask?.cancel() + autoSaveTask = Task { + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled else { return } + await MainActor.run { + persistNote() + } + } + } + + private func saveNote() { + persistNote() + closeEditor() + } + + private func createNoteFromClipboard() { + let pasteboard = NSPasteboard.general + + // Priority 1: Check for real image files (highest quality) + if let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] { + let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif"] + if let firstImageURL = fileURLs.first(where: { imageExtensions.contains($0.pathExtension.lowercased()) }), + let imageData = try? Data(contentsOf: firstImageURL) { + createNoteWithContent("") + editorImageData = imageData + return + } + } + + // Priority 2: Direct image data + if let tiffData = pasteboard.data(forType: .tiff) { + createNoteWithContent("") + editorImageData = tiffData + return + } else if let pngData = pasteboard.data(forType: .png) { + createNoteWithContent("") + editorImageData = pngData + return + } + + if let text = pasteboard.string(forType: .string) { + createNoteWithContent(text) + } + } + + private func deleteNote(_ indexSet: IndexSet) { + var notes = savedNotes + for index in indexSet { + if index < notes.count { + if let fileName = notes[index].imageFileName { + let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + } + notes.remove(atOffsets: indexSet) + savedNotes = notes + } + + private func deleteNoteItem(_ note: NoteItem) { + var notes = savedNotes + if let index = notes.firstIndex(where: { $0.id == note.id }) { + // Clean up image file + if let fileName = notes[index].imageFileName { + let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + notes.remove(at: index) + savedNotes = notes + } + } + + private func clearAllNotes() { + // Clean up all image files + for note in savedNotes { + if let fileName = note.imageFileName { + let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + savedNotes.removeAll() + } + + private func cancelEdit() { + persistNote() + closeEditor() + } + + private func togglePin(_ note: NoteItem) { + var notes = savedNotes + if let index = notes.firstIndex(where: { $0.id == note.id }) { + notes[index].isPinned.toggle() + savedNotes = notes + } + } + + private func scaleDownEditor() { + closeEditor() + } + + private func closeEditor() { + autoSaveTask?.cancel() + autoSaveTask = nil + isEditingNewNote = false + selectedNoteId = nil + // Tiny delay to clear state after animation + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + editorTitle = "" + editorContent = "" + } + updateLayoutState() + } + + private func updateLayoutState() { + let newState: NotesLayoutState + if enableNotes && (isEditingNewNote || selectedNoteId != nil) { + newState = .editor + } else if showSplitView { + newState = .split + } else { + newState = .list + } + + if coordinator.notesLayoutState != newState { + coordinator.notesLayoutState = newState + } + } +} + +// MARK: - Subviews + +struct NotchClipboardList: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var clipboardManager = ClipboardManager.shared + @State private var hoveredItemId: UUID? + @State private var justCopiedId: UUID? + @State private var suppressionToken = UUID() + @State private var isSuppressing = false + @State private var showClearHistoryAlert = false + @State private var autoCloseToken = UUID() + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Clipboard") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + + Spacer() + + if !clipboardManager.clipboardHistory.isEmpty { + Button(action: { showClearHistoryAlert = true }) { + Image(systemName: "trash") + .font(.system(size: 14)) + .foregroundStyle(.red) + .padding(6) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 12) + + if clipboardManager.clipboardHistory.isEmpty { + VStack(spacing: 8) { + Image(systemName: "doc.on.clipboard") + .font(.system(size: 32)) + .foregroundStyle(.secondary.opacity(0.3)) + Text("No copies yet") + .font(.headline) + .foregroundStyle(.secondary) + Text("Copy text to see it here") + .font(.caption) + .foregroundStyle(.secondary.opacity(0.7)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 30) // Add padding so it's not touching header + } else { + ZStack { + ScrollView { + LazyVStack(spacing: 4) { // Tighter spacing + ForEach(clipboardManager.clipboardHistory) { item in + NotchClipboardItemRow( + item: item, + isHovered: hoveredItemId == item.id, + justCopied: justCopiedId == item.id + ) + .contentShape(Rectangle()) + .onHover { isHovered in + hoveredItemId = isHovered ? item.id : nil + } + .onTapGesture { + clipboardManager.copyToClipboard(item) + withAnimation { + justCopiedId = item.id + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if justCopiedId == item.id { + withAnimation { + justCopiedId = nil + } + } + } + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 20) + } + + LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .frame(maxHeight: .infinity, alignment: .top) + + LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .frame(maxHeight: .infinity, alignment: .bottom) + } + } + } + .frame(maxHeight: .infinity) + .onHover { hovering in + updateSuppression(for: hovering) + } + .onDisappear { + updateSuppression(for: false) + } + .alert("Clear Clipboard History?", isPresented: $showClearHistoryAlert) { + Button("Clear History", role: .destructive) { + clipboardManager.clearHistory() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes every saved clipboard item from the notch tab.") + } + .onChange(of: showClearHistoryAlert) { _, isShowing in + vm.setAutoCloseSuppression(isShowing, token: autoCloseToken) + } + .onAppear { + if !clipboardManager.isMonitoring { + clipboardManager.startMonitoring() + } + } + } + + private func updateSuppression(for hovering: Bool) { + guard hovering != isSuppressing else { return } + isSuppressing = hovering + vm.setScrollGestureSuppression(hovering, token: suppressionToken) + } +} + +struct NotchClipboardItemRow: View { + let item: ClipboardItem + let isHovered: Bool + let justCopied: Bool + + var body: some View { + HStack(spacing: 12) { + if item.type == .image, let data = item.getImageData(), let nsImage = NSImage(data: data) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.white.opacity(0.1), lineWidth: 0.5)) + } else { + Image(systemName: justCopied ? "checkmark.circle.fill" : item.type.icon) + .font(.system(size: 14)) + .foregroundColor(justCopied ? .green : .blue) + .frame(width: 20) + } + + VStack(alignment: .leading, spacing: 4) { + Text(item.preview) + .font(.system(size: 13)) + .foregroundStyle(.white) + .lineLimit(1) + + HStack { + Text(item.type.displayName) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + Spacer() + Text(timeAgoString(from: item.timestamp)) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + } + .padding(10) + .background(Color(nsColor: .controlBackgroundColor).opacity(isHovered ? 0.3 : 0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + if interval < 60 { return String(localized: "Just now") } + if interval < 3600 { return String(localized: "\(Int(interval/60))m") } + return String(localized: "\(Int(interval/3600))h") + } +} + + +struct NoteListView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + let notes: [NoteItem] + let onSelect: (NoteItem) -> Void + let onCreate: () -> Void + let onDelete: (IndexSet) -> Void + let onDeleteItem: (NoteItem) -> Void + let onClearAll: () -> Void + let onTogglePin: (NoteItem) -> Void + let onCreateFromClipboard: () -> Void + + @Default(.enableNoteSearch) var enableNoteSearch + @Default(.enableNoteColorFiltering) var enableNoteColorFiltering + @Default(.enableCreateFromClipboard) var enableCreateFromClipboard + + @State private var searchText = "" + @State private var selectedColorFilter: Int? = nil + @State private var isSearchExpanded = false + @State private var suppressionToken = UUID() + @State private var isSuppressing = false + @State private var showClearNotesAlert = false + @State private var autoCloseToken = UUID() + + var sortedNotes: [NoteItem] { + var filtered = searchText.isEmpty ? notes : notes.filter { + $0.title.localizedCaseInsensitiveContains(searchText) || + $0.content.localizedCaseInsensitiveContains(searchText) + } + + if let colorIndex = selectedColorFilter { + filtered = filtered.filter { $0.colorIndex == colorIndex } + } + + return filtered.sorted { + if $0.isPinned != $1.isPinned { + return $0.isPinned + } + return $0.creationDate > $1.creationDate + } + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Notes") + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + + Spacer() + + if enableNoteSearch || enableNoteColorFiltering { + Button(action: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + isSearchExpanded.toggle() + } + }) { + Image(systemName: "magnifyingglass") + .font(.system(size: 14, weight: isSearchExpanded ? .bold : .medium)) + .symbolVariant(isSearchExpanded ? .circle.fill : .none) + .frame(width: 16, height: 16) // Fixed frame to stabilize + .foregroundStyle(isSearchExpanded ? .blue : .white.opacity(0.6)) + .padding(5) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + } + + if enableCreateFromClipboard { + Button(action: onCreateFromClipboard) { + Image(systemName: "doc.on.clipboard") + .font(.system(size: 13)) + .frame(width: 16, height: 16) + .foregroundStyle(.white.opacity(0.6)) + .padding(5) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + .help("Create from Clipboard") + } + + if !notes.isEmpty { + Button(action: { showClearNotesAlert = true }) { + Image(systemName: "trash") + .font(.system(size: 14)) + .frame(width: 16, height: 16) + .foregroundStyle(.red) + .padding(5) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + } + + Button(action: onCreate) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .bold)) + .frame(width: 16, height: 16) + .foregroundStyle(.white) + .padding(5) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(.horizontal, 16) // Reduced from 20 + .padding(.top, 8) + .padding(.bottom, isSearchExpanded ? 4 : 2) + + if isSearchExpanded { + VStack(spacing: 6) { + if enableNoteSearch && !notes.isEmpty { + HStack { + Image(systemName: "magnifyingglass") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + TextField("Search notes...", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 12)) + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 4) // Reduced from 6 + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal, 16) + } + + if enableNoteColorFiltering && !notes.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { // Reduced from 8 + Button(action: { selectedColorFilter = nil }) { + Text("All") + .font(.system(size: 10, weight: .medium)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(selectedColorFilter == nil ? Color.blue.opacity(0.3) : Color.white.opacity(0.1)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + + ForEach(0.. 3 + let columns = useGrid ? [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)] : [GridItem(.flexible())] + + LazyVGrid(columns: columns, spacing: 8) { + ForEach(sortedNotes) { note in + NoteRow( + note: note, + onDelete: { onDeleteItem(note) }, + onTogglePin: { onTogglePin(note) }, + isCompact: useGrid + ) + .contentShape(Rectangle()) + .onTapGesture { + onSelect(note) + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 20) + } + + LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .frame(maxHeight: .infinity, alignment: .top) + + LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .frame(maxHeight: .infinity, alignment: .bottom) + } + } + } + .frame(maxHeight: .infinity) + .onHover { hovering in + updateSuppression(for: hovering) + } + .onDisappear { + updateSuppression(for: false) + } + .alert("Delete All Notes?", isPresented: $showClearNotesAlert) { + Button("Delete", role: .destructive) { + onClearAll() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This permanently removes every saved note and its attachments.") + } + .onChange(of: showClearNotesAlert) { _, isShowing in + vm.setAutoCloseSuppression(isShowing, token: autoCloseToken) + } + } + + private func updateSuppression(for hovering: Bool) { + guard hovering != isSuppressing else { return } + isSuppressing = hovering + vm.setScrollGestureSuppression(hovering, token: suppressionToken) + } +} + +struct NoteRow: View { + let note: NoteItem + let onDelete: () -> Void + let onTogglePin: () -> Void + let isCompact: Bool + @State private var isHovered = false + @State private var isCopied = false + @Default(.enableNotePinning) var enableNotePinning + + var body: some View { + ZStack(alignment: .topTrailing) { + HStack(spacing: isCompact ? 8 : 12) { + // Color Indicator + RoundedRectangle(cornerRadius: 3) + .fill(note.color) + .frame(width: isCompact ? 3 : 4) + .padding(.vertical, isCompact ? 4 : 2) + + VStack(alignment: .leading, spacing: isCompact ? 1 : 4) { + HStack(spacing: 4) { + if note.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: isCompact ? 7 : 8)) + .foregroundStyle(note.color) + } + Text(note.title) + .font(.system(size: isCompact ? 12 : 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + } + + Text(note.content.isEmpty ? "No content" : note.content) + .font(.system(size: isCompact ? 10 : 12)) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(isCompact ? 1 : 2) + .multilineTextAlignment(.leading) + } + .animation(.easeInOut(duration: 0.2), value: isHovered) + + Spacer(minLength: 0) + } + .padding(.trailing, isCompact ? 50 : 130) // Standardized padding for alignment + .padding(isCompact ? 8 : 12) + + } + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(isHovered ? 0.12 : 0.08)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(note.color.opacity(0.2), lineWidth: 1) + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) // Ensure everything stays inside the frame + .overlay(alignment: .trailing) { + HStack(spacing: 8) { + if isHovered { + HStack(spacing: isCompact ? 3 : 4) { + if enableNotePinning { + Button(action: onTogglePin) { + Image(systemName: note.isPinned ? "pin.slash.fill" : "pin.fill") + .font(.system(size: isCompact ? 9 : 11)) + .foregroundStyle(.white) + .padding(isCompact ? 5 : 6) + .background(Color.white.opacity(0.2)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + } + + Button(action: { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(note.content, forType: .string) + + // Also copy image data if present + if let imageData = note.getImageData(), let tiffData = NSImage(data: imageData)?.tiffRepresentation { + pasteboard.setData(tiffData, forType: .tiff) + } + + withAnimation { + isCopied = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { + isCopied = false + } + } + }) { + Image(systemName: isCopied ? "checkmark" : "doc.on.doc") + .font(.system(size: isCompact ? 9 : 11)) + .foregroundStyle(isCopied ? .green : .white) + .padding(isCompact ? 5 : 6) + .background(Color.white.opacity(0.2)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: onDelete) { + Image(systemName: "trash") + .font(.system(size: isCompact ? 9 : 11)) + .foregroundStyle(.red) + .padding(isCompact ? 5 : 6) + .background(Color.white.opacity(0.2)) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(.horizontal, isCompact ? 6 : 8) + .padding(.vertical, isCompact ? 2 : 3) + .background( + Capsule() + .fill(Color(white: 0.12)) // Softer dark gray, not solid black + .overlay( + Capsule() + .stroke(Color.white.opacity(0.1), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.3), radius: 3) + ) + .padding(.vertical, 4) // Ensure it stays within note borders + .transition(.asymmetric(insertion: .opacity.combined(with: .move(edge: .trailing)), removal: .opacity)) + } else if !isCompact { + Text(timeAgoString(from: note.creationDate)) + .font(.system(size: 10)) + .foregroundStyle(.secondary.opacity(0.6)) + } + + if let imageData = note.getImageData(), let nsImage = NSImage(data: imageData) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: isCompact ? 32 : 50, height: isCompact ? 32 : 50) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.white.opacity(0.2), lineWidth: 1)) + .shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 1) + .padding(.trailing, isCompact ? 4 : 8) + } + } + .padding(.trailing, isCompact ? 2 : 4) + } + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovered = hovering + } + } + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + if interval < 60 { return String(localized: "Just now") } + if interval < 3600 { return String(localized: "\(Int(interval/60))m") } + if interval < 86400 { return String(localized: "\(Int(interval/3600))h") } + return date.formatted(.dateTime.day().month()) + } +} + +struct NoteEditorView: View { + @Binding var title: String + @Binding var content: String + @Binding var imageData: Data? + @Binding var colorIndex: Int + let onSave: () -> Void + let cancelAction: () -> Void + let isNew: Bool + + init(title: Binding, content: Binding, imageData: Binding, colorIndex: Binding, onSave: @escaping () -> Void, onCancel: @escaping () -> Void, isNew: Bool) { + self._title = title + self._content = content + self._imageData = imageData + self._colorIndex = colorIndex + self.onSave = onSave + self.cancelAction = onCancel + self.isNew = isNew + } + + @FocusState private var isContentFocused: Bool + + var body: some View { + VStack(spacing: 0) { + // Toolbar + HStack { + Button(action: cancelAction) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text("Notes") + } + .font(.system(size: 15, weight: .medium, design: .rounded)) + .foregroundStyle(.white) + } + .buttonStyle(PlainButtonStyle()) + + Spacer() + + Button(action: onSave) { + Text("Done") + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(NoteItem.colors[colorIndex]) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(16) + + // Title & Color Picker Row + HStack(alignment: .center, spacing: 12) { + TextField("Title", text: $title) + .font(.system(size: 17, weight: .bold, design: .rounded)) + .textFieldStyle(.plain) + + Spacer() + + // Color Picker + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(0... + */ import SwiftUI diff --git a/DynamicIsland/components/Notch/NotchShelfView.swift b/DynamicIsland/components/Notch/NotchShelfView.swift index 5ec7528f..84b0bd7e 100644 --- a/DynamicIsland/components/Notch/NotchShelfView.swift +++ b/DynamicIsland/components/Notch/NotchShelfView.swift @@ -1,64 +1,29 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI struct NotchShelfView: View { @EnvironmentObject var vm: DynamicIslandViewModel - @StateObject var tvm = TrayDrop.shared var body: some View { - HStack { - AirDropView() - panel - .onDrop(of: [.data], isTargeted: $vm.dropZoneTargeting) { providers in - vm.dropEvent = true - DispatchQueue.global().async { - tvm.load(providers) - } - return true - } - } - } - - var panel: some View { - RoundedRectangle(cornerRadius: 16) - .strokeBorder(style: StrokeStyle(lineWidth: 4, dash: [10])) - .foregroundStyle(.white.opacity(0.1)) - .overlay { - content - .padding() - } - .animation(vm.animation, value: tvm.items) - .animation(vm.animation, value: tvm.isLoading) - } - - var content: some View { - Group { - if tvm.isEmpty { - VStack(spacing: 10) { - Image(systemName: "tray.and.arrow.down") - .symbolVariant(.fill) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.white, .gray) - .imageScale(.large) - - Text("Drop files here") - .foregroundStyle(.gray) - .font(.system(.title3, design: .rounded)) - .fontWeight(.medium) - } - } else { - ScrollView(.horizontal) { - HStack(spacing: spacing) { - ForEach(tvm.items) { item in - DropItemView(item: item) - } - } - .padding(spacing) - } - .padding(-spacing) - .scrollIndicators(.never) - } - } + ShelfView() + .environmentObject(vm) } } diff --git a/DynamicIsland/components/Notch/NotchStatsView.swift b/DynamicIsland/components/Notch/NotchStatsView.swift new file mode 100644 index 00000000..dc6a7b5a --- /dev/null +++ b/DynamicIsland/components/Notch/NotchStatsView.swift @@ -0,0 +1,702 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +// Graph data protocol for unified interface +protocol GraphData { + var title: String { get } + var color: Color { get } + var icon: String { get } + var type: GraphType { get } +} + +enum GraphType { + case single + case dual +} + +// Single value graph data +struct SingleGraphData: GraphData { + let title: String + let value: String + let data: [Double] + let color: Color + let icon: String + let type: GraphType = .single +} + +// Dual value graph data (for network/disk) +struct DualGraphData: GraphData { + let title: String + let positiveValue: String + let negativeValue: String + let positiveData: [Double] + let negativeData: [Double] + let positiveColor: Color + let negativeColor: Color + let color: Color // Primary color for the component + let icon: String + let type: GraphType = .dual +} + +struct NotchStatsView: View { + @ObservedObject var statsManager = StatsManager.shared + @Default(.enableStatsFeature) var enableStatsFeature + @Default(.showCpuGraph) var showCpuGraph + @Default(.showMemoryGraph) var showMemoryGraph + @Default(.showGpuGraph) var showGpuGraph + @Default(.showNetworkGraph) var showNetworkGraph + @Default(.showDiskGraph) var showDiskGraph + @State private var showingCPUPopover = false + @State private var showingMemoryPopover = false + @State private var showingGPUPopover = false + @State private var showingNetworkPopover = false + @State private var showingDiskPopover = false + @State private var isHoveringCPUPopover = false + @State private var isHoveringMemoryPopover = false + @State private var isHoveringGPUPopover = false + @State private var isHoveringNetworkPopover = false + @State private var isHoveringDiskPopover = false + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + + var availableGraphs: [GraphData] { + var graphs: [GraphData] = [] + + if showCpuGraph { + graphs.append(SingleGraphData( + title: String(localized: "CPU"), + value: statsManager.cpuUsageString, + data: statsManager.cpuHistory, + color: .blue, + icon: "cpu" + )) + } + + if showMemoryGraph { + graphs.append(SingleGraphData( + title: String(localized: "Memory"), + value: statsManager.memoryUsageString, + data: statsManager.memoryHistory, + color: .green, + icon: "memorychip" + )) + } + + if showGpuGraph { + graphs.append(SingleGraphData( + title: String(localized: "GPU"), + value: statsManager.gpuUsageString, + data: statsManager.gpuHistory, + color: .purple, + icon: "display" + )) + } + + if showNetworkGraph { + graphs.append(DualGraphData( + title: String(localized: "Network"), + positiveValue: "↓" + statsManager.networkDownloadString, + negativeValue: "↑" + statsManager.networkUploadString, + positiveData: statsManager.networkDownloadHistory, + negativeData: statsManager.networkUploadHistory, + positiveColor: .orange, + negativeColor: .red, + color: .orange, + icon: "network" + )) + } + + if showDiskGraph { + graphs.append(DualGraphData( + title: String(localized: "Disk"), + positiveValue: String(localized: "R ") + statsManager.diskReadString, + negativeValue: String(localized: "W ") + statsManager.diskWriteString, + positiveData: statsManager.diskReadHistory, + negativeData: statsManager.diskWriteHistory, + positiveColor: .cyan, + negativeColor: .yellow, + color: .cyan, + icon: "internaldrive" + )) + } + + return graphs + } + + // Smart grid layout system for different graph counts + @ViewBuilder + var statsGridLayout: some View { + let graphCount = availableGraphs.count + + if graphCount <= 3 { + // 1-3 graphs: Single row with equal spacing + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: graphCount), + spacing: 12 + ) { + ForEach(0.. some View { + let graphData = availableGraphs[index] + + if let rankingType = rankingTypeForGraph(graphData) { + Button(action: { + handleGraphClick(for: graphData) + }) { + UnifiedStatsCard(graphData: graphData) + } + .buttonStyle(PlainButtonStyle()) + .popover(isPresented: bindingForGraph(graphData)) { + RankedProcessPopover( + rankingType: rankingType, + onHoverChange: { hovering in + switch graphData.title { + case "CPU": + isHoveringCPUPopover = hovering + case "Memory": + isHoveringMemoryPopover = hovering + case "GPU": + isHoveringGPUPopover = hovering + case "Network": + isHoveringNetworkPopover = hovering + case "Disk": + isHoveringDiskPopover = hovering + default: + break + } + } + ) + .onDisappear { + switch graphData.title { + case "CPU": + isHoveringCPUPopover = false + case "Memory": + isHoveringMemoryPopover = false + case "GPU": + isHoveringGPUPopover = false + case "Network": + isHoveringNetworkPopover = false + case "Disk": + isHoveringDiskPopover = false + default: + break + } + DispatchQueue.main.async { + updateStatsPopoverState() + } + } + } + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)), + removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)) + )) + } else { + UnifiedStatsCard(graphData: graphData) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)), + removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)) + )) + } + } + + private func handleGraphClick(for graphData: GraphData) { + switch graphData.title { + case "CPU": + showingCPUPopover = true + case "Memory": + showingMemoryPopover = true + case "GPU": + showingGPUPopover = true + case "Network": + showingNetworkPopover = true + case "Disk": + showingDiskPopover = true + default: + break + } + } + + private func bindingForGraph(_ graphData: GraphData) -> Binding { + switch graphData.title { + case "CPU": + return $showingCPUPopover + case "Memory": + return $showingMemoryPopover + case "GPU": + return $showingGPUPopover + case "Network": + return $showingNetworkPopover + case "Disk": + return $showingDiskPopover + default: + return .constant(false) + } + } + + private func rankingTypeForGraph(_ graphData: GraphData) -> ProcessRankingType? { + switch graphData.title { + case "CPU": + return .cpu + case "Memory": + return .memory + case "GPU": + return .gpu + case "Network": + return .network + case "Disk": + return .disk + default: + return nil + } + } + + // Helper function to create graph views using unified component + @ViewBuilder + func graphView(for graphData: GraphData) -> some View { + UnifiedStatsCard(graphData: graphData) + } + + var body: some View { + VStack(spacing: 0) { + if !enableStatsFeature { + // Disabled state + VStack(spacing: 12) { + Image(systemName: "chart.line.uptrend.xyaxis") + .font(.system(size: 40)) + .foregroundColor(.secondary) + + Text("Stats Disabled") + .font(.headline) + .foregroundColor(.primary) + + Text("Enable stats monitoring in Settings to view system performance data.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else if availableGraphs.isEmpty { + // No graphs enabled state + VStack(spacing: 12) { + Image(systemName: "chart.xyaxis.line") + .font(.system(size: 40)) + .foregroundColor(.secondary) + + Text("No Graphs Enabled") + .font(.headline) + .foregroundColor(.primary) + + Text("Enable graph visibility in Settings → Stats to view performance data.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else { + VStack(spacing: 8) { + statsGridLayout + } + .padding(12) + .animation(.easeInOut(duration: 0.4), value: availableGraphs.count) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)), + removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)) + )) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + // Note: Smart monitoring will handle starting/stopping based on notch state and current view + } + .onDisappear { + // Keep monitoring running when tab is not visible + updateStatsPopoverState() + } + .animation(.easeInOut(duration: 0.4), value: enableStatsFeature) + .animation(.easeInOut(duration: 0.4), value: availableGraphs.count) + .onChange(of: showingCPUPopover) { _, newValue in + updateStatsPopoverState() + } + .onChange(of: showingMemoryPopover) { _, newValue in + updateStatsPopoverState() + } + .onChange(of: showingGPUPopover) { _, newValue in + updateStatsPopoverState() + } + .onChange(of: showingNetworkPopover) { _, newValue in + updateStatsPopoverState() + } + .onChange(of: showingDiskPopover) { _, newValue in + updateStatsPopoverState() + } + .onChange(of: isHoveringCPUPopover) { _, _ in + updateStatsPopoverState() + } + .onChange(of: isHoveringMemoryPopover) { _, _ in + updateStatsPopoverState() + } + .onChange(of: isHoveringGPUPopover) { _, _ in + updateStatsPopoverState() + } + .onChange(of: isHoveringNetworkPopover) { _, _ in + updateStatsPopoverState() + } + .onChange(of: isHoveringDiskPopover) { _, _ in + updateStatsPopoverState() + } + } + private func updateStatsPopoverState() { + let anyPopoverOpen = showingCPUPopover || showingMemoryPopover || showingGPUPopover || showingNetworkPopover || showingDiskPopover + let newState = anyPopoverOpen + if vm.isStatsPopoverActive != newState { + vm.isStatsPopoverActive = newState + #if DEBUG + print("📊 Stats popover state updated: \(newState)") + print(" CPU open=\(showingCPUPopover)") + print(" Memory open=\(showingMemoryPopover)") + print(" GPU open=\(showingGPUPopover)") + print(" Network open=\(showingNetworkPopover)") + print(" Disk open=\(showingDiskPopover)") + #endif + } + } +} + +// Unified Stats Card Component - handles both single and dual data types, matches boring.notch sizing +struct UnifiedStatsCard: View { + let graphData: GraphData + @State private var isHovered = false + + var body: some View { + VStack(spacing: 3) { // Match boring.notch spacing + // Header - consistent across all card types + HStack(spacing: 4) { + Image(systemName: graphData.icon) + .foregroundStyle(graphData.color) + .font(.caption) // Match boring.notch font size + + Text(graphData.title) + .font(.caption) // Match boring.notch font size + .fontWeight(.medium) + .foregroundStyle(Color.white.opacity(0.8)) + + Spacer() + + // Show value on right for single graphs like boring.notch + if let singleData = graphData as? SingleGraphData { + Text(singleData.value) + .font(.caption) // Match boring.notch font size + .fontWeight(.bold) + .foregroundStyle(.white) + } + } + + // Values section for dual graphs only + if let dualData = graphData as? DualGraphData { + HStack(spacing: 6) { + Text(dualData.positiveValue) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(dualData.positiveColor) + + Text("•") + .font(.caption2) + .foregroundStyle(Color.white.opacity(0.45)) + + Text(dualData.negativeValue) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(dualData.negativeColor) + } + .frame(height: 18) // Smaller for dual graphs + } + + // Graph section - adapts based on graph type + Group { + if let singleData = graphData as? SingleGraphData { + MiniGraph(data: singleData.data, color: singleData.color) + } else if let dualData = graphData as? DualGraphData { + DualQuadrantGraph( + positiveData: dualData.positiveData, + negativeData: dualData.negativeData, + positiveColor: dualData.positiveColor, + negativeColor: dualData.negativeColor + ) + } + } + .frame(height: 36) // Match boring.notch exactly - reduced from 50 + + // Click hint - shown for graphs that open popovers + if ["CPU", "Memory", "GPU", "Network", "Disk"].contains(graphData.title) { + Text("Click for details") + .font(.caption2) + .foregroundStyle(Color.white.opacity(0.75)) + .opacity(isHovered ? 1.0 : 0.0) + .animation(.easeInOut(duration: 0.2), value: isHovered) + } + } + .padding(8) // Match boring.notch padding - reduced from 10 + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.white.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.white.opacity(isHovered ? 0.45 : 0.18), lineWidth: 1) + ) + ) + .scaleEffect(isHovered ? 1.02 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isHovered) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onHover { hovering in + isHovered = hovering + } + } +} + +struct MiniGraph: View { + let data: [Double] + let color: Color + + var body: some View { + GeometryReader { geometry in + let maxValue = data.max() ?? 1.0 + let normalizedData = maxValue > 0 ? data.map { $0 / maxValue } : data + + Path { path in + guard !normalizedData.isEmpty else { return } + + let stepX = geometry.size.width / CGFloat(normalizedData.count - 1) + + for (index, value) in normalizedData.enumerated() { + let x = CGFloat(index) * stepX + let y = geometry.size.height * (1 - CGFloat(value)) + + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(color, lineWidth: 2) + + // Gradient fill + Path { path in + guard !normalizedData.isEmpty else { return } + + let stepX = geometry.size.width / CGFloat(normalizedData.count - 1) + + path.move(to: CGPoint(x: 0, y: geometry.size.height)) + + for (index, value) in normalizedData.enumerated() { + let x = CGFloat(index) * stepX + let y = geometry.size.height * (1 - CGFloat(value)) + path.addLine(to: CGPoint(x: x, y: y)) + } + + path.addLine(to: CGPoint(x: geometry.size.width, y: geometry.size.height)) + path.closeSubpath() + } + .fill( + LinearGradient( + gradient: Gradient(colors: [color.opacity(0.3), color.opacity(0.1)]), + startPoint: .top, + endPoint: .bottom + ) + ) + } + } +} + + + +struct DualQuadrantGraph: View { + let positiveData: [Double] + let negativeData: [Double] + let positiveColor: Color + let negativeColor: Color + + var body: some View { + GeometryReader { geometry in + let maxPositive = positiveData.max() ?? 1.0 + let maxNegative = negativeData.max() ?? 1.0 + let maxValue = max(maxPositive, maxNegative) + + let normalizedPositive = maxValue > 0 ? positiveData.map { $0 / maxValue } : positiveData + let normalizedNegative = maxValue > 0 ? negativeData.map { $0 / maxValue } : negativeData + + let centerY = geometry.size.height / 2 + + ZStack { + // Center dividing line + Path { path in + path.move(to: CGPoint(x: 0, y: centerY)) + path.addLine(to: CGPoint(x: geometry.size.width, y: centerY)) + } + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + + // Positive quadrant (upper half) + Path { path in + guard !normalizedPositive.isEmpty else { return } + + let stepX = geometry.size.width / CGFloat(normalizedPositive.count - 1) + + for (index, value) in normalizedPositive.enumerated() { + let x = CGFloat(index) * stepX + let y = centerY - (centerY * CGFloat(value)) // Above center + + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(positiveColor, lineWidth: 2) + + // Positive fill + Path { path in + guard !normalizedPositive.isEmpty else { return } + + let stepX = geometry.size.width / CGFloat(normalizedPositive.count - 1) + + path.move(to: CGPoint(x: 0, y: centerY)) + + for (index, value) in normalizedPositive.enumerated() { + let x = CGFloat(index) * stepX + let y = centerY - (centerY * CGFloat(value)) + path.addLine(to: CGPoint(x: x, y: y)) + } + + path.addLine(to: CGPoint(x: geometry.size.width, y: centerY)) + path.closeSubpath() + } + .fill( + LinearGradient( + gradient: Gradient(colors: [positiveColor.opacity(0.3), positiveColor.opacity(0.1)]), + startPoint: .top, + endPoint: .center + ) + ) + + // Negative quadrant (lower half) + Path { path in + guard !normalizedNegative.isEmpty else { return } + + let stepX = geometry.size.width / CGFloat(normalizedNegative.count - 1) + + for (index, value) in normalizedNegative.enumerated() { + let x = CGFloat(index) * stepX + let y = centerY + (centerY * CGFloat(value)) // Below center + + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(negativeColor, lineWidth: 2) + + // Negative fill + Path { path in + guard !normalizedNegative.isEmpty else { return } + + let stepX = geometry.size.width / CGFloat(normalizedNegative.count - 1) + + path.move(to: CGPoint(x: 0, y: centerY)) + + for (index, value) in normalizedNegative.enumerated() { + let x = CGFloat(index) * stepX + let y = centerY + (centerY * CGFloat(value)) + path.addLine(to: CGPoint(x: x, y: y)) + } + + path.addLine(to: CGPoint(x: geometry.size.width, y: centerY)) + path.closeSubpath() + } + .fill( + LinearGradient( + gradient: Gradient(colors: [negativeColor.opacity(0.3), negativeColor.opacity(0.1)]), + startPoint: .bottom, + endPoint: .center + ) + ) + } + } + } +} + +#Preview { + NotchStatsView() + .frame(width: 400, height: 300) + .background(Color.black) +} diff --git a/DynamicIsland/components/Notch/NotchTerminalView.swift b/DynamicIsland/components/Notch/NotchTerminalView.swift new file mode 100644 index 00000000..2db4e1f2 --- /dev/null +++ b/DynamicIsland/components/Notch/NotchTerminalView.swift @@ -0,0 +1,203 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import SwiftTerm +import Defaults + +// MARK: - NSViewRepresentable wrapper for SwiftTerm + +/// Bridges SwiftTerm's `LocalProcessTerminalView` (NSView) into SwiftUI. +/// +/// Returns `TerminalManager.containerView` (a stable NSView) so that +/// SwiftUI's view lifecycle never tears down the actual terminal. The +/// real `LocalProcessTerminalView` lives as a subview of that container +/// and survives notch close/open and tab-switch cycles. +struct TerminalNSViewRepresentable: NSViewRepresentable { + @ObservedObject var terminalManager = TerminalManager.shared + + func makeNSView(context: Context) -> NSView { + context.coordinator.terminalManager = terminalManager + terminalManager.ensureTerminalView(delegate: context.coordinator) + if !terminalManager.isProcessRunning { + terminalManager.startShellProcess() + } + return terminalManager.containerView + } + + func updateNSView(_ nsView: NSView, context: Context) { + // Re-mount terminal if it was restarted (generation bumped). + terminalManager.ensureTerminalView(delegate: context.coordinator) + if !terminalManager.isProcessRunning { + terminalManager.startShellProcess() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: NSObject, LocalProcessTerminalViewDelegate { + var terminalManager: TerminalManager? + + func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) { + // Terminal resized — no action needed; SwiftTerm handles reflow. + } + + func setTerminalTitle(source: LocalProcessTerminalView, title: String) { + Task { @MainActor in + terminalManager?.updateTitle(title) + } + } + + func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) { + // Could be used for breadcrumbs in the future. + } + + func processTerminated(source: TerminalView, exitCode: Int32?) { + Task { @MainActor in + terminalManager?.processDidTerminate(exitCode: exitCode) + } + } + } +} + +// MARK: - Notch Tab View + +/// Guake-style dropdown terminal tab for the notch. +/// Dynamically sizes up to half the screen height; content scrolls when the +/// terminal buffer exceeds the visible area (handled internally by SwiftTerm). +struct NotchTerminalView: View { + @ObservedObject var terminalManager = TerminalManager.shared + @EnvironmentObject var vm: DynamicIslandViewModel + @Default(.enableTerminalFeature) var enableTerminalFeature + @Default(.cornerRadiusScaling) var cornerRadiusScaling + @Default(.enableMinimalisticUI) var enableMinimalisticUI + @State private var suppressionToken = UUID() + @State private var isSuppressing = false + + /// Top corners stay mild; bottom follows shell − padding (see `notchTerminalBottomCornerRadii`). + private static let terminalClipTopCornerRadius: CGFloat = 6 + + private var currentScreenName: String { + vm.screen ?? DynamicIslandViewCoordinator.shared.selectedScreen + } + + private var isDynamicIslandMode: Bool { + shouldUseDynamicIslandMode(for: currentScreenName) + } + + private var terminalClipShape: UnevenRoundedRectangle { + let innerBottom = notchTerminalBottomCornerRadii( + isDynamicIslandMode: isDynamicIslandMode, + notchState: vm.notchState, + cornerRadiusScaling: cornerRadiusScaling, + enableMinimalisticUI: enableMinimalisticUI, + closedNotchHeight: vm.closedNotchSize.height + ).innerBottom + return UnevenRoundedRectangle( + topLeadingRadius: Self.terminalClipTopCornerRadius, + bottomLeadingRadius: innerBottom, + bottomTrailingRadius: innerBottom, + topTrailingRadius: Self.terminalClipTopCornerRadius, + style: .continuous + ) + } + + var body: some View { + VStack(spacing: 0) { + if enableTerminalFeature { + // Terminal header bar + HStack { + Image(systemName: "apple.terminal") + .foregroundStyle(.secondary) + .font(.system(size: 11)) + + Text(terminalManager.terminalTitle) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() + + // Restart button + Button { + terminalManager.restartShell() + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Restart shell") + } + .padding(.horizontal, 12) + .padding(.top, 6) + .padding(.bottom, 4) + + Divider() + .padding(.horizontal, notchTerminalContentEdgePadding.horizontal) + + // Terminal content — the containerView is stable across + // notch close/open cycles; updateNSView handles restart. + TerminalNSViewRepresentable(terminalManager: terminalManager) + .clipShape(terminalClipShape) + .padding( + EdgeInsets( + top: notchTerminalContentEdgePadding.top, + leading: notchTerminalContentEdgePadding.horizontal, + bottom: notchTerminalContentEdgePadding.bottom, + trailing: notchTerminalContentEdgePadding.horizontal + ) + ) + .onHover { hovering in + updateSuppression(for: hovering) + } + } else { + // Feature disabled placeholder + VStack(spacing: 8) { + Image(systemName: "apple.terminal") + .font(.system(size: 28)) + .foregroundStyle(.secondary) + + Text("Terminal is disabled") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + + Text("Enable it in Settings → Terminal") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .onDisappear { + updateSuppression(for: false) + } + .onAppear { + terminalManager.refreshTerminalAppearanceIfNeeded() + terminalManager.focusTerminalIfPossible() + } + } + + private func updateSuppression(for hovering: Bool) { + guard hovering != isSuppressing else { return } + isSuppressing = hovering + vm.setScrollGestureSuppression(hovering, token: suppressionToken) + } +} diff --git a/DynamicIsland/components/Notch/NotchTimerView.swift b/DynamicIsland/components/Notch/NotchTimerView.swift index 0a65b12d..b0e3c1bf 100644 --- a/DynamicIsland/components/Notch/NotchTimerView.swift +++ b/DynamicIsland/components/Notch/NotchTimerView.swift @@ -1,250 +1,732 @@ -// -// NotchTimerView.swift -// DynamicIsland -// -// Timer tab interface for the Dynamic Island -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import Defaults +#if canImport(AppKit) +import AppKit +#endif struct NotchTimerView: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var timerManager = TimerManager.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared - + @Default(.enableTimerFeature) var enableTimerFeature + @Default(.enableMinimalisticUI) private var enableMinimalisticUI + @Default(.timerPresets) private var timerPresets + @Default(.timerIconColorMode) private var colorMode + @Default(.timerSolidColor) private var solidColor + @Default(.timerShowsProgress) private var showsProgress + @Default(.timerProgressStyle) private var progressStyle + @Default(.showTimerPresetsInNotchTab) private var showTimerPresetsInNotchTab + + @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 isSyncingCustomDuration = false + @State private var lockedAccentColor: Color? + var body: some View { - HStack(alignment: .center, spacing: 32) { - // Timer Progress and Controls - timerProgressSection - - // Timer Control Panel - controlsSection - } - .padding(.horizontal, 20) - .transition(.opacity.combined(with: .blurReplace)) - } - - private var timerProgressSection: some View { - VStack(spacing: 16) { - // Circular progress display - ZStack { - // Background ring - Circle() - .stroke(.white.opacity(0.1), lineWidth: 8) - .frame(width: 100, height: 100) - - // Progress ring - Circle() - .trim(from: 0, to: timerManager.progress) - .stroke( - timerManager.currentColor, - style: StrokeStyle(lineWidth: 8, lineCap: .round) - ) - .frame(width: 100, height: 100) - .rotationEffect(.degrees(-90)) - .animation(.easeInOut(duration: 0.5), value: timerManager.progress) - - // Timer icon in center - Image(systemName: "timer") - .font(.system(size: 32, weight: .medium)) - .foregroundStyle(timerManager.currentColor) - .scaleEffect(timerManager.isRunning ? 1.1 : 1.0) - .animation(.easeInOut(duration: 0.3).repeatForever(autoreverses: true), value: timerManager.isRunning) - } - - // Time display - VStack(spacing: 4) { - Text(timerManager.formattedTimeRemaining) - .font(.system(size: 24, weight: .semibold, design: .monospaced)) - .foregroundStyle(timerManager.isOvertime ? .red : .white) - - if timerManager.isTimerActive && timerManager.isPaused { - Text("Paused") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.secondary) + Group { + if enableTimerFeature { + HStack(alignment: .top, spacing: timerManager.isTimerActive ? 0 : 20) { + leftColumn + if shouldShowPresetColumn { + Divider() + .frame(height: max(0, maxTabContentHeight - 8)) + .opacity(0.2) + presetColumn + } } + .frame(maxHeight: maxTabContentHeight, alignment: .top) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .transition(.opacity.combined(with: .blurReplace)) + .onAppear { syncCustomDuration(with: customTimerDuration) } + .onChange(of: customTimerDuration) { _, newValue in syncCustomDuration(with: newValue) } + .onChange(of: customHours) { _, _ in updateStoredCustomDuration() } + .onChange(of: customMinutes) { _, _ in updateStoredCustomDuration() } + .onChange(of: customSeconds) { _, _ in updateStoredCustomDuration() } + } else { + disabledState + } + } + .onAppear { + lockAccentColorIfNeeded() + } + .onChange(of: timerManager.isTimerActive) { _, isActive in + if isActive { + lockAccentColorIfNeeded() + } else { + lockedAccentColor = nil + } + } + .onChange(of: timerManager.activePresetId) { _, _ in + if timerManager.isTimerActive && lockedAccentColor == nil { + lockAccentColorIfNeeded() } } } - - private var controlsSection: some View { - VStack(spacing: 20) { + + private var leftColumn: some View { + VStack(alignment: .leading, spacing: 16) { if timerManager.isTimerActive { - // Active timer controls - activeTimerControls + Spacer(minLength: 0) + activeTimerCard + Spacer(minLength: 0) } else { - // Quick timer setup - quickTimerControls + customTimerComposer + Spacer(minLength: 0) } } + .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxHeight: maxTabContentHeight, alignment: .top) + .padding(.bottom, 2) } - - private var activeTimerControls: some View { - VStack(spacing: 16) { - if timerManager.isOvertime { - // Overtime controls - only show stop button - Button(action: { - timerManager.forceStopTimer() - }) { - HStack(spacing: 8) { - Image(systemName: "stop.fill") - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(.red) - Text("Stop") - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(.red) - } - .frame(height: 40) - .frame(minWidth: 100) - .background(.white.opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - .buttonStyle(PlainButtonStyle()) - .onHover { isHovering in - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } + + private var presetColumn: some View { + VStack(spacing: 6) { + if timerPresets.isEmpty { + Text("Configure presets in Settings to see them here.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } else { - // Normal timer controls - HStack(spacing: 16) { - // Pause/Resume button - Button(action: { - if timerManager.isPaused { - timerManager.resumeTimer() - } else { - timerManager.pauseTimer() - } - }) { - HStack(spacing: 8) { - Image(systemName: timerManager.isPaused ? "play.fill" : "pause.fill") - .font(.system(size: 16, weight: .medium)) - Text(timerManager.isPaused ? "Resume" : "Pause") - .font(.system(size: 16, weight: .medium)) - } - .foregroundStyle(.white) - .frame(height: 40) - .frame(minWidth: 100) - .background(.white.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - .buttonStyle(PlainButtonStyle()) - .onHover { isHovering in - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - - // Stop button - Button(action: { - timerManager.stopTimer() - }) { - HStack(spacing: 8) { - Image(systemName: "stop.fill") - .font(.system(size: 16, weight: .medium)) - Text("Stop") - .font(.system(size: 16, weight: .medium)) - } - .foregroundStyle(.white) - .frame(height: 40) - .frame(minWidth: 100) - .background(.white.opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - .buttonStyle(PlainButtonStyle()) - .onHover { isHovering in - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() + let computedHeight = CGFloat(timerPresets.count) * 60 + 4 + let listHeight = min(max(0, maxTabContentHeight - 16), computedHeight) + ZStack { + List { + ForEach(timerPresets) { preset in + TimerPresetCard(preset: preset, isActive: timerManager.activePresetId == preset.id) { + timerManager.startTimer(duration: preset.duration, name: preset.name, preset: preset) + if !enableMinimalisticUI { + coordinator.currentView = .timer + } + } + .listRowInsets(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollIndicators(.never) + + LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .alignmentGuide(.top) { d in d[.top] } + .frame(maxHeight: .infinity, alignment: .top) + + LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .alignmentGuide(.bottom) { d in d[.bottom] } + .frame(maxHeight: .infinity, alignment: .bottom) } + .frame(height: listHeight) } } + .frame(width: 210, alignment: .leading) + .frame(maxHeight: maxTabContentHeight, alignment: .top) + .padding(.bottom, 2) } - - private var quickTimerControls: some View { - VStack(spacing: 16) { - // Quick timer grid - VStack(spacing: 12) { - HStack(spacing: 12) { - quickTimerButton(minutes: 1, title: "1 min") - quickTimerButton(minutes: 5, title: "5 min") - quickTimerButton(minutes: 15, title: "15 min") + + private var activeTimerCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 14) { + leadingControlSection + .frame(width: 128, alignment: .leading) + + timerTitleSection + + countdownSection + } + + progressSection + } + .padding(.horizontal, 4) + } + + @ViewBuilder + private var leadingControlSection: some View { + if timerManager.allowsManualInteraction { + HStack(spacing: 10) { + if !timerManager.isOvertime { + TimerControlButton( + icon: pauseIconName, + foreground: .white.opacity(0.95), + background: timerAccentColor.opacity(0.32), + accessibilityLabel: pauseAccessibilityLabel, + action: togglePauseAction + ) + + TimerControlButton( + icon: "xmark", + foreground: .white.opacity(0.95), + background: Color.white.opacity(0.16), + accessibilityLabel: String(localized: "Cancel"), + action: stopTimerAction + ) + } else { + TimerControlButton( + icon: "stop.fill", + foreground: .white.opacity(0.95), + background: Color.white.opacity(0.16), + accessibilityLabel: String(localized: "Stop"), + action: stopTimerAction + ) } - - // Demo timer - Button(action: { - timerManager.startDemoTimer(duration: 10) // 10 second demo - }) { - HStack(spacing: 8) { - Image(systemName: "flask") - .font(.system(size: 14, weight: .medium)) - Text("Demo (10s)") - .font(.system(size: 16, weight: .medium)) - } - .foregroundStyle(.white) - .frame(height: 40) - .frame(minWidth: 120) - .background(.white.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(.white.opacity(0.1), lineWidth: 1) + } + } else { + inactiveTimerPlaceholder + } + } + + private var timerTitleSection: some View { + GeometryReader { geometry in + let status = timerStatusText + let spacing: CGFloat = status == nil ? 0 : 8 + let badgeWidth: CGFloat = status.map(statusBadgeWidth) ?? 0 + let marqueeWidth = max(48, geometry.size.width - badgeWidth - spacing) + + HStack(alignment: .center, spacing: spacing) { + MarqueeText( + .constant(timerDisplayName), + font: .system(size: 20, weight: .semibold), + nsFont: .title3, + textColor: .white, + minDuration: 0.2, + frameWidth: marqueeWidth + ) + .frame(width: marqueeWidth, height: 24, alignment: .leading) + + if let status { + statusBadge(status) + .frame(width: badgeWidth) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 32) + } + + @ViewBuilder + private var countdownSection: some View { + if showsProgress && progressStyle == .ring { + TimerProgressRing( + progress: timerManager.progress, + tint: timerAccentColor, + timeText: timerManager.formattedRemainingTime(), + isOvertime: timerManager.isOvertime, + remainingTime: timerManager.remainingTime + ) + } else { + VStack(alignment: .trailing, spacing: 4) { + Text(timerManager.formattedRemainingTime()) + .font(.system(size: 36, weight: .black, design: .monospaced)) + .foregroundStyle(timerManager.isOvertime ? Color.red : .white) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: timerManager.remainingTime) + .lineLimit(1) + .minimumScaleFactor(0.8) + + if timerManager.isOvertime { + Text(String(localized: "Overtime")) + .font(.caption) + .foregroundStyle(.red) + } + } + .frame(width: 190, alignment: .trailing) + } + } + + @ViewBuilder + private var progressSection: some View { + if showsProgress && progressStyle == .bar { + Capsule() + .fill(Color.white.opacity(0.12)) + .frame(height: 4) + .overlay(alignment: .leading) { + Capsule() + .fill(timerAccentColor) + .frame(height: 4) + .scaleEffect(x: normalizedProgress, y: 1, anchor: .leading) + .animation(.smooth(duration: 0.25), value: timerManager.progress) + } + .frame(maxWidth: .infinity) + .padding(.top, 6) + .padding(.bottom, 2) + } + } + + private func togglePauseAction() { + guard timerManager.allowsManualInteraction else { return } + timerManager.isPaused ? timerManager.resumeTimer() : timerManager.pauseTimer() + } + + private func stopTimerAction() { + if timerManager.allowsManualInteraction { + timerManager.stopTimer() + } else { + timerManager.endExternalTimer(triggerSmoothClose: false) + } + } + + private var customTimerComposer: some View { + Group { + if showTimerPresetsInNotchTab { + VStack(alignment: .leading, spacing: 12) { + DurationInputRow( + hours: $customHours, + minutes: $customMinutes, + seconds: $customSeconds, + fieldWidth: durationFieldWidth ) + + HStack(spacing: 10) { + startButton + resetButton + } } - .buttonStyle(PlainButtonStyle()) - .onHover { isHovering in - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() + } else { + HStack(alignment: .center, spacing: 16) { + DurationInputRow( + hours: $customHours, + minutes: $customMinutes, + seconds: $customSeconds, + fieldWidth: durationFieldWidth + ) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(spacing: 10) { + startButton + resetButton } + .frame(width: buttonColumnWidth, alignment: .top) } } } + .padding(12) + .background(Color.white.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } - - private func quickTimerButton(minutes: Int, title: String) -> some View { - Button(action: { - timerManager.startDemoTimer(duration: TimeInterval(minutes * 60)) - }) { - VStack(spacing: 4) { - Text("\(minutes)") - .font(.system(size: 22, weight: .bold)) - .foregroundStyle(.white) - Text("min") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) + + private var inactiveTimerPlaceholder: some View { + Color.clear + .frame(height: 46) + } + + private var disabledState: some View { + VStack(spacing: 16) { + Image(systemName: "timer.slash") + .font(.system(size: 48, weight: .light)) + .foregroundStyle(.secondary) + + Text("Timer Disabled") + .font(.title2) + .fontWeight(.medium) + + Text("Enable the timer feature in Settings to access this tab.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var shouldShowPresetColumn: Bool { + !timerManager.isTimerActive && showTimerPresetsInNotchTab + } + + private var resolvedNotchHeight: CGFloat { + let height = vm.notchSize.height + return height > 0 ? height : openNotchSize.height + } + + private var headerHeight: CGFloat { + max(24, vm.effectiveClosedNotchHeight) + } + + private var maxTabContentHeight: CGFloat { + let available = resolvedNotchHeight - headerHeight - 36 + return max(130, available) + } + + private func lockAccentColorIfNeeded() { + if timerManager.isTimerActive { + lockedAccentColor = resolvedAccentColor + } + } + + private var timerAccentColor: Color { + lockedAccentColor ?? resolvedAccentColor + } + + private var resolvedAccentColor: Color { + switch colorMode { + case .adaptive: + return timerManager.activePreset?.color ?? timerManager.timerColor + case .solid: + return solidColor + } + } + + private var normalizedProgress: CGFloat { + CGFloat(max(0, min(timerManager.progress, 1))) + } + + private var timerDisplayName: String { + timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName + } + + private var timerStatusText: String? { + if timerManager.isOvertime { + return String(localized: "Overtime") + } else if timerManager.isPaused { + return String(localized: "Paused") + } else if timerManager.isFinished { + return "Completed" + } + return nil + } + + private var timerStatusColor: Color { + timerManager.isOvertime ? .red : timerAccentColor + } + + private func statusBadge(_ text: String) -> some View { + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(timerStatusColor) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(timerStatusColor.opacity(0.18)) + .clipShape(Capsule()) + } + + private func statusBadgeWidth(for text: String) -> CGFloat { +#if canImport(AppKit) + let font = NSFont.systemFont(ofSize: 12, weight: .semibold) +#elseif canImport(UIKit) + let font = UIFont.systemFont(ofSize: 12, weight: .semibold) +#else + return 80 +#endif + let width = (text as NSString).size(withAttributes: [.font: font]).width + return width + 24 + } + + private var pauseIconName: String { + timerManager.isPaused ? "play.fill" : "pause.fill" + } + + private var pauseAccessibilityLabel: String { + timerManager.isPaused ? String(localized: "Resume") : String(localized: "Pause") + } + + private var startButtonColor: Color { + Color(red: 0.142, green: 0.633, blue: 0.265) + } + + private var isStartDisabled: Bool { + customDurationInSeconds == 0 + } + + private var durationFieldWidth: CGFloat { + showTimerPresetsInNotchTab ? 64 : 78 + } + + private var buttonColumnWidth: CGFloat { 210 } + + private var startButton: some View { + Button { + withAnimation(.smooth) { + timerManager.startTimer(duration: customDurationInSeconds, name: String(localized: "Custom Timer")) + if !enableMinimalisticUI { + coordinator.currentView = .timer + } } - .frame(width: 80, height: 60) - .background(.white.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(.white.opacity(0.1), lineWidth: 1) - ) + } label: { + Label(String(localized: "Start"), systemImage: "play.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(startButtonColor.opacity(isStartDisabled ? 0.5 : 1)) + ) } - .buttonStyle(PlainButtonStyle()) - .onHover { isHovering in - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.15), lineWidth: 1) + ) + .opacity(isStartDisabled ? 0.7 : 1) + .disabled(isStartDisabled) + } + + private var resetButton: some View { + Button(action: resetCustomTimerInputs) { + Label(String(localized: "Reset"), systemImage: "arrow.counterclockwise") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.white.opacity(0.16)) + ) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + } + + private var customDurationInSeconds: TimeInterval { + TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds) + } + + private func resetCustomTimerInputs() { + withAnimation(.smooth(duration: 0.2)) { + customHours = 0 + customMinutes = 0 + customSeconds = 0 + } + customTimerDuration = 0 + } + + private func syncCustomDuration(with value: Double) { + isSyncingCustomDuration = true + let components = TimerPreset.components(for: value) + customHours = components.hours + customMinutes = components.minutes + customSeconds = components.seconds + isSyncingCustomDuration = false + } + + private func updateStoredCustomDuration() { + guard !isSyncingCustomDuration else { return } + customTimerDuration = customDurationInSeconds + } +} + +private struct TimerControlButton: View { + let icon: String + let foreground: Color + let background: Color + let accessibilityLabel: String + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(foreground) + .frame(width: 46, height: 46) + .background(background.opacity(isHovering ? 0.95 : 0.8)) + .clipShape(Circle()) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + .contentShape(Circle()) + .help(accessibilityLabel) + .onHover { hovering in isHovering = hovering } + } +} + +private struct TimerProgressRing: View { + let progress: Double + let tint: Color + let timeText: String + let isOvertime: Bool + let remainingTime: TimeInterval + + private var clampedProgress: Double { min(max(progress, 0), 1) } + + var body: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.12), lineWidth: 8) + + Circle() + .trim(from: 0, to: clampedProgress) + .stroke(tint, style: StrokeStyle(lineWidth: 8, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.3), value: clampedProgress) + + Text(timeText) + .font(.system(size: 28, weight: .black, design: .monospaced)) + .foregroundStyle(isOvertime ? Color.red : .white) + .minimumScaleFactor(0.6) + .lineLimit(1) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: remainingTime) + } + .frame(width: 110, height: 110) + } +} + +private struct DurationInputRow: View { + @Binding var hours: Int + @Binding var minutes: Int + @Binding var seconds: Int + let fieldWidth: CGFloat + + init( + hours: Binding, + minutes: Binding, + seconds: Binding, + fieldWidth: CGFloat = 64 + ) { + _hours = hours + _minutes = minutes + _seconds = seconds + self.fieldWidth = fieldWidth + } + + var body: some View { + HStack(alignment: .center, spacing: 8) { + DurationField(label: String(localized: "Hours"), value: $hours, range: 0...23, width: fieldWidth) + colon + DurationField(label: String(localized: "Minutes"), value: $minutes, range: 0...59, width: fieldWidth) + colon + DurationField(label: String(localized: "Seconds"), value: $seconds, range: 0...59, width: fieldWidth) + } + } + + private var colon: some View { + Text(":") + .font(.system(size: 26, weight: .black, design: .monospaced)) + .foregroundStyle(Color.white.opacity(0.65)) + } +} + +private struct DurationField: View { + let label: String + @Binding var value: Int + let range: ClosedRange + let width: CGFloat + + init( + label: String, + value: Binding, + range: ClosedRange, + width: CGFloat = 64 + ) { + self.label = label + _value = value + self.range = range + self.width = width + } + + var body: some View { + VStack(spacing: 6) { + TextField("00", text: binding) + .font(.system(size: 28, weight: .semibold, design: .monospaced)) + .multilineTextAlignment(.center) + .textFieldStyle(.plain) + .foregroundColor(.white) + .tint(.white) + .frame(width: width, height: 46) + .background(Color.white.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + + Text(label) + .font(.caption) + .foregroundStyle(Color.white.opacity(0.65)) + } + } + + private var binding: Binding { + Binding( + get: { String(format: "%02d", value) }, + set: { newValue in + let digits = newValue.filter { $0.isNumber } + let number = min(max(range.lowerBound, Int(digits) ?? 0), range.upperBound) + value = number + } + ) + } +} + +private struct TimerPresetCard: 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: 30, height: 30) + .overlay( + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(preset.name) + .font(.system(size: 14, weight: .semibold)) + .lineLimit(1) + Text(preset.formattedDuration) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + .foregroundStyle(preset.color) + + Spacer() + + Image(systemName: isActive ? "checkmark" : "play.fill") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(isActive ? preset.color : Color.secondary) + .padding(6) + .background(isActive ? preset.color.opacity(0.2) : Color.white.opacity(0.08)) + .clipShape(Circle()) } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(isActive ? preset.color.opacity(0.12) : Color.white.opacity(0.04)) + ) } + .buttonStyle(.plain) } } #Preview { NotchTimerView() .environmentObject(DynamicIslandViewModel()) - .frame(width: 500, height: 200) + .frame(width: 600, height: 320) .background(.black) } diff --git a/DynamicIsland/components/OSD/CircularHUDView.swift b/DynamicIsland/components/OSD/CircularHUDView.swift new file mode 100644 index 00000000..6e3e467e --- /dev/null +++ b/DynamicIsland/components/OSD/CircularHUDView.swift @@ -0,0 +1,207 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#if os(macOS) +import SwiftUI +import Defaults +import CoreAudio + +struct CircularHUDView: View { + @Binding var type: SneakContentType + @Binding var value: CGFloat + @Binding var icon: String + + @Default(.circularHUDShowValue) var showValue + @Default(.circularHUDSize) var size + @Default(.circularHUDStrokeWidth) var strokeWidth + @Default(.circularHUDUseAccentColor) var useAccentColor + + @Environment(\.colorScheme) var colorScheme + + @Default(.useColorCodedVolumeDisplay) var useColorCodedVolume + @Default(.useSmoothColorGradient) var useSmoothGradient + + var body: some View { + ZStack { + // Background circle (SOLO OPAQUE - No transparency) + Circle() + .fill(Color(white: 0.1)) + .overlay { + Circle() + .stroke(Color(white: 0.2), lineWidth: 1) + } + + // 1. Static Background Track (Empty binario) + Circle() + .trim(from: 0.15, to: 0.85) + .stroke(Color(white: 0.15), style: StrokeStyle(lineWidth: strokeWidth * 1.5, lineCap: .round)) + .rotationEffect(.degrees(90)) + .frame(width: size, height: size) + + // 2. Animated Progress Arc (Filled part) + Circle() + .trim(from: 0.15, to: 0.15 + (0.7 * value)) + .stroke(strokeStyle, style: StrokeStyle(lineWidth: strokeWidth * 1.5, lineCap: .round)) + .rotationEffect(.degrees(90)) + .frame(width: size, height: size) + .animation(.interactiveSpring(response: 0.35, dampingFraction: 0.85, blendDuration: 0), value: value) + + // 3. The "Ball" Indicator (Oversized White Pallino) + GeometryReader { geometry in + let radius = size / 2 + let trackWidth = strokeWidth * 1.5 + let startAngle = 144.0 + let currentAngle = startAngle + (252.0 * value) + + let x = radius + radius * cos(currentAngle * .pi / 180) + let y = radius + radius * sin(currentAngle * .pi / 180) + + Circle() + .fill(.white) + .frame(width: trackWidth * 1.45, height: trackWidth * 1.45) + .shadow(color: .black.opacity(0.45), radius: 2, x: 0, y: 1.2) + .position(x: x, y: y) + .animation(.interactiveSpring(response: 0.35, dampingFraction: 0.85, blendDuration: 0), value: value) + } + .frame(width: size, height: size) + + // Central Icon + Image(systemName: symbolName) + .font(.system(size: size * 0.32, weight: .bold)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.white) + .contentTransition(.symbolEffect(.replace)) + .animation(.spring(response: 0.3, dampingFraction: 0.6), value: symbolName) + + // Bottom Value Label + if showValue { + VStack { + Spacer() + HUDNumericLabel( + value: value, + font: .system(size: size * 0.15, weight: .bold, design: .rounded), + color: .white.opacity(0.65) + ) + .padding(.bottom, size * 0.03) + } + } + + + + } + .frame(width: size, height: size) + .shadow(color: .black.opacity(0.2), radius: 15, x: 0, y: 5) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var strokeStyle: AnyShapeStyle { + if type == .volume && useColorCodedVolume { + return ColorCodedProgressBar.shapeStyle(for: value, mode: .volume, smoothGradient: useSmoothGradient) + } + + if useAccentColor { + return AnyShapeStyle(Color.accentColor) + } + + // Default to the approved solid dark gray to match reference when not colored + return AnyShapeStyle(Color(white: 0.25)) + } + + private var symbolName: String { + if !icon.isEmpty { return icon } + + switch type { + case .volume: + // Check if headphones/AirPods are connected + let deviceInfo = getAudioDeviceInfo() + + if deviceInfo.isAirPods { + // Use AirPods icon when AirPods are connected + if value < 0.01 { return "headphones.slash" } + else { return "airpods" } + } else if deviceInfo.isHeadphones { + // Use headphone icons when other headphones are connected + if value < 0.01 { return "headphones.slash" } + else { return "headphones" } + } else { + // Use speaker icons for built-in speakers + if value < 0.01 { return "speaker.slash.fill" } + else if value < 0.33 { return "speaker.wave.1.fill" } + else if value < 0.66 { return "speaker.wave.2.fill" } + else { return "speaker.wave.3.fill" } + } + case .brightness: + return "sun.max.fill" + case .backlight: + return "keyboard" + default: + return "questionmark" + } + } + + private struct AudioDeviceInfo { + let isAirPods: Bool + let isHeadphones: Bool + } + + private func getAudioDeviceInfo() -> AudioDeviceInfo { + #if os(macOS) + // Get default output device + var deviceID = AudioDeviceID() + var size = UInt32(MemoryLayout.size(ofValue: deviceID)) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID) == noErr else { + return AudioDeviceInfo(isAirPods: false, isHeadphones: false) + } + + // Get device name + var deviceName: CFString = "" as CFString + var nameSize = UInt32(MemoryLayout.size) + var nameAddress = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceNameCFString, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + guard AudioObjectGetPropertyData(deviceID, &nameAddress, 0, nil, &nameSize, &deviceName) == noErr else { + return AudioDeviceInfo(isAirPods: false, isHeadphones: false) + } + + let name = (deviceName as String).lowercased() + + // Check for AirPods specifically + let isAirPods = name.contains("airpod") + + // Check for other headphones + let isHeadphones = name.contains("headphone") || + name.contains("ear") || + name.contains("buds") || + name.contains("beats") + + return AudioDeviceInfo(isAirPods: isAirPods, isHeadphones: !isAirPods && isHeadphones) + #else + return AudioDeviceInfo(isAirPods: false, isHeadphones: false) + #endif + } +} +#endif diff --git a/DynamicIsland/components/OSD/CustomOSDView.swift b/DynamicIsland/components/OSD/CustomOSDView.swift new file mode 100644 index 00000000..19ea3ccf --- /dev/null +++ b/DynamicIsland/components/OSD/CustomOSDView.swift @@ -0,0 +1,219 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#if os(macOS) +import SwiftUI +import Defaults + +/// Custom OSD panel that mimics macOS native OSD appearance +/// Supports volume, brightness, and keyboard backlight displays +struct CustomOSDView: View { + @Binding var type: SneakContentType + @Binding var value: CGFloat + @Binding var icon: String + + @Default(.osdMaterial) var osdMaterial + @Default(.osdLiquidGlassCustomizationMode) var osdLiquidGlassCustomizationMode + @Default(.osdLiquidGlassVariant) var osdLiquidGlassVariant + @Default(.osdIconColorStyle) var osdIconColorStyle + @Environment(\.colorScheme) var colorScheme + + private let iconSize: CGFloat = 56 + private let progressBarHeight: CGFloat = 6 + private let spacing: CGFloat = 20 + private let segmentCount: Int = 16 + + var body: some View { + VStack(spacing: spacing) { + // Icon + ZStack { + Image(systemName: symbolName) + .font(.system(size: iconSize, weight: .regular)) + .foregroundStyle(iconColor) + .symbolRenderingMode(.hierarchical) + } + .frame(width: 80, height: 60, alignment: .center) // Fixed container for icon + + // Progress/Value Indicator + progressView + .frame(width: 140) + .padding(.top, 4) // Lower the bar slightly + } + .frame(width: 200, height: 200) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.black.opacity(0.1)) // Subtle backing for shadow source + .shadow(color: Color.black.opacity(0.25), radius: 24, x: 0, y: 8) + ) + .background( + backgroundView + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + ) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + + // MARK: - Symbol Logic + + private var symbolName: String { + if !icon.isEmpty { + return icon + } + + switch type { + case .volume: + if value < 0.01 { + return "speaker.slash.fill" + } else if value < 0.33 { + return "speaker.fill" + } else if value < 0.66 { + return "speaker.wave.1.fill" + } else { + return "speaker.wave.3.fill" + } + + case .brightness: + return "sun.max.fill" + + case .backlight: + return value >= 0.5 ? "light.max" : "light.min" + + default: + return "questionmark" + } + } + + private var iconColor: Color { + switch osdIconColorStyle { + case .white: + return .white + case .whiteTransparent: + return .white.opacity(0.85) + case .lightGray: + return Color(white: 0.85) + case .darkGray: + return Color(white: 0.3) + case .black: + return .black + case .blackTransparent: + return .black.opacity(0.85) + case .auto: + return colorScheme == .dark ? .white : .black + case .autoTransparent: + return colorScheme == .dark ? .white.opacity(0.85) : .black.opacity(0.85) + } + } + + private var textColor: Color { + switch osdIconColorStyle { + case .white, .whiteTransparent, .lightGray: + return .white + case .darkGray, .black, .blackTransparent: + return .black + case .auto, .autoTransparent: + return colorScheme == .dark ? .white : .black + } + } + + // MARK: - Progress View + + private var progressView: some View { + HStack(spacing: 3) { + ForEach(0... + */ + +#if os(macOS) +import SwiftUI + +/// Shared numeric label for HUD/OSD styles with rolling value animation. +struct HUDNumericLabel: View { + let value: CGFloat + var font: Font + var color: Color + var alignment: Alignment = .center + var width: CGFloat? = nil + var minimumDigits: Int = 3 + var animation: Animation = .interactiveSpring(response: 0.35, dampingFraction: 0.75, blendDuration: 0.15) + + @State private var animatedValue: CGFloat = 0 + + var body: some View { + Text(formattedValue) + .font(font) + .monospacedDigit() + .foregroundStyle(color) + .contentTransition(.numericText()) + .frame(width: width, alignment: alignment) + .onAppear { + animatedValue = value + } + .onChange(of: value) { newValue in + withAnimation(animation) { + animatedValue = newValue + } + } + } + + private var formattedValue: String { + let clamped = max(0, min(100, Int(round(animatedValue * 100)))) + let digits = max(1, minimumDigits) + let format = "%\(digits)d" + return String(format: format, clamped) + } +} +#endif diff --git a/DynamicIsland/components/OSD/VerticalHUDView.swift b/DynamicIsland/components/OSD/VerticalHUDView.swift new file mode 100644 index 00000000..e27e69c0 --- /dev/null +++ b/DynamicIsland/components/OSD/VerticalHUDView.swift @@ -0,0 +1,294 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#if os(macOS) +import SwiftUI +import Defaults +import Combine + +struct BumpEvent: Equatable { + let direction: Int + let timestamp: Date +} + +class VerticalHUDState: ObservableObject { + @Published var type: SneakContentType = .volume + @Published var value: CGFloat = 0 + @Published var icon: String = "" + @Published var bumpEvent: BumpEvent? + + init(type: SneakContentType = .volume, value: CGFloat = 0, icon: String = "") { + self.type = type + self.value = value + self.icon = icon + } +} + +struct VerticalHUDView: View { + @ObservedObject var state: VerticalHUDState + + @Default(.verticalHUDShowValue) var showValue + @Default(.verticalHUDHeight) var hudHeight + @Default(.verticalHUDWidth) var hudWidth + @Default(.verticalHUDUseAccentColor) var useAccentColor + @Default(.verticalHUDMaterial) var verticalHUDMaterial + @Default(.verticalHUDLiquidGlassCustomizationMode) var verticalHUDLiquidGlassCustomizationMode + @Default(.verticalHUDLiquidGlassVariant) var verticalHUDLiquidGlassVariant + @Default(.verticalHUDInteractive) var isInteractive + + @Environment(\.colorScheme) var colorScheme + + // Interaction State + @State private var isDragging: Bool = false + @State private var isHovering: Bool = false + @State private var stretchAmount: CGFloat = 0 + @State private var stretchOffset: CGFloat = 0 + + @Default(.useColorCodedVolumeDisplay) var useColorCodedVolume + @Default(.useSmoothColorGradient) var useSmoothGradient + + // Constants + private let maxStretch: CGFloat = 30 // Reduced max stretch for tighter feel + + var body: some View { + // Full Window Container + ZStack { + // The Actual HUD Pill + ZStack { + // Background + verticalBackground + .clipShape(Capsule()) + .overlay { + Capsule() + .stroke(.white.opacity(0.1), lineWidth: 1) + } + + // Fill Bar + GeometryReader { geo in + VStack(spacing: 0) { + Spacer(minLength: 0) + Rectangle() + .fill(fillStyle) + .frame(height: geo.size.height * state.value) + // Super Smooth Fill + .animation(.interactiveSpring(response: 0.55, dampingFraction: 0.85, blendDuration: 0.3), value: state.value) + } + } + .clipShape(Capsule()) + + // Icon & Text overlay + VStack { + if showValue { + HUDNumericLabel( + value: state.value, + font: .system(size: 10, weight: .bold), + color: valueLabelColor, + alignment: .center, + width: hudWidth * 0.8 + ) + .padding(.top, 8 + (stretchOffset < 0 ? abs(stretchOffset/4) : 0)) + } + + Spacer() + + Image(systemName: symbolName) + .font(.system(size: hudWidth * 0.4, weight: .semibold)) + .foregroundStyle(state.value > 0.15 ? (useAccentColor ? .white : .black) : .secondary) + .symbolRenderingMode(.hierarchical) + .padding(.bottom, hudWidth * 0.35) + .offset(y: stretchOffset > 0 ? -stretchOffset/4 : 0) + } + // Always visible + } + .frame(width: currentWidth, height: hudHeight + stretchAmount) + .offset(y: stretchOffset) + .shadow(color: .black.opacity(0.15), radius: 10, x: 0, y: 4) + .onHover { hovering in + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + isHovering = hovering + } + } + // Listen for Elastic Bump (Keyboard) - Debounced via Task cancellation + .task(id: state.bumpEvent) { + guard let event = state.bumpEvent else { return } + + let direction = CGFloat(event.direction) + let stretch: CGFloat = 15 // Constant stretch while holding + + // Animate In (Maintain Stretch) + // If a new event comes in, this task is cancelled and restarted, keeping it stretched. + withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.6)) { + stretchAmount = stretch + stretchOffset = direction * (-stretch / 2) + } + + // Wait for key release (Short debounce window) + try? await Task.sleep(nanoseconds: 150 * 1_000_000) // 150ms + + // Animate Back (only if not cancelled) + withAnimation(.spring(response: 0.55, dampingFraction: 0.65)) { + stretchAmount = 0 + stretchOffset = 0 + } + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { gesture in + guard isInteractive else { return } + + withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) { + isDragging = true + } + + let currentY = gesture.startLocation.y + gesture.translation.height + let percentage = 1.0 - (currentY / hudHeight) + + // Elastic Rubber Banding + if percentage > 1.0 { + let excess = (percentage - 1.0) * hudHeight + let stretch = min(sqrt(abs(excess)) * 1.5, maxStretch) // Reduced multiplier (was 3.0) + state.value = 1.0 + + withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.65)) { // Tighter damping + stretchAmount = stretch + stretchOffset = -stretch / 2 + } + } else if percentage < 0.0 { + let excess = abs(percentage) * hudHeight + let stretch = min(sqrt(abs(excess)) * 1.5, maxStretch) // Reduced multiplier + state.value = 0.0 + + withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.65)) { + stretchAmount = stretch + stretchOffset = stretch / 2 + } + } else { + state.value = percentage + withAnimation(.interactiveSpring(response: 0.45, dampingFraction: 0.8)) { + stretchAmount = 0 + stretchOffset = 0 + } + } + updateSystemLevel(state.value) + } + .onEnded { _ in + withAnimation(.spring(response: 0.55, dampingFraction: 0.7)) { // Tighter release + isDragging = false + stretchAmount = 0 + stretchOffset = 0 + if state.value > 1.0 { state.value = 1.0 } + if state.value < 0.0 { state.value = 0.0 } + } + updateSystemLevel(state.value) + } + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .edgesIgnoringSafeArea(.all) + } + + private var currentWidth: CGFloat { + // Only shrink slightly when dragging, otherwise full size + return isDragging ? hudWidth * 0.9 : hudWidth + } + + private func updateSystemLevel(_ level: CGFloat) { + if state.type == .volume { + SystemVolumeController.shared.setVolume(Float(level)) + } else if state.type == .brightness { + SystemBrightnessController.shared.setBrightness(Float(level)) + } + } + + private var symbolName: String { + if !state.icon.isEmpty { return state.icon } + + switch state.type { + case .volume: + if state.value < 0.01 { return "speaker.slash.fill" } + else if state.value < 0.33 { return "speaker.wave.1.fill" } + else if state.value < 0.66 { return "speaker.wave.2.fill" } + else { return "speaker.wave.3.fill" } + case .brightness: + return "sun.max.fill" + case .backlight: + return state.value >= 0.5 ? "light.max" : "light.min" + default: + return "questionmark" + } + } + + // MARK: - Helper Computing Properties + + private var fillStyle: AnyShapeStyle { + if state.type == .volume && useColorCodedVolume { + let intensity = state.value + if useSmoothGradient { + let endColor = ColorCodedPalette.color(for: intensity, smooth: true) + let startColor = ColorCodedPalette.color(for: max(intensity - 0.15, 0), smooth: true) + return AnyShapeStyle(LinearGradient(colors: [startColor, endColor], startPoint: .bottom, endPoint: .top)) + } else { + return AnyShapeStyle(ColorCodedPalette.color(for: intensity, smooth: false)) + } + } + + return AnyShapeStyle(useAccentColor ? Color.accentColor : Color.white) + } + + private var valueLabelColor: Color { + if state.value > 0.85 { + return useAccentColor ? .white : .black + } + return .secondary + } + + @ViewBuilder + private var verticalBackground: some View { + switch verticalHUDMaterial { + case .frosted: + Capsule().fill(.ultraThinMaterial) + case .liquid: + if #available(macOS 26.0, *) { + if verticalHUDLiquidGlassCustomizationMode == .customLiquid { + LiquidGlassBackground( + variant: verticalHUDLiquidGlassVariant, + cornerRadius: max(currentWidth, hudWidth) + ) { + Color.white.opacity(0.04) + } + } else { + Capsule() + .fill(.clear) + .glassEffect( + .clear.interactive(), + in: .capsule + ) + } + } else { + Capsule().fill(.ultraThinMaterial) + } + case .solidDark: + Capsule().fill(Color.black.opacity(0.85)) + case .solidLight: + Capsule().fill(Color.white.opacity(0.85)) + case .solidAuto: + Capsule().fill((colorScheme == .dark ? Color.black : Color.white).opacity(0.85)) + } + } +} +#endif diff --git a/DynamicIsland/components/Onboarding/ActivationView.swift b/DynamicIsland/components/Onboarding/ActivationView.swift deleted file mode 100644 index 0192284f..00000000 --- a/DynamicIsland/components/Onboarding/ActivationView.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// OnboardingSettings.swift -// DynamicIsland -// -// Created by Richard Kunkli on 2024. 09. 26.. -// - -import SwiftUI - -struct ActivationWindow: View { - @State private var email: String = "" - @State private var key: String = "" - var body: some View { - VStack { - Image("logo") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: 80) - .padding(.top, 30) - .padding(.bottom, 10) - Text("Activate your license") - .font(.largeTitle.bold()) - .fontDesign(.rounded) - Text("Transform your notch truly yours") - .foregroundStyle(.secondary) - .font(.title2) - .padding(.bottom, 20) - Group { - TextField("Email address", text: $email) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) - TextField("License key", text: $key) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) - } - .textFieldStyle(PlainTextFieldStyle()) - .scrollContentBackground(.hidden) - .toggleStyle(.switch) - Spacer() - VStack(alignment: .center, spacing: 14) { - HStack(alignment: .center) { - HStack { - Button {} label: { - Text("Cancel") - .padding(.horizontal, 18) - } - .buttonStyle(AccessoryBarButtonStyle()) - } - .frame(maxWidth: .infinity, alignment: .leading) - Image("dynamicisland") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: 18) - .offset(y: 4) - .blendMode(.overlay) - HStack { - Button {} label: { - Text("Activate") - .padding(.horizontal, 18) - } - .buttonStyle(BorderedProminentButtonStyle()) - } - .frame(maxWidth: .infinity, alignment: .trailing) - - } - .controlSize(.extraLarge) - } - } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .ignoresSafeArea() - .background { - VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) - .ignoresSafeArea() - } - .frame(width: 350, height: 350) - } -} diff --git a/DynamicIsland/components/Onboarding/MusicControllerSelectionView.swift b/DynamicIsland/components/Onboarding/MusicControllerSelectionView.swift index a4cc8cf8..99b05ae2 100644 --- a/DynamicIsland/components/Onboarding/MusicControllerSelectionView.swift +++ b/DynamicIsland/components/Onboarding/MusicControllerSelectionView.swift @@ -1,9 +1,24 @@ -// -// MusicControllerSelectionView.swift -// DynamicIsland -// -// Created by Alexander on 2025-06-23. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import Defaults @@ -121,13 +136,15 @@ extension MediaControllerType { var description: String { switch self { case .nowPlaying: - return "Works with most media apps, including browsers, to detect what's playing. Note: This may be removed in a future macOS version." + return String(localized: "Works with most media apps, including browsers, to detect what's playing. Note: This may be removed in a future macOS version.") case .spotify: - return "Connects directly to the Spotify app." + return String(localized: "Connects directly to the Spotify app.") case .appleMusic: - return "Connects directly to the Apple Music app." + return String(localized: "Connects directly to the Apple Music app.") case .youtubeMusic: - return "Requires a third-party client with API plugin enabled." + return String(localized: "Requires a third-party client with API plugin enabled.") + case .amazonMusic: + return String(localized: "Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek.") } } } diff --git a/DynamicIsland/components/Onboarding/OnboardingFinishView.swift b/DynamicIsland/components/Onboarding/OnboardingFinishView.swift index 776e3d26..a404e248 100644 --- a/DynamicIsland/components/Onboarding/OnboardingFinishView.swift +++ b/DynamicIsland/components/Onboarding/OnboardingFinishView.swift @@ -1,10 +1,24 @@ -// -// OnboardingFinishView.swift -// DynamicIsland -// -// Created by Alexander on 2025-06-23. -// - +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI @@ -45,6 +59,19 @@ struct OnboardingFinishView: View { .buttonStyle(.borderedProminent) .controlSize(.large) .keyboardShortcut(.defaultAction) + + // Privacy Policy Link + Button(action: { + if let url = URL(string: "https://ebullioscopic.github.io/DynamicIsland/privacy-policy") { + NSWorkspace.shared.open(url) + } + }) { + Text("Privacy Policy") + .font(.caption) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .padding(.top, 4) } .padding(24) } diff --git a/DynamicIsland/components/Onboarding/OnboardingView.swift b/DynamicIsland/components/Onboarding/OnboardingView.swift index d652011d..5697ab3c 100644 --- a/DynamicIsland/components/Onboarding/OnboardingView.swift +++ b/DynamicIsland/components/Onboarding/OnboardingView.swift @@ -1,18 +1,35 @@ -// -// OnboardingView.swift -// DynamicIsland -// -// Created by Alexander on 2025-06-23. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import AVFoundation +import Defaults enum OnboardingStep { case welcome case cameraPermission case calendarPermission case musicPermission + case profileSelection case finished } @@ -20,6 +37,8 @@ private let calendarService = CalendarService() struct OnboardingView: View { @State private var step: OnboardingStep = .welcome + @State private var showFocusMonitoringChoice = false + @State private var didPresentFocusMonitoringChoice = false let onFinish: () -> Void let onOpenSettings: () -> Void @@ -37,9 +56,9 @@ struct OnboardingView: View { case .cameraPermission: PermissionRequestView( icon: Image(systemName: "camera.fill"), - title: "Enable Camera Access", - description: "Dynamic Island includes a mirror feature that lets you quickly check your appearance using your camera, right from the notch. Camera access is required only to show this live preview. You can turn the mirror feature on or off at any time in the app.", - privacyNote: "Your camera is never used without your consent, and nothing is recorded or stored.", + title: String(localized: "Enable Camera Access"), + description: String(localized: "Atoll includes a mirror feature that lets you quickly check your appearance using your camera, right from the notch. Camera access is required only to show this live preview. You can turn the mirror feature on or off at any time in the app."), + privacyNote: String(localized: "Your camera is never used without your consent, and nothing is recorded or stored."), onAllow: { Task { await requestCameraPermission() @@ -59,9 +78,9 @@ struct OnboardingView: View { case .calendarPermission: PermissionRequestView( icon: Image(systemName: "calendar"), - title: "Enable Calendar Access", - description: "Dynamic Island can show all your upcoming events in one place. Access to your calendar is needed to display your schedule.", - privacyNote: "Your calendar data is only used to show your events and is never shared.", + title: String(localized: "Enable Calendar Access"), + description: String(localized: "Atoll can show all your upcoming events in one place. Access to your calendar is needed to display your schedule."), + privacyNote: String(localized: "Your calendar data is only used to show your events and is never shared."), onAllow: { Task { await requestCalendarPermission() @@ -81,6 +100,17 @@ struct OnboardingView: View { case .musicPermission: MusicControllerSelectionView( onContinue: { + withAnimation(.easeInOut(duration: 0.6)) { + step = .profileSelection + } + } + ) + .transition(.opacity) + + case .profileSelection: + ProfileSelectionView( + onContinue: { profiles in + applyProfileSettings(profiles) withAnimation(.easeInOut(duration: 0.6)) { step = .finished } @@ -93,6 +123,28 @@ struct OnboardingView: View { } } .frame(width: 400, height: 600) + .onAppear { + guard !didPresentFocusMonitoringChoice else { return } + didPresentFocusMonitoringChoice = true + showFocusMonitoringChoice = true + } + .confirmationDialog( + "Focus detection mode", + isPresented: $showFocusMonitoringChoice, + titleVisibility: .visible + ) { + Button("Use DevTools") { + Defaults[.focusMonitoringMode] = .useDevTools + } + + Button("Use without DevTools") { + Defaults[.focusMonitoringMode] = .withoutDevTools + } + + Button("Later", role: .cancel) {} + } message: { + Text("This is optional. You can change it any time from the menu bar.") + } } // MARK: - Permission Request Logic @@ -105,3 +157,4 @@ struct OnboardingView: View { await calendarService.requestAccess() } } + diff --git a/DynamicIsland/components/Onboarding/PermissionsRequestView.swift b/DynamicIsland/components/Onboarding/PermissionsRequestView.swift index 72002cea..f380c227 100644 --- a/DynamicIsland/components/Onboarding/PermissionsRequestView.swift +++ b/DynamicIsland/components/Onboarding/PermissionsRequestView.swift @@ -1,9 +1,24 @@ -// -// PermissionsRequestView.swift -// DynamicIsland -// -// Created by Alexander on 2025-06-23. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI diff --git a/DynamicIsland/components/Onboarding/ProOnboarding.swift b/DynamicIsland/components/Onboarding/ProOnboarding.swift index 9d8b2c8d..97177fa9 100644 --- a/DynamicIsland/components/Onboarding/ProOnboarding.swift +++ b/DynamicIsland/components/Onboarding/ProOnboarding.swift @@ -1,9 +1,20 @@ -// -// ProOnboarding.swift -// DynamicIsland -// -// Created by Richard Kunkli on 2024. 09. 26.. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import SwiftUIIntrospect @@ -47,7 +58,7 @@ struct ProOnboard: View { .padding(.bottom, 30) } Button { - if let url = URL(string: "https://buymeacoffee.com/jfxh67wvfxq") { + if let url = URL(string: "https://www.linkedin.com/in/hariharan-mudaliar/") { NSWorkspace.shared.open(url) } } label: { diff --git a/DynamicIsland/components/Onboarding/ProfileSelectionView.swift b/DynamicIsland/components/Onboarding/ProfileSelectionView.swift new file mode 100644 index 00000000..d2a072c3 --- /dev/null +++ b/DynamicIsland/components/Onboarding/ProfileSelectionView.swift @@ -0,0 +1,299 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct UserProfile: Identifiable, Hashable { + let id: String + let name: String + let icon: String + let description: String + let gradient: [Color] +} + +struct ProfileSelectionView: View { + @State private var selectedProfiles: Set = [] + let onContinue: (Set) -> Void + + let profiles: [UserProfile] = [ + UserProfile( + id: "developer", + name: String(localized: "Developer"), + icon: "terminal.fill", + description: String(localized: "Code and debug with color picker, stats monitoring, and screen assistant."), + gradient: [Color.blue, Color.purple] + ), + UserProfile( + id: "designer", + name: String(localized: "Designer"), + icon: "paintbrush.fill", + description: String(localized: "Create and design with color picker, mirror, and visual effects."), + gradient: [Color.pink, Color.orange] + ), + UserProfile( + id: "lightuse", + name: String(localized: "Light Use"), + icon: "sparkles", + description: String(localized: "Simple and minimal interface with just the essentials for everyday tasks."), + gradient: [Color.green, Color.mint] + ), + UserProfile( + id: "student", + name: String(localized: "Student"), + icon: "book.fill", + description: String(localized: "Stay organized with calendar, timer, and battery monitoring."), + gradient: [Color.indigo, Color.cyan] + ) + ] + + var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 8) { + Image(systemName: "person.3.fill") + .font(.system(size: 50)) + .foregroundStyle(.linearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)) + .padding(.bottom, 8) + + Text("Choose Your Profile") + .font(.largeTitle) + .fontWeight(.bold) + + Text("Select one or more profiles to customize your experience") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + .padding(.top, 20) + + // Profile Grid + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { + ForEach(profiles) { profile in + ProfileCard( + profile: profile, + isSelected: selectedProfiles.contains(profile.id), + onTap: { + toggleProfile(profile.id) + } + ) + } + } + .padding(.horizontal, 24) + + Spacer() + + // Continue Button + Button(action: { + if !selectedProfiles.isEmpty { + onContinue(selectedProfiles) + } + }) { + Text("Continue") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .disabled(selectedProfiles.isEmpty) + .padding(.horizontal, 24) + .padding(.bottom, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + VisualEffectView(material: .underWindowBackground, blendingMode: .behindWindow) + .ignoresSafeArea() + ) + } + + private func toggleProfile(_ profileId: String) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + if selectedProfiles.contains(profileId) { + selectedProfiles.remove(profileId) + } else { + selectedProfiles.insert(profileId) + } + } + } +} + +struct ProfileCard: View { + let profile: UserProfile + let isSelected: Bool + let onTap: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: onTap) { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: profile.icon) + .font(.system(size: 28)) + .foregroundStyle(.linearGradient(colors: profile.gradient, startPoint: .topLeading, endPoint: .bottomTrailing)) + + Spacer() + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(.linearGradient(colors: profile.gradient, startPoint: .topLeading, endPoint: .bottomTrailing)) + .transition(.scale.combined(with: .opacity)) + } + } + + Text(profile.name) + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Text(profile.description) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .frame(height: 160) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(isSelected ? + LinearGradient(colors: profile.gradient.map { $0.opacity(0.15) }, startPoint: .topLeading, endPoint: .bottomTrailing) : + LinearGradient(colors: [Color.gray.opacity(0.1)], startPoint: .topLeading, endPoint: .bottomTrailing) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .strokeBorder( + isSelected ? + LinearGradient(colors: profile.gradient, startPoint: .topLeading, endPoint: .bottomTrailing) : + LinearGradient(colors: [Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing), + lineWidth: isSelected ? 2 : 0 + ) + ) + .scaleEffect(isHovering ? 1.02 : 1.0) + .shadow(color: isSelected ? profile.gradient[0].opacity(0.3) : .clear, radius: 8, x: 0, y: 4) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = hovering + } + } + } +} + +// MARK: - Profile Settings Configuration + +func applyProfileSettings(_ profiles: Set) { + // Clipboard is ALWAYS enabled (per user request) + Defaults[.enableClipboardManager] = true + + // Developer Profile Settings + let isDeveloper = profiles.contains("developer") + if isDeveloper { + Defaults[.enableColorPickerFeature] = true + Defaults[.enableStatsFeature] = true + Defaults[.enableTerminalFeature] = true + Defaults[.enableTimerFeature] = true + Defaults[.enableScreenAssistant] = true + Defaults[.showMirror] = false + Defaults[.enableMinimalisticUI] = false + } + + // Designer Profile Settings + let isDesigner = profiles.contains("designer") + if isDesigner { + Defaults[.enableColorPickerFeature] = true + Defaults[.showMirror] = true + Defaults[.lightingEffect] = true + Defaults[.inlineHUD] = true + Defaults[.enableStatsFeature] = false + Defaults[.enableTimerFeature] = false + Defaults[.enableMinimalisticUI] = false + Defaults[.enableScreenAssistant] = false + } + + // Light Use Profile Settings + let isLightUse = profiles.contains("lightuse") + if isLightUse { + Defaults[.enableMinimalisticUI] = true + Defaults[.enableColorPickerFeature] = false + Defaults[.showMirror] = false + Defaults[.enableStatsFeature] = false + Defaults[.enableTimerFeature] = true + Defaults[.inlineHUD] = true + Defaults[.enableScreenAssistant] = false + Defaults[.enableLyrics] = false + } + + // Student Profile Settings + let isStudent = profiles.contains("student") + if isStudent { + Defaults[.enableTimerFeature] = true + Defaults[.showCalendar] = true + Defaults[.enableColorPickerFeature] = false + Defaults[.showMirror] = false + Defaults[.enableStatsFeature] = false + Defaults[.enableMinimalisticUI] = false + Defaults[.enableScreenAssistant] = false + } + + // If Light Use is NOT selected but others are, ensure minimalistic is OFF + if !isLightUse && !profiles.isEmpty { + Defaults[.enableMinimalisticUI] = false + } + + // Common settings for all profiles + Defaults[.menubarIcon] = true + Defaults[.enableHaptics] = true + + // Lyrics disabled by default for all profiles + Defaults[.enableLyrics] = false + + // Weather widget defaults to inline style + Defaults[.lockScreenWeatherWidgetStyle] = .inline + + // Auto-detect notch: Dynamic Island for non-notch Macs, standard notch otherwise + if mainScreenHasNotch() { + Defaults[.externalDisplayStyle] = .notch + } else { + Defaults[.externalDisplayStyle] = .dynamicIsland + } + + // Lock screen glass: custom liquid glass v11 on macOS 26+ + if #available(macOS 26.0, *) { + Defaults[.lockScreenGlassStyle] = .liquid + Defaults[.lockScreenGlassCustomizationMode] = .customLiquid + Defaults[.lockScreenMusicLiquidGlassVariant] = .v11 + } + + print("✅ Applied profile settings for: \(profiles.joined(separator: ", "))") +} + +/// Returns `true` when the main screen has a physical notch (safe area insets > 0). +private func mainScreenHasNotch() -> Bool { + guard let screen = NSScreen.main else { return false } + return screen.safeAreaInsets.top > 0 +} + +#Preview { + ProfileSelectionView(onContinue: { profiles in + print("Selected profiles: \(profiles)") + }) +} diff --git a/DynamicIsland/components/Onboarding/SparkleView.swift b/DynamicIsland/components/Onboarding/SparkleView.swift index d0d26685..a44f592c 100644 --- a/DynamicIsland/components/Onboarding/SparkleView.swift +++ b/DynamicIsland/components/Onboarding/SparkleView.swift @@ -1,9 +1,24 @@ -// -// SparkleView.swift -// DynamicIsland -// -// Created by Richard Kunkli on 2024. 09. 26.. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import AppKit diff --git a/DynamicIsland/components/Onboarding/WelcomeView.swift b/DynamicIsland/components/Onboarding/WelcomeView.swift index a7667504..814df1c3 100644 --- a/DynamicIsland/components/Onboarding/WelcomeView.swift +++ b/DynamicIsland/components/Onboarding/WelcomeView.swift @@ -1,9 +1,24 @@ -// -// WelcomeView.swift -// DynamicIsland -// -// Created by Richard Kunkli on 2024. 09. 26.. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import SwiftUIIntrospect @@ -26,7 +41,7 @@ struct WelcomeView: View { .aspectRatio(contentMode: .fit) .frame(width: 100, height: 100) .padding(.bottom, 8) - Text("Dynamic Island") + Text("Atoll") .font(.system(.largeTitle, design: .default)) .fontWeight(.semibold) Text("Welcome") @@ -58,11 +73,24 @@ struct WelcomeView: View { .padding(.vertical, 6) } .buttonStyle(BorderedProminentButtonStyle()) + + // Privacy Policy Link + Button(action: { + if let url = URL(string: "https://ebullioscopic.github.io/DynamicIsland/privacy-policy") { + NSWorkspace.shared.open(url) + } + }) { + Text("Privacy Policy") + .font(.caption) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .padding(.top, 8) } .padding(.top) } - Image("theboringteam") + Image("ebullioscopic") .resizable() .aspectRatio(contentMode: .fit) .frame(height: 22) diff --git a/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift b/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift new file mode 100644 index 00000000..f959a510 --- /dev/null +++ b/DynamicIsland/components/Privacy/PrivacyLiveActivity.swift @@ -0,0 +1,139 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct PrivacyLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var privacyManager = PrivacyIndicatorManager.shared + @ObservedObject var recordingManager = ScreenRecordingManager.shared + @State private var isHovering: Bool = false + @State private var gestureProgress: CGFloat = 0 + @State private var isExpanded: Bool = false + + // Calculate if both camera and mic are active + private var bothIndicatorsActive: Bool { + privacyManager.indicatorLayout.showsCameraIndicator && + privacyManager.indicatorLayout.showsMicrophoneIndicator + } + + var body: some View { + HStack(spacing: 0) { + // Left side - Recording pulsator OR microphone (if both indicators active without recording) + Color.clear + .background { + if isExpanded { + HStack { + if recordingManager.isRecording { + // Recording pulsator + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.red.opacity(0.15)) + + Circle() + .fill(Color.red) + .frame(width: 10, height: 10) + .modifier(PulsingModifier()) + } + .frame(width: vm.effectiveClosedNotchHeight - 12, height: vm.effectiveClosedNotchHeight - 12) + } else if bothIndicatorsActive { + // Microphone on left when both active and no recording + PrivacyIcon(type: .microphone) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + } + .frame(width: isExpanded && (recordingManager.isRecording || bothIndicatorsActive) ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2 + 20) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + + // Center - Black fill + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0)) + + // Right side - Privacy indicators + Color.clear + .background { + if isExpanded { + HStack(spacing: 4) { + // When both active without recording: only camera on right (mic is on left) + // When recording + both: show both on right + // When only one active: show that one + + if bothIndicatorsActive && recordingManager.isRecording { + // Both indicators on right when recording + PrivacyIcon(type: .microphone) + PrivacyIcon(type: .camera) + } else if bothIndicatorsActive { + // Only camera on right when both active (mic is on left) + PrivacyIcon(type: .camera) + } else { + // Single indicator + if privacyManager.indicatorLayout.showsMicrophoneIndicator { + PrivacyIcon(type: .microphone) + } + if privacyManager.indicatorLayout.showsCameraIndicator { + PrivacyIcon(type: .camera) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + } + .frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2 + 20) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .onAppear { + withAnimation(.smooth(duration: 0.4)) { + isExpanded = true + } + } + .onChange(of: privacyManager.hasAnyIndicator) { _, newValue in + if !newValue { + withAnimation(.smooth(duration: 0.4)) { + isExpanded = false + } + } else { + withAnimation(.smooth(duration: 0.4)) { + isExpanded = true + } + } + } + } +} + +// Individual privacy icon component +struct PrivacyIcon: View { + let type: PrivacyIndicatorType + + // Color based on type: camera = #26CC41, mic = #FF9402 + private var iconColor: Color { + type == .camera ? Color(red: 0.152, green: 0.804, blue: 0.256) : Color(red: 1.000, green: 0.584, blue: 0.010) + } + + var body: some View { + // Simple icon without blur/glow effects + Image(systemName: type.icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(iconColor) + .frame(width: 24, height: 24) + .scaleEffect(1.0) + .opacity(1.0) + } +} diff --git a/DynamicIsland/components/PrivacyIndicatorIcon.swift b/DynamicIsland/components/PrivacyIndicatorIcon.swift new file mode 100644 index 00000000..b2bd34b9 --- /dev/null +++ b/DynamicIsland/components/PrivacyIndicatorIcon.swift @@ -0,0 +1,278 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +// MARK: - Privacy Indicator Type +enum PrivacyIndicatorType { + case camera + case microphone + + var icon: String { + switch self { + case .camera: + return "video.fill" + case .microphone: + return "mic.fill" + } + } + + var label: String { + switch self { + case .camera: + return "Camera Active" + case .microphone: + return "Microphone Active" + } + } + + var accessibilityLabel: String { + switch self { + case .camera: + return "Camera is being used by an application" + case .microphone: + return "Microphone is being used by an application" + } + } +} + +// MARK: - Privacy Indicator Icon View +struct PrivacyIndicatorIcon: View { + // MARK: - Properties + let type: PrivacyIndicatorType + let showLabel: Bool + + @State private var isAnimating: Bool = false + + // MARK: - Initialization + init(type: PrivacyIndicatorType, showLabel: Bool = false) { + self.type = type + self.showLabel = showLabel + } + + // MARK: - Body + var body: some View { + HStack(spacing: 4) { + // Icon with glow effect + ZStack { + // Background glow + Circle() + .fill( + RadialGradient( + colors: [ + Color.orange.opacity(0.4), + Color.orange.opacity(0.2), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 12 + ) + ) + .frame(width: 24, height: 24) + .opacity(isAnimating ? 0.8 : 0.5) + + // Icon background circle + Circle() + .fill(Color.orange.opacity(0.2)) + .frame(width: 18, height: 18) + + // Icon + Image(systemName: type.icon) + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.orange) + } + .frame(width: 24, height: 24) + + // Optional label + if showLabel { + Text(type.label) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.primary.opacity(0.8)) + } + } + .accessibilityLabel(type.accessibilityLabel) + .onAppear { + startPulseAnimation() + } + } + + // MARK: - Animation + private func startPulseAnimation() { + withAnimation( + .easeInOut(duration: 1.5) + .repeatForever(autoreverses: true) + ) { + isAnimating = true + } + } +} + +// MARK: - Privacy Indicator Container +/// Container view that shows both camera and microphone indicators based on layout +struct PrivacyIndicatorContainer: View { + // MARK: - Environment + @EnvironmentObject var privacyManager: PrivacyIndicatorManager + + // MARK: - Properties + let showLabels: Bool + let spacing: CGFloat + + // MARK: - Initialization + init(showLabels: Bool = false, spacing: CGFloat = 8) { + self.showLabels = showLabels + self.spacing = spacing + } + + // MARK: - Body + var body: some View { + HStack(spacing: spacing) { + // Microphone indicator (on the left when both are shown) + if privacyManager.indicatorLayout.showsMicrophoneIndicator { + PrivacyIndicatorIcon(type: .microphone, showLabel: showLabels) + .transition(.scale.combined(with: .opacity)) + } + + // Camera indicator (on the right when both are shown) + if privacyManager.indicatorLayout.showsCameraIndicator { + PrivacyIndicatorIcon(type: .camera, showLabel: showLabels) + .transition(.scale.combined(with: .opacity)) + } + } + .animation(.smooth(duration: 0.3), value: privacyManager.indicatorLayout) + } +} + +// MARK: - Privacy Indicator Detail View +/// Detailed view showing privacy indicator status (for settings/tips) +struct PrivacyIndicatorDetailView: View { + @EnvironmentObject var privacyManager: PrivacyIndicatorManager + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Privacy Indicators") + .font(.headline) + + // Camera status + HStack(spacing: 12) { + PrivacyIndicatorIcon(type: .camera) + + VStack(alignment: .leading, spacing: 2) { + Text("Camera") + .font(.subheadline.weight(.medium)) + + Text(privacyManager.cameraActive ? "Currently active" : "Not active") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Circle() + .fill(privacyManager.cameraActive ? Color.green : Color.gray.opacity(0.3)) + .frame(width: 8, height: 8) + } + .padding(12) + .background(Color.primary.opacity(0.05)) + .cornerRadius(10) + + // Microphone status + HStack(spacing: 12) { + PrivacyIndicatorIcon(type: .microphone) + + VStack(alignment: .leading, spacing: 2) { + Text("Microphone") + .font(.subheadline.weight(.medium)) + + Text(privacyManager.microphoneActive ? "Currently active" : "Not active") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Circle() + .fill(privacyManager.microphoneActive ? Color.green : Color.gray.opacity(0.3)) + .frame(width: 8, height: 8) + } + .padding(12) + .background(Color.primary.opacity(0.05)) + .cornerRadius(10) + + // Info text + Text("These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators.") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 4) + } + } +} + +// MARK: - Preview +#Preview("Single Indicators") { + VStack(spacing: 20) { + PrivacyIndicatorIcon(type: .camera) + PrivacyIndicatorIcon(type: .microphone) + PrivacyIndicatorIcon(type: .camera, showLabel: true) + PrivacyIndicatorIcon(type: .microphone, showLabel: true) + } + .padding() + .background(Color.black) +} + +#Preview("Container") { + let manager = PrivacyIndicatorManager.shared + + VStack(spacing: 20) { + Text("Both Active") + PrivacyIndicatorContainer() + .onAppear { + manager.cameraActive = true + manager.microphoneActive = true + } + + Text("Camera Only") + PrivacyIndicatorContainer() + .onAppear { + manager.cameraActive = true + manager.microphoneActive = false + } + + Text("Microphone Only") + PrivacyIndicatorContainer() + .onAppear { + manager.cameraActive = false + manager.microphoneActive = true + } + } + .padding() + .background(Color.black) + .environmentObject(manager) +} + +#Preview("Detail View") { + let manager = PrivacyIndicatorManager.shared + + PrivacyIndicatorDetailView() + .padding() + .environmentObject(manager) + .onAppear { + manager.cameraActive = true + manager.microphoneActive = false + } +} diff --git a/DynamicIsland/components/ProgressIndicator.swift b/DynamicIsland/components/ProgressIndicator.swift index 20db7496..c253f8a2 100644 --- a/DynamicIsland/components/ProgressIndicator.swift +++ b/DynamicIsland/components/ProgressIndicator.swift @@ -1,9 +1,24 @@ - // - // ProgressIndicator.swift - // DynamicIsland - // - // Created by Harsh Vardhan Goswami on 11/08/24. - // +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation import SwiftUI diff --git a/DynamicIsland/components/Recording/RecordingLiveActivity.swift b/DynamicIsland/components/Recording/RecordingLiveActivity.swift new file mode 100644 index 00000000..cc48ebda --- /dev/null +++ b/DynamicIsland/components/Recording/RecordingLiveActivity.swift @@ -0,0 +1,96 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct RecordingLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var recordingManager = ScreenRecordingManager.shared + @State private var isHovering: Bool = false + @State private var gestureProgress: CGFloat = 0 + @State private var isExpanded: Bool = false + + var body: some View { + HStack(spacing: 0) { + // Left - Red circle with animation + Color.clear + .background { + if isExpanded { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.red.opacity(0.15)) + + Circle() + .fill(Color.red) + .frame(width: 10, height: 10) + .modifier(PulsingModifier()) + } + .frame(width: vm.effectiveClosedNotchHeight - 12, height: vm.effectiveClosedNotchHeight - 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + } + .frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + + // Center - Black fill + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0)) + + // Right - Empty for symmetry with animation + Color.clear + .frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .onAppear { + withAnimation(.smooth(duration: 0.4)) { + isExpanded = true + } + } + .onChange(of: recordingManager.isRecording) { _, newValue in + if !newValue { + withAnimation(.smooth(duration: 0.4)) { + isExpanded = false + } + } else { + withAnimation(.smooth(duration: 0.4)) { + isExpanded = true + } + } + } + } +} + +// Pulsing animation modifier for recording indicator +// Note: Also used by PrivacyLiveActivity +struct PulsingModifier: ViewModifier { + @State private var isPulsing = false + + func body(content: Content) -> some View { + content + .scaleEffect(isPulsing ? 1.2 : 1.0) + .opacity(isPulsing ? 0.7 : 1.0) + .onAppear { + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + isPulsing = true + } + } + } +} diff --git a/DynamicIsland/components/ScreenAssistant/ChatPanels.swift b/DynamicIsland/components/ScreenAssistant/ChatPanels.swift new file mode 100644 index 00000000..2f79dee7 --- /dev/null +++ b/DynamicIsland/components/ScreenAssistant/ChatPanels.swift @@ -0,0 +1,812 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI +import Defaults + +private func applyChatPanelCornerMask(_ view: NSView, radius: CGFloat) { + view.wantsLayer = true + view.layer?.masksToBounds = true + view.layer?.cornerRadius = radius + view.layer?.backgroundColor = NSColor.clear.cgColor + if #available(macOS 13.0, *) { + view.layer?.cornerCurve = .continuous + } +} + +// MARK: - Chat Messages Panel (Left Side) +class ChatMessagesPanel: NSPanel { + + init() { + super.init( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + + setupWindow() + setupContentView() + } + + override var canBecomeKey: Bool { + return false // Don't steal focus from input panel + } + + override var canBecomeMain: Bool { + return false + } + + private func setupWindow() { + backgroundColor = .clear + isOpaque = false + hasShadow = true + level = .floating + isMovableByWindowBackground = false // Fixed position + titlebarAppearsTransparent = true + titleVisibility = .hidden + isFloatingPanel = true + + collectionBehavior = [ + .canJoinAllSpaces, + .stationary, + .fullScreenAuxiliary + ] + + ScreenCaptureVisibilityManager.shared.register(self, scope: .panelsOnly) + + acceptsMouseMovedEvents = true + } + + private func setupContentView() { + let contentView = ChatMessagesView() + let hostingView = NSHostingView(rootView: contentView) + applyChatPanelCornerMask(hostingView, radius: 16) + self.contentView = hostingView + + // Set size for chat messages panel (wider and taller) + let preferredSize = CGSize(width: 600, height: 500) + hostingView.setFrameSize(preferredSize) + setContentSize(preferredSize) + } + + func positionOnLeftSide() { + guard let screen = NSScreen.main else { return } + + let screenFrame = screen.visibleFrame + let panelFrame = frame + + // Position on the left side of the screen + let xPosition = screenFrame.minX + 50 // 50pt from left edge + let yPosition = screenFrame.maxY - panelFrame.height - 100 // 100pt from top + + setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) + } + + deinit { + ScreenCaptureVisibilityManager.shared.unregister(self) + } +} + +// MARK: - Chat Input Panel (Center) +class ChatInputPanel: NSPanel { + + init() { + super.init( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + + setupWindow() + setupContentView() + } + + override var canBecomeKey: Bool { + return true // Can receive focus for text input + } + + override var canBecomeMain: Bool { + return true + } + + // Handle ESC key globally for the panel + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { // ESC key + ScreenAssistantManager.shared.closePanels() + } else { + super.keyDown(with: event) + } + } + + private func setupWindow() { + backgroundColor = .clear + isOpaque = false + hasShadow = true + level = .floating + isMovableByWindowBackground = true // Enable dragging + titlebarAppearsTransparent = true + titleVisibility = .hidden + isFloatingPanel = true + + styleMask.insert(.fullSizeContentView) + + collectionBehavior = [ + .canJoinAllSpaces, + .stationary, + .fullScreenAuxiliary + ] + + ScreenCaptureVisibilityManager.shared.register(self, scope: .panelsOnly) + + acceptsMouseMovedEvents = true + } + + private func setupContentView() { + let contentView = ChatInputView() + let hostingView = NSHostingView(rootView: contentView) + applyChatPanelCornerMask(hostingView, radius: 16) + self.contentView = hostingView + + // Set compact size for single-line input panel + let preferredSize = CGSize(width: 500, height: 60) + hostingView.setFrameSize(preferredSize) + setContentSize(preferredSize) + } + + func positionInCenter() { + guard let screen = NSScreen.main else { return } + + let screenFrame = screen.visibleFrame + let panelFrame = frame + + // Position in the center-bottom of the screen (like a search bar) + let xPosition = (screenFrame.width - panelFrame.width) / 2 + screenFrame.minX + let yPosition = screenFrame.minY + 100 // 100pt from bottom + + setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) + } + + deinit { + ScreenCaptureVisibilityManager.shared.unregister(self) + } +} + +// MARK: - Chat Messages View (Redesigned for standalone panel) +struct ChatMessagesView: View { + @ObservedObject var screenAssistantManager = ScreenAssistantManager.shared + + var body: some View { + VStack(spacing: 0) { + // Header with close button + HStack { + Text("AI Assistant") + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Spacer() + + Button(action: { + screenAssistantManager.resetConversationContext() + }) { + HStack(spacing: 6) { + Image(systemName: "arrow.counterclockwise") + .font(.caption) + Text("Reset Context") + .font(.caption) + } + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.gray.opacity(0.12)) + .cornerRadius(8) + } + .disabled(screenAssistantManager.isLoading) + .buttonStyle(PlainButtonStyle()) + .help("Clear conversation and attachments") + + Button(action: { + screenAssistantManager.closePanels() + }) { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + .help("Close assistant") + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color.gray.opacity(0.05)) + + Divider() + + // Chat content + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + if screenAssistantManager.chatMessages.isEmpty { + VStack(spacing: 20) { + Image(systemName: "brain.head.profile") + .font(.system(size: 60)) + .foregroundColor(.blue.opacity(0.6)) + + VStack(spacing: 8) { + Text("AI Assistant") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + + Text("Start a conversation to see your chat history here") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 80) + } else { + ForEach(screenAssistantManager.chatMessages) { message in + StreamingChatMessageBubble(message: message) + .id(message.id) + } + + if screenAssistantManager.isLoading { + HStack(spacing: 12) { + ProgressView() + .scaleEffect(0.8) + Text("AI is thinking...") + .font(.body) + .foregroundColor(.secondary) + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + } + } + .padding(.vertical, 20) + } + .onChange(of: screenAssistantManager.chatMessages.count) { _, _ in + if let lastMessage = screenAssistantManager.chatMessages.last { + withAnimation(.easeOut(duration: 0.5)) { + proxy.scrollTo(lastMessage.id, anchor: .bottom) + } + } + } + .onChange(of: screenAssistantManager.isLoading) { _, _ in + if screenAssistantManager.isLoading { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + withAnimation(.easeOut(duration: 0.5)) { + if let lastMessage = screenAssistantManager.chatMessages.last { + proxy.scrollTo(lastMessage.id, anchor: .bottom) + } + } + } + } + } + } + } + .background(ChatPanelsVisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.3), radius: 15, x: 0, y: 8) + } +} + +// MARK: - Chat Input View (Single Line Panel) +struct ChatInputView: View { + @ObservedObject var screenAssistantManager = ScreenAssistantManager.shared + @State private var messageText = "" + @State private var isDraggingFiles = false + @State private var showingApiKeyAlert = false + @FocusState private var isTextFieldFocused: Bool + + // Current model information + private var currentProvider: AIModelProvider { + Defaults[.selectedAIProvider] + } + + private var currentModel: AIModel? { + Defaults[.selectedAIModel] + } + + var body: some View { + VStack(spacing: 0) { + // Current model indicator + HStack { + HStack(spacing: 6) { + Image(systemName: iconForProvider(currentProvider)) + .font(.caption) + .foregroundColor(.blue) + + Text(currentModel?.name ?? currentProvider.displayName) + .font(.caption) + .foregroundColor(.secondary) + + if currentModel?.supportsThinking == true && Defaults[.enableThinkingMode] { + Text("• Thinking") + .font(.caption2) + .foregroundColor(.purple) + } + } + + Spacer() + + Button("Change", action: openModelSelection) + .font(.caption) + .foregroundColor(.blue) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.gray.opacity(0.05)) + + // File attachments row (if any) + if !screenAssistantManager.attachedFiles.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(screenAssistantManager.attachedFiles) { file in + AttachedFileChip(file: file) { + screenAssistantManager.removeFile(file) + } + } + } + .padding(.horizontal, 12) + } + .padding(.vertical, 8) + .background(Color.gray.opacity(0.05)) + + Divider() + } + + // Single line input row + HStack(spacing: 12) { + // Add files button + AddFilesButton() + + // Screenshot snipping button + ScreenshotButton() + + // Text input - SINGLE LINE + TextField("Ask me anything...", text: $messageText) + .textFieldStyle(.plain) + .font(.system(size: 14)) + .focused($isTextFieldFocused) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + .onSubmit { + sendMessage() + } + + // Model selection button + Button(action: openModelSelection) { + Image(systemName: "brain.head.profile.fill") + .foregroundColor(.purple) + .font(.system(size: 20)) + } + .buttonStyle(PlainButtonStyle()) + .help("Choose AI model") + + // Recording button + RecordingButton() + + // Send button + Button(action: sendMessage) { + Image(systemName: "paperplane.fill") + .foregroundColor(.white) + .font(.system(size: 12)) + .padding(8) + .background(canSend ? Color.blue : Color.gray) + .cornerRadius(6) + } + .buttonStyle(PlainButtonStyle()) + .disabled(!canSend) + } + .padding(12) + } + .background(ChatPanelsVisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.3), radius: 15, x: 0, y: 8) + .onDrop(of: [.fileURL], isTargeted: $isDraggingFiles) { providers in + handleFilesDrop(providers) + } + .alert("API Key Required", isPresented: $showingApiKeyAlert) { + Button("Open Model Settings") { + openModelSelection() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Please configure your API key for the selected AI provider in model settings.") + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isTextFieldFocused = true + } + } + } + + private func iconForProvider(_ provider: AIModelProvider) -> String { + switch provider { + case .gemini: return "sparkles" + case .openai: return "brain.head.profile" + case .claude: return "doc.text" + case .local: return "server.rack" + case .groq: return "bolt.fill" + } + } + + private var canSend: Bool { + !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !screenAssistantManager.attachedFiles.isEmpty + } + + private func sendMessage() { + // Check if API key is configured for the selected provider + let provider = Defaults[.selectedAIProvider] + var apiKey = "" + + switch provider { + case .gemini: + apiKey = Defaults[.geminiApiKey] + case .openai: + apiKey = Defaults[.openaiApiKey] + case .claude: + apiKey = Defaults[.claudeApiKey] + case .local: + // Local models don't need API keys + apiKey = "local" + case .groq: + apiKey = Defaults[.groqApiKey] + } + + if apiKey.isEmpty { + showingApiKeyAlert = true + return + } + + // Prepare the message + let userMessage = messageText.trimmingCharacters(in: .whitespacesAndNewlines) + if userMessage.isEmpty && screenAssistantManager.attachedFiles.isEmpty { + return + } + + // Send message through manager + screenAssistantManager.sendMessage(userMessage) + messageText = "" + } + + private func openModelSelection() { + let panel = ModelSelectionPanel() + panel.positionInCenter() + panel.makeKeyAndOrderFront(nil) + panel.orderFrontRegardless() + + // Activate the app to ensure proper focus handling + NSApp.activate(ignoringOtherApps: true) + } + + private func handleFilesDrop(_ providers: [NSItemProvider]) -> Bool { + for provider in providers { + _ = provider.loadObject(ofClass: URL.self) { url, error in + if let url = url { + DispatchQueue.main.async { + screenAssistantManager.addFiles([url]) + } + } + } + } + return true + } +} + +// MARK: - Enhanced Chat Message Bubble (No Auto-Streaming) +struct StreamingChatMessageBubble: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .top, spacing: 12) { + if message.isFromUser { + Spacer() + } + + // Avatar + if !message.isFromUser { + Image(systemName: "brain.head.profile") + .font(.title2) + .foregroundColor(.blue) + .frame(width: 32, height: 32) + .background(Color.blue.opacity(0.1)) + .clipShape(Circle()) + } + + VStack(alignment: message.isFromUser ? .trailing : .leading, spacing: 8) { + // Header with name and timestamp + HStack { + Text(message.isFromUser ? "You" : "AI Assistant") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(message.isFromUser ? .blue : .green) + + Spacer() + + Text(message.timestamp, style: .time) + .font(.caption2) + .foregroundColor(.secondary) + } + + // File attachments (if any) + if let files = message.attachedFiles, !files.isEmpty { + HStack(spacing: 6) { + ForEach(files.prefix(3)) { file in + HStack(spacing: 4) { + Image(systemName: file.type.iconName) + .font(.caption2) + Text(file.name) + .font(.caption2) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + } + + if files.count > 3 { + Text("+\(files.count - 3) more") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + + // Message content - NO AUTO STREAMING + MarkdownText(content: message.content) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(message.isFromUser ? Color.blue : Color.gray.opacity(0.15)) + ) + .foregroundColor(message.isFromUser ? .white : .primary) + } + .frame(maxWidth: 400, alignment: message.isFromUser ? .trailing : .leading) + + // User avatar + if message.isFromUser { + Image(systemName: "person.circle.fill") + .font(.title2) + .foregroundColor(.blue) + .frame(width: 32, height: 32) + } else { + Spacer() + } + } + .padding(.horizontal, 20) + } +} + +// MARK: - Note: Shared Components (MarkdownText, AttachedFileChip, AddFilesButton, RecordingButton, ApiKeyAlertView) +// are defined in ScreenAssistantPanel.swift to avoid redeclaration conflicts. + +// MARK: - Screenshot Button Component +struct ScreenshotButton: View { + @ObservedObject var screenAssistantManager = ScreenAssistantManager.shared + @StateObject private var screenshotTool = ScreenshotSnippingTool.shared + @State private var showingScreenshotOptions = false + + var body: some View { + HStack(spacing: 4) { + // Main screenshot button + Button(action: startQuickScreenshot) { + Image(systemName: getIconName()) + .foregroundColor(getIconColor()) + .font(.system(size: 20)) + } + .buttonStyle(PlainButtonStyle()) + .help("Take area screenshot") + .disabled(screenshotTool.isSnipping) + .scaleEffect(screenshotTool.isSnipping ? 1.1 : 1.0) + .animation(.easeInOut(duration: 0.2), value: screenshotTool.isSnipping) + + // Options dropdown button + Button(action: { showingScreenshotOptions.toggle() }) { + Image(systemName: "chevron.down") + .foregroundColor(.secondary) + .font(.system(size: 12)) + } + .buttonStyle(PlainButtonStyle()) + .help("Screenshot options") + .disabled(screenshotTool.isSnipping) + .popover(isPresented: $showingScreenshotOptions) { + ScreenshotOptionsPopover { type in + startScreenshot(type: type) + showingScreenshotOptions = false + } + } + } + } + + private func getIconName() -> String { + if screenshotTool.isSnipping { + return "camera.viewfinder" + } else { + return "camera.aperture" + } + } + + private func getIconColor() -> Color { + if screenshotTool.isSnipping { + return .red + } else { + return .green + } + } + + private func startQuickScreenshot() { + // Default to area screenshot for quick action + startScreenshot(type: .area) + } + + private func startScreenshot(type: ScreenshotSnippingTool.ScreenshotType) { + // Start snipping with direct callback (ScreenshotApp-based approach) + screenshotTool.startSnipping(type: type) { [weak screenAssistantManager] screenshotURL in + guard let manager = screenAssistantManager else { + print("❌ ScreenshotTool: ScreenAssistantManager deallocated during callback") + return + } + + print("📁 ScreenshotTool: Adding \(type.displayName.lowercased()) screenshot to chat: \(screenshotURL.lastPathComponent)") + manager.addFiles([screenshotURL]) + print("📸 \(type.displayName) screenshot captured and added to chat successfully") + } + } +} + +// MARK: - Visual Effect View for Chat Panels (to avoid conflicts) +struct ChatPanelsVisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +// MARK: - Screenshot Options Popover (Hidden from Screen Recording) +struct ScreenshotOptionsPopover: View { + let onOptionSelected: (ScreenshotSnippingTool.ScreenshotType) -> Void + + var body: some View { + VStack(spacing: 8) { + Text("Screenshot Type") + .font(.headline) + .padding(.top, 8) + + VStack(spacing: 4) { + ScreenshotOptionButton( + type: .area, + description: "Select an area", + onTap: onOptionSelected + ) + + ScreenshotOptionButton( + type: .window, + description: "Select a window", + onTap: onOptionSelected + ) + + ScreenshotOptionButton( + type: .full, + description: "Capture full screen", + onTap: onOptionSelected + ) + } + .padding(.bottom, 8) + } + .padding(.horizontal, 12) + .frame(width: 200) + .background( + ScreenshotPopoverBackground() + ) + } +} + +// MARK: - Screenshot Option Button +struct ScreenshotOptionButton: View { + let type: ScreenshotSnippingTool.ScreenshotType + let description: String + let onTap: (ScreenshotSnippingTool.ScreenshotType) -> Void + + var body: some View { + Button(action: { onTap(type) }) { + HStack(spacing: 12) { + Image(systemName: type.iconName) + .foregroundColor(.blue) + .font(.system(size: 16)) + .frame(width: 20, alignment: .center) + + VStack(alignment: .leading, spacing: 2) { + Text(type.displayName) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + + Text(description) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.clear) + .contentShape(Rectangle()) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.clear, lineWidth: 1) + ) + } + .buttonStyle(PlainButtonStyle()) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(NSColor.controlBackgroundColor)) + .opacity(0.5) + ) + .onHover { isHovered in + // Add subtle hover effect if needed + } + } +} + +// MARK: - Screenshot Popover Background (Hidden from Screen Recording) +struct ScreenshotPopoverBackground: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + let view = NSView() + + DispatchQueue.main.async { + guard let window = view.window else { return } + ScreenCaptureVisibilityManager.shared.register(window, scope: .panelsOnly) + } + + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + if let window = nsView.window { + ScreenCaptureVisibilityManager.shared.register(window, scope: .panelsOnly) + } + } + + static func dismantleNSView(_ nsView: NSView, coordinator: ()) { + if let window = nsView.window { + ScreenCaptureVisibilityManager.shared.unregister(window) + } + } +} diff --git a/DynamicIsland/components/ScreenAssistant/ModelSelectionPanel.swift b/DynamicIsland/components/ScreenAssistant/ModelSelectionPanel.swift new file mode 100644 index 00000000..4786607b --- /dev/null +++ b/DynamicIsland/components/ScreenAssistant/ModelSelectionPanel.swift @@ -0,0 +1,576 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI +import Defaults + +private func applyModelSelectionCornerMask(_ view: NSView, radius: CGFloat) { + view.wantsLayer = true + view.layer?.masksToBounds = true + view.layer?.cornerRadius = radius + view.layer?.backgroundColor = NSColor.clear.cgColor + if #available(macOS 13.0, *) { + view.layer?.cornerCurve = .continuous + } +} + +// MARK: - Model Selection Panel +class ModelSelectionPanel: NSPanel { + + init() { + super.init( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + + setupWindow() + setupContentView() + } + + override var canBecomeKey: Bool { + return true // Can receive focus for interaction + } + + override var canBecomeMain: Bool { + return true + } + + // Handle ESC key globally for the panel + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { // ESC key + close() + } else { + super.keyDown(with: event) + } + } + + private func setupWindow() { + backgroundColor = .clear + isOpaque = false + hasShadow = true + level = .floating + isMovableByWindowBackground = true // Enable dragging + titlebarAppearsTransparent = true + titleVisibility = .hidden + isFloatingPanel = true + + styleMask.insert(.fullSizeContentView) + + collectionBehavior = [ + .canJoinAllSpaces, + .stationary, + .fullScreenAuxiliary + ] + + ScreenCaptureVisibilityManager.shared.register(self, scope: .panelsOnly) + + acceptsMouseMovedEvents = true + } + + private func setupContentView() { + let contentView = ModelSelectionView() + let hostingView = NSHostingView(rootView: contentView) + applyModelSelectionCornerMask(hostingView, radius: 16) + self.contentView = hostingView + + // Set size for model selection panel + let preferredSize = CGSize(width: 450, height: 600) + hostingView.setFrameSize(preferredSize) + setContentSize(preferredSize) + } + + func positionInCenter() { + guard let screen = NSScreen.main else { return } + + let screenFrame = screen.visibleFrame + let panelFrame = frame + + // Position in the center of the screen + let xPosition = (screenFrame.width - panelFrame.width) / 2 + screenFrame.minX + let yPosition = (screenFrame.height - panelFrame.height) / 2 + screenFrame.minY + + setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) + } + + deinit { + ScreenCaptureVisibilityManager.shared.unregister(self) + } +} + +// MARK: - Model Selection View +struct ModelSelectionView: View { + private let primaryProviders: [AIModelProvider] = [.gemini, .openai, .claude, .local] + @State private var selectedProvider: AIModelProvider = Defaults[.selectedAIProvider] + @State private var selectedModel: AIModel? = Defaults[.selectedAIModel] + @State private var enableThinking: Bool = Defaults[.enableThinkingMode] + + // API Keys + @State private var geminiApiKey: String = Defaults[.geminiApiKey] + @State private var openaiApiKey: String = Defaults[.openaiApiKey] + @State private var claudeApiKey: String = Defaults[.claudeApiKey] + @State private var localEndpoint: String = Defaults[.localModelEndpoint] + @State private var groqApiKey: String = Defaults[.groqApiKey] + + @State private var showingApiKeyAlert = false + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("AI Model Selection") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.primary) + + Text("Choose your preferred AI model and configuration") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Button(action: closePanel) { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + .help("Close") + } + .padding(.horizontal, 24) + .padding(.vertical, 16) + .background(Color.gray.opacity(0.05)) + + Divider() + + // Content + ScrollView { + VStack(spacing: 24) { + // Provider Selection + VStack(alignment: .leading, spacing: 12) { + Text("AI Provider") + .font(.headline) + .foregroundColor(.primary) + + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 12) { + ForEach(primaryProviders) { provider in + ProviderCard( + provider: provider, + isSelected: selectedProvider == provider, + onSelect: { selectProvider(provider) } + ) + } + } + + ProviderCard( + provider: .groq, + isSelected: selectedProvider == .groq, + onSelect: { selectProvider(.groq) }, + isWide: true + ) + } + + Divider() + + // Model Selection + if !selectedProvider.supportedModels.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("\(selectedProvider.displayName) Models") + .font(.headline) + .foregroundColor(.primary) + + VStack(spacing: 8) { + ForEach(selectedProvider.supportedModels) { model in + ModelRow( + model: model, + isSelected: selectedModel?.id == model.id, + onSelect: { selectedModel = model } + ) + } + } + } + } + + Divider() + + // Thinking Mode Toggle + if selectedModel?.supportsThinking == true { + VStack(alignment: .leading, spacing: 12) { + Text("Reasoning Mode") + .font(.headline) + .foregroundColor(.primary) + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Enable Thinking Mode") + .font(.body) + .foregroundColor(.primary) + + Text("Shows the model's reasoning process before the final answer") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Toggle("", isOn: $enableThinking) + .toggleStyle(SwitchToggleStyle()) + } + .padding(16) + .background(Color.gray.opacity(0.1)) + .cornerRadius(12) + } + } + + Divider() + + // API Configuration + VStack(alignment: .leading, spacing: 12) { + Text("API Configuration") + .font(.headline) + .foregroundColor(.primary) + + ApiConfigurationSection( + provider: selectedProvider, + geminiApiKey: $geminiApiKey, + openaiApiKey: $openaiApiKey, + claudeApiKey: $claudeApiKey, + localEndpoint: $localEndpoint, + groqApiKey: $groqApiKey + ) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 20) + } + + Divider() + + // Footer with Save/Cancel buttons + HStack { + Button("Cancel") { + closePanel() + } + .buttonStyle(PlainButtonStyle()) + + Spacer() + + Button("Save Configuration") { + saveConfiguration() + } + .buttonStyle(.borderedProminent) + .disabled(!isConfigurationValid) + } + .padding(.horizontal, 24) + .padding(.vertical, 16) + } + .background(ModelSelectionVisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.3), radius: 20, x: 0, y: 10) + .onAppear { + loadCurrentConfiguration() + } + } + + private var isConfigurationValid: Bool { + switch selectedProvider { + case .gemini: + return !geminiApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .openai: + return !openaiApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .claude: + return !claudeApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .local: + return !localEndpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .groq: + return !groqApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + private func loadCurrentConfiguration() { + selectedProvider = Defaults[.selectedAIProvider] + selectedModel = Defaults[.selectedAIModel] + ensureValidModelSelection() + enableThinking = Defaults[.enableThinkingMode] + + geminiApiKey = Defaults[.geminiApiKey] + openaiApiKey = Defaults[.openaiApiKey] + claudeApiKey = Defaults[.claudeApiKey] + localEndpoint = Defaults[.localModelEndpoint] + groqApiKey = Defaults[.groqApiKey] + } + + private func saveConfiguration() { + ensureValidModelSelection() + + Defaults[.selectedAIProvider] = selectedProvider + Defaults[.selectedAIModel] = selectedModel + Defaults[.enableThinkingMode] = enableThinking + + Defaults[.geminiApiKey] = geminiApiKey + Defaults[.openaiApiKey] = openaiApiKey + Defaults[.claudeApiKey] = claudeApiKey + Defaults[.localModelEndpoint] = localEndpoint + Defaults[.groqApiKey] = groqApiKey + + closePanel() + + // Notify that configuration changed + NotificationCenter.default.post(name: .aiModelConfigurationChanged, object: nil) + } + + private func selectProvider(_ provider: AIModelProvider) { + selectedProvider = provider + ensureValidModelSelection() + } + + private func ensureValidModelSelection() { + if selectedModel == nil || !selectedProvider.supportedModels.contains(where: { $0.id == selectedModel?.id }) { + selectedModel = selectedProvider.supportedModels.first + } + } + + private func closePanel() { + if let window = NSApp.windows.first(where: { $0 is ModelSelectionPanel }) { + window.close() + } + } +} + +// MARK: - Provider Card +struct ProviderCard: View { + private let wideCardMinHeight: CGFloat = 110 + let provider: AIModelProvider + let isSelected: Bool + let onSelect: () -> Void + var isWide: Bool = false + + var body: some View { + VStack(spacing: 12) { + // Icon + ZStack { + Circle() + .fill(isSelected ? Color.blue : Color.gray.opacity(0.2)) + .frame(width: 50, height: 50) + + Image(systemName: iconForProvider(provider)) + .font(.title2) + .foregroundColor(isSelected ? .white : .primary) + } + + // Name and description + VStack(spacing: 4) { + Text(provider.displayName) + .font(.headline) + .foregroundColor(.primary) + + Text(provider.description) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .lineLimit(2) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? Color.blue.opacity(0.1) : Color.gray.opacity(0.05)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2) + ) + ) + .onTapGesture { + onSelect() + } + .animation(.easeInOut(duration: 0.2), value: isSelected) + .frame(maxWidth: .infinity, minHeight: isWide ? wideCardMinHeight : nil) + } + + private func iconForProvider(_ provider: AIModelProvider) -> String { + switch provider { + case .gemini: return "sparkles" + case .openai: return "brain.head.profile" + case .claude: return "doc.text" + case .local: return "server.rack" + case .groq: return "bolt.fill" + } + } +} + +// MARK: - Model Row +struct ModelRow: View { + let model: AIModel + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(model.name) + .font(.body) + .foregroundColor(.primary) + + if model.supportsThinking { + Text("Supports reasoning mode") + .font(.caption) + .foregroundColor(.blue) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.blue.opacity(0.1)) + .cornerRadius(4) + } + } + + Spacer() + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.blue) + .font(.title3) + } else { + Circle() + .stroke(Color.gray.opacity(0.5), lineWidth: 2) + .frame(width: 20, height: 20) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? Color.blue.opacity(0.1) : Color.clear) + ) + .onTapGesture { + onSelect() + } + .animation(.easeInOut(duration: 0.2), value: isSelected) + } +} + +// MARK: - API Configuration Section +struct ApiConfigurationSection: View { + let provider: AIModelProvider + @Binding var geminiApiKey: String + @Binding var openaiApiKey: String + @Binding var claudeApiKey: String + @Binding var localEndpoint: String + @Binding var groqApiKey: String + + var body: some View { + VStack(spacing: 12) { + switch provider { + case .gemini: + ApiKeyField( + title: "Gemini API Key", + placeholder: "Enter your Gemini API key", + value: $geminiApiKey, + helpText: "Get your API key from Google AI Studio" + ) + + case .openai: + ApiKeyField( + title: "OpenAI API Key", + placeholder: "Enter your OpenAI API key", + value: $openaiApiKey, + helpText: "Get your API key from OpenAI Platform" + ) + + case .claude: + ApiKeyField( + title: "Claude API Key", + placeholder: "Enter your Claude API key", + value: $claudeApiKey, + helpText: "Get your API key from Anthropic Console" + ) + + case .local: + ApiKeyField( + title: "Local Endpoint", + placeholder: "http://localhost:11434", + value: $localEndpoint, + helpText: "Ollama or compatible API endpoint", + isSecure: false + ) + case .groq: + ApiKeyField( + title: "Groq API Key", + placeholder: "Enter your Groq API key", + value: $groqApiKey, + helpText: "Get your API key from Groq Console" + ) + } + } + .padding(16) + .background(Color.gray.opacity(0.05)) + .cornerRadius(12) + } +} + +// MARK: - API Key Field +struct ApiKeyField: View { + let title: String + let placeholder: String + @Binding var value: String + let helpText: String + var isSecure: Bool = true + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.primary) + + if isSecure { + SecureField(placeholder, text: $value) + .textFieldStyle(.roundedBorder) + } else { + TextField(placeholder, text: $value) + .textFieldStyle(.roundedBorder) + } + + Text(helpText) + .font(.caption) + .foregroundColor(.secondary) + } + } +} + +// MARK: - Visual Effect View +struct ModelSelectionVisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +// MARK: - Notification Extension +extension Notification.Name { + static let aiModelConfigurationChanged = Notification.Name("aiModelConfigurationChanged") +} diff --git a/DynamicIsland/components/ScreenAssistant/ScreenAssistantPanel.swift b/DynamicIsland/components/ScreenAssistant/ScreenAssistantPanel.swift new file mode 100644 index 00000000..dc041663 --- /dev/null +++ b/DynamicIsland/components/ScreenAssistant/ScreenAssistantPanel.swift @@ -0,0 +1,263 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI +import Defaults +import UniformTypeIdentifiers + +// MARK: - Backward Compatibility Wrapper +class ScreenAssistantPanel: NSPanel { + + init() { + super.init( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + + // This is now a compatibility wrapper + // The actual functionality is handled by the new ChatPanels + setupWindow() + } + + override var canBecomeKey: Bool { + return false + } + + override var canBecomeMain: Bool { + return false + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { // ESC key + close() + } else { + super.keyDown(with: event) + } + } + + private func setupWindow() { + backgroundColor = .clear + isOpaque = false + hasShadow = false + level = .floating + isMovableByWindowBackground = false + titlebarAppearsTransparent = true + titleVisibility = .hidden + isFloatingPanel = true + + collectionBehavior = [ + .canJoinAllSpaces, + .stationary, + .fullScreenAuxiliary + ] + + ScreenCaptureVisibilityManager.shared.register(self, scope: .panelsOnly) + + // Set minimal size since this is just a wrapper + setContentSize(CGSize(width: 1, height: 1)) + setFrameOrigin(CGPoint(x: -1000, y: -1000)) // Move offscreen + } + + // Trigger the new panel system + override func makeKeyAndOrderFront(_ sender: Any?) { + ScreenAssistantManager.shared.showPanels() + // Don't actually show this wrapper panel + } + + override func close() { + ScreenAssistantManager.shared.closePanels() + super.close() + } + + deinit { + ScreenCaptureVisibilityManager.shared.unregister(self) + } +} + +// Keep the old panel view for any existing references, but make it redirect +struct ScreenAssistantPanelView: View { + let onClose: () -> Void + + var body: some View { + VStack { + Text("Screen Assistant") + .onAppear { + // Redirect to new panel system + ScreenAssistantManager.shared.showPanels() + onClose() + } + } + .frame(width: 1, height: 1) + } +} + +// MARK: - Shared Components (moved to ChatPanels.swift for the new implementation) +// These are kept here for backward compatibility only + +struct MarkdownText: View { + let content: String + + var body: some View { + // Simple markdown parsing for now + Text(parseMarkdown(content)) + .font(.system(size: 14)) + .textSelection(.enabled) + } + + private func parseMarkdown(_ text: String) -> AttributedString { + var attributedString = AttributedString(text) + + // Simple bold parsing (**text**) + let boldPattern = #"\*\*(.*?)\*\*"# + if let regex = try? NSRegularExpression(pattern: boldPattern) { + let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) + for match in matches.reversed() { + if let range = Range(match.range, in: text) { + let boldText = String(text[range]).replacingOccurrences(of: "**", with: "") + if let attrRange = Range(match.range, in: attributedString) { + var boldAttributedText = AttributedString(boldText) + boldAttributedText.font = .system(size: 14, weight: .bold) + attributedString.replaceSubrange(attrRange, with: boldAttributedText) + } + } + } + } + + return attributedString + } +} + +struct AttachedFileChip: View { + let file: ScreenAssistantFile + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 6) { + Image(systemName: file.type.iconName) + .foregroundColor(.blue) + .font(.system(size: 12)) + + Text(file.name) + .font(.system(size: 11)) + .lineLimit(1) + .truncationMode(.middle) + + Button(action: onRemove) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.gray) + .font(.system(size: 10)) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.1)) + .cornerRadius(6) + .frame(maxWidth: 120) + } +} + +struct AddFilesButton: View { + @ObservedObject var screenAssistantManager = ScreenAssistantManager.shared + + var body: some View { + Button(action: selectFiles) { + Image(systemName: "plus.circle.fill") + .foregroundColor(.blue) + .font(.system(size: 20)) + } + .buttonStyle(PlainButtonStyle()) + .help("Add files") + } + + private func selectFiles() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowedContentTypes = [.data, .image, .movie, .audio, .text, .pdf] + + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + panel.beginSheetModal(for: window) { response in + if response == .OK { + screenAssistantManager.addFiles(panel.urls) + } + } + } else { + if panel.runModal() == .OK { + screenAssistantManager.addFiles(panel.urls) + } + } + } +} + +struct RecordingButton: View { + @ObservedObject var screenAssistantManager = ScreenAssistantManager.shared + + var body: some View { + Button(action: { + screenAssistantManager.toggleRecording() + }) { + ZStack { + Circle() + .fill(screenAssistantManager.isRecording ? Color.red : Color.blue.opacity(0.2)) + .frame(width: 32, height: 32) + + Image(systemName: screenAssistantManager.isRecording ? "stop.fill" : "mic.fill") + .foregroundColor(screenAssistantManager.isRecording ? .white : .blue) + .font(.system(size: 14)) + } + } + .buttonStyle(PlainButtonStyle()) + .help(screenAssistantManager.isRecording ? "Stop recording" : "Start recording") + .scaleEffect(screenAssistantManager.isRecording ? 1.1 : 1.0) + .animation(.easeInOut(duration: 0.2), value: screenAssistantManager.isRecording) + } +} + +struct ApiKeyAlertView: View { + @State private var apiKey = "" + + var body: some View { + VStack(spacing: 12) { + TextField("Enter your Gemini API Key", text: $apiKey) + .textFieldStyle(.roundedBorder) + + HStack { + Button("Cancel") { + // Dialog will close automatically + } + + Button("Save") { + Defaults[.geminiApiKey] = apiKey + } + .disabled(apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .frame(width: 300) + } +} + +#Preview { + ScreenAssistantPanelView { + print("Close panel") + } +} \ No newline at end of file diff --git a/DynamicIsland/components/ScreenAssistant/ScreenshotSnippingTool.swift b/DynamicIsland/components/ScreenAssistant/ScreenshotSnippingTool.swift new file mode 100644 index 00000000..877b6097 --- /dev/null +++ b/DynamicIsland/components/ScreenAssistant/ScreenshotSnippingTool.swift @@ -0,0 +1,202 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI +import Foundation + +// MARK: - Simplified Screenshot Tool (Based on ScreenshotApp Research) +class ScreenshotSnippingTool: NSObject, ObservableObject { + static let shared = ScreenshotSnippingTool() + + @Published var isSnipping = false + private var completion: ((URL) -> Void)? + + // MARK: - Screenshot Types (Based on ScreenshotApp) + enum ScreenshotType { + case full + case window + case area + + var processArguments: [String] { + switch self { + case .full: + return ["-c"] // -c = clipboard + case .window: + return ["-cw"] // -c = clipboard, -w = window selection + case .area: + return ["-cs"] // -c = clipboard, -s = area selection + } + } + + var displayName: String { + switch self { + case .full: return "Full Screen" + case .window: return "Window" + case .area: return "Area" + } + } + + var iconName: String { + switch self { + case .full: return "rectangle.dashed" + case .window: return "macwindow" + case .area: return "viewfinder.rectangular" + } + } + } + + enum ScreenshotError: Error { + case captureFailed + case noImageInPasteboard + case saveFailed + } + + override init() { + super.init() + } + + // MARK: - Enhanced API (Based on ScreenshotApp Implementation) + func startSnipping(type: ScreenshotType = .area, completion: @escaping (URL) -> Void) { + guard !isSnipping else { return } + + print("🖼️ ScreenshotTool: Starting \(type.displayName.lowercased()) screenshot using screencapture tool") + self.completion = completion + isSnipping = true + + // Use the same approach as ScreenshotApp - direct screencapture command + takeScreenshot(type: type) + } + + // MARK: - Convenience Methods for Different Types + func startAreaScreenshot(completion: @escaping (URL) -> Void) { + startSnipping(type: .area, completion: completion) + } + + func startFullScreenshot(completion: @escaping (URL) -> Void) { + startSnipping(type: .full, completion: completion) + } + + func startWindowScreenshot(completion: @escaping (URL) -> Void) { + startSnipping(type: .window, completion: completion) + } + + // MARK: - ScreenshotApp-Style Implementation + private func takeScreenshot(type: ScreenshotType) { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + task.arguments = type.processArguments + + do { + print("📸 ScreenshotTool: Running screencapture \(type.processArguments.joined(separator: " ")) command") + try task.run() + task.waitUntilExit() + + // Process completed - check if successful + if task.terminationStatus == 0 { + print("✅ ScreenshotTool: screencapture completed successfully") + getImageFromPasteboard() + } else { + print("❌ ScreenshotTool: screencapture failed with status: \(task.terminationStatus)") + finishSnipping() + } + + } catch { + print("❌ ScreenshotTool: Failed to run screencapture: \(error)") + finishSnipping() + } + } + + // MARK: - Pasteboard Integration (ScreenshotApp Pattern) + private func getImageFromPasteboard() { + print("� ScreenshotTool: Checking pasteboard for screenshot") + + guard NSPasteboard.general.canReadItem(withDataConformingToTypes: NSImage.imageTypes) else { + print("❌ ScreenshotTool: No image data in pasteboard") + finishSnipping() + return + } + + guard let image = NSImage(pasteboard: NSPasteboard.general) else { + print("❌ ScreenshotTool: Failed to create NSImage from pasteboard") + finishSnipping() + return + } + + print("✅ ScreenshotTool: Got image from pasteboard: \(image.size)") + saveImageAndComplete(image: image) + } + + // MARK: - Image Saving + private func saveImageAndComplete(image: NSImage) { + let filename = "screenshot_\(Int(Date().timeIntervalSince1970)).png" + let screenshotDir = ScreenAssistantManager.screenshotDataDirectory + + // Ensure directory exists + if !FileManager.default.fileExists(atPath: screenshotDir.path) { + try? FileManager.default.createDirectory(at: screenshotDir, withIntermediateDirectories: true) + } + + let screenshotURL = screenshotDir.appendingPathComponent(filename) + + // Convert NSImage to PNG data + guard let imageData = image.tiffRepresentation, + let bitmapRep = NSBitmapImageRep(data: imageData), + let pngData = bitmapRep.representation(using: NSBitmapImageRep.FileType.png, properties: [:]) else { + print("❌ ScreenshotTool: Failed to convert image to PNG") + finishSnipping() + return + } + + do { + try pngData.write(to: screenshotURL) + print("✅ ScreenshotTool: Screenshot saved to: \(screenshotURL.path)") + + // Execute completion callback + let callback = self.completion + self.completion = nil + finishSnipping() + + // Call completion on main thread + DispatchQueue.main.async { + callback?(screenshotURL) + } + + } catch { + print("❌ ScreenshotTool: Failed to save image: \(error)") + finishSnipping() + } + } + + // MARK: - State Management + private func finishSnipping() { + print("🔄 ScreenshotTool: Finishing snipping process") + + DispatchQueue.main.async { + self.isSnipping = false + self.completion = nil + print("✅ ScreenshotTool: Snipping process completed") + } + } + + func cancelSnipping() { + print("❌ ScreenshotTool: Snipping cancelled") + finishSnipping() + } +} + diff --git a/DynamicIsland/components/Settings/AnimationEditorView.swift b/DynamicIsland/components/Settings/AnimationEditorView.swift new file mode 100644 index 00000000..9480841a --- /dev/null +++ b/DynamicIsland/components/Settings/AnimationEditorView.swift @@ -0,0 +1,638 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import LottieUI +import Defaults + +struct AnimationEditorView: View { + @Environment(\.dismiss) var dismiss + + let sourceURL: URL + let isRemoteURL: Bool + @Binding var animation: CustomIdleAnimation? + let existingAnimation: CustomIdleAnimation? + + @State private var name: String + @State private var speed: CGFloat = 1.0 + @State private var scale: CGFloat = 1.0 + @State private var offsetX: CGFloat = 0 + @State private var offsetY: CGFloat = 0 + @State private var cropWidth: CGFloat = 30 + @State private var cropHeight: CGFloat = 20 + @State private var rotation: CGFloat = 0 + @State private var opacity: CGFloat = 1.0 + @State private var paddingBottom: CGFloat = 0 + @State private var expandWithAnimation: Bool = false + @State private var loopMode: AnimationLoopMode = .loop + + @State private var showingError = false + @State private var errorMessage = "" + @State private var isImporting = false + + // Preview state + @State private var previewScale: CGFloat = 10.0 + + init(sourceURL: URL, isRemoteURL: Bool, animation: Binding, existingAnimation: CustomIdleAnimation? = nil) { + self.sourceURL = sourceURL + self.isRemoteURL = isRemoteURL + self._animation = animation + self.existingAnimation = existingAnimation + + // Initialize from existing animation if editing, otherwise from URL + if let existing = existingAnimation { + _name = State(initialValue: existing.name) + _speed = State(initialValue: existing.speed) + + // Load from overrides if they exist + let config = Defaults[.animationTransformOverrides][existing.id.uuidString] ?? .default + _scale = State(initialValue: config.scale) + _offsetX = State(initialValue: config.offsetX) + _offsetY = State(initialValue: config.offsetY) + _cropWidth = State(initialValue: config.cropWidth) + _cropHeight = State(initialValue: config.cropHeight) + _rotation = State(initialValue: config.rotation) + _opacity = State(initialValue: config.opacity) + _paddingBottom = State(initialValue: config.paddingBottom) + _expandWithAnimation = State(initialValue: config.expandWithAnimation) + _loopMode = State(initialValue: config.loopMode) + } else { + let fileName = sourceURL.deletingPathExtension().lastPathComponent + _name = State(initialValue: fileName) + } + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + HStack(spacing: 0) { + previewPanel + .frame(minWidth: 320, maxWidth: 360) + Divider() + controlsPanel + } + Divider() + footer + } + .frame(idealWidth: 980, idealHeight: 640) + .frame(minWidth: 860, minHeight: 620) + .fixedSize() + .alert("Import Error", isPresented: $showingError) { + Button("OK") { } + } message: { + Text(errorMessage) + } + } + + // MARK: - Layout Sections + + private var header: some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 4) { + Text(existingAnimation != nil ? "Edit Animation" : "Customize Animation") + .font(.title2) + .fontWeight(.semibold) + Text(sourceDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + } + .padding() + } + + private var footer: some View { + HStack { + Label("Blue outline matches the notch canvas", systemImage: "info.circle") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button(existingAnimation != nil ? "Save Changes" : "Import") { + importAnimation() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(name.isEmpty || isImporting) + } + .padding() + } + + private var previewPanel: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Live Preview") + .font(.headline) + Text("Dash outline equals the exact notch bounds.") + .font(.caption) + .foregroundStyle(.secondary) + } + previewCanvas + previewZoomControls + previewStats + Spacer(minLength: 0) + } + .padding(20) + } + + @ViewBuilder + private var previewCanvas: some View { + ZStack { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.15), radius: 16, x: 0, y: 8) + + Rectangle() + .strokeBorder(Color.blue.opacity(0.55), style: StrokeStyle(lineWidth: 2, dash: [6, 3])) + .frame(width: 30 * previewScale, height: 20 * previewScale) + + Group { + LottieView(state: LUStateData( + type: .loadedFrom(sourceURL), + speed: speed, + loopMode: loopMode.lottieLoopMode + )) + } + .frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale) + .offset(x: offsetX * previewScale, y: offsetY * previewScale) + .rotationEffect(.degrees(rotation)) + .opacity(opacity) + .padding(.bottom, paddingBottom * previewScale) + .clipped() + } + .frame(maxWidth: .infinity) + .frame(height: 280) + } + + private var previewZoomControls: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("Preview Zoom", systemImage: "magnifyingglass") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(previewScale))x") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + HStack(spacing: 10) { + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + previewScale = max(1, previewScale - 1) + } + } label: { + Image(systemName: "minus") + .frame(width: 28, height: 28) + } + .buttonStyle(.bordered) + .controlSize(.mini) + + Slider(value: $previewScale, in: 1...10) + + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + previewScale = min(10, previewScale + 1) + } + } label: { + Image(systemName: "plus") + .frame(width: 28, height: 28) + } + .buttonStyle(.bordered) + .controlSize(.mini) + + Button("Reset") { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + previewScale = 10 + } + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + } + } + + private var previewStats: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Current Transform") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + PreviewStatChip(title: "Scale", value: String(format: "%.2fx", scale)) + PreviewStatChip(title: "Output", value: "\(Int(cropWidth * scale))x\(Int(cropHeight * scale)) px") + PreviewStatChip(title: "Offset", value: String(format: "%.1f / %.1f", offsetX, offsetY)) + } + } + } + + private var controlsPanel: some View { + ScrollView { + VStack(spacing: 16) { + EditorSectionCard(title: "Animation Details", subtitle: "Give it a friendly name and control playback speed.") { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text("Animation Name") + .font(.subheadline) + .fontWeight(.medium) + TextField("Name", text: $name) + .textFieldStyle(.roundedBorder) + } + ParameterSliderRow( + title: "Playback Speed", + value: $speed, + range: 0.1...3.0, + formatter: { String(format: "%.2fx", $0) }, + resetLabel: "1x", + resetValue: 1.0, + step: 0.05 + ) + VStack(alignment: .leading, spacing: 6) { + Text("Loop Mode") + .font(.subheadline) + .fontWeight(.medium) + Picker("Loop Mode", selection: $loopMode) { + ForEach(AnimationLoopMode.allCases, id: \.self) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + } + } + } + + EditorSectionCard(title: "Size & Crop", subtitle: "Match the notch footprint or intentionally exceed it.") { + VStack(spacing: 12) { + ParameterSliderRow( + title: "Scale", + value: $scale, + range: 0.1...5.0, + formatter: { String(format: "%.2fx", $0) }, + resetLabel: "Reset", + resetValue: 1.0 + ) + ParameterSliderRow( + title: "Visible Width", + value: $cropWidth, + range: 5...100, + formatter: { "\(Int($0)) px" }, + resetLabel: "30", + resetValue: 30 + ) + ParameterSliderRow( + title: "Visible Height", + value: $cropHeight, + range: 5...100, + formatter: { "\(Int($0)) px" }, + resetLabel: "20", + resetValue: 20 + ) + } + } + + EditorSectionCard(title: "Position & Padding", subtitle: "Nudge the animation inside the notch.") { + VStack(spacing: 12) { + ParameterSliderRow( + title: "Horizontal Offset", + value: $offsetX, + range: -50...50, + formatter: { String(format: "%.1f px", $0) }, + resetLabel: "Center", + resetValue: 0 + ) + ParameterSliderRow( + title: "Vertical Offset", + value: $offsetY, + range: -50...50, + formatter: { String(format: "%.1f px", $0) }, + resetLabel: "Center", + resetValue: 0 + ) + ParameterSliderRow( + title: "Bottom Padding", + value: $paddingBottom, + range: -20...20, + formatter: { String(format: "%.1f px", $0) }, + resetLabel: "Reset", + resetValue: 0 + ) + } + } + + EditorSectionCard(title: "Transform & Display", subtitle: "Dial in the final polish.") { + VStack(spacing: 12) { + ParameterSliderRow( + title: "Rotation", + value: $rotation, + range: -180...180, + formatter: { String(format: "%.0f°", $0) }, + resetLabel: "Reset", + resetValue: 0, + step: 1 + ) + ParameterSliderRow( + title: "Opacity", + value: $opacity, + range: 0...1, + formatter: { "\(Int($0 * 100))%" }, + resetLabel: "100%", + resetValue: 1, + step: 0.01 + ) + Toggle(isOn: $expandWithAnimation) { + VStack(alignment: .leading, spacing: 4) { + Text("Expand notch to follow animation width") + .fontWeight(.medium) + Text("Enable when scaling beyond 30x20px so the Atoll opens gracefully.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + EditorSectionCard(title: "Quick Presets", subtitle: "Start from a popular layout and tweak from there.") { + let columns = [GridItem(.adaptive(minimum: 160), spacing: 12)] + LazyVGrid(columns: columns, spacing: 12) { + Button("Fit to Notch") { + withAnimation { + scale = 1.0 + cropWidth = 30 + cropHeight = 20 + offsetX = 0 + offsetY = 0 + rotation = 0 + paddingBottom = 0 + expandWithAnimation = false + } + } + .buttonStyle(.bordered) + + Button("Fill Notch") { + withAnimation { + scale = 1.5 + cropWidth = 30 + cropHeight = 20 + offsetX = 0 + offsetY = 0 + paddingBottom = 0 + expandWithAnimation = false + } + } + .buttonStyle(.bordered) + + Button("Scale Up 2x") { + withAnimation { + scale = 2.0 + expandWithAnimation = true + } + } + .buttonStyle(.bordered) + + Button("Scale Down") { + withAnimation { + scale = 0.75 + } + } + .buttonStyle(.bordered) + + Button("Reset All") { + withAnimation { + scale = 1.0 + cropWidth = 30 + cropHeight = 20 + offsetX = 0 + offsetY = 0 + rotation = 0 + opacity = 1.0 + speed = 1.0 + paddingBottom = 0 + expandWithAnimation = false + } + } + .buttonStyle(.bordered) + .tint(.red) + } + } + } + .padding(20) + } + .frame(minWidth: 500, maxWidth: .infinity, maxHeight: .infinity) + } + + private var sourceDescription: String { + if let existing = existingAnimation { + return existing.name + } + if isRemoteURL { + return sourceURL.absoluteString + } + return sourceURL.deletingPathExtension().lastPathComponent + } + + // MARK: - Import Logic + + private func importAnimation() { + guard !name.isEmpty else { return } + + isImporting = true + + // Create animation config with transform settings + let config = AnimationTransformConfig( + scale: scale, + offsetX: offsetX, + offsetY: offsetY, + cropWidth: cropWidth, + cropHeight: cropHeight, + rotation: rotation, + opacity: opacity, + paddingBottom: paddingBottom, + expandWithAnimation: expandWithAnimation, + loopMode: loopMode + ) + + // If editing existing animation, save transform config to overrides + if let existing = existingAnimation { + // Store only the transform config override + var overrides = Defaults[.animationTransformOverrides] + overrides[existing.id.uuidString] = config + Defaults[.animationTransformOverrides] = overrides + + print("✅ [AnimationEditor] Saved transform override for: \(existing.name)") + print("✅ [AnimationEditor] Override: \(config)") + + // Force view refresh by updating selectedIdleAnimation if this is the selected one + if Defaults[.selectedIdleAnimation]?.id == existing.id { + // Trigger refresh by re-setting the same animation + let current = Defaults[.selectedIdleAnimation] + Defaults[.selectedIdleAnimation] = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + Defaults[.selectedIdleAnimation] = current + } + } + + dismiss() + return + } + + // Import with config (new animation) + let result = IdleAnimationManager.shared.importLottieFile( + from: sourceURL, + name: name, + speed: speed + ) + + switch result { + case .success(let importedAnimation): + animation = importedAnimation + dismiss() + + case .failure(let error): + errorMessage = error.localizedDescription + showingError = true + isImporting = false + } + } +} + +// MARK: - Supporting Views + +private struct EditorSectionCard: View { + let title: String + let subtitle: String? + let content: Content + + init(title: String, subtitle: String? = nil, @ViewBuilder content: () -> Content) { + self.title = title + self.subtitle = subtitle + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + content + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.white.opacity(0.05), lineWidth: 1) + ) + } +} + +private struct ParameterSliderRow: View { + let title: String + @Binding var value: CGFloat + let range: ClosedRange + let formatter: (CGFloat) -> String + var resetLabel: String? = nil + var resetValue: CGFloat? = nil + var step: CGFloat? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title) + .fontWeight(.medium) + Spacer() + Text(formatter(value)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + HStack(spacing: 10) { + slider + if let resetLabel, let resetValue { + Button(resetLabel) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { + value = resetValue + } + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + } + } + } + + @ViewBuilder + private var slider: some View { + let binding = Binding( + get: { Double(value) }, + set: { value = CGFloat($0) } + ) + let bounds = Double(range.lowerBound)...Double(range.upperBound) + if let step { + Slider(value: binding, in: bounds, step: Double(step)) + } else { + Slider(value: binding, in: bounds) + } + } +} + +private struct PreviewStatChip: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + Text(value) + .font(.caption) + .fontWeight(.semibold) + .monospacedDigit() + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.white.opacity(0.06)) + ) + } +} + +// MARK: - Preview +#Preview { + AnimationEditorView( + sourceURL: URL(string: "https://assets9.lottiefiles.com/packages/lf20_mniampqn.json")!, + isRemoteURL: true, + animation: .constant(nil) + ) +} diff --git a/DynamicIsland/components/Settings/EditPanelView.swift b/DynamicIsland/components/Settings/EditPanelView.swift index 66eb36c8..7f22c03a 100644 --- a/DynamicIsland/components/Settings/EditPanelView.swift +++ b/DynamicIsland/components/Settings/EditPanelView.swift @@ -1,9 +1,24 @@ -// -// EditPanelView.swift -// DynamicIsland -// -// Created by Richard Kunkli on 12/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI diff --git a/DynamicIsland/components/Settings/ExtensionsSettings.swift b/DynamicIsland/components/Settings/ExtensionsSettings.swift new file mode 100644 index 00000000..cda6147a --- /dev/null +++ b/DynamicIsland/components/Settings/ExtensionsSettings.swift @@ -0,0 +1,518 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults +import AtollExtensionKit + +struct ExtensionsSettingsView: View { + @ObservedObject private var authManager = ExtensionAuthorizationManager.shared + @State private var searchText = "" + @State private var selectedEntry: ExtensionAuthorizationEntry? + @State private var showingRemoveConfirmation = false + + private func highlightID(_ title: String) -> String { + "extensions-\(title)" + } + + private var filteredEntries: [ExtensionAuthorizationEntry] { + guard !searchText.isEmpty else { return authManager.entries } + let query = searchText.lowercased() + return authManager.entries.filter { + $0.bundleIdentifier.lowercased().contains(query) || + $0.appName.lowercased().contains(query) + } + } + + var body: some View { + Form { + globalTogglesSection + + if authManager.isExtensionsFeatureEnabled { + authorizedAppsSection + } + } + .navigationTitle("Extensions") + .alert("Remove Extension", isPresented: $showingRemoveConfirmation, presenting: selectedEntry) { entry in + Button("Cancel", role: .cancel) { } + Button("Remove", role: .destructive) { + authManager.removeEntry(bundleIdentifier: entry.bundleIdentifier) + selectedEntry = nil + } + } message: { entry in + Text("Remove \(entry.appName) from the authorized extensions list? This will dismiss all active live activities, lock screen widgets, and notch experiences from this app.") + } + } + + private var globalTogglesSection: some View { + Section { + Defaults.Toggle(String(localized:"Enable third-party extensions"), key: .enableThirdPartyExtensions) + .settingsHighlight(id: highlightID("Enable third-party extensions")) + + if Defaults[.enableThirdPartyExtensions] { + Defaults.Toggle(String(localized:"Allow extension live activities"), key: .enableExtensionLiveActivities) + .settingsHighlight(id: highlightID("Allow extension live activities")) + + Defaults.Toggle(String(localized:"Allow extension lock screen widgets"), key: .enableExtensionLockScreenWidgets) + .settingsHighlight(id: highlightID("Allow extension lock screen widgets")) + + Defaults.Toggle(String(localized:"Allow extension notch experiences"), key: .enableExtensionNotchExperiences) + .settingsHighlight(id: highlightID("Allow extension notch experiences")) + + if Defaults[.enableExtensionNotchExperiences] { + VStack(alignment: .leading, spacing: 8) { + Defaults.Toggle(String(localized:"Show extension tabs"), key: .enableExtensionNotchTabs) + .tint(.accentColor) + Defaults.Toggle(String(localized:"Allow minimalistic overrides"), key: .enableExtensionNotchMinimalisticOverrides) + .tint(.accentColor) + Defaults.Toggle(String(localized:"Allow interactive web content"), key: .enableExtensionNotchInteractiveWebViews) + .tint(.accentColor) + } + .padding(.leading, 4) + } + + Defaults.Toggle(String(localized:"Enable extension diagnostics logging"), key: .extensionDiagnosticsLoggingEnabled) + .settingsHighlight(id: highlightID("Enable extension diagnostics logging")) + } + } header: { + Text("Global Settings") + } footer: { + if Defaults[.enableThirdPartyExtensions] { + Text("Third-party apps using AtollExtensionKit can display live activities, lock screen widgets, and dedicated notch experiences. Toggle features above or manage individual app permissions below.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Enable extensions to allow third-party apps to display live activities and lock screen widgets in Atoll.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var authorizedAppsSection: some View { + Section { + if authManager.entries.isEmpty { + VStack(alignment: .center, spacing: 12) { + Image(systemName: "puzzlepiece.extension") + .font(.system(size: 48)) + .foregroundStyle(.secondary.opacity(0.5)) + + Text("No extensions yet") + .font(.headline) + .foregroundStyle(.secondary) + + Text("Apps using AtollExtensionKit will appear here once they request permission") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else { + if authManager.entries.count > 3 { + TextField("Search extensions...", text: $searchText) + .textFieldStyle(.roundedBorder) + } + + ForEach(filteredEntries) { entry in + ExtensionEntryRow(entry: entry, onRemove: { + selectedEntry = entry + showingRemoveConfirmation = true + }) + } + } + } header: { + HStack { + Text("App Permissions") + Spacer() + if !authManager.entries.isEmpty { + Text("\(authManager.entries.count) \(authManager.entries.count == 1 ? "app" : "apps")") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .settingsHighlight(id: highlightID("App permissions list")) + } footer: { + if !authManager.entries.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Permission States:") + .font(.caption.weight(.semibold)) + + HStack(spacing: 16) { + Label("Authorized", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + Label("Pending", systemImage: "clock.fill") + .foregroundStyle(.orange) + Label("Denied/Revoked", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + } + .font(.caption2) + } + .foregroundStyle(.secondary) + } + } + } +} + +@MainActor +private struct ExtensionEntryRow: View { + @ObservedObject private var authManager = ExtensionAuthorizationManager.shared + @ObservedObject private var liveActivityManager = ExtensionLiveActivityManager.shared + @ObservedObject private var widgetManager = ExtensionLockScreenWidgetManager.shared + @ObservedObject private var notchExperienceManager = ExtensionNotchExperienceManager.shared + let entry: ExtensionAuthorizationEntry + let onRemove: () -> Void + + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Main row + HStack(spacing: 12) { + // Status indicator + statusIndicator + + // App info + VStack(alignment: .leading, spacing: 3) { + Text(entry.appName) + .font(.system(size: 13, weight: .medium)) + Text(entry.bundleIdentifier) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + Spacer() + + // Expand button + Button { + withAnimation(.snappy(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .buttonStyle(.plain) + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.snappy(duration: 0.2)) { + isExpanded.toggle() + } + } + + // Expanded details + if isExpanded { + expandedDetails + .padding(.top, 12) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.vertical, 8) + } + + private var statusIndicator: some View { + ZStack { + Circle() + .fill(statusColor.opacity(0.15)) + .frame(width: 32, height: 32) + + Image(systemName: statusIcon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(statusColor) + } + } + + private var statusColor: Color { + switch entry.status { + case .authorized: return .green + case .pending: return .orange + case .denied, .revoked: return .red + } + } + + private var statusIcon: String { + switch entry.status { + case .authorized: return "checkmark.circle.fill" + case .pending: return "clock.fill" + case .denied, .revoked: return "xmark.circle.fill" + } + } + + private var expandedDetails: some View { + VStack(alignment: .leading, spacing: 14) { + // Status info + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Status:") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(entry.status.rawValue.capitalized) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(statusColor.opacity(0.15)) + .foregroundStyle(statusColor) + .clipShape(Capsule()) + } + + if let grantedAt = entry.grantedAt { + infoRow(label: "Granted", value: formatDate(grantedAt)) + } + + if let lastActivity = entry.lastActivityAt { + infoRow(label: "Last Activity", value: formatDate(lastActivity)) + } + + if let deniedReason = entry.lastDeniedReason { + VStack(alignment: .leading, spacing: 3) { + Text("Last Denied Reason:") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(deniedReason) + .font(.caption) + .foregroundStyle(.red.opacity(0.9)) + } + } + } + + Divider() + + // Scopes section + if entry.status == .authorized { + scopeToggles + Divider() + } + + // Rate limits info + if let rateLimitRecord = authManager.rateLimitRecords.first(where: { $0.bundleIdentifier == entry.bundleIdentifier }), + !rateLimitRecord.activityTimestamps.isEmpty || !rateLimitRecord.widgetTimestamps.isEmpty || !rateLimitRecord.notchExperienceTimestamps.isEmpty { + rateLimitInfo(record: rateLimitRecord) + Divider() + } + + // Actions + actionButtons + } + .padding(12) + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private var scopeToggles: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Allowed Features") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + Toggle("Live Activities", isOn: Binding( + get: { entry.allowedScopes.contains(.liveActivities) }, + set: { enabled in + var newScopes = entry.allowedScopes + if enabled { + newScopes.insert(.liveActivities) + } else { + newScopes.remove(.liveActivities) + } + authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes) + } + )) + .font(.caption) + .disabled(!authManager.areLiveActivitiesEnabled) + + Toggle("Lock Screen Widgets", isOn: Binding( + get: { entry.allowedScopes.contains(.lockScreenWidgets) }, + set: { enabled in + var newScopes = entry.allowedScopes + if enabled { + newScopes.insert(.lockScreenWidgets) + } else { + newScopes.remove(.lockScreenWidgets) + } + authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes) + } + )) + .font(.caption) + .disabled(!authManager.areLockScreenWidgetsEnabled) + + Toggle("Notch Experiences", isOn: Binding( + get: { entry.allowedScopes.contains(.notchExperiences) }, + set: { enabled in + var newScopes = entry.allowedScopes + if enabled { + newScopes.insert(.notchExperiences) + } else { + newScopes.remove(.notchExperiences) + } + authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes) + } + )) + .font(.caption) + .disabled(!authManager.areNotchExperiencesEnabled) + } + } + + private func rateLimitInfo(record: ExtensionRateLimitRecord) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Recent Activity (last 5 minutes)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + HStack(spacing: 20) { + if !record.activityTimestamps.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Live Activities") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(record.activityTimestamps.count)") + .font(.caption.monospacedDigit()) + } + } + + if !record.widgetTimestamps.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Widget Updates") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(record.widgetTimestamps.count)") + .font(.caption.monospacedDigit()) + } + } + + if !record.notchExperienceTimestamps.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Notch Experiences") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(record.notchExperienceTimestamps.count)") + .font(.caption.monospacedDigit()) + } + } + } + + Button("Reset Rate Limits") { + authManager.resetRateLimits(for: entry.bundleIdentifier) + } + .font(.caption) + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + private var actionButtons: some View { + HStack(spacing: 8) { + switch entry.status { + case .pending: + Button("Authorize") { + authManager.authorize(bundleIdentifier: entry.bundleIdentifier, appName: entry.appName) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + + Button("Deny") { + authManager.deny(bundleIdentifier: entry.bundleIdentifier, reason: "Denied by user") + } + .buttonStyle(.bordered) + .controlSize(.small) + + case .authorized: + Button("Revoke Access") { + authManager.revoke(bundleIdentifier: entry.bundleIdentifier, reason: "Revoked by user") + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.orange) + + case .denied, .revoked: + Button("Re-authorize") { + authManager.authorize(bundleIdentifier: entry.bundleIdentifier, appName: entry.appName) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + + Spacer() + + resetMenu + + Button("Remove") { + onRemove() + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.red) + } + } + + private var resetMenu: some View { + Menu { + Button("Reset Live Activities") { + liveActivityManager.dismissAll(for: entry.bundleIdentifier) + } + .disabled(!hasLiveActivities) + + Button("Reset Lock Screen Widgets") { + widgetManager.dismissAll(for: entry.bundleIdentifier) + } + .disabled(!hasWidgets) + + Button("Reset Notch Experiences") { + notchExperienceManager.dismissAll(for: entry.bundleIdentifier) + } + .disabled(!hasNotchExperiences) + } label: { + Label("Reset", systemImage: "arrow.counterclockwise.circle") + } + .menuStyle(.borderlessButton) + .controlSize(.small) + } + + private var hasLiveActivities: Bool { + liveActivityManager.activeActivities.contains { $0.bundleIdentifier == entry.bundleIdentifier } + } + + private var hasWidgets: Bool { + widgetManager.activeWidgets.contains { $0.bundleIdentifier == entry.bundleIdentifier } + } + + private var hasNotchExperiences: Bool { + notchExperienceManager.activeExperiences.contains { $0.bundleIdentifier == entry.bundleIdentifier } + } + + private func infoRow(label: String, value: String) -> some View { + HStack { + Text("\(label):") + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.caption) + } + } + + private func formatDate(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: .now) + } +} + +#Preview { + ExtensionsSettingsView() +} diff --git a/DynamicIsland/components/Settings/IdleAnimationsSettingsSection.swift b/DynamicIsland/components/Settings/IdleAnimationsSettingsSection.swift new file mode 100644 index 00000000..fa587bc8 --- /dev/null +++ b/DynamicIsland/components/Settings/IdleAnimationsSettingsSection.swift @@ -0,0 +1,509 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults +import LottieUI +import UniformTypeIdentifiers + +// MARK: - Editor State for Sheet Presentation +private struct EditorState: Identifiable { + let id = UUID() + let url: URL? + let isRemote: Bool + let existingAnimation: CustomIdleAnimation? +} + +struct IdleAnimationsSettingsSection: View { + @Default(.customIdleAnimations) var customIdleAnimations + @Default(.selectedIdleAnimation) var selectedIdleAnimation + @Default(.showNotHumanFace) var showNotHumanFace + + @State private var showingFilePicker = false + @State private var showingURLSheet = false + @State private var urlInput = "" + @State private var nameInput = "" + @State private var selectedForDeletion: CustomIdleAnimation? + @State private var showingDeleteAlert = false + @State private var importError: String? + @State private var showingError = false + + // Animation editor state + @State private var showingEditor = false + @State private var editorSourceURL: URL? + @State private var editorIsRemote = false + @State private var editedAnimation: CustomIdleAnimation? + @State private var editingExistingAnimation: CustomIdleAnimation? + + var body: some View { + Section { + if showNotHumanFace { + // Horizontal scrollable grid of animations + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 16) { + ForEach(customIdleAnimations) { animation in + AnimationPreviewCard( + animation: animation, + isSelected: selectedIdleAnimation?.id == animation.id, + onSelect: { + withAnimation(.easeInOut(duration: 0.2)) { + selectedIdleAnimation = animation + } + }, + onDelete: animation.isBuiltIn ? nil : { + selectedForDeletion = animation + showingDeleteAlert = true + }, + onEdit: { + print("🔧 [Edit] Attempting to edit animation: \(animation.name)") + print("🔧 [Edit] Animation source: \(animation.source)") + + // Set state immediately + editingExistingAnimation = animation + + switch animation.source { + case .lottieFile(let url): + print("🔧 [Edit] Lottie file URL: \(url)") + print("🔧 [Edit] File exists: \(FileManager.default.fileExists(atPath: url.path))") + editorSourceURL = url + editorIsRemote = false + + case .lottieURL(let url): + print("🔧 [Edit] Lottie URL: \(url)") + editorSourceURL = url + editorIsRemote = true + } + + // Show editor with slight delay to ensure state is set + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + showingEditor = true + print("🔧 [Edit] Sheet should now be visible") + } + } + ) + } + + // Add animation button + AddAnimationCard { + showingFilePicker = true + } onAddURL: { + urlInput = "" + nameInput = "" + showingURLSheet = true + } + } + .padding(.vertical, 12) + .padding(.horizontal, 4) + } + .frame(height: 140) + } else { + Text("Enable \"Idle Animation\" to customize animations") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 8) + } + } header: { + HStack { + Text("Idle Animation Style") + Spacer() + if showNotHumanFace { + Text("\(customIdleAnimations.count) animations") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } footer: { + if showNotHumanFace { + Text("Choose animation to display when Atoll is idle. Tap to select, hold to delete custom animations.") + } + } + .fileImporter( + isPresented: $showingFilePicker, + allowedContentTypes: [UTType.json], + allowsMultipleSelection: false + ) { result in + handleFileImport(result) + } + .sheet(isPresented: $showingURLSheet) { + URLImportSheet( + urlInput: $urlInput, + nameInput: $nameInput, + onImport: { handleURLImport() }, + onCancel: { showingURLSheet = false } + ) + } + .sheet(item: Binding( + get: { showingEditor ? EditorState(url: editorSourceURL, isRemote: editorIsRemote, existingAnimation: editingExistingAnimation) : nil }, + set: { newValue in + showingEditor = (newValue != nil) + if newValue == nil { + // Clean up when sheet dismisses + editorSourceURL = nil + editingExistingAnimation = nil + } + } + )) { state in + AnimationEditorView( + sourceURL: state.url ?? URL(string: "builtin://face")!, + isRemoteURL: state.isRemote, + animation: $editedAnimation, + existingAnimation: state.existingAnimation + ) + .interactiveDismissDisabled(false) + .onChange(of: editedAnimation) { oldValue, newValue in + if let animation = newValue { + // Animation was successfully imported/edited via editor + if let existingAnim = editingExistingAnimation { + // Editing existing animation - update it + var animations = Defaults[.customIdleAnimations] + if let index = animations.firstIndex(where: { $0.id == existingAnim.id }) { + animations[index] = animation + Defaults[.customIdleAnimations] = animations + + // Update selection if this was the selected animation + if selectedIdleAnimation?.id == existingAnim.id { + Defaults[.selectedIdleAnimation] = animation + } + } + } else { + // New import - just select it + withAnimation { + selectedIdleAnimation = animation + } + } + showingEditor = false + // Reset editor state + editorSourceURL = nil + editedAnimation = nil + editingExistingAnimation = nil + } + } + } + .alert("Delete Animation", isPresented: $showingDeleteAlert) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { + if let animation = selectedForDeletion { + IdleAnimationManager.shared.deleteAnimation(animation) + } + } + } message: { + if let animation = selectedForDeletion { + Text("Are you sure you want to delete \"\(animation.name)\"?") + } + } + .alert("Import Error", isPresented: $showingError) { + Button("OK") { } + } message: { + Text(importError ?? "Unknown error occurred") + } + } + + // MARK: - Import Handlers + + private func handleFileImport(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first else { return } + + // Show editor instead of directly importing + editorSourceURL = url + editorIsRemote = false + editedAnimation = nil + editingExistingAnimation = nil // Reset to nil for new imports + + // Show editor with slight delay to ensure state is set + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + showingEditor = true + print("📥 [Import] Opening editor for new import") + } + + case .failure(let error): + importError = error.localizedDescription + showingError = true + } + } + + private func handleURLImport() { + guard let url = URL(string: urlInput), !nameInput.isEmpty else { + importError = "Please provide both a valid URL and name" + showingError = true + return + } + + // Show editor instead of directly importing + editorSourceURL = url + editorIsRemote = true + editedAnimation = nil + editingExistingAnimation = nil // Reset to nil for new imports + showingURLSheet = false + + // Show editor with slight delay to ensure state is set + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + showingEditor = true + print("📥 [Import] Opening editor for URL import") + } + } +} + +// MARK: - Animation Preview Card +struct AnimationPreviewCard: View { + let animation: CustomIdleAnimation + let isSelected: Bool + let onSelect: () -> Void + let onDelete: (() -> Void)? + let onEdit: (() -> Void)? + + @State private var isHovering = false + + var body: some View { + VStack(spacing: 8) { + // Preview area + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + isSelected ? Color.accentColor : Color.clear, + lineWidth: 2.5 + ) + ) + + // Animation preview + AnimationPreview(animation: animation) + .frame(width: 60, height: 40) + + // Delete button (only for custom animations) + if let onDelete = onDelete, isHovering { + VStack { + HStack { + Spacer() + Button { + onDelete() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.white, .red) + .font(.system(size: 20)) + } + .buttonStyle(.plain) + .padding(4) + } + Spacer() + } + } + } + .frame(width: 100, height: 80) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + isHovering = hovering + } + } + + // Name and badge + VStack(spacing: 4) { + Text(animation.name) + .font(.caption) + .fontWeight(.medium) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(width: 100) + + if isSelected { + Text("Selected") + .font(.system(size: 10)) + .fontWeight(.medium) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color.accentColor) + ) + } else if animation.isBuiltIn { + Text("Built-in") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + } + .contentShape(Rectangle()) + .onTapGesture { + onSelect() + } + .contextMenu { + if let onEdit = onEdit { + Button("Edit Animation") { + onEdit() + } + Divider() + } + if let onDelete = onDelete { + Button("Delete", role: .destructive) { + onDelete() + } + } + } + } +} + +// MARK: - Animation Preview +struct AnimationPreview: View { + let animation: CustomIdleAnimation + + var body: some View { + switch animation.source { + case .lottieFile(let url): + LottieView(state: LUStateData( + type: .loadedFrom(url), + speed: animation.speed, + loopMode: .loop + )) + .frame(width: 60, height: 40) + + case .lottieURL(let url): + LottieView(state: LUStateData( + type: .loadedFrom(url), + speed: animation.speed, + loopMode: .loop + )) + .frame(width: 60, height: 40) + } + } +} + +// MARK: - Add Animation Card +struct AddAnimationCard: View { + let onAddFile: () -> Void + let onAddURL: () -> Void + + @State private var showingMenu = false + + var body: some View { + VStack(spacing: 8) { + Button { + showingMenu.toggle() + } label: { + ZStack { + RoundedRectangle(cornerRadius: 12) + .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [5, 3])) + .fill(Color(nsColor: .controlBackgroundColor)) + .foregroundStyle(.secondary) + + VStack(spacing: 4) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 28)) + .foregroundStyle(.secondary) + Text("Add") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(width: 100, height: 80) + } + .buttonStyle(.plain) + .popover(isPresented: $showingMenu, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 0) { + Button { + showingMenu = false + onAddFile() + } label: { + Label("Import File...", systemImage: "doc.badge.plus") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .padding(8) + + Divider() + + Button { + showingMenu = false + onAddURL() + } label: { + Label("Add from URL...", systemImage: "link.badge.plus") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .padding(8) + } + .frame(width: 180) + } + + Text("Add New") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .frame(width: 100) + } + } +} + +// MARK: - URL Import Sheet +struct URLImportSheet: View { + @Binding var urlInput: String + @Binding var nameInput: String + let onImport: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 20) { + Text("Add Animation from URL") + .font(.headline) + + VStack(alignment: .leading, spacing: 8) { + Text("Lottie JSON URL") + .font(.caption) + .foregroundStyle(.secondary) + TextField("https://example.com/animation.json", text: $urlInput) + .textFieldStyle(.roundedBorder) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Animation Name") + .font(.caption) + .foregroundStyle(.secondary) + TextField("My Animation", text: $nameInput) + .textFieldStyle(.roundedBorder) + } + + HStack { + Button("Cancel") { + onCancel() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Add") { + onImport() + } + .keyboardShortcut(.defaultAction) + .disabled(urlInput.isEmpty || nameInput.isEmpty) + } + } + .padding(24) + .frame(width: 400) + } +} + +// MARK: - Preview +#Preview { + Form { + IdleAnimationsSettingsSection() + } + .frame(width: 600, height: 400) +} diff --git a/DynamicIsland/components/Settings/ListItemPopover.swift b/DynamicIsland/components/Settings/ListItemPopover.swift index eea2bb2a..b630a88e 100644 --- a/DynamicIsland/components/Settings/ListItemPopover.swift +++ b/DynamicIsland/components/Settings/ListItemPopover.swift @@ -1,9 +1,24 @@ -// -// ListItemPopover.swift -// DynamicIsland -// -// Created by Richard Kunkli on 15/09/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI diff --git a/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift b/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift new file mode 100644 index 00000000..34576047 --- /dev/null +++ b/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift @@ -0,0 +1,379 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Defaults +import SwiftUI +import UniformTypeIdentifiers + +struct MusicSlotConfigurationView: View { + @Default(.musicControlSlots) private var musicControlSlots + @Default(.showMediaOutputControl) private var showMediaOutputControl + @ObservedObject private var musicManager = MusicManager.shared + @State private var hoveredSlotIndex: Int? = nil + @State private var targetedSlotIndex: Int? = nil + @State private var trashDropIsTargeted: Bool = false + + private let slotCount = MusicControlButton.slotCount + + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + layoutPreview + Divider() + palette + resetButton + } + .onAppear { + ensureSlotCapacity(slotCount) + removeDisallowedControls() + } + .onChange(of: showMediaOutputControl) { _, _ in + removeDisallowedControls() + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Layout Preview") + .font(.headline) + Text("Drag items between slots or drop from the palette to remap controls.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + private var layoutPreview: some View { + HStack(alignment: .top, spacing: 12) { + HStack(spacing: 8) { + ForEach(0.. some View { + let slot = slotValue(at: index) + let isHovered = hoveredSlotIndex == index + let isDropTarget = targetedSlotIndex == index + + return ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(slotBackgroundColor(isHovered: isHovered, isTargeted: isDropTarget)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(slotBorderColor(isHovered: isHovered, isTargeted: isDropTarget), lineWidth: borderWidth(isHovered: isHovered, isTargeted: isDropTarget)) + ) + + slotContent(for: slot) + } + .frame(width: 48, height: 48) + .contentShape(RoundedRectangle(cornerRadius: 10)) + .onHover { isInside in + withAnimation(.easeInOut(duration: 0.12)) { + hoveredSlotIndex = isInside ? index : nil + } + } + .onDrag { + hoveredSlotIndex = index + return NSItemProvider(object: NSString(string: "slot:\(index)")) + } + .onDrop(of: [UTType.plainText.identifier], isTargeted: dropTargetBinding(for: index)) { providers in + return handleDrop(providers, toIndex: index) + } + } + + @ViewBuilder + private func slotContent(for slot: MusicControlButton) -> some View { + if slot == .none { + RoundedRectangle(cornerRadius: 6) + .stroke(style: StrokeStyle(lineWidth: 1, dash: [4, 4])) + .foregroundStyle(.secondary.opacity(0.35)) + .frame(width: 26, height: 26) + } else { + Image(systemName: slot.iconName) + .font(.system(size: slot.prefersLargeScale ? 18 : 15, weight: .medium)) + .foregroundStyle(previewIconColor(for: slot)) + } + } + + private func isControlDisabled(_ control: MusicControlButton) -> Bool { + if control == .mediaOutput && !showMediaOutputControl { return true } + if control.isAppleMusicExclusive && !isAppleMusicActive { return true } + return false + } + + private func paletteItem(for control: MusicControlButton) -> some View { + let disabled = isControlDisabled(control) + + return VStack(spacing: 6) { + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .controlBackgroundColor)) + .frame(width: 44, height: 44) + .overlay { + Image(systemName: control.iconName) + .font(.system(size: control.prefersLargeScale ? 18 : 15, weight: .medium)) + .foregroundStyle(disabled ? .secondary : .primary) + } + .onDrag { + NSItemProvider(object: NSString(string: "control:\(control.rawValue)")) + } + .onTapGesture { + place(control) + } + + Text(control.label) + .font(.caption2) + .foregroundStyle(.secondary) + .frame(width: 72) + .lineLimit(1) + .minimumScaleFactor(0.8) + .multilineTextAlignment(.center) + } + .opacity(disabled ? 0.4 : 1) + .disabled(disabled) + } + + private func place(_ control: MusicControlButton) { + if let emptyIndex = musicControlSlots.firstIndex(of: .none) { + updateSlot(control, at: emptyIndex) + } else { + updateSlot(control, at: 0) + } + } + + private func slotValue(at index: Int) -> MusicControlButton { + let normalized = musicControlSlots.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive) + guard normalized.indices.contains(index) else { return .none } + return normalized[index] + } + + private var pickerOptions: [MusicControlButton] { + var base = MusicControlButton.pickerOptions + if !showMediaOutputControl { + base = base.filter { $0 != .mediaOutput } + } + if !isAppleMusicActive { + base = base.filter { !$0.isAppleMusicExclusive } + } + return base + } + + private func previewIconColor(for slot: MusicControlButton) -> Color { + switch slot { + case .shuffle: + return musicManager.isShuffled ? .red : .primary + case .repeatMode: + return musicManager.repeatMode == .off ? .primary : .red + case .lyrics: + return Defaults[.enableLyrics] ? .accentColor : .primary + default: + return .primary + } + } + + private func ensureSlotCapacity(_ target: Int) { + guard target > musicControlSlots.count else { return } + let padding = target - musicControlSlots.count + musicControlSlots.append(contentsOf: Array(repeating: .none, count: padding)) + } + + private func handleDrop(_ providers: [NSItemProvider], toIndex: Int) -> Bool { + for provider in providers where provider.canLoadObject(ofClass: NSString.self) { + provider.loadObject(ofClass: NSString.self) { item, _ in + guard let raw = item as? String else { return } + DispatchQueue.main.async { + processDropString(raw, toIndex: toIndex) + } + } + return true + } + return false + } + + private func handleDropOnTrash(_ providers: [NSItemProvider]) -> Bool { + for provider in providers where provider.canLoadObject(ofClass: NSString.self) { + provider.loadObject(ofClass: NSString.self) { item, _ in + guard let raw = item as? String else { return } + DispatchQueue.main.async { + if raw.hasPrefix("slot:"), let index = Int(raw.dropFirst(5)) { + clearSlot(at: index) + } + } + } + return true + } + return false + } + + private func processDropString(_ raw: String, toIndex: Int) { + if raw.hasPrefix("slot:"), let source = Int(raw.dropFirst(5)) { + swapSlot(from: source, to: toIndex) + } else if raw.hasPrefix("control:"), let control = MusicControlButton(rawValue: String(raw.dropFirst(8))) { + updateSlot(control, at: toIndex) + } + } + + private func clearSlot(at index: Int) { + guard index >= 0 && index < musicControlSlots.count else { return } + musicControlSlots[index] = .none + } + + private func swapSlot(from source: Int, to destination: Int) { + guard source != destination else { return } + ensureSlotCapacity(max(source, destination) + 1) + musicControlSlots.swapAt(source, destination) + } + + private func updateSlot(_ value: MusicControlButton, at index: Int) { + ensureSlotCapacity(index + 1) + var current = musicControlSlots + current[index] = value + musicControlSlots = current + } + + private func removeDisallowedControls() { + if showMediaOutputControl { return } + let filtered = musicControlSlots.map { $0 == .mediaOutput ? .none : $0 } + musicControlSlots = filtered + } + + private func slotBackgroundColor(isHovered: Bool, isTargeted: Bool) -> Color { + if isTargeted { + return Color.accentColor.opacity(0.25) + } else if isHovered { + return Color.accentColor.opacity(0.15) + } + return Color(nsColor: .controlBackgroundColor) + } + + private func slotBorderColor(isHovered: Bool, isTargeted: Bool) -> Color { + if isTargeted { + return Color.accentColor + } else if isHovered { + return Color.accentColor.opacity(0.8) + } + return .clear + } + + private func borderWidth(isHovered: Bool, isTargeted: Bool) -> CGFloat { + if isTargeted { return 2 } + if isHovered { return 1.5 } + return 0 + } + + private func dropTargetBinding(for index: Int) -> Binding { + Binding( + get: { targetedSlotIndex == index }, + set: { newValue in + if newValue { + targetedSlotIndex = index + } else if targetedSlotIndex == index { + targetedSlotIndex = nil + } + } + ) + } +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 74a360ea..ec9aa6c3 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -4,8 +4,9 @@ // // Created by Richard Kunkli on 07/08/2024. // - +import AppKit import AVFoundation +import Combine import Defaults import EventKit import KeyboardShortcuts @@ -14,114 +15,1022 @@ import LottieUI import Sparkle import SwiftUI import SwiftUIIntrospect +import UniformTypeIdentifiers + +/// Groups for organizing settings tabs in the sidebar. +private enum SettingsTabGroup: String, CaseIterable, Identifiable { + case core + case mediaAndDisplay + case system + case productivity + case utilities + case developer + case integrations + case info + + var id: String { rawValue } + + /// Display title for the section header. `nil` means no visible header. + var title: String? { + switch self { + case .core: return nil + case .mediaAndDisplay: return String(localized: "Media & Display") + case .system: return String(localized: "System") + case .productivity: return String(localized: "Productivity") + case .utilities: return String(localized: "Utilities") + case .developer: return String(localized: "Developer") + case .integrations: return String(localized: "Integrations") + case .info: return nil + } + } +} + +private enum SettingsTab: String, CaseIterable, Identifiable { + case general + case liveActivities + case appearance + case lockScreen + case media + case devices + case extensions + case timer + case calendar + case hudAndOSD + case battery + case stats + case clipboard + case screenAssistant + case colorPicker + case downloads + case shelf + case shortcuts + case notes + case terminal + case about + + var id: String { rawValue } + + /// Which sidebar group this tab belongs to. + var group: SettingsTabGroup { + switch self { + case .general, .appearance: return .core + case .media, .liveActivities, .lockScreen, .devices: return .mediaAndDisplay + case .hudAndOSD, .battery: return .system + case .timer, .calendar, .notes: return .productivity + case .clipboard, .screenAssistant, .colorPicker, .shelf, + .downloads, .shortcuts: return .utilities + case .stats, .terminal: return .developer + case .extensions: return .integrations + case .about: return .info + } + } + + var title: String { + switch self { + case .general: return String(localized: "General") + case .liveActivities: return String(localized: "Live Activities") + case .appearance: return String(localized: "Appearance") + case .lockScreen: return String(localized: "Lock Screen") + case .media: return String(localized: "Media") + case .devices: return String(localized: "Devices") + case .extensions: return String(localized: "Extensions") + case .timer: return String(localized: "Timer") + case .calendar: return String(localized: "Calendar") + case .hudAndOSD: return String(localized: "Controls") + case .battery: return String(localized: "Battery") + case .stats: return String(localized: "Stats") + case .clipboard: return String(localized: "Clipboard") + case .screenAssistant: return String(localized: "Screen Assistant") + case .colorPicker: return String(localized: "Color Picker") + case .downloads: return String(localized: "Downloads") + case .shelf: return String(localized: "Shelf") + case .shortcuts: return String(localized: "Shortcuts") + case .notes: return String(localized: "Notes") + case .terminal: return String(localized: "Terminal") + case .about: return String(localized: "About") + } + } + + var systemImage: String { + switch self { + case .general: return "gear" + case .liveActivities: return "waveform.path.ecg" + case .appearance: return "paintpalette" + case .lockScreen: return "lock.laptopcomputer" + case .media: return "play.laptopcomputer" + case .devices: return "headphones" + case .extensions: return "puzzlepiece.extension" + case .timer: return "timer" + case .calendar: return "calendar" + case .hudAndOSD: return "dial.medium.fill" + case .battery: return "battery.100.bolt" + case .stats: return "chart.xyaxis.line" + case .clipboard: return "clipboard" + case .screenAssistant: return "brain.head.profile" + case .colorPicker: return "eyedropper" + case .downloads: return "square.and.arrow.down" + case .shelf: return "books.vertical" + case .shortcuts: return "keyboard" + case .notes: return "note.text" + case .terminal: return "apple.terminal" + case .about: return "info.circle" + } + } + + var tint: Color { + switch self { + case .general: return .blue + case .liveActivities: return .pink + case .appearance: return .purple + case .lockScreen: return .orange + case .media: return .green + case .devices: return Color(red: 0.1, green: 0.11, blue: 0.12) + case .extensions: return Color(red: 0.557, green: 0.353, blue: 0.957) + case .timer: return .red + case .calendar: return .cyan + case .hudAndOSD: return .indigo + case .battery: return Color(red: 0.202, green: 0.783, blue: 0.348, opacity: 1.000) + case .stats: return .teal + case .clipboard: return .mint + case .screenAssistant: return .pink + case .colorPicker: return .accentColor + case .downloads: return .gray + case .shelf: return .brown + case .shortcuts: return .orange + case .notes: return Color(red: 0.979, green: 0.716, blue: 0.153, opacity: 1.000) + case .terminal: return Color(red: 0.2, green: 0.8, blue: 0.4) + case .about: return .secondary + } + } + + func highlightID(for title: String) -> String { + "\(rawValue)-\(title)" + } +} + +private struct SettingsSearchEntry: Identifiable { + let tab: SettingsTab + let title: String + let keywords: [String] + let highlightID: String? + + var id: String { "\(tab.rawValue)-\(title)" } +} + +final class SettingsHighlightCoordinator: ObservableObject { + struct ScrollRequest: Identifiable, Equatable { + let id: String + fileprivate let tab: SettingsTab + } + + @Published fileprivate var pendingScrollRequest: ScrollRequest? + @Published private(set) var activeHighlightID: String? + + private var clearWorkItem: DispatchWorkItem? + + fileprivate func focus(on entry: SettingsSearchEntry) { + guard let highlightID = entry.highlightID else { return } + pendingScrollRequest = ScrollRequest(id: highlightID, tab: entry.tab) + activateHighlight(id: highlightID) + } + + func consumeScrollRequest(_ request: ScrollRequest) { + guard pendingScrollRequest?.id == request.id else { return } + pendingScrollRequest = nil + } + + private func activateHighlight(id: String) { + activeHighlightID = id + clearWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard self?.activeHighlightID == id else { return } + self?.activeHighlightID = nil + } + + clearWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: workItem) + } +} + +private struct SettingsHighlightModifier: ViewModifier { + let id: String + @EnvironmentObject private var highlightCoordinator: SettingsHighlightCoordinator + @State private var animatePulse = false + + private var isActive: Bool { + highlightCoordinator.activeHighlightID == id + } + + func body(content: Content) -> some View { + content + .id(id) + .background(highlightBackground) + .onChange(of: isActive) { _, active in + animatePulse = active + } + .onAppear { + if isActive { + animatePulse = true + } + } + } + + private var highlightBackground: some View { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke( + Color.accentColor.opacity(isActive ? (animatePulse ? 0.95 : 0.4) : 0), + lineWidth: 2 + ) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.accentColor.opacity(isActive ? 0.08 : 0)) + ) + .padding(-4) + .shadow(color: Color.accentColor.opacity(isActive ? 0.25 : 0), radius: animatePulse ? 8 : 2) + .animation( + isActive ? .easeInOut(duration: 0.8).repeatForever(autoreverses: true) : .default, + value: animatePulse + ) + } +} + +extension View { + func settingsHighlight(id: String) -> some View { + modifier(SettingsHighlightModifier(id: id)) + } + + @ViewBuilder + func settingsHighlightIfPresent(_ id: String?) -> some View { + if let id { + settingsHighlight(id: id) + } else { + self + } + } +} + +private struct SettingsForm: View { + let tab: SettingsTab + @ViewBuilder var content: () -> Content + + @EnvironmentObject private var highlightCoordinator: SettingsHighlightCoordinator + + var body: some View { + ScrollViewReader { proxy in + content() + .onReceive(highlightCoordinator.$pendingScrollRequest.compactMap { request -> SettingsHighlightCoordinator.ScrollRequest? in + guard let request, request.tab == tab else { return nil } + return request + }) { request in + withAnimation(.easeInOut(duration: 0.45)) { + proxy.scrollTo(request.id, anchor: .center) + } + highlightCoordinator.consumeScrollRequest(request) + } + } + } +} struct SettingsView: View { - @StateObject var extensionManager = DynamicIslandExtensionManager() - @StateObject private var calendarManager = CalendarManager() - - @State private var selectedTab = "General" - + @State private var selectedTab: SettingsTab = .general + @State private var searchText: String = "" + @StateObject private var highlightCoordinator = SettingsHighlightCoordinator() + @Default(.enableMinimalisticUI) var enableMinimalisticUI + let updaterController: SPUStandardUpdaterController? - + init(updaterController: SPUStandardUpdaterController? = nil) { self.updaterController = updaterController } - + var body: some View { NavigationSplitView { - List(selection: $selectedTab) { - NavigationLink(value: "General") { - Label("General", systemImage: "gear") - } - NavigationLink(value: "Appearance") { - Label("Appearance", systemImage: "eye") - } - NavigationLink(value: "Media") { - Label("Media", systemImage: "play.laptopcomputer") - } - NavigationLink(value: "Calendar") { - Label("Calendar", systemImage: "calendar") - } - if extensionManager.installedExtensions - .contains(where: { $0.bundleIdentifier == hudExtension }) { - NavigationLink(value: "HUD") { - Label("HUDs", systemImage: "dial.medium.fill") + VStack(spacing: 12) { + SettingsSidebarSearchBar( + text: $searchText, + suggestions: searchSuggestions, + onSuggestionSelected: handleSearchSuggestionSelection + ) + .padding(.horizontal, 12) + .padding(.top, 12) + + Divider() + .padding(.horizontal, 12) + + List(selection: selectionBinding) { + ForEach(groupedFilteredTabs, id: \.group) { section in + Section { + ForEach(section.tabs) { tab in + NavigationLink(value: tab) { + sidebarRow(for: tab) + } + } + } header: { + if let title = section.group.title { + Text(title) + } + } } } - NavigationLink(value: "Battery") { - Label("Battery", systemImage: "battery.100.bolt") - } - if extensionManager.installedExtensions - .contains(where: { $0.bundleIdentifier == downloadManagerExtension }) { - NavigationLink(value: "Downloads") { - Label("Downloads", systemImage: "square.and.arrow.down") + .listStyle(SidebarListStyle()) + .frame(minWidth: 200) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .toolbar(removing: .sidebarToggle) + .navigationSplitViewColumnWidth(min: 200, ideal: 210, max: 240) + .environment(\.defaultMinListRowHeight, 44) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } detail: { + detailView(for: resolvedSelection) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .navigationSplitViewStyle(.balanced) + .toolbar(removing: .sidebarToggle) + .toolbar { toolbarSpacingShim } + .environmentObject(highlightCoordinator) + .formStyle(.grouped) + .frame(width: 700) + .onChange(of: searchText) { _, newValue in + let matches = tabsMatchingSearch(newValue) + guard let firstMatch = matches.first else { return } + if !matches.contains(resolvedSelection) { + selectedTab = firstMatch + } + } + .background { + Group { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .glassEffect( + .clear + .tint(Color.white.opacity(0.1)) + .interactive(), + in: .rect(cornerRadius: 18) + ) + } else { + ZStack { + Color(NSColor.windowBackgroundColor) + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.ultraThinMaterial) } } - NavigationLink(value: "Shelf") { - Label("Shelf", systemImage: "books.vertical") - } - NavigationLink(value: "Shortcuts") { - Label("Shortcuts", systemImage: "keyboard") - } - NavigationLink(value: "Extensions") { - Label("Extensions", systemImage: "puzzlepiece.extension") + } + .ignoresSafeArea() + } + } + + private var resolvedSelection: SettingsTab { + availableTabs.contains(selectedTab) ? selectedTab : (availableTabs.first ?? .general) + } + + @ToolbarContentBuilder + private var toolbarSpacingShim: some ToolbarContent { + if #available(macOS 26.0, *) { + ToolbarItem(placement: .primaryAction) { + toolbarSpacerView + } + .sharedBackgroundVisibility(.hidden) + } else { + ToolbarItem(placement: .primaryAction) { + toolbarSpacerView + } + } + } + + @ViewBuilder + private var toolbarSpacerView: some View { + Color.clear + .frame(width: 96, height: 32) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + private var filteredTabs: [SettingsTab] { + tabsMatchingSearch(searchText) + } + + private var selectionBinding: Binding { + Binding( + get: { resolvedSelection }, + set: { newValue in + selectedTab = newValue + } + ) + } + + @ViewBuilder + private func sidebarIcon(for tab: SettingsTab) -> some View { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill( + LinearGradient( + colors: [ + tab.tint.opacity(1), + tab.tint.opacity(0.7) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 26, height: 26) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.white.opacity(0.2), lineWidth: 0.7) + .blendMode(.plusLighter) + } + .shadow(color: tab.tint.opacity(0.35), radius: 2, x: 0, y: 1) + .overlay { + Image(systemName: tab.systemImage) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.white) + } + } + + @ViewBuilder + private func sidebarRow(for tab: SettingsTab) -> some View { + HStack(spacing: 10) { + sidebarIcon(for: tab) + Text(tab.title) + if tab == .downloads { + Spacer() + Text("BETA") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color.blue) + ) + } else if tab == .extensions { + Spacer() + Text("BETA") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color.blue) + ) + } + } + .padding(.vertical, 4) + } + + private var availableTabs: [SettingsTab] { + // Ordered to match group layout: core → media & display → system → + // productivity → utilities → developer → integrations → info. + let ordered: [SettingsTab] = [ + // Core + .general, + .appearance, + // Media & Display + .media, + .liveActivities, + .lockScreen, + .devices, + // System + .hudAndOSD, + .battery, + // Productivity + .timer, + .calendar, + .notes, + // Utilities + .clipboard, + .screenAssistant, + .colorPicker, + .shelf, + .downloads, + .shortcuts, + // Developer + .stats, + .terminal, + // Integrations + .extensions, + // Info + .about + ] + + return ordered.filter { isTabVisible($0) } + } + + /// Groups the filtered tabs into sidebar sections, preserving both + /// the group order and the per-group tab order from `availableTabs`. + private var groupedFilteredTabs: [(group: SettingsTabGroup, tabs: [SettingsTab])] { + let visible = filteredTabs + var result: [(group: SettingsTabGroup, tabs: [SettingsTab])] = [] + + for group in SettingsTabGroup.allCases { + let tabs = visible.filter { $0.group == group } + if !tabs.isEmpty { + result.append((group: group, tabs: tabs)) + } + } + + return result + } + + private func tabsMatchingSearch(_ query: String) -> [SettingsTab] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return availableTabs } + + let entryMatches = searchEntries(matching: trimmed) + let matchingTabs = Set(entryMatches.map(\.tab)) + + return availableTabs.filter { tab in + tab.title.localizedCaseInsensitiveContains(trimmed) || matchingTabs.contains(tab) + } + } + + private var searchSuggestions: [SettingsSearchEntry] { + Array(searchEntries(matching: searchText).filter { $0.tab != .downloads }.prefix(8)) + } + + private func handleSearchSuggestionSelection(_ suggestion: SettingsSearchEntry) { + guard suggestion.tab != .downloads else { return } + highlightCoordinator.focus(on: suggestion) + selectedTab = suggestion.tab + } + + private struct SettingsSidebarSearchBar: View { + @Binding var text: String + let suggestions: [SettingsSearchEntry] + let onSuggestionSelected: (SettingsSearchEntry) -> Void + + @FocusState private var isFocused: Bool + @State private var hoveredSuggestionID: SettingsSearchEntry.ID? + + var body: some View { + VStack(spacing: 6) { + searchField + if showSuggestions { + suggestionList } - NavigationLink(value: "About") { - Label("About", systemImage: "info.circle") + } + .animation(.easeInOut(duration: 0.15), value: showSuggestions) + } + + private var showSuggestions: Bool { + isFocused && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !suggestions.isEmpty + } + + private var searchField: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(Color.secondary) + + TextField("Search Settings", text: $text) + .textFieldStyle(.plain) + .focused($isFocused) + .onSubmit(triggerFirstSuggestion) + + if !text.isEmpty { + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") } } - .listStyle(SidebarListStyle()) - .toolbar(removing: .sidebarToggle) - .navigationSplitViewColumnWidth(200) - } detail: { - Group { - switch selectedTab { - case "General": - GeneralSettings() - case "Appearance": - Appearance() - case "Media": - Media() - case "Calendar": - CalendarSettings() - case "HUD": - HUD() - case "Battery": - Charge() - case "Downloads": - Downloads() - case "Shelf": - Shelf() - case "Shortcuts": - Shortcuts() - case "Extensions": - Extensions() - case "About": - if let controller = updaterController { - About(updaterController: controller) - } else { - // Fallback with a default controller - About(updaterController: SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil)) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.ultraThinMaterial) + ) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.08)) + ) + } + + private var suggestionList: some View { + VStack(spacing: 0) { + ForEach(suggestions) { suggestion in + Button { + selectSuggestion(suggestion) + } label: { + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(suggestion.tab.tint) + .frame(width: 28, height: 28) + .overlay { + Image(systemName: suggestion.tab.systemImage) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.white) + } + + VStack(alignment: .leading, spacing: 2) { + Text(suggestion.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.primary) + Text(suggestion.tab.title) + .font(.system(size: 11)) + .foregroundStyle(Color.secondary) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .contentShape(Rectangle()) + .background(rowBackground(for: suggestion)) + } + .buttonStyle(.plain) + .onHover { hovering in + hoveredSuggestionID = hovering ? suggestion.id : (hoveredSuggestionID == suggestion.id ? nil : hoveredSuggestionID) + } + + if suggestion.id != suggestions.last?.id { + Divider() + .padding(.leading, 48) } - default: - GeneralSettings() } } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.ultraThinMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.08)) + ) + .shadow(color: Color.black.opacity(0.2), radius: 8, y: 4) + .transition(.opacity.combined(with: .move(edge: .top))) } - .navigationSplitViewStyle(.balanced) - .toolbar(removing: .sidebarToggle) - .toolbar { - Button("") {} // Empty label, does nothing - .controlSize(.extraLarge) - .opacity(0) // Invisible, but reserves space for a consistent look between tabs - .disabled(true) + + private func rowBackground(for suggestion: SettingsSearchEntry) -> some View { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(hoveredSuggestionID == suggestion.id ? Color.white.opacity(0.08) : Color.clear) + } + + private func selectSuggestion(_ suggestion: SettingsSearchEntry) { + onSuggestionSelected(suggestion) + isFocused = false + } + + private func triggerFirstSuggestion() { + guard let first = suggestions.first else { return } + selectSuggestion(first) + } + } + + private func searchEntries(matching query: String) -> [SettingsSearchEntry] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + return settingsSearchIndex + .filter { availableTabs.contains($0.tab) } + .filter { entry in + entry.title.localizedCaseInsensitiveContains(trimmed) || + entry.keywords.contains { $0.localizedCaseInsensitiveContains(trimmed) } + } + } + + private var settingsSearchIndex: [SettingsSearchEntry] { + [ + // General + SettingsSearchEntry(tab: .general, title: "Enable Minimalistic UI", keywords: ["minimalistic", "ui mode", "general"], highlightID: SettingsTab.general.highlightID(for: "Enable Minimalistic UI")), + SettingsSearchEntry(tab: .general, title: "Menubar icon", keywords: ["menu bar", "status bar", "icon"], highlightID: SettingsTab.general.highlightID(for: "Menubar icon")), + SettingsSearchEntry(tab: .general, title: "Launch at login", keywords: ["autostart", "startup"], highlightID: SettingsTab.general.highlightID(for: "Launch at login")), + SettingsSearchEntry(tab: .general, title: "Show on all displays", keywords: ["multi-display", "external monitor"], highlightID: SettingsTab.general.highlightID(for: "Show on all displays")), + SettingsSearchEntry(tab: .general, title: "Show on a specific display", keywords: ["preferred screen", "display picker"], highlightID: SettingsTab.general.highlightID(for: "Show on a specific display")), + SettingsSearchEntry(tab: .general, title: "Automatically switch displays", keywords: ["auto switch", "displays"], highlightID: SettingsTab.general.highlightID(for: "Automatically switch displays")), + SettingsSearchEntry(tab: .general, title: "Hide Dynamic Island during screenshots & recordings", keywords: ["privacy", "screenshot", "recording"], highlightID: SettingsTab.general.highlightID(for: "Hide Dynamic Island during screenshots & recordings")), + SettingsSearchEntry(tab: .general, title: "Enable gestures", keywords: ["gestures", "trackpad"], highlightID: SettingsTab.general.highlightID(for: "Enable gestures")), + SettingsSearchEntry(tab: .general, title: "Close gesture", keywords: ["pinch", "swipe"], highlightID: SettingsTab.general.highlightID(for: "Close gesture")), + SettingsSearchEntry(tab: .general, title: "Reverse swipe gestures", keywords: ["reverse", "swipe", "media"], highlightID: SettingsTab.general.highlightID(for: "Reverse swipe gestures")), + SettingsSearchEntry(tab: .general, title: "Reverse scroll gestures", keywords: ["reverse", "scroll", "open", "close"], highlightID: SettingsTab.general.highlightID(for: "Reverse scroll gestures")), + SettingsSearchEntry(tab: .general, title: "Extend hover area", keywords: ["hover", "cursor"], highlightID: SettingsTab.general.highlightID(for: "Extend hover area")), + SettingsSearchEntry(tab: .general, title: "Enable haptics", keywords: ["haptic", "feedback"], highlightID: SettingsTab.general.highlightID(for: "Enable haptics")), + SettingsSearchEntry(tab: .general, title: "Open notch on hover", keywords: ["hover to open", "auto open"], highlightID: SettingsTab.general.highlightID(for: "Open notch on hover")), + SettingsSearchEntry(tab: .general, title: "External display style", keywords: ["dynamic island", "pill", "external display", "non-notch", "floating", "capsule"], highlightID: SettingsTab.general.highlightID(for: "External display style")), + SettingsSearchEntry(tab: .general, title: "Hide until hovered", keywords: ["hide", "hover", "external", "non-notch", "auto hide", "slide"], highlightID: SettingsTab.general.highlightID(for: "Hide until hovered")), + SettingsSearchEntry(tab: .general, title: "Notch display height", keywords: ["display height", "menu bar size"], highlightID: SettingsTab.general.highlightID(for: "Notch display height")), + + // Live Activities + SettingsSearchEntry(tab: .liveActivities, title: "Enable Screen Recording Detection", keywords: ["screen recording", "indicator"], highlightID: SettingsTab.liveActivities.highlightID(for: "Enable Screen Recording Detection")), + SettingsSearchEntry(tab: .liveActivities, title: "Show Recording Indicator", keywords: ["recording indicator", "red dot"], highlightID: SettingsTab.liveActivities.highlightID(for: "Show Recording Indicator")), + SettingsSearchEntry(tab: .liveActivities, title: "Enable Focus Detection", keywords: ["focus", "do not disturb", "dnd"], highlightID: SettingsTab.liveActivities.highlightID(for: "Enable Focus Detection")), + SettingsSearchEntry(tab: .liveActivities, title: "Show Focus Indicator", keywords: ["focus icon", "moon"], highlightID: SettingsTab.liveActivities.highlightID(for: "Show Focus Indicator")), + SettingsSearchEntry(tab: .liveActivities, title: "Show Focus Label", keywords: ["focus label", "text"], highlightID: SettingsTab.liveActivities.highlightID(for: "Show Focus Label")), + SettingsSearchEntry(tab: .liveActivities, title: "Enable Camera Detection", keywords: ["camera", "privacy indicator"], highlightID: SettingsTab.liveActivities.highlightID(for: "Enable Camera Detection")), + SettingsSearchEntry(tab: .liveActivities, title: "Enable Microphone Detection", keywords: ["microphone", "privacy"], highlightID: SettingsTab.liveActivities.highlightID(for: "Enable Microphone Detection")), + SettingsSearchEntry(tab: .liveActivities, title: "Enable music live activity", keywords: ["music", "now playing"], highlightID: SettingsTab.liveActivities.highlightID(for: "Enable music live activity")), + SettingsSearchEntry(tab: .liveActivities, title: "Enable reminder live activity", keywords: ["reminder", "live activity"], highlightID: SettingsTab.liveActivities.highlightID(for: "Enable reminder live activity")), + + // Battery (Charge) + SettingsSearchEntry(tab: .battery, title: "Show battery indicator", keywords: ["battery hud", "charge"], highlightID: SettingsTab.battery.highlightID(for: "Show battery indicator")), + SettingsSearchEntry(tab: .battery, title: "Show battery percentage", keywords: ["battery percent"], highlightID: SettingsTab.battery.highlightID(for: "Show battery percentage")), + SettingsSearchEntry(tab: .battery, title: "Show power status notifications", keywords: ["notifications", "power"], highlightID: SettingsTab.battery.highlightID(for: "Show power status notifications")), + SettingsSearchEntry(tab: .battery, title: "Show power status icons", keywords: ["power icons", "charging icon"], highlightID: SettingsTab.battery.highlightID(for: "Show power status icons")), + SettingsSearchEntry(tab: .battery, title: "Play low battery alert sound", keywords: ["low battery", "alert", "sound"], highlightID: SettingsTab.battery.highlightID(for: "Play low battery alert sound")), + SettingsSearchEntry(tab: .battery, title: "Charging HUD", keywords: ["battery", "charging", "temporary activity"], highlightID: SettingsTab.battery.highlightID(for: "Charging HUD")), + SettingsSearchEntry(tab: .battery, title: "Low battery HUD", keywords: ["battery", "low", "temporary activity"], highlightID: SettingsTab.battery.highlightID(for: "Low battery HUD")), + SettingsSearchEntry(tab: .battery, title: "Fully charged HUD", keywords: ["battery", "full", "temporary activity"], highlightID: SettingsTab.battery.highlightID(for: "Fully charged HUD")), + SettingsSearchEntry(tab: .battery, title: "Charging duration", keywords: ["charging", "duration", "seconds"], highlightID: SettingsTab.battery.highlightID(for: "Charging duration")), + SettingsSearchEntry(tab: .battery, title: "Low battery duration", keywords: ["low battery", "duration", "seconds"], highlightID: SettingsTab.battery.highlightID(for: "Low battery duration")), + SettingsSearchEntry(tab: .battery, title: "Full battery duration", keywords: ["full battery", "duration", "seconds"], highlightID: SettingsTab.battery.highlightID(for: "Full battery duration")), + SettingsSearchEntry(tab: .battery, title: "Test charging HUD", keywords: ["battery", "test", "charging", "preview"], highlightID: nil), + SettingsSearchEntry(tab: .battery, title: "Test low battery HUD", keywords: ["battery", "test", "low", "preview"], highlightID: nil), + SettingsSearchEntry(tab: .battery, title: "Test full battery HUD", keywords: ["battery", "test", "full", "preview"], highlightID: nil), + SettingsSearchEntry(tab: .battery, title: "Low battery style", keywords: ["battery", "style", "compact", "standard"], highlightID: SettingsTab.battery.highlightID(for: "Low battery style")), + SettingsSearchEntry(tab: .battery, title: "Low battery threshold", keywords: ["battery", "threshold", "percent"], highlightID: SettingsTab.battery.highlightID(for: "Low battery threshold")), + SettingsSearchEntry(tab: .battery, title: "Full battery style", keywords: ["battery", "style", "compact", "standard"], highlightID: SettingsTab.battery.highlightID(for: "Full battery style")), + SettingsSearchEntry(tab: .battery, title: "Full charge threshold", keywords: ["battery", "threshold", "full"], highlightID: SettingsTab.battery.highlightID(for: "Full charge threshold")), + + // HUDs + SettingsSearchEntry(tab: .devices, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.devices.highlightID(for: "Show Bluetooth device connections")), + SettingsSearchEntry(tab: .devices, title: "Use circular battery indicator", keywords: ["battery", "circular"], highlightID: SettingsTab.devices.highlightID(for: "Use circular battery indicator")), + SettingsSearchEntry(tab: .devices, title: "Show battery percentage text in HUD", keywords: ["battery text"], highlightID: SettingsTab.devices.highlightID(for: "Show battery percentage text in HUD")), + SettingsSearchEntry(tab: .devices, title: "Scroll device name in HUD", keywords: ["marquee", "device name"], highlightID: SettingsTab.devices.highlightID(for: "Scroll device name in HUD")), + SettingsSearchEntry(tab: .devices, title: "Use 3D Bluetooth HUD icon", keywords: ["bluetooth", "3d", "animation", "mov"], highlightID: SettingsTab.devices.highlightID(for: "Use 3D Bluetooth HUD icon")), + SettingsSearchEntry(tab: .devices, title: "Color-coded battery display", keywords: ["color", "battery"], highlightID: SettingsTab.devices.highlightID(for: "Color-coded battery display")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Color-coded volume display", keywords: ["volume", "color"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Color-coded volume display")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Smooth color transitions", keywords: ["gradient", "smooth"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Smooth color transitions")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Show percentages beside progress bars", keywords: ["percentages", "progress"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Show percentages beside progress bars")), + SettingsSearchEntry(tab: .hudAndOSD, title: "HUD style", keywords: ["inline", "compact"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "HUD style")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Progressbar style", keywords: ["progress", "style"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Progressbar style")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Enable glowing effect", keywords: ["glow", "indicator"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Enable glowing effect")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Use accent color", keywords: ["accent", "color"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Use accent color")), + + // Custom OSD + SettingsSearchEntry(tab: .hudAndOSD, title: "Enable Custom OSD", keywords: ["osd", "on-screen display", "custom osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Enable Custom OSD")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Volume OSD", keywords: ["volume", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Volume OSD")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Brightness OSD", keywords: ["brightness", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Brightness OSD")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Keyboard Backlight OSD", keywords: ["keyboard", "backlight", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Keyboard Backlight OSD")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Material")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Icon & Progress Color", keywords: ["color", "icon", "white", "black", "gray", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Icon & Progress Color")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Volume step", keywords: ["volume", "step", "percent"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Volume step")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Volume fine step", keywords: ["volume", "fine", "step", "percent"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Volume fine step")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Brightness step", keywords: ["brightness", "step", "percent"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Brightness step")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Brightness fine step", keywords: ["brightness", "fine", "step", "percent"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Brightness fine step")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Third-party DDC app integration", keywords: ["ddc", "third party", "external", "display", "betterdisplay", "lunar"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Third-party DDC app integration")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Third-party DDC provider", keywords: ["provider", "betterdisplay", "lunar", "integration", "refresh detection"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Third-party DDC provider")), + SettingsSearchEntry(tab: .hudAndOSD, title: "Enable external volume control listener", keywords: ["external volume", "ddc volume", "betterdisplay volume", "lunar volume", "disable native volume"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Enable external volume control listener")), + + // Media + SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"], highlightID: SettingsTab.media.highlightID(for: "Music Source")), + SettingsSearchEntry(tab: .media, title: "Skip buttons", keywords: ["skip", "controls", "±10"], highlightID: SettingsTab.media.highlightID(for: "Skip buttons")), + SettingsSearchEntry(tab: .media, title: "Sneak Peek Style", keywords: ["sneak peek", "preview"], highlightID: SettingsTab.media.highlightID(for: "Sneak Peek Style")), + SettingsSearchEntry(tab: .media, title: "Enable lyrics", keywords: ["lyrics", "song text"], highlightID: SettingsTab.media.highlightID(for: "Enable lyrics")), + SettingsSearchEntry(tab: .media, title: "Show live canvas in Dynamic Island", keywords: ["canvas", "live canvas", "album art", "dynamic island", "spotify canvas"], highlightID: SettingsTab.media.highlightID(for: "Show live canvas in Dynamic Island")), + SettingsSearchEntry(tab: .media, title: "Auto-hide inactive notch media player", keywords: ["auto hide", "inactive", "placeholder", "notch media"], highlightID: SettingsTab.media.highlightID(for: "Auto-hide inactive notch media player")), + SettingsSearchEntry(tab: .media, title: "Show Change Media Output control", keywords: ["airplay", "route picker", "media output"], highlightID: SettingsTab.media.highlightID(for: "Show Change Media Output control")), + SettingsSearchEntry(tab: .media, title: "Enable album art parallax", keywords: ["parallax", "lock screen", "album art"], highlightID: SettingsTab.media.highlightID(for: "Enable album art parallax")), + SettingsSearchEntry(tab: .media, title: "Enable album art parallax effect", keywords: ["parallax", "parallax effect", "album art"], highlightID: SettingsTab.media.highlightID(for: "Enable album art parallax effect")), + + // Calendar + SettingsSearchEntry(tab: .calendar, title: "Show calendar", keywords: ["calendar", "events"], highlightID: SettingsTab.calendar.highlightID(for: "Show calendar")), + SettingsSearchEntry(tab: .calendar, title: "Enable reminder live activity", keywords: ["reminder", "live activity"], highlightID: SettingsTab.calendar.highlightID(for: "Enable reminder live activity")), + SettingsSearchEntry(tab: .calendar, title: "Countdown style", keywords: ["reminder countdown"], highlightID: SettingsTab.calendar.highlightID(for: "Countdown style")), + SettingsSearchEntry(tab: .calendar, title: "Show lock screen reminder", keywords: ["lock screen", "reminder widget"], highlightID: SettingsTab.calendar.highlightID(for: "Show lock screen reminder")), + SettingsSearchEntry(tab: .calendar, title: "Show next calendar event", keywords: ["calendar widget", "lock screen", "next event"], highlightID: SettingsTab.calendar.highlightID(for: "Show next calendar event")), + SettingsSearchEntry(tab: .calendar, title: "Show events within the next", keywords: ["calendar widget", "lookahead"], highlightID: SettingsTab.calendar.highlightID(for: "Show events within the next")), + SettingsSearchEntry(tab: .calendar, title: "Show events from all calendars", keywords: ["calendar widget", "selection"], highlightID: SettingsTab.calendar.highlightID(for: "Show events from all calendars")), + SettingsSearchEntry(tab: .calendar, title: "Show countdown", keywords: ["calendar widget", "countdown"], highlightID: SettingsTab.calendar.highlightID(for: "Show countdown")), + SettingsSearchEntry(tab: .calendar, title: "Show event for entire duration", keywords: ["calendar widget", "duration"], highlightID: SettingsTab.calendar.highlightID(for: "Show event for entire duration")), + SettingsSearchEntry(tab: .calendar, title: "Hide active event and show next upcoming event", keywords: ["calendar widget", "after start"], highlightID: SettingsTab.calendar.highlightID(for: "Hide active event and show next upcoming event")), + SettingsSearchEntry(tab: .calendar, title: "Show time remaining", keywords: ["calendar widget", "remaining"], highlightID: SettingsTab.calendar.highlightID(for: "Show time remaining")), + SettingsSearchEntry(tab: .calendar, title: "Show start time after event begins", keywords: ["calendar widget", "start time"], highlightID: SettingsTab.calendar.highlightID(for: "Show start time after event begins")), + SettingsSearchEntry(tab: .calendar, title: "Chip color", keywords: ["reminder chip", "color"], highlightID: SettingsTab.calendar.highlightID(for: "Chip color")), + SettingsSearchEntry(tab: .calendar, title: "Hide all-day events", keywords: ["calendar", "all-day"], highlightID: SettingsTab.calendar.highlightID(for: "Hide all-day events")), + SettingsSearchEntry(tab: .calendar, title: "Hide completed reminders", keywords: ["reminder", "completed"], highlightID: SettingsTab.calendar.highlightID(for: "Hide completed reminders")), + SettingsSearchEntry(tab: .calendar, title: "Show full event titles", keywords: ["calendar", "titles"], highlightID: SettingsTab.calendar.highlightID(for: "Show full event titles")), + SettingsSearchEntry(tab: .calendar, title: "Auto-scroll to next event", keywords: ["calendar", "scroll"], highlightID: SettingsTab.calendar.highlightID(for: "Auto-scroll to next event")), + + // Shelf + SettingsSearchEntry(tab: .shelf, title: "Enable shelf", keywords: ["shelf", "dock"], highlightID: SettingsTab.shelf.highlightID(for: "Enable shelf")), + SettingsSearchEntry(tab: .shelf, title: "Open shelf tab by default if items added", keywords: ["auto open", "shelf tab"], highlightID: SettingsTab.shelf.highlightID(for: "Open shelf tab by default if items added")), + SettingsSearchEntry(tab: .shelf, title: "Expanded drag detection area", keywords: ["shelf", "drag"], highlightID: SettingsTab.shelf.highlightID(for: "Expanded drag detection area")), + SettingsSearchEntry(tab: .shelf, title: "Copy items on drag", keywords: ["shelf", "drag", "copy"], highlightID: SettingsTab.shelf.highlightID(for: "Copy items on drag")), + SettingsSearchEntry(tab: .shelf, title: "Remove from shelf after dragging", keywords: ["shelf", "drag", "remove"], highlightID: SettingsTab.shelf.highlightID(for: "Remove from shelf after dragging")), + SettingsSearchEntry(tab: .shelf, title: "Quick Share Service", keywords: ["shelf", "share", "airdrop", "localsend"], highlightID: SettingsTab.shelf.highlightID(for: "Quick Share Service")), + SettingsSearchEntry(tab: .shelf, title: "LocalSend Device Picker Style", keywords: ["localsend", "glass", "picker", "material"], highlightID: SettingsTab.shelf.highlightID(for: "Device Picker Style")), + + // Appearance + SettingsSearchEntry(tab: .appearance, title: "Main screen style", keywords: ["dynamic island", "pill", "non-notch", "display style", "notch style"], highlightID: SettingsTab.appearance.highlightID(for: "Main screen style")), + SettingsSearchEntry(tab: .appearance, title: "Settings icon in notch", keywords: ["settings button", "toolbar"], highlightID: SettingsTab.appearance.highlightID(for: "Settings icon in notch")), + SettingsSearchEntry(tab: .appearance, title: "Enable window shadow", keywords: ["shadow", "appearance"], highlightID: SettingsTab.appearance.highlightID(for: "Enable window shadow")), + SettingsSearchEntry(tab: .appearance, title: "Corner radius scaling", keywords: ["corner radius", "shape"], highlightID: SettingsTab.appearance.highlightID(for: "Corner radius scaling")), + SettingsSearchEntry(tab: .appearance, title: "Use simpler close animation", keywords: ["close animation", "notch"], highlightID: SettingsTab.appearance.highlightID(for: "Use simpler close animation")), + SettingsSearchEntry(tab: .appearance, title: "Notch Width", keywords: ["expanded notch", "width", "resize"], highlightID: SettingsTab.appearance.highlightID(for: "Expanded notch width")), + SettingsSearchEntry(tab: .appearance, title: "Enable colored spectrograms", keywords: ["spectrogram", "audio"], highlightID: SettingsTab.appearance.highlightID(for: "Enable colored spectrograms")), + SettingsSearchEntry(tab: .appearance, title: "Enable blur effect behind album art", keywords: ["blur", "album art"], highlightID: SettingsTab.appearance.highlightID(for: "Enable blur effect behind album art")), + SettingsSearchEntry(tab: .appearance, title: "Slider color", keywords: ["slider", "accent"], highlightID: SettingsTab.appearance.highlightID(for: "Slider color")), + SettingsSearchEntry(tab: .appearance, title: "Enable Dynamic mirror", keywords: ["mirror", "reflection"], highlightID: SettingsTab.appearance.highlightID(for: "Enable Dynamic mirror")), + SettingsSearchEntry(tab: .appearance, title: "Mirror shape", keywords: ["mirror shape", "circle", "rectangle"], highlightID: SettingsTab.appearance.highlightID(for: "Mirror shape")), + SettingsSearchEntry(tab: .appearance, title: "Idle Animation", keywords: ["face animation", "idle", "cool face"], highlightID: SettingsTab.appearance.highlightID(for: "Idle Animation")), + SettingsSearchEntry(tab: .appearance, title: "App icon", keywords: ["app icon", "custom icon"], highlightID: SettingsTab.appearance.highlightID(for: "App icon")), + + // Lock Screen + SettingsSearchEntry(tab: .lockScreen, title: "Preview lock screen widgets", keywords: ["preview", "lock screen", "widgets"], highlightID: SettingsTab.lockScreen.highlightID(for: "Preview lock screen widgets")), + SettingsSearchEntry(tab: .lockScreen, title: "Enable lock screen live activity", keywords: ["lock screen", "live activity"], highlightID: SettingsTab.lockScreen.highlightID(for: "Enable lock screen live activity")), + SettingsSearchEntry(tab: .lockScreen, title: "Play lock/unlock sounds", keywords: ["chime", "sound"], highlightID: SettingsTab.lockScreen.highlightID(for: "Play lock/unlock sounds")), + SettingsSearchEntry(tab: .lockScreen, title: "Material", keywords: ["glass", "frosted", "liquid"], highlightID: SettingsTab.lockScreen.highlightID(for: "Material")), + SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen media panel", keywords: ["media panel", "lock screen media"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show lock screen media panel")), + SettingsSearchEntry(tab: .lockScreen, title: "Show media app icon", keywords: ["app icon", "media"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show media app icon")), + SettingsSearchEntry(tab: .lockScreen, title: "Show panel border", keywords: ["panel border"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show panel border")), + SettingsSearchEntry(tab: .lockScreen, title: "Enable media panel blur", keywords: ["blur", "media panel"], highlightID: SettingsTab.lockScreen.highlightID(for: "Enable media panel blur")), + SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen timer", keywords: ["timer widget", "lock screen timer"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show lock screen timer")), + SettingsSearchEntry(tab: .lockScreen, title: "Timer surface", keywords: ["timer glass", "classic", "blur"], highlightID: SettingsTab.lockScreen.highlightID(for: "Timer surface")), + SettingsSearchEntry(tab: .lockScreen, title: "Timer glass material", keywords: ["frosted", "liquid", "timer material"], highlightID: SettingsTab.lockScreen.highlightID(for: "Timer glass material")), + SettingsSearchEntry(tab: .lockScreen, title: "Timer liquid mode", keywords: ["timer", "standard", "custom"], highlightID: SettingsTab.lockScreen.highlightID(for: "Timer liquid mode")), + SettingsSearchEntry(tab: .lockScreen, title: "Timer widget variant", keywords: ["timer variant", "liquid"], highlightID: SettingsTab.lockScreen.highlightID(for: "Timer widget variant")), + SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen weather", keywords: ["weather widget"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show lock screen weather")), + SettingsSearchEntry(tab: .lockScreen, title: "Layout", keywords: ["inline", "circular", "weather layout"], highlightID: SettingsTab.lockScreen.highlightID(for: "Layout")), + SettingsSearchEntry(tab: .lockScreen, title: "Weather data provider", keywords: ["wttr", "open meteo"], highlightID: SettingsTab.lockScreen.highlightID(for: "Weather data provider")), + SettingsSearchEntry(tab: .lockScreen, title: "Temperature unit", keywords: ["celsius", "fahrenheit"], highlightID: SettingsTab.lockScreen.highlightID(for: "Temperature unit")), + SettingsSearchEntry(tab: .lockScreen, title: "Show location label", keywords: ["location", "weather"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show location label")), + SettingsSearchEntry(tab: .lockScreen, title: "Show charging status", keywords: ["charging", "weather"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show charging status")), + SettingsSearchEntry(tab: .lockScreen, title: "Show charging percentage", keywords: ["charging percentage"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show charging percentage")), + SettingsSearchEntry(tab: .lockScreen, title: "Show battery indicator", keywords: ["battery gauge", "weather"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show battery indicator")), + SettingsSearchEntry(tab: .lockScreen, title: "Use MacBook icon when on battery", keywords: ["laptop icon", "battery"], highlightID: SettingsTab.lockScreen.highlightID(for: "Use MacBook icon when on battery")), + SettingsSearchEntry(tab: .lockScreen, title: "Show Bluetooth battery", keywords: ["bluetooth", "gauge"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show Bluetooth battery")), + SettingsSearchEntry(tab: .lockScreen, title: "Show AQI widget", keywords: ["air quality", "aqi"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show AQI widget")), + SettingsSearchEntry(tab: .lockScreen, title: "Air quality scale", keywords: ["aqi", "scale"], highlightID: SettingsTab.lockScreen.highlightID(for: "Air quality scale")), + SettingsSearchEntry(tab: .lockScreen, title: "Use colored gauges", keywords: ["gauge tint", "monochrome"], highlightID: SettingsTab.lockScreen.highlightID(for: "Use colored gauges")), + SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen reminder", keywords: ["lock screen", "reminder widget"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show lock screen reminder")), + SettingsSearchEntry(tab: .lockScreen, title: "Chip color", keywords: ["reminder chip", "color"], highlightID: SettingsTab.lockScreen.highlightID(for: "Chip color")), + SettingsSearchEntry(tab: .lockScreen, title: "Reminder alignment", keywords: ["reminder", "alignment", "position"], highlightID: SettingsTab.lockScreen.highlightID(for: "Reminder alignment")), + SettingsSearchEntry(tab: .lockScreen, title: "Reminder vertical offset", keywords: ["reminder", "offset", "position"], highlightID: SettingsTab.lockScreen.highlightID(for: "Reminder vertical offset")), + + // Extensions + SettingsSearchEntry(tab: .extensions, title: "Enable third-party extensions", keywords: ["extensions", "authorization", "third party"], highlightID: SettingsTab.extensions.highlightID(for: "Enable third-party extensions")), + SettingsSearchEntry(tab: .extensions, title: "Allow extension live activities", keywords: ["extensions", "live activities", "permissions"], highlightID: SettingsTab.extensions.highlightID(for: "Allow extension live activities")), + SettingsSearchEntry(tab: .extensions, title: "Allow extension lock screen widgets", keywords: ["extensions", "lock screen", "widgets"], highlightID: SettingsTab.extensions.highlightID(for: "Allow extension lock screen widgets")), + SettingsSearchEntry(tab: .extensions, title: "Enable extension diagnostics logging", keywords: ["extensions", "diagnostics", "logging"], highlightID: SettingsTab.extensions.highlightID(for: "Enable extension diagnostics logging")), + SettingsSearchEntry(tab: .extensions, title: "Manage app permissions", keywords: ["extensions", "permissions", "apps"], highlightID: SettingsTab.extensions.highlightID(for: "App permissions list")), + + // Shortcuts + SettingsSearchEntry(tab: .shortcuts, title: "Enable global keyboard shortcuts", keywords: ["keyboard", "shortcut"], highlightID: SettingsTab.shortcuts.highlightID(for: "Enable global keyboard shortcuts")), + + // Timer + SettingsSearchEntry(tab: .timer, title: "Enable timer feature", keywords: ["timer", "enable"], highlightID: SettingsTab.timer.highlightID(for: "Enable timer feature")), + SettingsSearchEntry(tab: .timer, title: "Mirror macOS Clock timers", keywords: ["system timer", "clock app"], highlightID: SettingsTab.timer.highlightID(for: "Mirror macOS Clock timers")), + SettingsSearchEntry(tab: .timer, title: "Show lock screen timer widget", keywords: ["lock screen", "timer widget"], highlightID: SettingsTab.timer.highlightID(for: "Show lock screen timer widget")), + SettingsSearchEntry(tab: .timer, title: "Timer surface", keywords: ["timer glass", "classic", "blur"], highlightID: SettingsTab.timer.highlightID(for: "Timer surface")), + SettingsSearchEntry(tab: .timer, title: "Timer glass material", keywords: ["frosted", "liquid", "timer material"], highlightID: SettingsTab.timer.highlightID(for: "Timer glass material")), + SettingsSearchEntry(tab: .timer, title: "Timer liquid mode", keywords: ["timer", "standard", "custom"], highlightID: SettingsTab.timer.highlightID(for: "Timer liquid mode")), + SettingsSearchEntry(tab: .timer, title: "Timer widget variant", keywords: ["timer variant", "liquid"], highlightID: SettingsTab.timer.highlightID(for: "Timer widget variant")), + SettingsSearchEntry(tab: .timer, title: "Timer tint", keywords: ["timer colour", "preset"], highlightID: SettingsTab.timer.highlightID(for: "Timer tint")), + SettingsSearchEntry(tab: .timer, title: "Solid colour", keywords: ["timer colour", "custom"], highlightID: SettingsTab.timer.highlightID(for: "Solid colour")), + SettingsSearchEntry(tab: .timer, title: "Progress style", keywords: ["progress", "bar", "ring"], highlightID: SettingsTab.timer.highlightID(for: "Progress style")), + SettingsSearchEntry(tab: .timer, title: "Accent colour", keywords: ["accent", "timer"], highlightID: SettingsTab.timer.highlightID(for: "Accent colour")), + + // Stats + SettingsSearchEntry(tab: .stats, title: "Enable system stats monitoring", keywords: ["stats", "monitoring"], highlightID: SettingsTab.stats.highlightID(for: "Enable system stats monitoring")), + SettingsSearchEntry(tab: .stats, title: "Stop monitoring after closing the notch", keywords: ["stats", "auto stop"], highlightID: SettingsTab.stats.highlightID(for: "Stop monitoring after closing the notch")), + SettingsSearchEntry(tab: .stats, title: "CPU Usage", keywords: ["cpu", "graph"], highlightID: SettingsTab.stats.highlightID(for: "CPU Usage")), + SettingsSearchEntry(tab: .stats, title: "Temperature unit", keywords: ["cpu", "temperature", "celsius", "fahrenheit"], highlightID: SettingsTab.stats.highlightID(for: "Temperature unit")), + SettingsSearchEntry(tab: .stats, title: "Memory Usage", keywords: ["memory", "ram"], highlightID: SettingsTab.stats.highlightID(for: "Memory Usage")), + SettingsSearchEntry(tab: .stats, title: "GPU Usage", keywords: ["gpu", "graphics"], highlightID: SettingsTab.stats.highlightID(for: "GPU Usage")), + SettingsSearchEntry(tab: .stats, title: "Network Activity", keywords: ["network", "graph"], highlightID: SettingsTab.stats.highlightID(for: "Network Activity")), + SettingsSearchEntry(tab: .stats, title: "Disk I/O", keywords: ["disk", "io"], highlightID: SettingsTab.stats.highlightID(for: "Disk I/O")), + + // Clipboard + SettingsSearchEntry(tab: .clipboard, title: "Enable Clipboard Manager", keywords: ["clipboard", "manager"], highlightID: SettingsTab.clipboard.highlightID(for: "Enable Clipboard Manager")), + SettingsSearchEntry(tab: .clipboard, title: "Show Clipboard Icon", keywords: ["icon", "clipboard"], highlightID: SettingsTab.clipboard.highlightID(for: "Show Clipboard Icon")), + SettingsSearchEntry(tab: .clipboard, title: "Display Mode", keywords: ["list", "grid", "clipboard"], highlightID: SettingsTab.clipboard.highlightID(for: "Display Mode")), + SettingsSearchEntry(tab: .clipboard, title: "History Size", keywords: ["history", "clipboard"], highlightID: SettingsTab.clipboard.highlightID(for: "History Size")), + + // Screen Assistant + SettingsSearchEntry(tab: .screenAssistant, title: "Enable Screen Assistant", keywords: ["screen assistant", "ai"], highlightID: SettingsTab.screenAssistant.highlightID(for: "Enable Screen Assistant")), + SettingsSearchEntry(tab: .screenAssistant, title: "Display Mode", keywords: ["screen assistant", "mode"], highlightID: SettingsTab.screenAssistant.highlightID(for: "Display Mode")), + + // Color Picker + SettingsSearchEntry(tab: .colorPicker, title: "Enable Color Picker", keywords: ["color picker", "eyedropper"], highlightID: SettingsTab.colorPicker.highlightID(for: "Enable Color Picker")), + SettingsSearchEntry(tab: .colorPicker, title: "Show Color Picker Icon", keywords: ["color icon", "toolbar"], highlightID: SettingsTab.colorPicker.highlightID(for: "Show Color Picker Icon")), + SettingsSearchEntry(tab: .colorPicker, title: "Display Mode", keywords: ["color", "list"], highlightID: SettingsTab.colorPicker.highlightID(for: "Display Mode")), + SettingsSearchEntry(tab: .colorPicker, title: "History Size", keywords: ["color history"], highlightID: SettingsTab.colorPicker.highlightID(for: "History Size")), + SettingsSearchEntry(tab: .colorPicker, title: "Show All Color Formats", keywords: ["hex", "hsl", "color formats"], highlightID: SettingsTab.colorPicker.highlightID(for: "Show All Color Formats")), + + // Terminal + SettingsSearchEntry(tab: .terminal, title: "Enable terminal", keywords: ["terminal", "guake", "shell"], highlightID: SettingsTab.terminal.highlightID(for: "Enable terminal")), + SettingsSearchEntry(tab: .terminal, title: "Shell path", keywords: ["shell", "zsh", "bash", "terminal"], highlightID: SettingsTab.terminal.highlightID(for: "Shell path")), + SettingsSearchEntry(tab: .terminal, title: "Font size", keywords: ["terminal", "font", "text size"], highlightID: SettingsTab.terminal.highlightID(for: "Font size")), + SettingsSearchEntry(tab: .terminal, title: "Terminal opacity", keywords: ["terminal", "opacity", "transparency", "blur", "background"], highlightID: SettingsTab.terminal.highlightID(for: "Terminal opacity")), + SettingsSearchEntry(tab: .terminal, title: "Maximum height", keywords: ["terminal", "height", "size"], highlightID: SettingsTab.terminal.highlightID(for: "Maximum height")), + SettingsSearchEntry(tab: .terminal, title: "Background color", keywords: ["terminal", "background", "color", "theme"], highlightID: SettingsTab.terminal.highlightID(for: "Background color")), + SettingsSearchEntry(tab: .terminal, title: "Foreground color", keywords: ["terminal", "foreground", "text color", "theme"], highlightID: SettingsTab.terminal.highlightID(for: "Foreground color")), + SettingsSearchEntry(tab: .terminal, title: "Cursor color", keywords: ["terminal", "cursor", "caret", "color"], highlightID: SettingsTab.terminal.highlightID(for: "Cursor color")), + SettingsSearchEntry(tab: .terminal, title: "Bold as bright", keywords: ["terminal", "bold", "bright", "colors"], highlightID: SettingsTab.terminal.highlightID(for: "Bold as bright")), + SettingsSearchEntry(tab: .terminal, title: "Cursor style", keywords: ["terminal", "cursor", "block", "underline", "bar", "blink"], highlightID: SettingsTab.terminal.highlightID(for: "Cursor style")), + SettingsSearchEntry(tab: .terminal, title: "Scrollback lines", keywords: ["terminal", "scrollback", "buffer", "history"], highlightID: SettingsTab.terminal.highlightID(for: "Scrollback lines")), + SettingsSearchEntry(tab: .terminal, title: "Option as Meta", keywords: ["terminal", "option", "meta", "alt", "key"], highlightID: SettingsTab.terminal.highlightID(for: "Option as Meta")), + SettingsSearchEntry(tab: .terminal, title: "Mouse reporting", keywords: ["terminal", "mouse", "reporting", "vim", "tmux"], highlightID: SettingsTab.terminal.highlightID(for: "Mouse reporting")), + ] + } + + private func isTabVisible(_ tab: SettingsTab) -> Bool { + switch tab { + case .timer, .stats, .clipboard, .screenAssistant, .colorPicker, .shelf, .notes, .terminal: + return !enableMinimalisticUI + default: + return true + } + } + + @ViewBuilder + private func detailView(for tab: SettingsTab) -> some View { + switch tab { + case .general: + SettingsForm(tab: .general) { + GeneralSettings() + } + case .liveActivities: + SettingsForm(tab: .liveActivities) { + LiveActivitiesSettings() + } + case .appearance: + SettingsForm(tab: .appearance) { + Appearance() + } + case .lockScreen: + SettingsForm(tab: .lockScreen) { + LockScreenSettings() + } + case .media: + SettingsForm(tab: .media) { + Media() + } + case .devices: + SettingsForm(tab: .devices) { + DevicesSettingsView() + } + case .extensions: + SettingsForm(tab: .extensions) { + ExtensionsSettingsView() + } + case .timer: + SettingsForm(tab: .timer) { + TimerSettings() + } + case .calendar: + SettingsForm(tab: .calendar) { + CalendarSettings() + } + case .hudAndOSD: + SettingsForm(tab: .hudAndOSD) { + HUDAndOSDSettingsView() + } + case .battery: + SettingsForm(tab: .battery) { + Charge() + } + case .stats: + SettingsForm(tab: .stats) { + StatsSettings() + } + case .clipboard: + SettingsForm(tab: .clipboard) { + ClipboardSettings() + } + case .screenAssistant: + SettingsForm(tab: .screenAssistant) { + ScreenAssistantSettings() + } + case .colorPicker: + SettingsForm(tab: .colorPicker) { + ColorPickerSettings() + } + case .downloads: + SettingsForm(tab: .downloads) { + Downloads() + } + case .shelf: + SettingsForm(tab: .shelf) { + Shelf() + } + case .shortcuts: + SettingsForm(tab: .shortcuts) { + Shortcuts() + } + case .notes: + SettingsForm(tab: .notes) { + NotesSettingsView() + } + case .terminal: + SettingsForm(tab: .terminal) { + TerminalSettings() + } + case .about: + if let controller = updaterController { + SettingsForm(tab: .about) { + About(updaterController: controller) + } + } else { + SettingsForm(tab: .about) { + About(updaterController: SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil)) + } + } } - .environmentObject(extensionManager) - .environmentObject(calendarManager) - .formStyle(.grouped) - .frame(width: 700) - .background(Color(NSColor.windowBackgroundColor)) } } @@ -129,7 +1038,6 @@ struct GeneralSettings: View { @State private var screens: [String] = NSScreen.screens.compactMap { $0.localizedName } @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared - @Default(.mirrorShape) var mirrorShape @Default(.showEmojis) var showEmojis @Default(.gestureSensitivity) var gestureSensitivity @@ -142,19 +1050,59 @@ struct GeneralSettings: View { @Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay @Default(.enableGestures) var enableGestures @Default(.openNotchOnHover) var openNotchOnHover - @Default(.alwaysHideInFullscreen) var alwaysHideInFullscreen - + @Default(.enableMinimalisticUI) var enableMinimalisticUI + @Default(.enableHorizontalMusicGestures) var enableHorizontalMusicGestures + @Default(.musicGestureBehavior) var musicGestureBehavior + @Default(.reverseSwipeGestures) var reverseSwipeGestures + @Default(.reverseScrollGestures) var reverseScrollGestures + @Default(.externalDisplayStyle) var externalDisplayStyle + @Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover + + private func highlightID(_ title: String) -> String { + SettingsTab.general.highlightID(for: title) + } + var body: some View { Form { Section { - Defaults.Toggle("Menubar icon", key: .menubarIcon) - LaunchAtLogin.Toggle("Launch at login") + Defaults.Toggle(key: .enableMinimalisticUI) { + Text("Enable Minimalistic UI") + } + .onChange(of: enableMinimalisticUI) { _, newValue in + if newValue { + // Auto-enable simpler animation mode + Defaults[.useModernCloseAnimation] = true + } + } + .settingsHighlight(id: highlightID("Enable Minimalistic UI")) + + Defaults.Toggle(key: .showBatteryPercentInside) { + Text("Show battery percentage inside icon") + } + .disabled(!enableMinimalisticUI) + .settingsHighlight(id: highlightID("Show battery percentage inside icon")) + } header: { + Text("UI Mode") + } footer: { + Text("Minimalistic mode focuses on media controls and system HUDs, hiding all extra features for a clean, focused experience. Automatically enables simpler animations.") + } + + Section { + Defaults.Toggle(key: .menubarIcon) { + Text("Menubar icon") + } + .settingsHighlight(id: highlightID("Menubar icon")) + LaunchAtLogin.Toggle { + Text("Launch at login") + } + .settingsHighlight(id: highlightID("Launch at login")) Defaults.Toggle(key: .showOnAllDisplays) { Text("Show on all displays") } .onChange(of: showOnAllDisplays) { NotificationCenter.default.post(name: Notification.Name.showOnAllDisplaysChanged, object: nil) } + .settingsHighlight(id: highlightID("Show on all displays")) Picker("Show on a specific display", selection: $coordinator.preferredScreen) { ForEach(screens, id: \.self) { screen in Text(screen) @@ -164,36 +1112,45 @@ struct GeneralSettings: View { screens = NSScreen.screens.compactMap({$0.localizedName}) } .disabled(showOnAllDisplays) - Defaults.Toggle("Automatically switch displays", key: .automaticallySwitchDisplay) + .settingsHighlight(id: highlightID("Show on a specific display")) + Defaults.Toggle(key: .automaticallySwitchDisplay) { + Text("Automatically switch displays") + } .onChange(of: automaticallySwitchDisplay) { NotificationCenter.default.post(name: Notification.Name.automaticallySwitchDisplayChanged, object: nil) } .disabled(showOnAllDisplays) + .settingsHighlight(id: highlightID("Automatically switch displays")) + Defaults.Toggle(key: .hideDynamicIslandFromScreenCapture) { + Text("Hide Dynamic Island during screenshots & recordings") + } + .settingsHighlight(id: highlightID("Hide Dynamic Island during screenshots & recordings")) } header: { Text("System features") } - + Section { Picker(selection: $notchHeightMode, label: - Text("Notch display height")) { - Text("Match real notch size") - .tag(WindowHeightMode.matchRealNotchSize) - Text("Match menubar height") - .tag(WindowHeightMode.matchMenuBar) - Text("Custom height") - .tag(WindowHeightMode.custom) - } - .onChange(of: notchHeightMode) { - switch notchHeightMode { - case .matchRealNotchSize: - notchHeight = 38 - case .matchMenuBar: - notchHeight = 44 - case .custom: - notchHeight = 38 + Text("Notch display height")) { + Text("Match real notch size") + .tag(WindowHeightMode.matchRealNotchSize) + Text("Match menubar height") + .tag(WindowHeightMode.matchMenuBar) + Text("Custom height") + .tag(WindowHeightMode.custom) + } + .onChange(of: notchHeightMode) { + switch notchHeightMode { + case .matchRealNotchSize: + notchHeight = 38 + case .matchMenuBar: + notchHeight = 44 + case .custom: + notchHeight = 38 + } + NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) } - NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) - } + .settingsHighlight(id: highlightID("Notch display height")) if notchHeightMode == .custom { Slider(value: $notchHeight, in: 15...45, step: 1) { Text("Custom notch size - \(notchHeight, specifier: "%.0f")") @@ -232,9 +1189,9 @@ struct GeneralSettings: View { } header: { Text("Notch Height") } - + NotchBehaviour() - + gestureControls() } .toolbar { @@ -250,16 +1207,45 @@ struct GeneralSettings: View { } } } - + @ViewBuilder func gestureControls() -> some View { Section { - Defaults.Toggle("Enable gestures", key: .enableGestures) - .disabled(!openNotchOnHover) + Defaults.Toggle(key: .enableGestures) { + Text("Enable gestures") + } + .disabled(!openNotchOnHover) + .settingsHighlight(id: highlightID("Enable gestures")) if enableGestures { - Toggle("Media change with horizontal gestures", isOn: .constant(false)) - .disabled(true) - Defaults.Toggle("Close gesture", key: .closeGestureEnabled) + Defaults.Toggle(key: .enableHorizontalMusicGestures) { + Text("Media change with horizontal gestures") + } + .settingsHighlight(id: highlightID("Horizontal media gestures")) + + if enableHorizontalMusicGestures { + Picker("Gesture skip behavior", selection: $musicGestureBehavior) { + ForEach(MusicSkipBehavior.allCases) { behavior in + Text(behavior.displayName) + .tag(behavior) + } + } + .pickerStyle(.segmented) + .settingsHighlight(id: highlightID("Gesture skip behavior")) + + Text(musicGestureBehavior.description) + .font(.caption) + .foregroundStyle(.secondary) + + Defaults.Toggle(key: .reverseSwipeGestures) { + Text("Reverse swipe gestures") + } + .settingsHighlight(id: highlightID("Reverse swipe gestures")) + } + + Defaults.Toggle(key: .closeGestureEnabled) { + Text("Close gesture") + } + .settingsHighlight(id: highlightID("Close gesture")) Slider(value: $gestureSensitivity, in: 100...300, step: 100) { HStack { Text("Gesture sensitivity") @@ -268,6 +1254,11 @@ struct GeneralSettings: View { .foregroundStyle(.secondary) } } + + Defaults.Toggle(key: .reverseScrollGestures) { + Text("Reverse open/close scroll gestures") + } + .settingsHighlight(id: highlightID("Reverse scroll gestures")) } } header: { HStack { @@ -281,13 +1272,22 @@ struct GeneralSettings: View { .font(.caption) } } - + @ViewBuilder func NotchBehaviour() -> some View { Section { - Defaults.Toggle("Extend hover area", key: .extendHoverArea) - Defaults.Toggle("Enable haptics", key: .enableHaptics) - Defaults.Toggle("Open notch on hover", key: .openNotchOnHover) + Defaults.Toggle(key: .extendHoverArea) { + Text("Extend hover area") + } + .settingsHighlight(id: highlightID("Extend hover area")) + Defaults.Toggle(key: .enableHaptics) { + Text("Enable haptics") + } + .settingsHighlight(id: highlightID("Enable haptics")) + Defaults.Toggle(key: .openNotchOnHover) { + Text("Open notch on hover") + } + .settingsHighlight(id: highlightID("Open notch on hover")) Toggle("Remember last tab", isOn: $coordinator.openLastTabByDefault) if openNotchOnHover { Slider(value: $minimumHoverDuration, in: 0...1, step: 0.1) { @@ -302,6 +1302,26 @@ struct GeneralSettings: View { NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) } } + Picker("External display style", selection: $externalDisplayStyle) { + ForEach(ExternalDisplayStyle.allCases) { style in + Text(style.localizedName) + .tag(style) + } + } + .onChange(of: externalDisplayStyle) { + NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) + } + .settingsHighlight(id: highlightID("External display style")) + Text(externalDisplayStyle.description) + .font(.caption) + .foregroundStyle(.secondary) + Defaults.Toggle(key: .hideNonNotchUntilHover) { + Text("Hide until hovered on non-notch displays") + } + .settingsHighlight(id: highlightID("Hide until hovered")) + Text("When enabled, the notch slides up and hides on external (non-notch) displays until you hover over it.") + .font(.caption) + .foregroundStyle(.secondary) } header: { Text("Notch behavior") } @@ -309,886 +1329,7139 @@ struct GeneralSettings: View { } struct Charge: View { - var body: some View { - Form { - Section { - Defaults.Toggle("Show battery indicator", key: .showBatteryIndicator) - Defaults.Toggle("Show power status notifications", key: .showPowerStatusNotifications) - } header: { - Text("General") - } - Section { - Defaults.Toggle("Show battery percentage", key: .showBatteryPercentage) - Defaults.Toggle("Show power status icons", key: .showPowerStatusIcons) - } header: { - Text("Battery Information") - } - } - .navigationTitle("Battery") + @ObservedObject private var batteryStatusViewModel = BatteryStatusViewModel.shared + @Default(.showPowerStatusNotifications) private var showPowerStatusNotifications + @Default(.showChargingBatteryHUD) private var showChargingBatteryHUD + @Default(.showLowBatteryHUD) private var showLowBatteryHUD + @Default(.showFullBatteryHUD) private var showFullBatteryHUD + @Default(.chargingBatteryHUDDuration) private var chargingBatteryHUDDuration + @Default(.lowBatteryHUDDuration) private var lowBatteryHUDDuration + @Default(.fullBatteryHUDDuration) private var fullBatteryHUDDuration + @Default(.lowBatteryHUDThreshold) private var lowBatteryHUDThreshold + @Default(.fullBatteryHUDThreshold) private var fullBatteryHUDThreshold + @Default(.lowBatteryHUDStyle) private var lowBatteryHUDStyle + @Default(.fullBatteryHUDStyle) private var fullBatteryHUDStyle + + private func highlightID(_ title: String) -> String { + SettingsTab.battery.highlightID(for: title) + } + + private var chargingDurationBinding: Binding { + Binding( + get: { Double(chargingBatteryHUDDuration) }, + set: { chargingBatteryHUDDuration = Int($0.rounded()) } + ) + } + + private var lowBatteryDurationBinding: Binding { + Binding( + get: { Double(lowBatteryHUDDuration) }, + set: { lowBatteryHUDDuration = Int($0.rounded()) } + ) + } + + private var fullBatteryDurationBinding: Binding { + Binding( + get: { Double(fullBatteryHUDDuration) }, + set: { fullBatteryHUDDuration = Int($0.rounded()) } + ) + } + + private var lowBatteryThresholdBinding: Binding { + Binding( + get: { Double(lowBatteryHUDThreshold) }, + set: { lowBatteryHUDThreshold = Int($0.rounded()) } + ) + } + + private var fullBatteryThresholdBinding: Binding { + Binding( + get: { Double(fullBatteryHUDThreshold) }, + set: { fullBatteryHUDThreshold = Int($0.rounded()) } + ) + } + + private func sectionOpacity(_ isEnabled: Bool) -> Double { + isEnabled ? 1 : 0.5 } -} -struct Downloads: View { - @Default(.selectedDownloadIndicatorStyle) var selectedDownloadIndicatorStyle - @Default(.selectedDownloadIconStyle) var selectedDownloadIconStyle var body: some View { Form { - warningBadge("We don't support downloads yet", "It will be supported later on.") - Section { - Defaults.Toggle("Show download progress", key: .enableDownloadListener) - .disabled(true) - Defaults.Toggle("Enable Safari Downloads", key: .enableSafariDownloads) - .disabled(!Defaults[.enableDownloadListener]) - Picker("Download indicator style", selection: $selectedDownloadIndicatorStyle) { - Text("Progress bar") - .tag(DownloadIndicatorStyle.progress) - Text("Percentage") - .tag(DownloadIndicatorStyle.percentage) - } - Picker("Download icon style", selection: $selectedDownloadIconStyle) { - Text("Only app icon") - .tag(DownloadIconStyle.onlyAppIcon) - Text("Only download icon") - .tag(DownloadIconStyle.onlyIcon) - Text("Both") - .tag(DownloadIconStyle.iconAndAppIcon) + if BatteryActivityManager.shared.hasBattery() { + Section { + Defaults.Toggle(key: .showBatteryIndicator) { + Text("Show battery indicator") + } + .settingsHighlight(id: highlightID("Show battery indicator")) + Defaults.Toggle(key: .showPowerStatusNotifications) { + Text("Show power status notifications") + } + .settingsHighlight(id: highlightID("Show power status notifications")) + Defaults.Toggle(key: .playLowBatteryAlertSound) { + Text("Play low battery alert sound") + } + .settingsHighlight(id: highlightID("Play low battery alert sound")) + } header: { + Text("General") } - - } header: { - HStack { - Text("Download indicators") - comingSoonTag() + Section { + Defaults.Toggle(key: .showBatteryPercentage) { + Text("Show battery percentage") + } + .settingsHighlight(id: highlightID("Show battery percentage")) + Defaults.Toggle(key: .showPowerStatusIcons) { + Text("Show power status icons") + } + .settingsHighlight(id: highlightID("Show power status icons")) + } header: { + Text("Battery Information") } - } - Section { - List { - ForEach([].indices, id: \.self) { index in - Text("\(index)") + Section { + Defaults.Toggle(key: .showChargingBatteryHUD) { + Text("Charging HUD") + } + .settingsHighlight(id: highlightID("Charging HUD")) + + Defaults.Toggle(key: .showLowBatteryHUD) { + Text("Low battery HUD") + } + .settingsHighlight(id: highlightID("Low battery HUD")) + + Defaults.Toggle(key: .showFullBatteryHUD) { + Text("Fully charged HUD") } + .settingsHighlight(id: highlightID("Fully charged HUD")) + } header: { + Text("Battery HUDs") + } footer: { + Text("These temporary HUDs recreate the charging, low-battery, and full-battery notch alerts.") } - .frame(minHeight: 96) - .overlay { - if true { - Text("No excluded apps") - .foregroundStyle(Color(.secondaryLabelColor)) + Section { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Charging duration") + Spacer() + Text("\(chargingBatteryHUDDuration)s") + .foregroundStyle(.secondary) + } + Slider(value: chargingDurationBinding, in: 1...10, step: 1) + } + .settingsHighlight(id: highlightID("Charging duration")) + .disabled(!showPowerStatusNotifications || !showChargingBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showChargingBatteryHUD)) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Low battery duration") + Spacer() + Text("\(lowBatteryHUDDuration)s") + .foregroundStyle(.secondary) + } + Slider(value: lowBatteryDurationBinding, in: 1...10, step: 1) + } + .settingsHighlight(id: highlightID("Low battery duration")) + .disabled(!showPowerStatusNotifications || !showLowBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showLowBatteryHUD)) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Full battery duration") + Spacer() + Text("\(fullBatteryHUDDuration)s") + .foregroundStyle(.secondary) + } + Slider(value: fullBatteryDurationBinding, in: 1...10, step: 1) } + .settingsHighlight(id: highlightID("Full battery duration")) + .disabled(!showPowerStatusNotifications || !showFullBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showFullBatteryHUD)) + } header: { + Text("HUD Duration") } - .actionBar(padding: 0) { - Group { - Button {} label: { - Image(systemName: "plus") - .frame(width: 25, height: 16, alignment: .center) - .contentShape(Rectangle()) + Section { + Button { + batteryStatusViewModel.triggerTestHUD(kind: .charging) + } label: { + Label("Test charging HUD", systemImage: "bolt.fill") + } + .disabled(!showPowerStatusNotifications || !showChargingBatteryHUD) + + Button { + batteryStatusViewModel.triggerTestHUD(kind: .lowBattery) + } label: { + Label("Test low battery HUD", systemImage: "battery.25") + } + .disabled(!showPowerStatusNotifications || !showLowBatteryHUD) + + Button { + batteryStatusViewModel.triggerTestHUD(kind: .fullBattery) + } label: { + Label("Test full battery HUD", systemImage: "battery.100") + } + .disabled(!showPowerStatusNotifications || !showFullBatteryHUD) + } header: { + Text("HUD Tests") + } footer: { + Text("Runs the real notch animation on the current target display. If an external screen is using Dynamic Island mode, the battery HUD is sent there first.") + } + Section { + VStack(alignment: .leading, spacing: 10) { + Picker("Low battery style", selection: $lowBatteryHUDStyle) { + ForEach(BatteryNotificationStyle.allCases) { style in + Text(style.title) + .tag(style) + } + } + .pickerStyle(.segmented) + Text("Compact matches the charging HUD. Standard uses the expanded DynamicNotch-style card.") + .font(.caption) + .foregroundStyle(.secondary) + } + .settingsHighlight(id: highlightID("Low battery style")) + + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Low battery threshold") + Spacer() + Text("\(lowBatteryHUDThreshold)%") .foregroundStyle(.secondary) } - - Divider() - Button {} label: { - Image(systemName: "minus") - .frame(width: 20, height: 16, alignment: .center) - .contentShape(Rectangle()) + Slider(value: lowBatteryThresholdBinding, in: 5...30, step: 1) + } + .settingsHighlight(id: highlightID("Low battery threshold")) + } header: { + Text("Low Battery") + } + .disabled(!showPowerStatusNotifications || !showLowBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showLowBatteryHUD)) + + Section { + VStack(alignment: .leading, spacing: 10) { + Picker("Full battery style", selection: $fullBatteryHUDStyle) { + ForEach(BatteryNotificationStyle.allCases) { style in + Text(style.title) + .tag(style) + } + } + .pickerStyle(.segmented) + Text("Compact keeps the alert inline. Standard uses the taller full-charge HUD with the charging animation.") + .font(.caption) + .foregroundStyle(.secondary) + } + .settingsHighlight(id: highlightID("Full battery style")) + + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Full charge threshold") + Spacer() + Text("\(fullBatteryHUDThreshold)%") .foregroundStyle(.secondary) } + Slider(value: fullBatteryThresholdBinding, in: 80...100, step: 1) } + .settingsHighlight(id: highlightID("Full charge threshold")) + } header: { + Text("Full Battery") } - } header: { - HStack(spacing: 4) { - Text("Exclude apps") - comingSoonTag() + .disabled(!showPowerStatusNotifications || !showFullBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showFullBatteryHUD)) + } else { + ContentUnavailableView { + VStack(spacing: 16) { + Image("battery.100percent.slash") + .font(.title) + Text("Battery settings and informations are only available on MacBooks") + .font(.title3) + } } } } - .navigationTitle("Downloads") + .navigationTitle("Battery") } } -struct HUD: View { - @EnvironmentObject var vm: DynamicIslandViewModel - @Default(.inlineHUD) var inlineHUD - @Default(.enableGradient) var enableGradient - @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared +struct Downloads: View { + @Default(.selectedDownloadIndicatorStyle) var selectedDownloadIndicatorStyle + @Default(.selectedDownloadIconStyle) var selectedDownloadIconStyle + + private func highlightID(_ title: String) -> String { + SettingsTab.downloads.highlightID(for: title) + } + var body: some View { - Form { - Section { - Toggle("Enable HUD replacement", isOn: $coordinator.hudReplacement) - } header: { - Text("General") - } + SwiftUI.Form { Section { - Picker("HUD style", selection: $inlineHUD) { - Text("Default") - .tag(false) - Text("Inline") - .tag(true) + Defaults.Toggle(key: .enableDownloadListener) { + Text("Enable download detection") } - .onChange(of: Defaults[.inlineHUD]) { - if Defaults[.inlineHUD] { - withAnimation { - Defaults[.systemEventIndicatorShadow] = false - Defaults[.enableGradient] = false + .settingsHighlight(id: highlightID("Enable download detection")) + VStack(alignment: .leading, spacing: 12) { + Text("Download indicator style") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + + HStack(spacing: 16) { + DownloadStyleButton( + style: .progress, + isSelected: selectedDownloadIndicatorStyle == .progress, + disabled: !Defaults[.enableDownloadListener] + ) { + selectedDownloadIndicatorStyle = .progress + } + + DownloadStyleButton( + style: .circle, + isSelected: selectedDownloadIndicatorStyle == .circle, + disabled: !Defaults[.enableDownloadListener] + ) { + selectedDownloadIndicatorStyle = .circle } } } - Picker("Progressbar style", selection: $enableGradient) { - Text("Hierarchical") - .tag(false) - Text("Gradient") - .tag(true) - } - Defaults.Toggle("Enable glowing effect", key: .systemEventIndicatorShadow) - Defaults.Toggle("Use accent color", key: .systemEventIndicatorUseAccent) + .settingsHighlight(id: highlightID("Download indicator style")) } header: { - HStack { - Text("Appearance") - } + Text("Download Detection") + } footer: { + Text("Monitor your Downloads folder for Chromium-style downloads (.crdownload files) and show a live activity in the Dynamic Island while downloads are in progress.") } } - .navigationTitle("HUDs") + .navigationTitle("Downloads") } -} -struct Media: View { - @Default(.waitInterval) var waitInterval - @Default(.mediaController) var mediaController - @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared - @Default(.hideNotchOption) var hideNotchOption - @Default(.enableSneakPeek) private var enableSneakPeek - @Default(.sneakPeekStyles) var sneakPeekStyles - - var body: some View { - Form { - Section { - Picker("Music Source", selection: $mediaController) { - ForEach(availableMediaControllers) { controller in - Text(controller.rawValue).tag(controller) + struct DownloadStyleButton: View { + let style: DownloadIndicatorStyle + let isSelected: Bool + let disabled: Bool + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + VStack(spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1) + ) + + if style == .progress { + ProgressView() + .progressViewStyle(.linear) + .tint(.accentColor) + .frame(width: 40) + } else { + SpinningCircleDownloadView() } } - .onChange(of: mediaController) { _, _ in - NotificationCenter.default.post( - name: Notification.Name.mediaControllerChanged, - object: nil - ) - } - } header: { - Text("Media Source") - } footer: { - if MusicManager.shared.isNowPlayingDeprecated { - HStack { - Text("YouTube Music requires this third-party app to be installed: ") - .foregroundStyle(.secondary) - .font(.caption) - Link("https://github.com/th-ch/youtube-music", destination: URL(string: "https://github.com/th-ch/youtube-music")!) - .font(.caption) - .foregroundColor(.blue) // Ensures it's visibly a link - } - } else { - Text("'Now Playing' was the only option on previous versions and works with all media apps.") - .foregroundStyle(.secondary) - .font(.caption) + .frame(width: 80, height: 60) + .onHover { hovering in + if !disabled { + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = hovering + } + } } + + Text(style.rawValue) + .font(.caption) + .fontWeight(.medium) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(width: 100) + .foregroundStyle(disabled ? .secondary : .primary) } - Section { - Toggle( - "Enable music live activity", - isOn: $coordinator.musicLiveActivityEnabled - ) - .animation(.easeInOut, value: coordinator.musicLiveActivityEnabled) - - Toggle( - "Enable timer live activity", - isOn: $coordinator.timerLiveActivityEnabled - ) - .animation(.easeInOut, value: coordinator.timerLiveActivityEnabled) - - // Demo timer button - HStack { - Button("Start 5-min Demo Timer") { - TimerManager.shared.startDemoTimer(duration: 300) - } - .buttonStyle(.borderedProminent) - - Spacer() - - Button("Stop Timer") { - TimerManager.shared.stopTimer() - } - .buttonStyle(.bordered) + .contentShape(Rectangle()) + .onTapGesture { + if !disabled { + action() } - - Toggle("Enable sneak peek", isOn: $enableSneakPeek) - Picker("Sneak Peek Style", selection: $sneakPeekStyles){ - ForEach(SneakPeekStyle.allCases) { style in - Text(style.rawValue).tag(style) - } - }.disabled(!enableSneakPeek) - HStack { - Stepper(value: $waitInterval, in: 0...10, step: 1) { - HStack { - Text("Media inactivity timeout") - Spacer() - Text("\(Defaults[.waitInterval], specifier: "%.0f") seconds") - .foregroundStyle(.secondary) - } - } + } + .opacity(disabled ? 0.5 : 1.0) + } + + private var backgroundColor: Color { + if disabled { return Color(nsColor: .controlBackgroundColor) } + if isSelected { return Color.accentColor.opacity(0.1) } + if isHovering { return Color.primary.opacity(0.05) } + return Color(nsColor: .controlBackgroundColor) + } + + private var borderColor: Color { + if isSelected { return Color.accentColor } + if isHovering { return Color.primary.opacity(0.1) } + return Color.clear + } + } +} + +final class HUDPreviewViewModel: ObservableObject { + @Published var level: Float = 0 + @Published var iconName: String = "speaker.wave.3.fill" + + private var cancellables = Set() + + init() { + setup() + } + + private func setup() { + // Ensure controllers are active + SystemVolumeController.shared.start() + SystemBrightnessController.shared.start() + SystemKeyboardBacklightController.shared.start() + + // Initial state from volume + let vol = SystemVolumeController.shared.currentVolume + self.level = vol + if vol <= 0.01 { self.iconName = "speaker.slash.fill" } + else if vol < 0.33 { self.iconName = "speaker.wave.1.fill" } + else if vol < 0.66 { self.iconName = "speaker.wave.2.fill" } + else { self.iconName = "speaker.wave.3.fill" } + + // Listeners + NotificationCenter.default.publisher(for: .systemVolumeDidChange) + .compactMap { $0.userInfo } + .receive(on: DispatchQueue.main) + .sink { [weak self] info in + guard let self else { return } + if let vol = info["value"] as? Float { + self.level = vol + if vol <= 0.01 { self.iconName = "speaker.slash.fill" } + else if vol < 0.33 { self.iconName = "speaker.wave.1.fill" } + else if vol < 0.66 { self.iconName = "speaker.wave.2.fill" } + else { self.iconName = "speaker.wave.3.fill" } } - } header: { - Text("Media playback live activity") } + .store(in: &cancellables) - Picker(selection: $hideNotchOption, label: - HStack { - Text("Hide DynamicIsland Options") - customBadge(text: "Beta") - }) { - Text("Always hide in fullscreen").tag(HideNotchOption.always) - Text("Hide only when NowPlaying app is in fullscreen").tag(HideNotchOption.nowPlayingOnly) - Text("Never hide").tag(HideNotchOption.never) + NotificationCenter.default.publisher(for: .systemBrightnessDidChange) + .compactMap { $0.userInfo } + .receive(on: DispatchQueue.main) + .sink { [weak self] info in + guard let self else { return } + if let val = info["value"] as? Float { + self.level = val + self.iconName = "sun.max.fill" } - .onChange(of: hideNotchOption) { _, newValue in - Defaults[.alwaysHideInFullscreen] = newValue == .always - Defaults[.enableFullscreenMediaDetection] = newValue != .never + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .keyboardBacklightDidChange) + .compactMap { $0.userInfo } + .receive(on: DispatchQueue.main) + .sink { [weak self] info in + guard let self else { return } + if let val = info["value"] as? Float { + self.level = val + self.iconName = val > 0.5 ? "light.max" : "light.min" } - } - .navigationTitle("Media") + } + .store(in: &cancellables) } - - // Only show controller options that are available on this macOS version - private var availableMediaControllers: [MediaControllerType] { - if MusicManager.shared.isNowPlayingDeprecated { - return MediaControllerType.allCases.filter { $0 != .nowPlaying } - } else { - return MediaControllerType.allCases +} + +private struct HUDAndOSDSettingsView: View { + @State private var selectedTab: Tab = { + if Defaults[.enableSystemHUD] { return .hud } + if Defaults[.enableCustomOSD] { return .osd } + if Defaults[.enableVerticalHUD] { return .vertical } + if Defaults[.enableCircularHUD] { return .circular } + return .hud + }() + @Default(.enableSystemHUD) var enableSystemHUD + @Default(.enableCustomOSD) var enableCustomOSD + @Default(.enableVerticalHUD) var enableVerticalHUD + @Default(.enableCircularHUD) var enableCircularHUD + @Default(.verticalHUDPosition) var verticalHUDPosition + @Default(.enableVolumeHUD) var enableVolumeHUD + @Default(.enableBrightnessHUD) var enableBrightnessHUD + @Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD + @Default(.enableThirdPartyDDCIntegration) var enableThirdPartyDDCIntegration + @Default(.verticalHUDShowValue) var verticalHUDShowValue + @Default(.verticalHUDInteractive) var verticalHUDInteractive + @Default(.verticalHUDHeight) var verticalHUDHeight + @Default(.verticalHUDWidth) var verticalHUDWidth + @Default(.verticalHUDPadding) var verticalHUDPadding + @Default(.verticalHUDUseAccentColor) var verticalHUDUseAccentColor + @Default(.verticalHUDMaterial) var verticalHUDMaterial + @Default(.verticalHUDLiquidGlassCustomizationMode) var verticalHUDLiquidGlassCustomizationMode + @Default(.verticalHUDLiquidGlassVariant) var verticalHUDLiquidGlassVariant + + // Circular HUD Props + @Default(.circularHUDShowValue) var circularHUDShowValue + @Default(.circularHUDSize) var circularHUDSize + @Default(.circularHUDStrokeWidth) var circularHUDStrokeWidth + @Default(.circularHUDUseAccentColor) var circularHUDUseAccentColor + @StateObject private var previewModel = HUDPreviewViewModel() + @ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared + + private enum Tab: String, CaseIterable, Identifiable { + case hud = "Dynamic Island HUD" + case osd = "Custom OSD" + case vertical = "Vertical Bar" + case circular = "Circular" + + var id: String { rawValue } + } + + private var paneBackgroundColor: Color { + Color(nsColor: .controlBackgroundColor) + } + + private var liquidVariantRange: ClosedRange { + Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound) + } + + private var availableVerticalMaterials: [OSDMaterial] { + if #available(macOS 26.0, *) { + return OSDMaterial.allCases } + return OSDMaterial.allCases.filter { $0 != .liquid } } -} -struct CalendarSettings: View { - @ObservedObject private var calendarManager = CalendarManager() - @Default(.showCalendar) var showCalendar: Bool + private var verticalLiquidVariantBinding: Binding { + Binding( + get: { Double(verticalHUDLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + verticalHUDLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } var body: some View { - Form { - if calendarManager.authorizationStatus != .fullAccess { - Text("Calendar access is denied. Please enable it in System Settings.") - .foregroundColor(.red) - .multilineTextAlignment(.center) - .padding() - Button("Open System Settings") { - if let settingsURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") { - NSWorkspace.shared.open(settingsURL) + VStack(spacing: 20) { + HStack(spacing: 16) { + HUDSelectionCard( + title: String(localized: "Dynamic Island"), + isSelected: selectedTab == .hud, + action: { + selectedTab = .hud + enableSystemHUD = true + enableCustomOSD = false + enableVerticalHUD = false + enableCircularHUD = false + } + ) { + VStack { + Capsule() + .fill(Color.black) + .frame(width: 64, height: 20) + .shadow(color: .black.opacity(0.3), radius: 4, y: 2) + .overlay { + HStack(spacing: 6) { + Image(systemName: previewModel.iconName) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 12) + + GeometryReader { geo in + Capsule() + .fill(Color.white.opacity(0.2)) + .overlay(alignment: .leading) { + Capsule() + .fill(Color.white) + .frame(width: geo.size.width * CGFloat(previewModel.level)) + .animation(.spring(response: 0.3), value: previewModel.level) + } + } + .frame(height: 4) + } + .padding(.horizontal, 8) + } } } - } else { - Toggle("Show calendar", isOn: $showCalendar) - Section(header: Text("Select Calendars")) { - List { - ForEach(calendarManager.allCalendars, id: \.id) { calendar in - Toggle(isOn: Binding( - get: { calendarManager.getCalendarSelected(calendar) }, - set: { isSelected in - Task { - await calendarManager.setCalendarSelected(calendar, isSelected: isSelected) + + HUDSelectionCard( + title: String(localized: "Custom OSD"), + isSelected: selectedTab == .osd, + action: { + selectedTab = .osd + enableCustomOSD = true + enableSystemHUD = false + enableVerticalHUD = false + enableCircularHUD = false + } + ) { + RoundedRectangle(cornerRadius: 12) + .fill(.ultraThinMaterial) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(.white.opacity(0.1), lineWidth: 1) + } + .shadow(color: .black.opacity(0.1), radius: 3, y: 1) + .overlay { + VStack(spacing: 6) { + Image(systemName: previewModel.iconName) + .font(.system(size: 16)) + .foregroundStyle(.secondary) + .symbolRenderingMode(.hierarchical) + .contentTransition(.symbolEffect(.replace)) + + GeometryReader { geo in + Capsule() + .fill(Color.secondary.opacity(0.2)) + .overlay(alignment: .leading) { + Capsule() + .fill(Color.primary) + .frame(width: geo.size.width * CGFloat(previewModel.level)) + .animation(.spring(response: 0.3), value: previewModel.level) + } + } + .frame(width: 36, height: 4) + } + } + .frame(width: 44, height: 44) + } + + HUDSelectionCard( + title: String(localized: "Vertical Bar"), + isSelected: selectedTab == .vertical, + action: { + selectedTab = .vertical + enableVerticalHUD = true + enableSystemHUD = false + enableCustomOSD = false + enableCircularHUD = false + } + ) { + RoundedRectangle(cornerRadius: 8) + .fill(.ultraThinMaterial) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(.white.opacity(0.1), lineWidth: 1) + } + .shadow(color: .black.opacity(0.1), radius: 3, y: 1) + .overlay { + VStack { + GeometryReader { geo in + VStack { + Spacer() + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.white) + .frame(height: max(0, geo.size.height * CGFloat(previewModel.level))) + .animation(.spring(response: 0.3), value: previewModel.level) } } - )) { - Text(calendar.title) + .mask(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .padding(.bottom, 2) + + Image(systemName: previewModel.iconName) + .font(.system(size: 9)) + .foregroundStyle(previewModel.level > 0.15 ? .black : .secondary) + .symbolRenderingMode(.hierarchical) + .contentTransition(.symbolEffect(.replace)) } + .padding(4) } + .frame(width: 22, height: 54) + } + + HUDSelectionCard( + title: String(localized: "Circular"), + isSelected: selectedTab == .circular, + action: { + selectedTab = .circular + enableCircularHUD = true + enableSystemHUD = false + enableCustomOSD = false + enableVerticalHUD = false + } + ) { + ZStack { + Circle() + .stroke(Color.secondary.opacity(0.2), lineWidth: 4) + Circle() + .trim(from: 0, to: CGFloat(previewModel.level)) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.spring(response: 0.3), value: previewModel.level) + Image(systemName: previewModel.iconName) + .font(.system(size: 16)) + .foregroundStyle(.primary) + .symbolRenderingMode(.hierarchical) + .contentTransition(.symbolEffect(.replace)) } + .frame(width: 44, height: 44) } } - } - .onAppear { - Task { - await calendarManager.checkCalendarAuthorization() - } - } - // Add navigation title if it's missing or adjust as needed - .navigationTitle("Calendar") - } -} + .padding(.top, 8) -struct About: View { - @State private var showBuildNumber: Bool = false - let updaterController: SPUStandardUpdaterController - @Environment(\.openWindow) var openWindow - var body: some View { - VStack { - Form { - Section { - HStack { - Text("Release name") - Spacer() - Text(Defaults[.releaseName]) + switch selectedTab { + case .hud: + HUD() + case .osd: + if #available(macOS 15.0, *) { + CustomOSDSettings() + } else { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundStyle(.orange) + + Text("macOS 15 or later required") + .font(.headline) + + Text("Custom OSD feature requires macOS 15 or later.") + .font(.subheadline) .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } - HStack { - Text("Version") - Spacer() - if showBuildNumber { - Text("(\(Bundle.main.buildVersionNumber ?? ""))") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + case .vertical: + Form { + if !accessibilityPermission.isAuthorized && !enableThirdPartyDDCIntegration { + Section { + SettingsPermissionCallout( + message: "Accessibility permission is needed to intercept system controls for the Vertical HUD.", + requestAction: { + accessibilityPermission.requestAuthorizationPrompt() + }, + openSettingsAction: { + accessibilityPermission.openSystemSettings() + } + ) + } header: { + Text("Accessibility") + } + } + + if accessibilityPermission.isAuthorized || enableThirdPartyDDCIntegration { + Section { + Toggle("Volume HUD", isOn: $enableVolumeHUD) + Toggle("Brightness HUD", isOn: $enableBrightnessHUD) + Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD) + .disabled(enableThirdPartyDDCIntegration) + .help(enableThirdPartyDDCIntegration ? "Disabled while external display integration is active — brightness keys are handled by the external app." : "") + } header: { + Text("Controls") + } footer: { + Text("Choose which system controls should display HUD notifications.") .foregroundStyle(.secondary) + .font(.caption) } - Text(Bundle.main.releaseVersionNumber ?? "unkown") + } + + Section { + Toggle("Show Percentage", isOn: $verticalHUDShowValue) + Toggle("Use Accent Color", isOn: $verticalHUDUseAccentColor) + Toggle("Interactive (Drag to Change)", isOn: $verticalHUDInteractive) + Picker("Material", selection: $verticalHUDMaterial) { + ForEach(availableVerticalMaterials, id: \.self) { material in + Text(material.rawValue).tag(material) + } + } + + if verticalHUDMaterial == .liquid { + if #available(macOS 26.0, *) { + Picker("Glass mode", selection: $verticalHUDLiquidGlassCustomizationMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + + if verticalHUDLiquidGlassCustomizationMode == .customLiquid { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Custom liquid variant") + Spacer() + Text("v\(verticalHUDLiquidGlassVariant.rawValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + Slider(value: verticalLiquidVariantBinding, in: liquidVariantRange, step: 1) + } + } + } else { + Text("Custom Liquid is available on macOS 26 or later.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Defaults.Toggle(key: .useColorCodedVolumeDisplay) { + Text("Color-coded Volume") + } + if Defaults[.useColorCodedVolumeDisplay] { + Defaults.Toggle(key: .useSmoothColorGradient) { + Text("Smooth color transitions") + } + } + } header: { + Text("Behavior & Style") + } + + Section { + Picker("HUD Position", selection: $verticalHUDPosition) { + Text("Left").tag("left") + Text("Right").tag("right") + } + .pickerStyle(.menu) + + VStack(alignment: .leading) { + Text("Screen Padding: \(Int(verticalHUDPadding))px") + Slider(value: $verticalHUDPadding, in: 0...100, step: 4) + } + } header: { + Text("Position") + } footer: { + Text("Choose directly on which side of the screen the vertical bar appears.") .foregroundStyle(.secondary) + .font(.caption) } - .onTapGesture { - withAnimation { - showBuildNumber.toggle() + + Section { + VStack(alignment: .leading) { + Text("Width: \(Int(verticalHUDWidth))px") + Slider(value: $verticalHUDWidth, in: 24...80, step: 2) + } + VStack(alignment: .leading) { + Text("Height: \(Int(verticalHUDHeight))px") + Slider(value: $verticalHUDHeight, in: 100...500, step: 10) + } + Button("Reset to Default") { + verticalHUDWidth = 36 + verticalHUDHeight = 160 + verticalHUDPadding = 24 } + } header: { + Text("Dimensions") } - } header: { - Text("Version info") } - - UpdaterSettingsView(updater: updaterController.updater) - - HStack(spacing: 30) { - Spacer(minLength: 0) - Button { - NSWorkspace.shared.open(sponsorPage) - } label: { - VStack(spacing: 5) { - Image(systemName: "cup.and.saucer.fill") - .imageScale(.large) - Text("Support Us") - .foregroundStyle(.white) + + case .circular: + Form { + if !accessibilityPermission.isAuthorized && !enableThirdPartyDDCIntegration { + Section { + SettingsPermissionCallout( + message: "Accessibility permission is needed to intercept system controls for the Circular HUD.", + requestAction: { + accessibilityPermission.requestAuthorizationPrompt() + }, + openSettingsAction: { + accessibilityPermission.openSystemSettings() + } + ) + } header: { + Text("Accessibility") } - .contentShape(Rectangle()) } - Spacer(minLength: 0) - Button { - NSWorkspace.shared.open(productPage) - } label: { - VStack(spacing: 5) { - Image("Github") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 18) - Text("GitHub") - .foregroundStyle(.white) + + if accessibilityPermission.isAuthorized || enableThirdPartyDDCIntegration { + Section { + Toggle("Volume HUD", isOn: $enableVolumeHUD) + Toggle("Brightness HUD", isOn: $enableBrightnessHUD) + Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD) + .disabled(enableThirdPartyDDCIntegration) + .help(enableThirdPartyDDCIntegration ? "Disabled while external display integration is active — brightness keys are handled by the external app." : "") + } header: { + Text("Controls") + } footer: { + Text("Choose which system controls should display HUD notifications.") + .foregroundStyle(.secondary) + .font(.caption) } - .contentShape(Rectangle()) } - Spacer(minLength: 0) + + Section { + Toggle("Show Percentage", isOn: $circularHUDShowValue) + Toggle("Use Accent Color", isOn: $circularHUDUseAccentColor) + Defaults.Toggle(key: .useColorCodedVolumeDisplay) { + Text("Color-coded Volume") + } + if Defaults[.useColorCodedVolumeDisplay] { + Defaults.Toggle(key: .useSmoothColorGradient) { + Text("Smooth color transitions") + } + } + } header: { + Text("Style") + } + + Section { + VStack(alignment: .leading) { + Text("Size: \(Int(circularHUDSize))px") + Slider(value: $circularHUDSize, in: 40...200, step: 5) + } + VStack(alignment: .leading) { + Text("Line Width: \(Int(circularHUDStrokeWidth))px") + Slider(value: $circularHUDStrokeWidth, in: 2...16, step: 1) + } + Button("Reset to Default") { + circularHUDSize = 65 + circularHUDStrokeWidth = 4 + } + } header: { + Text("Dimensions") + } } - .buttonStyle(PlainButtonStyle()) - } - VStack(spacing: 0) { - Divider() - Text("Made with 🫶🏻 by not so boring not.people") - .foregroundStyle(.secondary) - .padding(.top, 5) - .padding(.bottom, 7) - .multilineTextAlignment(.center) - .padding(.horizontal, 10) } - .frame(maxWidth: .infinity, alignment: .center) + + // Third-party display integrations (shared across all HUD variants) + ExternalDisplayIntegrationsSection() } - .toolbar { -// Button("Welcome window") { -// openWindow(id: "onboarding") -// } -// .controlSize(.extraLarge) - CheckForUpdatesView(updater: updaterController.updater) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(paneBackgroundColor) + .navigationTitle("Controls") + .onAppear { + if #unavailable(macOS 26.0), verticalHUDMaterial == .liquid { + verticalHUDMaterial = .frosted + verticalHUDLiquidGlassCustomizationMode = .standard + } } - .navigationTitle("About") } } -struct Shelf: View { - var body: some View { - Form { - Section { - Defaults.Toggle("Enable shelf", key: .dynamicShelf) - Defaults.Toggle("Open shelf tab by default if items added", key: .openShelfByDefault) - } header: { - HStack { - Text("General") - } - } - } - .navigationTitle("Shelf") +// MARK: - External Display Integrations Settings Section + +private struct ExternalDisplayIntegrationsSection: View { + @Default(.enableThirdPartyDDCIntegration) var enableThirdPartyDDCIntegration + @Default(.thirdPartyDDCProvider) var thirdPartyDDCProvider + @Default(.enableExternalVolumeControlListener) var enableExternalVolumeControlListener + @Default(.volumeStepPercent) var volumeStepPercent + @Default(.volumeFineStepPercent) var volumeFineStepPercent + @Default(.brightnessStepPercent) var brightnessStepPercent + @Default(.brightnessFineStepPercent) var brightnessFineStepPercent + @ObservedObject private var betterDisplayManager = BetterDisplayManager.shared + @ObservedObject private var lunarManager = LunarManager.shared + + private func highlightID(_ title: String) -> String { + SettingsTab.hudAndOSD.highlightID(for: title) + } + + private var providerStatusText: String { + switch thirdPartyDDCProvider { + case .betterDisplay: + if betterDisplayManager.isRunning { return "Running" } + if betterDisplayManager.isDetected { return "Not running" } + return "Not detected" + case .lunar: + if lunarManager.isConnected { return "Connected" } + if lunarManager.isRunning { return "Running" } + if lunarManager.isDetected { return "Not running" } + return "Not detected" + } + } + + private var providerStatusColor: Color { + switch thirdPartyDDCProvider { + case .betterDisplay: + if betterDisplayManager.isRunning { return .green } + if betterDisplayManager.isDetected { return .orange } + return .secondary + case .lunar: + if lunarManager.isConnected { return .green } + if lunarManager.isRunning { return .orange } + if lunarManager.isDetected { return .orange } + return .secondary + } + } + + private var providerStatusDescription: String { + switch thirdPartyDDCProvider { + case .betterDisplay: + if !betterDisplayManager.isDetected { + return "Install [BetterDisplay](https://betterdisplay.pro) to control external display brightness (and optional volume) through Atoll's HUD." + } + if !betterDisplayManager.isRunning { + return "BetterDisplay is installed but not currently running. Launch BetterDisplay to enable integration." + } + return "BetterDisplay OSD events will be routed through Atoll's active HUD style. Brightness is always routed; volume is routed when external volume control listener is enabled below. Make sure BetterDisplay's OSD integration is enabled in Settings › Application › Integration." + case .lunar: + if !lunarManager.isDetected { + return "Install [Lunar](https://lunar.fyi) to control external display brightness, contrast, and optional volume through Atoll's HUD via DDC." + } + if !lunarManager.isRunning { + return "Lunar is installed but not currently running. Launch Lunar to enable integration." + } + if lunarManager.isConnected { + return "Connected to Lunar's DDC socket. Brightness and contrast adjustments are shown through Atoll's HUD; volume follows when external volume control listener is enabled below." + } + return "Lunar is running but the socket connection is not yet established. It will connect automatically." + } + } + + private func refreshDetectionStatus() { + switch thirdPartyDDCProvider { + case .betterDisplay: + betterDisplayManager.refreshDetectionStatus() + case .lunar: + lunarManager.refreshDetectionStatus() + } } -} -struct Extensions: View { - @EnvironmentObject var extensionManager: DynamicIslandExtensionManager - @State private var effectTrigger: Bool = false var body: some View { Form { - //warningBadge("We don't support extensions yet") // Uhhhh You do? <><><> Oori.S Section { - List { - ForEach(extensionManager.installedExtensions.indices, id: \.self) { index in - let item = extensionManager.installedExtensions[index] - HStack { - AppIcon(for: item.bundleIdentifier) - .resizable() - .frame(width: 24, height: 24) - Text(item.name) - ListItemPopover { - Text("Description") - } - Spacer(minLength: 0) - HStack(spacing: 6) { - Circle() - .frame(width: 6, height: 6) - .foregroundColor(isExtensionRunning(item.bundleIdentifier) ? .green : item.status == .disabled ? .gray : .red) - .conditionalModifier(isExtensionRunning(item.bundleIdentifier)) { view in - view - .shadow(color: .green, radius: 3) - } - Text(isExtensionRunning(item.bundleIdentifier) ? "Running" : item.status == .disabled ? "Disabled" : "Stopped") - .contentTransition(.numericText()) - .foregroundStyle(.secondary) - .font(.footnote) - } - .frame(width: 60, alignment: .leading) - - Menu(content: { - Button("Restart") { - let ws = NSWorkspace.shared - - if let ext = ws.runningApplications.first(where: { $0.bundleIdentifier == item.bundleIdentifier }) { - ext.terminate() - } - - if let appURL = ws.urlForApplication(withBundleIdentifier: item.bundleIdentifier) { - ws.openApplication(at: appURL, configuration: .init(), completionHandler: nil) - } - } - .keyboardShortcut("R", modifiers: .command) - Button("Disable") { - if let ext = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == item.bundleIdentifier }) { - ext.terminate() - } - extensionManager.installedExtensions[index].status = .disabled - } - .keyboardShortcut("D", modifiers: .command) - Divider() - Button("Uninstall", role: .destructive) { - // - } - }, label: { - Image(systemName: "ellipsis.circle") - .foregroundStyle(.secondary) - }) - .controlSize(.regular) - } - .buttonStyle(PlainButtonStyle()) - .padding(.vertical, 5) + Stepper(value: $volumeStepPercent, in: 1...25) { + HStack { + Text("Volume step") + Spacer() + Text("\(volumeStepPercent)%") + .foregroundStyle(.secondary) + .monospacedDigit() } } - .frame(minHeight: 120) - .actionBar { - Button {} label: { - HStack(spacing: 3) { - Image(systemName: "plus") - Text("Add manually") - } - .foregroundStyle(.secondary) + .settingsHighlight(id: highlightID("Volume step")) + .disabled(enableExternalVolumeControlListener) + + Stepper(value: $volumeFineStepPercent, in: 1...25) { + HStack { + Text("Volume fine step") + Spacer() + Text("\(volumeFineStepPercent)%") + .foregroundStyle(.secondary) + .monospacedDigit() } - .disabled(true) - Spacer() - Button { - withAnimation(.linear(duration: 1)) { - effectTrigger.toggle() - } completion: { - effectTrigger.toggle() - } - extensionManager.checkIfExtensionsAreInstalled() - } label: { - HStack(spacing: 3) { - Image(systemName: "arrow.triangle.2.circlepath") - .rotationEffect(effectTrigger ? .degrees(360) : .zero) - } + } + .settingsHighlight(id: highlightID("Volume fine step")) + .disabled(enableExternalVolumeControlListener) + + if enableExternalVolumeControlListener { + Text("Disabled while external display volume integration is active.") + .font(.caption) .foregroundStyle(.secondary) + } + + Stepper(value: $brightnessStepPercent, in: 1...25) { + HStack { + Text("Brightness step") + Spacer() + Text("\(brightnessStepPercent)%") + .foregroundStyle(.secondary) + .monospacedDigit() } } - .controlSize(.small) - .buttonStyle(PlainButtonStyle()) - .overlay { - if extensionManager.installedExtensions.isEmpty { - Text("No extension installed") - .foregroundStyle(Color(.secondaryLabelColor)) - .padding(.bottom, 22) + .settingsHighlight(id: highlightID("Brightness step")) + .disabled(enableThirdPartyDDCIntegration) + + Stepper(value: $brightnessFineStepPercent, in: 1...25) { + HStack { + Text("Brightness fine step") + Spacer() + Text("\(brightnessFineStepPercent)%") + .foregroundStyle(.secondary) + .monospacedDigit() } } + .settingsHighlight(id: highlightID("Brightness fine step")) + .disabled(enableThirdPartyDDCIntegration) + + if enableThirdPartyDDCIntegration { + Text("Disabled while external display brightness integration is active.") + .font(.caption) + .foregroundStyle(.secondary) + } } header: { - HStack(spacing: 0) { - Text("Installed extensions") - if !extensionManager.installedExtensions.isEmpty { - Text(" – \(extensionManager.installedExtensions.count)") - .foregroundStyle(.secondary) + Text("Step size") + } footer: { + Text("Percent change per key press. Fine step applies when holding Shift+Option.") + .foregroundStyle(.secondary) + .font(.caption) + } + + Section { + Toggle("Enable third-party DDC app integration", isOn: $enableThirdPartyDDCIntegration) + .settingsHighlight(id: highlightID("Third-party DDC app integration")) + + if enableThirdPartyDDCIntegration { + Picker("Provider", selection: $thirdPartyDDCProvider) { + ForEach(ThirdPartyDDCProvider.allCases) { provider in + HStack { + AppIconImage( + bundleIdentifiers: provider.bundleIdentifiers, + symbolFallback: "display", + symbolColor: .secondary + ) + Text(provider.displayName) + } + .tag(provider) + } + } + .settingsHighlight(id: highlightID("Third-party DDC provider")) + + Toggle("Enable external volume control listener", isOn: $enableExternalVolumeControlListener) + .settingsHighlight(id: highlightID("Enable external volume control listener")) + + Text( + enableExternalVolumeControlListener + ? "Atoll's built-in volume key interception is disabled while external volume listening is on. Volume HUD/OSD will follow \(thirdPartyDDCProvider.displayName) payloads." + : "Atoll keeps native volume key interception. External provider volume payloads are ignored while this is off." + ) + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Text("Status") + Spacer() + Text(providerStatusText) + .font(.caption) + .foregroundStyle(providerStatusColor) + } + + Text(providerStatusDescription) + .font(.caption) + .foregroundStyle(.secondary) + + Button { + refreshDetectionStatus() + } label: { + Label("Refresh detection", systemImage: "arrow.clockwise") + .font(.caption) } + .buttonStyle(.link) + } else { + Text("Enable to route BetterDisplay or Lunar display adjustments through Atoll's active HUD style.") + .font(.caption) + .foregroundStyle(.secondary) + } + } footer: { + if enableThirdPartyDDCIntegration { + Text("Atoll always listens to selected-provider brightness events, and listens to provider volume events only when external volume listener is enabled.") + .foregroundStyle(.secondary) + .font(.caption) } } } - .navigationTitle("Extensions") - // TipsView() - // .padding(.horizontal, 19) } } -struct Appearance: View { - @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared - @Default(.mirrorShape) var mirrorShape - @Default(.sliderColor) var sliderColor - @Default(.useMusicVisualizer) var useMusicVisualizer - @Default(.customVisualizers) var customVisualizers - @Default(.selectedVisualizer) var selectedVisualizer - let icons: [String] = ["logo2"] - @State private var selectedIcon: String = "logo2" - @State private var selectedListVisualizer: CustomVisualizer? = nil - - @State private var isPresented: Bool = false - @State private var name: String = "" - @State private var url: String = "" - @State private var speed: CGFloat = 1.0 +private struct HUDSelectionCard: View { + let title: String + let isSelected: Bool + let action: () -> Void + @ViewBuilder let preview: Preview + + @State private var isHovering = false + var body: some View { - Form { - Section { - Toggle("Always show tabs", isOn: $coordinator.alwaysShowTabs) - Defaults.Toggle("Settings icon in notch", key: .settingsIconInNotch) - Defaults.Toggle("Enable window shadow", key: .enableShadow) - Defaults.Toggle("Corner radius scaling", key: .cornerRadiusScaling) - Defaults.Toggle("Use simpler close animation", key: .useModernCloseAnimation) - } header: { - Text("General") - } - - Section { - Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram) - Defaults - .Toggle("Player tinting", key: .playerColorTinting) - Defaults.Toggle("Enable blur effect behind album art", key: .lightingEffect) - Picker("Slider color", selection: $sliderColor) { - ForEach(SliderColorEnum.allCases, id: \.self) { option in - Text(option.rawValue) + Button(action: action) { + VStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + isSelected ? Color.accentColor : Color.clear, + lineWidth: 2.5 + ) + ) + .shadow(color: .black.opacity(0.05), radius: 2, y: 1) + + preview + } + .frame(width: 110, height: 80) + + VStack(spacing: 4) { + Text(title) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(isSelected ? .primary : .secondary) + + if isSelected { + Circle() + .fill(Color.accentColor) + .frame(width: 4, height: 4) + } else { + Color.clear + .frame(width: 4, height: 4) } } - } header: { - Text("Media") } - + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + isHovering = hovering + } + } + } +} + +private struct DevicesSettingsView: View { + @Default(.progressBarStyle) var progressBarStyle + @Default(.useBluetoothHUD3DIcon) private var useBluetoothHUD3DIcon + + private func highlightID(_ title: String) -> String { + SettingsTab.devices.highlightID(for: title) + } + + private var colorCodingDisabled: Bool { + progressBarStyle == .segmented + } + + var body: some View { + Form { Section { - Toggle( - "Use music visualizer spectrogram", - isOn: $useMusicVisualizer.animation() - ) - .disabled(true) - if !useMusicVisualizer { - if customVisualizers.count > 0 { - Picker( - "Selected animation", - selection: $selectedVisualizer + Defaults.Toggle(key: .showBluetoothDeviceConnections) { + Text("Show Bluetooth device connections") + } + .settingsHighlight(id: highlightID("Show Bluetooth device connections")) + Defaults.Toggle(key: .useCircularBluetoothBatteryIndicator) { + Text("Use circular battery indicator") + } + .settingsHighlight(id: highlightID("Use circular battery indicator")) + Defaults.Toggle(key: .showBluetoothBatteryPercentageText) { + Text("Show battery percentage text in HUD") + } + .settingsHighlight(id: highlightID("Show battery percentage text in HUD")) + Defaults.Toggle(key: .showBluetoothDeviceNameMarquee) { + Text("Scroll device name in HUD") + } + .settingsHighlight(id: highlightID("Scroll device name in HUD")) + VStack(alignment: .leading, spacing: 12) { + Text("HUD icon style") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + + HStack(spacing: 16) { + Spacer(minLength: 0) + BluetoothHUDIconStyleCard( + style: .symbol, + isSelected: !useBluetoothHUD3DIcon ) { - ForEach( - customVisualizers, - id: \.self - ) { visualizer in - Text(visualizer.name) - .tag(visualizer) - } + useBluetoothHUD3DIcon = false } - } else { - HStack { - Text("Selected animation") - Spacer() - Text("No custom animation available") - .foregroundStyle(.secondary) + BluetoothHUDIconStyleCard( + style: .threeD, + isSelected: useBluetoothHUD3DIcon + ) { + useBluetoothHUD3DIcon = true } + Spacer(minLength: 0) } } + .settingsHighlight(id: highlightID("Use 3D Bluetooth HUD icon")) } header: { - HStack { - Text("Custom music live activity animation") - customBadge(text: "Coming soon") - } + Text("Bluetooth Audio Devices") + } footer: { + Text("Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level.") + .foregroundStyle(.secondary) + .font(.caption) } - + Section { - List { - ForEach(customVisualizers, id: \.self) { visualizer in - HStack { - LottieView(state: LUStateData(type: .loadedFrom(visualizer.url), speed: visualizer.speed, loopMode: .loop)) - .frame(width: 30, height: 30, alignment: .center) - Text(visualizer.name) - Spacer(minLength: 0) - if selectedVisualizer == visualizer { - Text("selected") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .padding(.trailing, 8) - } - } - .buttonStyle(PlainButtonStyle()) - .padding(.vertical, 2) - .background( - selectedListVisualizer != nil ? selectedListVisualizer == visualizer ? Color.accentColor : Color.clear : Color.clear, - in: RoundedRectangle(cornerRadius: 5) - ) - .contentShape(Rectangle()) - .onTapGesture { - if selectedListVisualizer == visualizer { - selectedListVisualizer = nil - return - } - selectedListVisualizer = visualizer - } - } + Defaults.Toggle(key: .useColorCodedBatteryDisplay) { + Text("Color-coded battery display") } - .safeAreaPadding( - EdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0) - ) - .frame(minHeight: 120) - .actionBar { - HStack(spacing: 5) { - Button { - name = "" - url = "" - speed = 1.0 - isPresented.toggle() - } label: { - Image(systemName: "plus") - .foregroundStyle(.secondary) - .contentShape(Rectangle()) - } - Divider() - Button { - if selectedListVisualizer != nil { - let visualizer = selectedListVisualizer! - selectedListVisualizer = nil - customVisualizers.remove(at: customVisualizers.firstIndex(of: visualizer)!) - if visualizer == selectedVisualizer && customVisualizers.count > 0 { + .disabled(colorCodingDisabled) + .settingsHighlight(id: highlightID("Color-coded battery display")) + } header: { + Text("Battery Indicator Styling") + } footer: { + if progressBarStyle == .segmented { + Text("Color-coded fills are unavailable in Segmented mode. Switch to Hierarchical or Gradient inside Controls › Dynamic Island to adjust advanced options.") + .foregroundStyle(.secondary) + .font(.caption) + } else if Defaults[.useSmoothColorGradient] { + Text("Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill. Adjust gradient behavior from Controls › Dynamic Island.") + .foregroundStyle(.secondary) + .font(.caption) + } else { + Text("Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%).") + .foregroundStyle(.secondary) + .font(.caption) + } + } + } + .navigationTitle("Devices") + } +} + +struct HUD: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @Default(.inlineHUD) var inlineHUD + @Default(.progressBarStyle) var progressBarStyle + @Default(.enableSystemHUD) var enableSystemHUD + @Default(.enableVolumeHUD) var enableVolumeHUD + @Default(.enableBrightnessHUD) var enableBrightnessHUD + @Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD + @Default(.enableThirdPartyDDCIntegration) var enableThirdPartyDDCIntegration + @Default(.systemHUDSensitivity) var systemHUDSensitivity + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared + + private func highlightID(_ title: String) -> String { + SettingsTab.hudAndOSD.highlightID(for: title) + } + + private var hasAccessibilityPermission: Bool { + accessibilityPermission.isAuthorized + } + + private var colorCodingDisabled: Bool { + progressBarStyle == .segmented + } + + var body: some View { + Form { + if !hasAccessibilityPermission && !enableThirdPartyDDCIntegration { + Section { + SettingsPermissionCallout( + message: "Accessibility permission lets Dynamic Island replace the native volume, brightness, and keyboard HUDs.", + requestAction: { accessibilityPermission.requestAuthorizationPrompt() }, + openSettingsAction: { accessibilityPermission.openSystemSettings() } + ) + } header: { + Text("Accessibility") + } + } + + + + if enableSystemHUD && !Defaults[.enableCustomOSD] && (hasAccessibilityPermission || enableThirdPartyDDCIntegration) { + Section { + Toggle("Volume HUD", isOn: $enableVolumeHUD) + Toggle("Brightness HUD", isOn: $enableBrightnessHUD) + Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD) + .disabled(enableThirdPartyDDCIntegration) + .help(enableThirdPartyDDCIntegration ? "Disabled while external display integration is active \u{2014} brightness keys are handled by the external app." : "") + } header: { + Text("Controls") + } footer: { + Text("Choose which system controls should display HUD notifications.") + .foregroundStyle(.secondary) + .font(.caption) + } + } + + Section { + Defaults.Toggle(key: .playVolumeChangeFeedback) { + Text("Play feedback when volume is changed") + } + .settingsHighlight(id: highlightID("Play feedback when volume is changed")) + .help("Plays the supplied feedback clip whenever you press the hardware volume keys.") + } header: { + Text("Audio feedback") + } footer: { + Text("Requires Accessibility permission so Dynamic Island can intercept the hardware volume keys.") + .foregroundStyle(.secondary) + .font(.caption) + } + + Section { + Defaults.Toggle(key: .useColorCodedVolumeDisplay) { + Text("Color-coded volume display") + } + .disabled(colorCodingDisabled) + .settingsHighlight(id: highlightID("Color-coded volume display")) + + if !colorCodingDisabled && (Defaults[.useColorCodedBatteryDisplay] || Defaults[.useColorCodedVolumeDisplay]) { + Defaults.Toggle(key: .useSmoothColorGradient) { + Text("Smooth color transitions") + } + .settingsHighlight(id: highlightID("Smooth color transitions")) + } + + Defaults.Toggle(key: .showProgressPercentages) { + Text("Show percentages beside progress bars") + } + .settingsHighlight(id: highlightID("Show percentages beside progress bars")) + } header: { + Text("Dynamic Island Progress Bars") + } footer: { + if colorCodingDisabled { + 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 transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill.") + .foregroundStyle(.secondary) + .font(.caption) + } else { + Text("Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%).") + .foregroundStyle(.secondary) + .font(.caption) + } + } + + Section { + Picker("HUD style", selection: $inlineHUD) { + Text("Default") + .tag(false) + Text("Inline") + .tag(true) + } + .settingsHighlight(id: highlightID("HUD style")) + .onChange(of: Defaults[.inlineHUD]) { + if Defaults[.inlineHUD] { + withAnimation { + Defaults[.systemEventIndicatorShadow] = false + Defaults[.progressBarStyle] = .hierarchical + } + } + } + Picker("Progressbar style", selection: $progressBarStyle) { + Text("Hierarchical") + .tag(ProgressBarStyle.hierarchical) + Text("Gradient") + .tag(ProgressBarStyle.gradient) + Text("Segmented") + .tag(ProgressBarStyle.segmented) + } + .settingsHighlight(id: highlightID("Progressbar style")) + Defaults.Toggle(key: .systemEventIndicatorShadow) { + Text("Enable glowing effect") + } + .settingsHighlight(id: highlightID("Enable glowing effect")) + Defaults.Toggle(key: .systemEventIndicatorUseAccent) { + Text("Use accent color") + } + .settingsHighlight(id: highlightID("Use accent color")) + } header: { + HStack { + Text("Appearance") + } + } + } + .navigationTitle("Controls") + .onAppear { + accessibilityPermission.refreshStatus() + } + .onChange(of: accessibilityPermission.isAuthorized) { _, granted in + if !granted { + enableSystemHUD = false + } + } + } +} + +struct Media: View { + @Default(.waitInterval) var waitInterval + @Default(.mediaController) var mediaController + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @Default(.hideNotchOption) var hideNotchOption + @Default(.enableSneakPeek) private var enableSneakPeek + @Default(.sneakPeekStyles) var sneakPeekStyles + @Default(.enableMinimalisticUI) var enableMinimalisticUI + @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat + @Default(.musicSkipBehavior) private var musicSkipBehavior + @Default(.musicControlWindowEnabled) private var musicControlWindowEnabled + @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget + @Default(.showSneakPeekOnTrackChange) private var showSneakPeekOnTrackChange + @Default(.lockScreenGlassStyle) private var lockScreenGlassStyle + @Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode + @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled + @Default(.lockScreenMusicFullscreenArtworkEnabled) private var lockScreenMusicFullscreenArtworkEnabled + @Default(.showStandardMediaControls) private var showStandardMediaControls + @Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer + @Default(.parallaxEffectIntensity) private var parallaxEffectIntensity + + + @ObservedObject private var musicManager = MusicManager.shared + + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + + private func highlightID(_ title: String) -> String { + SettingsTab.media.highlightID(for: title) + } + + private var standardControlsSuppressed: Bool { + !showStandardMediaControls && !enableMinimalisticUI + } + + var body: some View { + Form { + Section { + Picker("Music Source", selection: $mediaController) { + ForEach(availableMediaControllers) { controller in + Text(controller.rawValue).tag(controller) + } + } + .onChange(of: mediaController) { _, _ in + NotificationCenter.default.post( + name: Notification.Name.mediaControllerChanged, + object: nil + ) + } + .settingsHighlight(id: highlightID("Music Source")) + } header: { + Text("Media Source") + } footer: { + if MusicManager.shared.isNowPlayingDeprecated { + HStack { + Text("YouTube Music requires this third-party app to be installed: ") + .foregroundStyle(.secondary) + .font(.caption) + Link("https://github.com/th-ch/youtube-music", destination: URL(string: "https://github.com/th-ch/youtube-music")!) + .font(.caption) + .foregroundColor(.blue) // Ensures it's visibly a link + } + } else { + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "'Now Playing' was the only option on previous versions and works with all media apps.")) + Text(String(localized: "Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek.")) + } + .foregroundStyle(.secondary) + .font(.caption) + } + } + + if mediaController == .spotify { + SpotifyAuthSettingsSection() + } + + Section { + Defaults.Toggle(key: .showStandardMediaControls) { + Text("Show media controls in Dynamic Island") + } + .disabled(enableMinimalisticUI) + .settingsHighlight(id: highlightID("Show media controls in Dynamic Island")) + + Defaults.Toggle(key: .autoHideInactiveNotchMediaPlayer) { + Text("Auto-hide inactive notch media player") + } + .disabled(enableMinimalisticUI || !showStandardMediaControls) + .settingsHighlight(id: highlightID("Auto-hide inactive notch media player")) + + if enableMinimalisticUI { + Text("Disable Minimalistic UI to configure the standard notch media controls.") + .font(.caption) + .foregroundStyle(.secondary) + } else if standardControlsSuppressed { + Text("Standard notch media controls are hidden. Re-enable the toggle above to restore them.") + .font(.caption) + .foregroundStyle(.secondary) + } else if !autoHideInactiveNotchMediaPlayer { + Text("When disabled, the notch music player stays visible with placeholder metadata even when playback is inactive.") + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("Dynamic Island Visibility") + } + Section { + Defaults.Toggle(key: .showShuffleAndRepeat) { + HStack { + Text("Enable customizable controls") + customBadge(text: "Beta") + } + } + if showShuffleAndRepeat { + Defaults.Toggle(key: .showMediaOutputControl) { + Text("Show \"Change Media Output\" control") + } + .settingsHighlight(id: highlightID("Show Change Media Output control")) + .help("Adds the AirPlay/route picker button back to the customizable controls palette.") + MusicSlotConfigurationView() + } else { + Text("Turn on customizable controls to rearrange media buttons.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 4) + } + } header: { + Text("Media controls") + } + + Section(header: Text("Lock Screen Media")) { + Defaults.Toggle(key: .lockScreenMusicAlbumParallaxEnabled) { + Text("Enable album art parallax") + } + .settingsHighlight(id: highlightID("Enable album art parallax")) + Text("Applies the notch-style parallax effect to the lock screen media widget album art.") + .font(.caption) + .foregroundStyle(.secondary) + } + if musicControlWindowEnabled { + Section { + Picker("Skip buttons", selection: $musicSkipBehavior) { + ForEach(MusicSkipBehavior.allCases) { behavior in + Text(behavior.displayName).tag(behavior) + } + } + .pickerStyle(.segmented) + .settingsHighlight(id: highlightID("Skip buttons")) + + Text(musicSkipBehavior.description) + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("Floating window panel skip behaviour") + } + } + Section { + Toggle( + "Enable music live activity", + isOn: $coordinator.musicLiveActivityEnabled.animation() + ) + .disabled(standardControlsSuppressed) + .help(standardControlsSuppressed ? "Standard notch media controls are hidden while this toggle is off." : "") + Defaults.Toggle(key: .musicControlWindowEnabled) { + Text("Show floating media controls") + } + .disabled(!coordinator.musicLiveActivityEnabled || standardControlsSuppressed) + .help("Displays play/pause and skip buttons beside the notch while music is active. Disabled by default.") + Toggle("Enable sneak peek", isOn: $enableSneakPeek) + Toggle("Show sneak peek on playback changes", isOn: $showSneakPeekOnTrackChange) + .disabled(!enableSneakPeek) + Defaults.Toggle(key: .enableLyrics) { + Text("Enable lyrics") + } + .settingsHighlight(id: highlightID("Enable lyrics")) + Defaults.Toggle(key: .showLiveCanvasInDynamicIsland) { + Text("Show live canvas in Dynamic Island") + } + .settingsHighlight(id: highlightID("Show live canvas in Dynamic Island")) + .help("Replaces the artwork tile with the live canvas when the current app provides one, and reuses that moving canvas for the surrounding lighting effect.") + + //Parallax Effect Intensity to control how much parallax is wanted + Slider(value: $parallaxEffectIntensity, in: 0...12, step: 1.0) { + HStack { + Text("Parallax Effect Intensity") + Spacer() + Text("\(parallaxEffectIntensity, specifier: "%0.1f")") + .foregroundStyle(.secondary) + } + } + .settingsHighlight(id: highlightID("Enable album art parallax effect")) + + Picker("Sneak Peek Style", selection: $sneakPeekStyles){ + ForEach(SneakPeekStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .disabled(!enableSneakPeek) + .settingsHighlight(id: highlightID("Sneak Peek Style")) + + HStack { + Stepper(value: $waitInterval, in: 0...10, step: 1) { + HStack { + Text("Media inactivity timeout") + Spacer() + Text("\(Defaults[.waitInterval], specifier: "%.0f") seconds") + .foregroundStyle(.secondary) + } + } + } + + Defaults.Toggle(key: .showSongMetadataInClosedNotch) { + Text("Show song title and artist on non-notch displays") + } + .settingsHighlight(id: highlightID("Show song title and artist in closed notch")) + } header: { + Text("Media playback live activity") + } + + Section { + Defaults.Toggle(key: .enableRealTimeWaveform) { + HStack { + Text("Enable real-time waveform") + customBadge(text: "Beta") + } + } + .settingsHighlight(id: highlightID("Enable real-time waveform")) + } header: { + Text("Music Visualizer") + } footer: { + Text("When enabled, the music visualizer displays real-time audio spectrum data synced to your music. Requires macOS 14.2+ and uses minimal CPU/GPU resources via the Accelerate framework.") + } + + Section { + Defaults.Toggle(key: .enableLockScreenMediaWidget) { + Text("Show lock screen media panel") + } + Defaults.Toggle(key: .lockScreenShowAppIcon) { + Text("Show media app icon") + } + .disabled(!enableLockScreenMediaWidget) + if isAppleMusicActive { + Defaults.Toggle(key: .lockScreenMusicMergedAirPlayOutput) { + Text("Show merged AirPlay and output devices") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Show merged AirPlay and output devices")) + } + Defaults.Toggle(key: .lockScreenPanelShowsBorder) { + Text("Show panel border") + } + .disabled(!enableLockScreenMediaWidget) + if lockScreenGlassCustomizationMode == .customLiquid { + Defaults.Toggle(key: .lockScreenMusicUsesEnhancedLiquidBorder) { + Text("Use enhanced liquid border") + } + .disabled(!enableLockScreenMediaWidget) + } + if lockScreenGlassCustomizationMode == .customLiquid { + customLiquidBlurRow + .opacity(enableLockScreenMediaWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Enable media panel blur")) + } else if lockScreenGlassStyle == .frosted { + Defaults.Toggle(key: .lockScreenPanelUsesBlur) { + Text("Enable media panel blur") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Enable media panel blur")) + } else { + unavailableBlurRow + .opacity(enableLockScreenMediaWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Enable media panel blur")) + } + VStack(alignment: .leading, spacing: 4) { + Defaults.Toggle(key: .lockScreenMusicFullscreenArtworkEnabled) { + Text("Fullscreen artwork on right-click") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Fullscreen artwork on right-click")) + Defaults.Toggle(key: .lockScreenUseArtworkLayoutOverFullscreenCanvas) { + Text("Use album art layout over fullscreen canvas") + } + .disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled) + .settingsHighlight(id: highlightID("Use album art layout over fullscreen canvas")) + Defaults.Toggle(key: .lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) { + Text("Keep album art visible during fullscreen artwork") + } + .disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled) + .settingsHighlight(id: highlightID("Keep album art visible during fullscreen artwork")) + Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } header: { + Text("Lock Screen Integration") + } footer: { + Text("These controls mirror the Lock Screen tab so you can tune the media overlay while focusing on playback settings.") + } + .disabled(!showStandardMediaControls) + .opacity(showStandardMediaControls ? 1 : 0.5) + + Picker(selection: $hideNotchOption, label: + HStack { + Text("Hide DynamicIsland Options") + customBadge(text: "Beta") + }) { + Text("Always hide in fullscreen").tag(HideNotchOption.always) + Text("Hide only when NowPlaying app is in fullscreen").tag(HideNotchOption.nowPlayingOnly) + Text("Never hide").tag(HideNotchOption.never) + } + .onChange(of: hideNotchOption) { + Defaults[.enableFullscreenMediaDetection] = hideNotchOption != .never + } + } + .navigationTitle("Media") + } + + // Only show controller options that are available on this macOS version + private var availableMediaControllers: [MediaControllerType] { + if MusicManager.shared.isNowPlayingDeprecated { + return MediaControllerType.allCases.filter { $0 != .nowPlaying } + } else { + return MediaControllerType.allCases + } + } + + private var unavailableBlurRow: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Enable media panel blur") + .foregroundStyle(.secondary) + Text("Only applies when Material is set to Frosted Glass.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + private var customLiquidBlurRow: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Enable media panel blur") + .foregroundStyle(.secondary) + Text("Custom liquid glass already renders with Apple's liquid material, so this option is managed automatically.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct CalendarSettings: View { + @ObservedObject private var calendarManager = CalendarManager.shared + @Default(.showCalendar) var showCalendar: Bool + @Default(.enableReminderLiveActivity) var enableReminderLiveActivity + @Default(.reminderPresentationStyle) var reminderPresentationStyle + @Default(.reminderLeadTime) var reminderLeadTime + @Default(.reminderSneakPeekDuration) var reminderSneakPeekDuration + @Default(.enableLockScreenReminderWidget) var enableLockScreenReminderWidget + @Default(.lockScreenReminderChipStyle) var lockScreenReminderChipStyle + @Default(.hideAllDayEvents) var hideAllDayEvents + @Default(.hideCompletedReminders) var hideCompletedReminders + @Default(.showFullEventTitles) var showFullEventTitles + @Default(.autoScrollToNextEvent) var autoScrollToNextEvent + @Default(.lockScreenShowCalendarCountdown) private var lockScreenShowCalendarCountdown + @Default(.lockScreenShowCalendarEvent) private var lockScreenShowCalendarEvent + @Default(.lockScreenShowCalendarEventEntireDuration) private var lockScreenShowCalendarEventEntireDuration + @Default(.lockScreenShowCalendarEventAfterStartWindow) private var lockScreenShowCalendarEventAfterStartWindow + @Default(.lockScreenShowCalendarTimeRemaining) private var lockScreenShowCalendarTimeRemaining + @Default(.lockScreenShowCalendarStartTimeAfterBegins) private var lockScreenShowCalendarStartTimeAfterBegins + @Default(.lockScreenCalendarEventLookaheadWindow) private var lockScreenCalendarEventLookaheadWindow + @Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode + @Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs + @Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled + @Default(.enableThirdPartyCalendarApp) private var enableThirdPartyCalendarApp + @Default(.selectedCalendarApp) private var selectedCalendarApp + @Default(.fantasticalDefaultView) private var fantasticalDefaultView + + private func highlightID(_ title: String) -> String { + SettingsTab.calendar.highlightID(for: title) + } + + private enum CalendarLookaheadOption: String, CaseIterable, Identifiable { + case mins15 = "15m" + case mins30 = "30m" + case hour1 = "1h" + case hours3 = "3h" + case hours6 = "6h" + case hours12 = "12h" + case restOfDay = "rest_of_day" + case allTime = "all_time" + + var id: String { rawValue } + + var title: String { + switch self { + case .mins15: return "15 mins" + case .mins30: return "30 mins" + case .hour1: return "1 hour" + case .hours3: return "3 hours" + case .hours6: return "6 hours" + case .hours12: return "12 hours" + case .restOfDay: return "Rest of the day" + case .allTime: return "All time" + } + } + } + + var body: some View { + Form { + if !calendarManager.hasCalendarAccess || !calendarManager.hasReminderAccess { + Text("Calendar or Reminder access is denied. Please enable it in System Settings.") + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding() + + HStack { + Button("Request Access") { + Task { + await calendarManager.checkCalendarAuthorization() + await calendarManager.checkReminderAuthorization() + } + } + .buttonStyle(.borderedProminent) + + Button("Open System Settings") { + if let settingsURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") { + NSWorkspace.shared.open(settingsURL) + } + } + } + } else { + // Permissions status + Section(header: Text("Permissions")) { + HStack { + Text("Calendars") + Spacer() + Text(statusText(for: calendarManager.calendarAuthorizationStatus)) + .foregroundColor(color(for: calendarManager.calendarAuthorizationStatus)) + } + HStack { + Text("Reminders") + Spacer() + Text(statusText(for: calendarManager.reminderAuthorizationStatus)) + .foregroundColor(color(for: calendarManager.reminderAuthorizationStatus)) + } + } + + Defaults.Toggle(key: .showCalendar) { + Text("Show calendar") + } + .settingsHighlight(id: highlightID("Show calendar")) + + Section(header: Text("Event List")) { + Toggle("Hide completed reminders", isOn: $hideCompletedReminders) + .settingsHighlight(id: highlightID("Hide completed reminders")) + Toggle("Show full event titles", isOn: $showFullEventTitles) + .settingsHighlight(id: highlightID("Show full event titles")) + Toggle("Auto-scroll to next event", isOn: $autoScrollToNextEvent) + .settingsHighlight(id: highlightID("Auto-scroll to next event")) + } + + Section(header: Text("All-Day Events")) { + Toggle("Hide all-day events", isOn: $hideAllDayEvents) + .settingsHighlight(id: highlightID("Hide all-day events")) + .disabled(!showCalendar) + + Text("Turn this off to include all-day entries in the notch calendar and reminder live activity.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Section(header: Text("Reminder Live Activity")) { + Defaults.Toggle(key: .enableReminderLiveActivity) { + Text("Enable reminder live activity") + } + .settingsHighlight(id: highlightID("Enable reminder live activity")) + + Picker("Countdown style", selection: $reminderPresentationStyle) { + ForEach(ReminderPresentationStyle.allCases) { style in + Text(style.displayName).tag(style) + } + } + .pickerStyle(.segmented) + .disabled(!enableReminderLiveActivity) + .settingsHighlight(id: highlightID("Countdown style")) + + HStack { + Text("Notify before") + Slider( + value: Binding( + get: { Double(reminderLeadTime) }, + set: { reminderLeadTime = Int($0) } + ), + in: 1...60, + step: 1 + ) + .disabled(!enableReminderLiveActivity) + Text("\(reminderLeadTime) min") + .foregroundStyle(.secondary) + .frame(width: 60, alignment: .trailing) + } + + HStack { + Text("Sneak peek duration") + Slider( + value: $reminderSneakPeekDuration, + in: 3...20, + step: 1 + ) + .disabled(!enableReminderLiveActivity) + Text("\(Int(reminderSneakPeekDuration)) s") + .foregroundStyle(.secondary) + .frame(width: 60, alignment: .trailing) + } + } + + Section(header: Text("Lock Screen Reminder Widget")) { + Defaults.Toggle(key: .enableLockScreenReminderWidget) { + Text("Show lock screen reminder") + } + .settingsHighlight(id: highlightID("Show lock screen reminder")) + + Picker("Chip color", selection: $lockScreenReminderChipStyle) { + ForEach(LockScreenReminderChipStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .pickerStyle(.segmented) + .disabled(!enableLockScreenReminderWidget || !enableReminderLiveActivity) + .settingsHighlight(id: highlightID("Chip color")) + } + + Section( + header: Text("Calendar Widget"), + footer: Text("Displays your next upcoming calendar event above or below the weather capsule. Calendar selection here is independent from the Dynamic Island calendar filter.") + .foregroundStyle(.secondary) + .font(.caption) + ) { + Defaults.Toggle(key: .lockScreenShowCalendarEvent) { + Text("Show next calendar event") + } + .settingsHighlight(id: highlightID("Show next calendar event")) + + LabeledContent("Show events within the next") { + HStack { + Spacer(minLength: 0) + Picker("", selection: $lockScreenCalendarEventLookaheadWindow) { + ForEach(CalendarLookaheadOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show events within the next")) + + Toggle("Show events from all calendars", isOn: Binding( + get: { lockScreenCalendarSelectionMode == "all" }, + set: { useAll in + if useAll { + lockScreenCalendarSelectionMode = "all" + } else { + lockScreenCalendarSelectionMode = "selected" + lockScreenSelectedCalendarIDs = Set(calendarManager.eventCalendars.map { $0.id }) + } + } + )) + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show events from all calendars")) + + if lockScreenCalendarSelectionMode != "all" { + HStack { + Spacer() + Button("Deselect All") { + lockScreenSelectedCalendarIDs = [] + } + .buttonStyle(.link) + } + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 8) { + ForEach(calendarManager.eventCalendars, id: \.id) { calendar in + Toggle(isOn: Binding( + get: { lockScreenSelectedCalendarIDs.contains(calendar.id) }, + set: { isOn in + if isOn { + lockScreenSelectedCalendarIDs.insert(calendar.id) + } else { + lockScreenSelectedCalendarIDs.remove(calendar.id) + } + } + )) { + HStack(spacing: 8) { + Circle() + .fill(Color(calendar.color)) + .frame(width: 8, height: 8) + Text(calendar.title) + } + } + } + } + .padding(.top, 4) + .padding(.leading, 2) + .disabled(!lockScreenShowCalendarEvent) + } + + Defaults.Toggle(key: .lockScreenShowCalendarCountdown) { + Text("Show countdown") + } + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show countdown")) + + Defaults.Toggle(key: .lockScreenShowCalendarEventEntireDuration) { + Text("Show event for entire duration") + } + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show event for entire duration")) + .onChange(of: Defaults[.lockScreenShowCalendarEventEntireDuration]) { _, newValue in + if newValue { + Defaults[.lockScreenShowCalendarEventAfterStartEnabled] = false + } + } + + Defaults.Toggle(key: .lockScreenShowCalendarEventAfterStartEnabled) { + Text("Hide active event and show next upcoming event") + } + .disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration) + .settingsHighlight(id: highlightID("Hide active event and show next upcoming event")) + + LabeledContent("Show event after it starts") { + HStack { + Spacer(minLength: 0) + Picker("", selection: $lockScreenShowCalendarEventAfterStartWindow) { + Text("1 min").tag("1m") + Text("5 mins").tag("5m") + Text("10 mins").tag("10m") + Text("15 mins").tag("15m") + Text("30 mins").tag("30m") + Text("45 mins").tag("45m") + Text("1 hour").tag("1h") + Text("2 hours").tag("2h") + } + .labelsHidden() + .pickerStyle(.menu) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration || !lockScreenShowCalendarEventAfterStartEnabled) + + Text("Turn off 'Show event for entire duration' to use the post-start duration option.") + .font(.caption) + .foregroundStyle(.secondary) + + Defaults.Toggle(key: .lockScreenShowCalendarTimeRemaining) { + Text("Show time remaining") + } + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show time remaining")) + + Defaults.Toggle(key: .lockScreenShowCalendarStartTimeAfterBegins) { + Text("Show start time after event begins") + } + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show start time after event begins")) + } + + // MARK: - Third-party Calendar Integration + Section { + Defaults.Toggle(key: .enableThirdPartyCalendarApp) { + HStack { + Image(systemName: "ellipsis.calendar") + .foregroundStyle(.secondary) + .frame(width: 20, height: 20) + Text("Enable third-party calendar app launch") + } + } + .settingsHighlight(id: highlightID("Enable third-party calendar app launch")) + + if enableThirdPartyCalendarApp { + Picker("Calendar App", selection: $selectedCalendarApp) { + ForEach(ThirdPartyCalendarApp.allCases) { app in + HStack { + AppIconImage( + bundleIdentifiers: app.bundleIdentifiers, + symbolFallback: app.fallbackIconName, + symbolColor: app.fallbackIconColor + ) + Text(app.displayName) + } + .tag(app) + } + } + .settingsHighlight(id: highlightID("Calendar App")) + + if selectedCalendarApp == .fantastical { + Picker("Default View", selection: $fantasticalDefaultView) { + ForEach(FantasticalViewStyle.allCases, id: \.self) { style in + Text(style.displayName).tag(style) + } + } + .settingsHighlight(id: highlightID("Fantastical Default View")) + } + } + } header: { + Text("Third-party Calendar Integration") + } footer: { + Text("When enabled, clicking on calendar events will open the selected third-party calendar app instead of Apple Calendar.") + } + + Section(header: Text("Select Calendars")) { + let grouped = Dictionary(grouping: calendarManager.allCalendars, by: \.accountName) + let sortedAccounts = grouped.keys.sorted() + + ForEach(sortedAccounts, id: \.self) { account in + let accountCalendars = grouped[account] ?? [] + let allAccountSelected = accountCalendars.allSatisfy { calendarManager.getCalendarSelected($0) } + + Section(header: HStack { + Text(account) + Spacer() + Toggle("", isOn: Binding( + get: { allAccountSelected }, + set: { isSelected in + Task { + await calendarManager.setCalendarsSelected(accountCalendars, isSelected: isSelected) + } + } + )) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.mini) + .disabled(!showCalendar) + }) { + ForEach(accountCalendars, id: \.id) { calendar in + Toggle(isOn: Binding( + get: { calendarManager.getCalendarSelected(calendar) }, + set: { isSelected in + Task { + await calendarManager.setCalendarSelected(calendar, isSelected: isSelected) + } + } + )) { + HStack(spacing: 8) { + Circle() + .fill(Color(calendar.color)) + .frame(width: 8, height: 8) + Text(calendar.title) + } + } + .disabled(!showCalendar) + } + } + } + } + } + } + .onAppear { + Task { + await calendarManager.checkCalendarAuthorization() + await calendarManager.checkReminderAuthorization() + } + } + .navigationTitle("Calendar") + } + + private func statusText(for status: EKAuthorizationStatus) -> String { + switch status { + case .fullAccess, .authorized: return String(localized: "Full Access") + case .writeOnly: return String(localized: "Write Only") + case .denied: return String(localized: "Denied") + case .restricted: return String(localized: "Restricted") + case .notDetermined: return String(localized: "Not Determined") + @unknown default: return String(localized: "Unknown") + } + } + + private func color(for status: EKAuthorizationStatus) -> Color { + switch status { + case .fullAccess, .authorized: return .green + case .writeOnly: return .yellow + case .denied, .restricted: return .red + case .notDetermined: return .secondary + @unknown default: return .secondary + } + } +} + +struct About: View { + @State private var showBuildNumber: Bool = false + let updaterController: SPUStandardUpdaterController + @Environment(\.openWindow) var openWindow + var body: some View { + VStack { + Form { + Section { + HStack { + Text("Release name") + Spacer() + Text(Defaults[.releaseName]) + .foregroundStyle(.secondary) + } + HStack { + Text("Version") + Spacer() + if showBuildNumber { + Text("(\(Bundle.main.buildVersionNumber ?? ""))") + .foregroundStyle(.secondary) + } + Text(Bundle.main.releaseVersionNumber ?? "unkown") + .foregroundStyle(.secondary) + } + .onTapGesture { + withAnimation { + showBuildNumber.toggle() + } + } + } header: { + Text("Version info") + } + + UpdaterSettingsView(updater: updaterController.updater) + + HStack(spacing: 30) { + Spacer(minLength: 0) + Button { + NSWorkspace.shared.open(sponsorPage) + } label: { + VStack(spacing: 5) { + Image(systemName: "cup.and.saucer.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + Text("Donate") + .foregroundStyle(.white) + } + .contentShape(Rectangle()) + } + Spacer(minLength: 0) + Button { + NSWorkspace.shared.open(productPage) + } label: { + VStack(spacing: 5) { + Image("Github") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18) + Text("GitHub") + .foregroundStyle(.white) + } + .contentShape(Rectangle()) + } + Spacer(minLength: 0) + } + .buttonStyle(PlainButtonStyle()) + Text("Your support funds software development learning for students in 9th–12th grade.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + VStack(spacing: 0) { + Divider() + Text("Made with ❤️ by Ebullioscopic") + .foregroundStyle(.secondary) + .padding(.top, 5) + .padding(.bottom, 7) + .multilineTextAlignment(.center) + .padding(.horizontal, 10) + } + .frame(maxWidth: .infinity, alignment: .center) + .background(.regularMaterial) + } + .toolbar { + // Button("Welcome window") { + // openWindow(id: "onboarding") + // } + // .controlSize(.extraLarge) + CheckForUpdatesView(updater: updaterController.updater) + } + .navigationTitle("About") + } +} + +private extension DevicesSettingsView { + enum BluetoothHUDIconStyle: String { + case symbol + case threeD + + var title: String { + switch self { + case .symbol: + return "Symbol" + case .threeD: + return "3D" + } + } + } + + struct BluetoothHUDIconStyleCard: View { + let style: BluetoothHUDIconStyle + let isSelected: Bool + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + VStack(spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1) + ) + + preview + } + .frame(width: 90, height: 64) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = hovering + } + } + + Text(style.title) + .font(.caption) + .fontWeight(.medium) + } + .contentShape(Rectangle()) + .onTapGesture { action() } + } + + private var preview: some View { + Group { + switch style { + case .symbol: + Image(systemName: BluetoothAudioDeviceType.airpods.sfSymbol) + .font(.system(size: 24, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + case .threeD: + if let url = Bundle.main.url( + forResource: BluetoothAudioDeviceType.airpods.inlineHUDAnimationBaseName, + withExtension: "mov" + ) { + SettingsLoopingVideoIcon(url: url, size: CGSize(width: 28, height: 28)) + .frame(width: 28, height: 28) + } else { + Image(systemName: BluetoothAudioDeviceType.airpods.sfSymbol) + .font(.system(size: 24, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + } + } + } + } + + private var backgroundColor: Color { + if isSelected { return Color.accentColor.opacity(0.12) } + if isHovering { return Color.primary.opacity(0.05) } + return Color(nsColor: .controlBackgroundColor) + } + + private var borderColor: Color { + if isSelected { return Color.accentColor } + if isHovering { return Color.primary.opacity(0.1) } + return Color.clear + } + } +} + +private struct SettingsLoopingVideoIcon: NSViewRepresentable { + let url: URL + let size: CGSize + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: NSRect(origin: .zero, size: size)) + view.wantsLayer = true + + let layer = AVPlayerLayer() + layer.videoGravity = .resizeAspect + layer.frame = view.bounds + view.layer?.addSublayer(layer) + context.coordinator.attach(layer: layer, url: url) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator { + private var controller: SettingsLoopingPlayerController? + + func attach(layer: AVPlayerLayer, url: URL) { + controller = SettingsLoopingPlayerController(url: url, autoPlay: true) + layer.player = controller?.player + } + } +} + +private final class SettingsLoopingPlayerController { + let player: AVQueuePlayer + private var looper: AVPlayerLooper? + + init(url: URL, autoPlay: Bool = true) { + let item = AVPlayerItem(url: url) + player = AVQueuePlayer() + player.isMuted = true + player.actionAtItemEnd = .none + looper = AVPlayerLooper(player: player, templateItem: item) + if autoPlay { + player.play() + } + } + + func play() { + player.play() + } + + func pause() { + player.pause() + } + + deinit { + player.pause() + looper = nil + } +} + +struct Shelf: View { + @Default(.quickShareProvider) var quickShareProvider + @Default(.expandedDragDetection) var expandedDragDetection + @Default(.copyOnDrag) var copyOnDrag + @Default(.autoRemoveShelfItems) var autoRemoveShelfItems + @StateObject private var quickShareService = QuickShareService.shared + @ObservedObject private var fullDiskAccessPermission = FullDiskAccessPermissionStore.shared + @ObservedObject private var shelfFolderAccessPermission = ShelfFolderAccessPermissionStore.shared + + private var hasDocumentsAndDownloadsAccess: Bool { + shelfFolderAccessPermission.hasDocumentsAndDownloadsAccess + } + + private var canEnableShelf: Bool { + fullDiskAccessPermission.isAuthorized || hasDocumentsAndDownloadsAccess + } + + private var selectedProvider: QuickShareProvider? { + quickShareService.availableProviders.first(where: { $0.id == quickShareProvider }) + } + + init() { + QuickShareService.shared.ensureDiscovered() + } + + private func highlightID(_ title: String) -> String { + SettingsTab.shelf.highlightID(for: title) + } + + var body: some View { + Form { + if !canEnableShelf || !fullDiskAccessPermission.isAuthorized { + Section { + if !canEnableShelf { + SettingsPermissionCallout( + title: "Additional folder access required", + message: "Enable Full Disk Access, or grant access to both Documents and Downloads folders to use Shelf.", + icon: "folder.badge.questionmark", + iconColor: .orange, + requestButtonTitle: "Request Folder Access", + openSettingsButtonTitle: "Open Privacy & Security", + requestAction: { shelfFolderAccessPermission.requestAccessPrompt() }, + openSettingsAction: { shelfFolderAccessPermission.openSystemSettings() } + ) + } + + if !fullDiskAccessPermission.isAuthorized { + SettingsPermissionCallout( + title: "Full Disk Access for global mode", + message: "Without Full Disk Access, Shelf can only read files from Documents and Downloads. Grant Full Disk Access to make Shelf work globally.", + icon: "externaldrive.fill", + iconColor: .purple, + requestButtonTitle: "Request Full Disk Access", + openSettingsButtonTitle: "Open Privacy & Security", + requestAction: { fullDiskAccessPermission.requestAccessPrompt() }, + openSettingsAction: { fullDiskAccessPermission.openSystemSettings() } + ) + } + } header: { + Text("Permissions") + } + } + + Section { + Defaults.Toggle(key: .dynamicShelf) { + Text("Enable shelf") + } + .disabled(!canEnableShelf) + .settingsHighlight(id: highlightID("Enable shelf")) + + Defaults.Toggle(key: .openShelfByDefault) { + Text("Open shelf tab by default if items added") + } + .settingsHighlight(id: highlightID("Open shelf tab by default if items added")) + + Defaults.Toggle(key: .expandedDragDetection) { + Text("Expanded drag detection area") + } + .settingsHighlight(id: highlightID("Expanded drag detection area")) + + Defaults.Toggle(key: .copyOnDrag) { + Text("Copy items on drag") + } + .settingsHighlight(id: highlightID("Copy items on drag")) + + Defaults.Toggle(key: .autoRemoveShelfItems) { + Text("Remove from shelf after dragging") + } + .settingsHighlight(id: highlightID("Remove from shelf after dragging")) + } header: { + HStack { + Text("General") + } + } + + Section { + Picker("Quick Share Service", selection: $quickShareProvider) { + ForEach(quickShareService.availableProviders, id: \.id) { provider in + HStack { + QuickShareProviderIconImage(provider: provider, size: 16) + Text(provider.id) + } + .tag(provider.id) + } + } + .pickerStyle(.menu) + .settingsHighlight(id: highlightID("Quick Share Service")) + + if let selectedProvider { + HStack { + QuickShareProviderIconImage(provider: selectedProvider, size: 16) + VStack(alignment: .leading, spacing: 2) { + Text("Currently selected: \(selectedProvider.id)") + .font(.caption) + .foregroundStyle(.secondary) + Text("Files dropped on the shelf will be shared via this service") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + } header: { + HStack { + Text("Quick Share") + } + } footer: { + Text("Choose which service to use when sharing files from the shelf. Drag files onto the shelf or click the shelf button to pick files.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if quickShareProvider == "LocalSend" { + LocalSendSettingsSection(highlightID: highlightID) + } + } + .accentColor(.effectiveAccent) + .navigationTitle("Shelf") + .onAppear { + fullDiskAccessPermission.refreshStatus() + shelfFolderAccessPermission.refreshStatus() + } + } +} + +// MARK: - LocalSend Settings Section + +private struct LocalSendSettingsSection: View { + let highlightID: (String) -> String + + @Default(.localSendDevicePickerGlassMode) private var glassMode + @Default(.localSendDevicePickerLiquidGlassVariant) private var liquidGlassVariant + + var body: some View { + Section { + Picker("Device Picker Style", selection: $glassMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.localizedName).tag(mode) + } + } + .pickerStyle(.menu) + + if glassMode == .customLiquid { + Picker("Liquid Glass Variant", selection: $liquidGlassVariant) { + ForEach(LiquidGlassVariant.allCases) { variant in + Text("Variant \(variant.rawValue)").tag(variant) + } + } + .pickerStyle(.menu) + } + } header: { + Text("LocalSend Device Picker") + } footer: { + Text("Customize the appearance of the LocalSend device selection popup that appears when you drop files.") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +struct LiveActivitiesSettings: View { + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @ObservedObject var recordingManager = ScreenRecordingManager.shared + @ObservedObject var privacyManager = PrivacyIndicatorManager.shared + @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared + @ObservedObject private var fullDiskAccessPermission = FullDiskAccessPermissionStore.shared + + @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection + @Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection + @Default(.focusIndicatorNonPersistent) var focusIndicatorNonPersistent + @Default(.capsLockIndicatorTintMode) var capsLockTintMode + + private func highlightID(_ title: String) -> String { + SettingsTab.liveActivities.highlightID(for: title) + } + + var body: some View { + Form { + Section { + Defaults.Toggle(key: .enableScreenRecordingDetection) { + Text("Enable Screen Recording Detection") + } + .settingsHighlight(id: highlightID("Enable Screen Recording Detection")) + + Defaults.Toggle(key: .showRecordingIndicator) { + Text("Show Recording Indicator") + } + .disabled(!enableScreenRecordingDetection) + .settingsHighlight(id: highlightID("Show Recording Indicator")) + + if recordingManager.isMonitoring { + HStack { + Text("Detection Status") + Spacer() + if recordingManager.isRecording { + HStack(spacing: 4) { + Circle() + .fill(Color.red) + .frame(width: 8, height: 8) + Text("Recording Detected") + .foregroundColor(.red) + } + } else { + Text("Active - No Recording") + .foregroundColor(.green) + } + } + } + } header: { + Text("Screen Recording") + } footer: { + Text("Uses event-driven private API for real-time screen recording detection") + } + + Section { + if !fullDiskAccessPermission.isAuthorized { + SettingsPermissionCallout( + title: String(localized: "Custom Focus metadata"), + message: String(localized: "Full Disk Access unlocks custom Focus icons, colors, and labels. Standard Focus detection still works without it—grant access only if you need personalized indicators."), + icon: "externaldrive.fill", + iconColor: .purple, + requestButtonTitle: String(localized: "Request Full Disk Access"), + openSettingsButtonTitle: String(localized: "Open Privacy & Security"), + requestAction: { fullDiskAccessPermission.requestAccessPrompt() }, + openSettingsAction: { fullDiskAccessPermission.openSystemSettings() } + ) + } + + Defaults.Toggle(key: .enableDoNotDisturbDetection) { + Text("Enable Focus Detection") + } + .settingsHighlight(id: highlightID("Enable Focus Detection")) + + Defaults.Toggle(key: .showDoNotDisturbIndicator) { + Text("Show Focus Indicator") + } + .disabled(!enableDoNotDisturbDetection) + .settingsHighlight(id: highlightID("Show Focus Indicator")) + + Defaults.Toggle(key: .showDoNotDisturbLabel) { + Text("Show Focus Label") + } + .disabled(!enableDoNotDisturbDetection || focusIndicatorNonPersistent) + .help(focusIndicatorNonPersistent ? "Labels are forced to compact on/off text while brief toast mode is enabled." : "Show the active Focus name inside the indicator.") + .settingsHighlight(id: highlightID("Show Focus Label")) + + Defaults.Toggle(key: .focusIndicatorNonPersistent) { + Text("Show Focus as brief toast") + } + .disabled(!enableDoNotDisturbDetection) + .settingsHighlight(id: highlightID("Show Focus as brief toast")) + .help("When enabled, Focus appears briefly (on/off) and then collapses instead of staying visible.") + + if doNotDisturbManager.isMonitoring { + HStack { + Text("Focus Status") + Spacer() + if doNotDisturbManager.isDoNotDisturbActive { + HStack(spacing: 4) { + Circle() + .fill(Color.purple) + .frame(width: 8, height: 8) + Text(doNotDisturbManager.currentFocusModeName.isEmpty ? "Focus Enabled" : doNotDisturbManager.currentFocusModeName) + .foregroundColor(.purple) + } + } else { + Text("Active - No Focus") + .foregroundColor(.green) + } + } + } else { + HStack { + Text("Focus Status") + Spacer() + Text("Disabled") + .foregroundColor(.secondary) + } + } + } header: { + Text("Do Not Disturb") + } footer: { + Text("Listens for Focus session changes via distributed notifications") + } + + Section { + Defaults.Toggle(key: .enableCapsLockIndicator) { + Text("Show Caps Lock Indicator") + } + .settingsHighlight(id: highlightID("Show Caps Lock Indicator")) + + Defaults.Toggle(key: .showCapsLockLabel) { + Text("Show Caps Lock label") + } + .disabled(!Defaults[.enableCapsLockIndicator]) + .settingsHighlight(id: highlightID("Show Caps Lock label")) + + Picker("Caps Lock color", selection: $capsLockTintMode) { + ForEach(CapsLockIndicatorTintMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(!Defaults[.enableCapsLockIndicator]) + .settingsHighlight(id: highlightID("Caps Lock color")) + } header: { + Text("Caps Lock Indicator") + } footer: { + Text("Adds a notch HUD when Caps Lock is enabled, with optional label and tint controls.") + } + + Section { + Defaults.Toggle(key: .enableCameraDetection) { + Text("Enable Camera Detection") + } + .settingsHighlight(id: highlightID("Enable Camera Detection")) + Defaults.Toggle(key: .enableMicrophoneDetection) { + Text("Enable Microphone Detection") + } + .settingsHighlight(id: highlightID("Enable Microphone Detection")) + + if privacyManager.isMonitoring { + HStack { + Text("Camera Status") + Spacer() + if privacyManager.cameraActive { + HStack(spacing: 4) { + Circle() + .fill(Color.green) + .frame(width: 8, height: 8) + Text("Camera Active") + .foregroundColor(.green) + } + } else { + Text("Inactive") + .foregroundColor(.secondary) + } + } + + HStack { + Text("Microphone Status") + Spacer() + if privacyManager.microphoneActive { + HStack(spacing: 4) { + Circle() + .fill(Color.yellow) + .frame(width: 8, height: 8) + Text("Microphone Active") + .foregroundColor(.yellow) + } + } else { + Text("Inactive") + .foregroundColor(.secondary) + } + } + } + } header: { + Text("Privacy Indicators") + } footer: { + Text("Shows green camera icon and yellow microphone icon when in use. Uses event-driven CoreAudio and CoreMediaIO APIs.") + } + + Section { + Toggle( + "Enable music live activity", + isOn: $coordinator.musicLiveActivityEnabled.animation() + ) + .settingsHighlight(id: highlightID("Enable music live activity")) + } header: { + Text("Media Live Activity") + } footer: { + Text("Use the Media tab to configure sneak peek, lyrics, and floating media controls.") + } + + Section { + Defaults.Toggle(key: .enableReminderLiveActivity) { + Text("Enable reminder live activity") + } + .settingsHighlight(id: highlightID("Enable reminder live activity")) + } header: { + Text("Reminder Live Activity") + } footer: { + Text("Configure countdown style and lock screen widgets in the Calendar tab.") + } + } + .navigationTitle("Live Activities") + .onAppear { + fullDiskAccessPermission.refreshStatus() + } + } +} + +struct Appearance: View { + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @ObservedObject var webcamManager = WebcamManager.shared + @Default(.mirrorShape) var mirrorShape + @Default(.selectedCameraID) var selectedCameraID + @Default(.sliderColor) var sliderColor + @Default(.useMusicVisualizer) var useMusicVisualizer + @Default(.customVisualizers) var customVisualizers + @Default(.selectedVisualizer) var selectedVisualizer + @Default(.customAppIcons) private var customAppIcons + @Default(.selectedAppIconID) private var selectedAppIconID + @Default(.openNotchWidth) var openNotchWidth + @Default(.enableMinimalisticUI) var enableMinimalisticUI + @Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode + @Default(.lockScreenGlassStyle) private var lockScreenGlassStyle + @Default(.lockScreenMusicLiquidGlassVariant) private var lockScreenMusicLiquidGlassVariant + @Default(.lockScreenTimerLiquidGlassVariant) private var lockScreenTimerLiquidGlassVariant + @Default(.lockScreenTimerGlassStyle) private var lockScreenTimerGlassStyle + @Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode + @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass + @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget + @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget + @Default(.externalDisplayStyle) private var externalDisplayStyle + @State private var selectedListVisualizer: CustomVisualizer? = nil + + @State private var isIconImporterPresented = false + @State private var isIconDropTarget = false + @State private var iconImportError: String? + + @State private var isPresented: Bool = false + @State private var name: String = "" + @State private var url: String = "" + @State private var speed: CGFloat = 1.0 + + /// Whether the main screen has a physical notch. + private var mainScreenHasPhysicalNotch: Bool { + guard let screen = NSScreen.main else { return false } + return screen.safeAreaInsets.top > 0 + } + + private var notchWidthRange: ClosedRange { + let minW = Double(currentRecommendedMinimumNotchWidth()) + let maxW = min(900, Double(maxAllowedNotchWidth())) + return minW...max(minW, maxW) + } + private var defaultOpenNotchWidth: CGFloat { + currentRecommendedMinimumNotchWidth() + } + + private func highlightID(_ title: String) -> String { + SettingsTab.appearance.highlightID(for: title) + } + + private var liquidVariantRange: ClosedRange { + Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound) + } + + private var appearanceMusicVariantBinding: Binding { + Binding( + get: { Double(lockScreenMusicLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + lockScreenMusicLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } + + private var appearanceTimerVariantBinding: Binding { + Binding( + get: { Double(lockScreenTimerLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + lockScreenTimerLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } + + private var timerSurfaceBinding: Binding { + Binding( + get: { timerGlassModeIsGlass ? .glass : .classic }, + set: { mode in timerGlassModeIsGlass = (mode == .glass) } + ) + } + + var body: some View { + Form { + Section { + Toggle("Always show tabs", isOn: $coordinator.alwaysShowTabs) + Defaults.Toggle(key: .settingsIconInNotch) { + Text("Settings icon in notch") + } + .settingsHighlight(id: highlightID("Settings icon in notch")) + Defaults.Toggle(key: .enableShadow) { + Text("Enable window shadow") + } + .settingsHighlight(id: highlightID("Enable window shadow")) + Defaults.Toggle(key: .cornerRadiusScaling) { + Text("Corner radius scaling") + } + .settingsHighlight(id: highlightID("Corner radius scaling")) + Defaults.Toggle(key: .useModernCloseAnimation) { + Text("Use simpler close animation") + } + .settingsHighlight(id: highlightID("Use simpler close animation")) + } header: { + Text("General") + } + + // Show display style picker only on non-notch Macs (main screen has no physical notch) + if !mainScreenHasPhysicalNotch { + Section { + Picker("Main screen style", selection: $externalDisplayStyle) { + ForEach(ExternalDisplayStyle.allCases) { style in + Text(style.localizedName) + .tag(style) + } + } + .onChange(of: externalDisplayStyle) { + NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) + } + .settingsHighlight(id: highlightID("Main screen style")) + Text(externalDisplayStyle.description) + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("Display Style") + } + } + + notchWidthControls() + + Section { + if #available(macOS 26.0, *) { + Picker("Material", selection: $lockScreenGlassStyle) { + ForEach(LockScreenGlassStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .settingsHighlight(id: highlightID("Lock screen material")) + } else { + Picker("Material", selection: $lockScreenGlassStyle) { + ForEach(LockScreenGlassStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .disabled(true) + .settingsHighlight(id: highlightID("Lock screen material")) + Text("Liquid Glass requires macOS 26 or later.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if lockScreenGlassStyle == .liquid { + Picker("Lock screen glass mode", selection: $lockScreenGlassCustomizationMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .settingsHighlight(id: highlightID("Lock screen glass mode")) + + if lockScreenGlassCustomizationMode == .customLiquid { + Text("Pick per-widget liquid-glass variants below. Changes mirror the Lock Screen tab.") + .font(.caption) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Music panel variant") + Spacer() + Text("v\(lockScreenMusicLiquidGlassVariant.rawValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + Slider(value: appearanceMusicVariantBinding, in: liquidVariantRange, step: 1) + + LockScreenGlassVariantPreviewCell(variant: $lockScreenMusicLiquidGlassVariant) + .padding(.top, 6) + } + .settingsHighlight(id: highlightID("Music panel variant (appearance)")) + .disabled(!enableLockScreenMediaWidget) + .opacity(enableLockScreenMediaWidget ? 1 : 0.4) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Timer widget variant") + Spacer() + Text("v\(lockScreenTimerLiquidGlassVariant.rawValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + Slider(value: appearanceTimerVariantBinding, in: liquidVariantRange, step: 1) + } + .settingsHighlight(id: highlightID("Timer widget variant (appearance)")) + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.4) + } + } else { + Text("Custom Liquid settings require the Liquid Glass material.") + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("Lock Screen Glass") + } footer: { + Text("Configure lock screen materials from the Appearance tab. Custom Liquid unlocks variant sliders for both widgets whenever Liquid Glass is selected.") + } + + Section { + Defaults.Toggle(key: .coloredSpectrogram) { + Text("Enable colored spectrograms") + } + .settingsHighlight(id: highlightID("Enable colored spectrograms")) + Defaults.Toggle(key: .playerColorTinting) { + Text("Enable colored spectograms") + } + Defaults.Toggle(key: .lightingEffect) { + Text("Enable blur effect behind album art") + } + .settingsHighlight(id: highlightID("Enable blur effect behind album art")) + Picker("Slider color", selection: $sliderColor) { + ForEach(SliderColorEnum.allCases, id: \.self) { option in + Text(option.rawValue) + } + } + .settingsHighlight(id: highlightID("Slider color")) + } header: { + Text("Media") + } + + Section { + Toggle( + "Use music visualizer spectrogram", + isOn: $useMusicVisualizer.animation() + ) + .disabled(true) + if !useMusicVisualizer { + if customVisualizers.count > 0 { + Picker( + "Selected animation", + selection: $selectedVisualizer + ) { + ForEach( + customVisualizers, + id: \.self + ) { visualizer in + Text(visualizer.name) + .tag(visualizer) + } + } + } else { + HStack { + Text("Selected animation") + Spacer() + Text("No custom animation available") + .foregroundStyle(.secondary) + } + } + } + } header: { + HStack { + Text("Custom music live activity animation") + customBadge(text: "Coming soon") + } + } + + Section { + List { + ForEach(customVisualizers, id: \.self) { visualizer in + HStack { + LottieView(state: LUStateData(type: .loadedFrom(visualizer.url), speed: visualizer.speed, loopMode: .loop)) + .frame(width: 30, height: 30, alignment: .center) + Text(visualizer.name) + Spacer(minLength: 0) + if selectedVisualizer == visualizer { + Text("selected") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } + } + .buttonStyle(PlainButtonStyle()) + .padding(.vertical, 2) + .background( + selectedListVisualizer != nil ? selectedListVisualizer == visualizer ? Color.accentColor : Color.clear : Color.clear, + in: RoundedRectangle(cornerRadius: 5) + ) + .contentShape(Rectangle()) + .onTapGesture { + if selectedListVisualizer == visualizer { + selectedListVisualizer = nil + return + } + selectedListVisualizer = visualizer + } + } + } + .safeAreaPadding( + EdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0) + ) + .frame(minHeight: 120) + .actionBar { + HStack(spacing: 5) { + Button { + name = "" + url = "" + speed = 1.0 + isPresented.toggle() + } label: { + Image(systemName: "plus") + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + Divider() + Button { + if selectedListVisualizer != nil { + let visualizer = selectedListVisualizer! + selectedListVisualizer = nil + customVisualizers.remove(at: customVisualizers.firstIndex(of: visualizer)!) + if visualizer == selectedVisualizer && customVisualizers.count > 0 { selectedVisualizer = customVisualizers[0] } } - } label: { - Image(systemName: "minus") - .foregroundStyle(.secondary) - .contentShape(Rectangle()) + } label: { + Image(systemName: "minus") + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + } + } + .controlSize(.small) + .buttonStyle(PlainButtonStyle()) + .overlay { + if customVisualizers.isEmpty { + Text("No custom visualizer") + .foregroundStyle(Color(.secondaryLabelColor)) + .padding(.bottom, 22) + } + } + .sheet(isPresented: $isPresented) { + VStack(alignment: .leading) { + Text("Add new visualizer") + .font(.largeTitle.bold()) + .padding(.vertical) + TextField("Name", text: $name) + TextField("Lottie JSON URL", text: $url) + HStack { + Text("Speed") + Spacer(minLength: 80) + Text("\(speed, specifier: "%.1f")s") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + Slider(value: $speed, in: 0...2, step: 0.1) + } + .padding(.vertical) + HStack { + Button { + isPresented.toggle() + } label: { + Text("Cancel") + .frame(maxWidth: .infinity, alignment: .center) + } + + Button { + let visualizer: CustomVisualizer = .init( + UUID: UUID(), + name: name, + url: URL(string: url)!, + speed: speed + ) + + if !customVisualizers.contains(visualizer) { + customVisualizers.append(visualizer) + } + + isPresented.toggle() + } label: { + Text("Add") + .frame(maxWidth: .infinity, alignment: .center) + } + .buttonStyle(BorderedProminentButtonStyle()) + } + } + .textFieldStyle(RoundedBorderTextFieldStyle()) + .controlSize(.extraLarge) + .padding() + } + } header: { + HStack(spacing: 0) { + Text("Custom vizualizers (Lottie)") + if !Defaults[.customVisualizers].isEmpty { + Text(" – \(Defaults[.customVisualizers].count)") + .foregroundStyle(.secondary) + } + } + } + + Section { + Defaults.Toggle(key: .showMirror) { + Text("Enable Dynamic mirror") + } + .disabled(!checkVideoInput()) + .settingsHighlight(id: highlightID("Enable Dynamic mirror")) + Picker("Mirror shape", selection: $mirrorShape) { + Text("Circle") + .tag(MirrorShapeEnum.circle) + Text("Square") + .tag(MirrorShapeEnum.rectangle) + } + .settingsHighlight(id: highlightID("Mirror shape")) + + if webcamManager.cameraAvailable { + Picker("Mirror Camera", selection: $selectedCameraID) { + ForEach(webcamManager.availableCameras, id: \.uniqueID) { device in + Text(device.localizedName) + .tag(device.uniqueID) + } + } + .onChange(of: selectedCameraID) { _, _ in + if Defaults[.showMirror] { + webcamManager.stopSession() + webcamManager.startSession() + } + } + .settingsHighlight(id: highlightID("Mirror Camera")) + } + Defaults.Toggle(key: .showNotHumanFace) { + Text("Idle Animation") + } + .settingsHighlight(id: highlightID("Idle Animation")) + } header: { + HStack { + Text("Additional features") + } + } + + // MARK: - Custom Idle Animations Section + IdleAnimationsSettingsSection() + + Section { + VStack(alignment: .leading, spacing: 12) { + let columns = [GridItem(.adaptive(minimum: 90), spacing: 12)] + LazyVGrid(columns: columns, spacing: 12) { + appIconCard( + title: "Default", + image: defaultAppIconImage(), + isSelected: selectedAppIconID == nil + ) { + selectedAppIconID = nil + applySelectedAppIcon() + } + + ForEach(customAppIcons) { icon in + appIconCard( + title: icon.name, + image: customIconImage(for: icon), + isSelected: selectedAppIconID == icon.id.uuidString + ) { + selectedAppIconID = icon.id.uuidString + applySelectedAppIcon() + } + .contextMenu { + Button("Remove") { + removeCustomIcon(icon) + } + } + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.secondary.opacity(isIconDropTarget ? 0.18 : 0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.accentColor.opacity(isIconDropTarget ? 0.8 : 0), lineWidth: 2) + ) + .onDrop(of: [UTType.fileURL], isTargeted: $isIconDropTarget) { providers in + handleIconDrop(providers) + } + + HStack(spacing: 8) { + Button("Add icon") { + iconImportError = nil + isIconImporterPresented = true + } + .buttonStyle(.borderedProminent) + + Button("Remove selected") { + if let id = selectedAppIconID, + let icon = customAppIcons.first(where: { $0.id.uuidString == id }) { + removeCustomIcon(icon) + } + } + .buttonStyle(.bordered) + .disabled(selectedAppIconID == nil) + } + + if let iconImportError { + Text(iconImportError) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Drop a PNG, JPEG, TIFF, or ICNS file to add it to your icon library.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .settingsHighlight(id: highlightID("App icon")) + } header: { + HStack { + Text("App icon") + } + } + } + .onAppear(perform: enforceLockScreenGlassConsistency) + .onChange(of: lockScreenGlassStyle) { _, _ in enforceLockScreenGlassConsistency() } + .onChange(of: lockScreenGlassCustomizationMode) { _, _ in enforceLockScreenGlassConsistency() } + .fileImporter( + isPresented: $isIconImporterPresented, + allowedContentTypes: [.png, .jpeg, .tiff, .icns, .image] + ) { result in + switch result { + case .success(let url): + importCustomIcon(from: url) + case .failure: + iconImportError = "Icon import was canceled or failed." + } + } + .navigationTitle("Appearance") + } + + private func defaultAppIconImage() -> NSImage? { + let fallbackName = Bundle.main.iconFileName ?? "AppIcon" + return NSImage(named: fallbackName) + } + + private func customIconImage(for icon: CustomAppIcon) -> NSImage? { + NSImage(contentsOf: icon.fileURL) + } + + private func appIconCard(title: String, image: NSImage?, isSelected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 6) { + Group { + if let image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "app.dashed") + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(.secondary) + } + } + .frame(width: 64, height: 64) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.black.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2) + ) + + Text(title) + .font(.caption) + .lineLimit(1) + .foregroundStyle(isSelected ? .white : .secondary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + Capsule().fill(isSelected ? Color.accentColor : .clear) + ) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } + + private func handleIconDrop(_ providers: [NSItemProvider]) -> Bool { + let matching = providers.first { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) } + guard let provider = matching else { return false } + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in + let url: URL? + if let directURL = item as? URL { + url = directURL + } else if let data = item as? Data { + url = URL(dataRepresentation: data, relativeTo: nil) + } else { + url = nil + } + guard let url else { return } + Task { @MainActor in importCustomIcon(from: url) } + } + return true + } + + private func importCustomIcon(from url: URL) { + guard let image = NSImage(contentsOf: url) else { + iconImportError = "That file could not be loaded as an image." + return + } + let name = url.deletingPathExtension().lastPathComponent + let ext = url.pathExtension.isEmpty ? "png" : url.pathExtension + let id = UUID() + let fileName = "custom-icon-\(id.uuidString).\(ext)" + let destination = CustomAppIcon.iconDirectory.appendingPathComponent(fileName) + + do { + let data = try Data(contentsOf: url) + try data.write(to: destination, options: [.atomic]) + } catch { + iconImportError = "Unable to save the icon file." + return + } + + let newIcon = CustomAppIcon(id: id, name: name.isEmpty ? "Custom Icon" : name, fileName: fileName) + if !customAppIcons.contains(newIcon) { + customAppIcons.append(newIcon) + } + selectedAppIconID = newIcon.id.uuidString + NSApp.applicationIconImage = image + iconImportError = nil + } + + private func removeCustomIcon(_ icon: CustomAppIcon) { + if let index = customAppIcons.firstIndex(of: icon) { + customAppIcons.remove(at: index) + } + if FileManager.default.fileExists(atPath: icon.fileURL.path) { + try? FileManager.default.removeItem(at: icon.fileURL) + } + if selectedAppIconID == icon.id.uuidString { + selectedAppIconID = nil + applySelectedAppIcon() + } + } + + func checkVideoInput() -> Bool { + if let _ = AVCaptureDevice.default(for: .video) { + return true + } + + return false + } + + @ViewBuilder + private func notchWidthControls() -> some View { + Section { + let recommendedMin = currentRecommendedMinimumNotchWidth() + let tabCount = enabledStandardTabCount() + let dynamicRange = Double(recommendedMin)...900 + + let widthBinding = Binding( + get: { Double(openNotchWidth) }, + set: { newValue in + let clamped = min(max(newValue, dynamicRange.lowerBound), dynamicRange.upperBound) + let value = CGFloat(clamped) + if openNotchWidth != value { + openNotchWidth = value + } + } + ) + + VStack(alignment: .leading, spacing: 10) { + Slider( + value: widthBinding, + in: dynamicRange, + step: 10 + ) { + HStack { + Text("Expanded notch width") + Spacer() + Text("\(Int(openNotchWidth)) px") + .foregroundStyle(.secondary) + } + } + .disabled(enableMinimalisticUI) + .settingsHighlight(id: highlightID("Expanded notch width")) + + HStack { + Text("\(tabCount) tab\(tabCount == 1 ? "" : "s") enabled · min \(Int(recommendedMin)) px") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Reset Width") { + openNotchWidth = recommendedMin + } + .disabled(abs(openNotchWidth - recommendedMin) < 0.5) + .buttonStyle(.bordered) + } + + let description = enableMinimalisticUI + ? String(localized: "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value.") + : String(localized: "Recommended minimum width adjusts automatically based on the number of enabled tabs.") + + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + .onAppear { + enforceMinimumNotchWidth() + } + } header: { + HStack { + Text("Notch Width") + customBadge(text: "Beta") + } + } + } + + private func enforceLockScreenGlassConsistency() { + if lockScreenGlassStyle == .frosted && lockScreenGlassCustomizationMode != .standard { + lockScreenGlassCustomizationMode = .standard + } + if lockScreenGlassCustomizationMode == .customLiquid && lockScreenGlassStyle != .liquid { + lockScreenGlassStyle = .liquid + } + } +} + +struct LockScreenSettings: View { + @ObservedObject private var calendarManager = CalendarManager.shared + @ObservedObject private var previewManager = LockScreenWidgetPreviewManager.shared + @Default(.lockScreenGlassStyle) private var lockScreenGlassStyle + @Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode + @Default(.lockScreenMusicLiquidGlassVariant) private var lockScreenMusicLiquidGlassVariant + @Default(.lockScreenTimerLiquidGlassVariant) private var lockScreenTimerLiquidGlassVariant + @Default(.lockScreenTimerGlassStyle) private var lockScreenTimerGlassStyle + @Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode + @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass + @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget + @Default(.lockScreenMusicFullscreenArtworkEnabled) private var lockScreenMusicFullscreenArtworkEnabled + @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget + @Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget + @Default(.enableLockScreenFocusWidget) private var enableLockScreenFocusWidget + @Default(.lockScreenWeatherWidgetStyle) private var lockScreenWeatherWidgetStyle + @Default(.lockScreenWeatherProviderSource) private var lockScreenWeatherProviderSource + @Default(.lockScreenWeatherTemperatureUnit) private var lockScreenWeatherTemperatureUnit + @Default(.lockScreenBatteryShowsCharging) private var lockScreenWeatherShowsCharging + @Default(.lockScreenBatteryShowsBatteryGauge) private var lockScreenWeatherShowsBatteryGauge + @Default(.lockScreenWeatherShowsAQI) private var lockScreenWeatherShowsAQI + @Default(.lockScreenWeatherShowsSunrise) private var lockScreenWeatherShowsSunrise + @Default(.lockScreenWeatherAQIScale) private var lockScreenWeatherAQIScale + @Default(.enableLockScreenReminderWidget) private var enableLockScreenReminderWidget + @Default(.lockScreenReminderChipStyle) private var lockScreenReminderChipStyle + @Default(.lockScreenReminderWidgetHorizontalAlignment) private var lockScreenReminderWidgetHorizontalAlignment + @Default(.lockScreenReminderWidgetVerticalOffset) private var lockScreenReminderWidgetVerticalOffset + @Default(.showStandardMediaControls) private var showStandardMediaControls + @Default(.lockScreenShowCalendarCountdown) private var lockScreenShowCalendarCountdown + @Default(.lockScreenShowCalendarEvent) private var lockScreenShowCalendarEvent + @Default(.lockScreenShowCalendarEventEntireDuration) private var lockScreenShowCalendarEventEntireDuration + @Default(.lockScreenShowCalendarEventAfterStartWindow) private var lockScreenShowCalendarEventAfterStartWindow + @Default(.lockScreenShowCalendarTimeRemaining) private var lockScreenShowCalendarTimeRemaining + @Default(.lockScreenShowCalendarStartTimeAfterBegins) private var lockScreenShowCalendarStartTimeAfterBegins + @Default(.lockScreenCalendarEventLookaheadWindow) private var lockScreenCalendarEventLookaheadWindow + @Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode + @Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs + @Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled + @Default(.lockScreenMusicMergedAirPlayOutput) private var lockScreenMusicMergedAirPlayOutput + @ObservedObject private var musicManager = MusicManager.shared + + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + + private func highlightID(_ title: String) -> String { + SettingsTab.lockScreen.highlightID(for: title) + } + + private var liquidVariantRange: ClosedRange { + Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound) + } + + private enum CalendarLookaheadOption: String, CaseIterable, Identifiable { + case mins15 = "15m" + case mins30 = "30m" + case hour1 = "1h" + case hours3 = "3h" + case hours6 = "6h" + case hours12 = "12h" + case restOfDay = "rest_of_day" + case allTime = "all_time" + + var id: String { rawValue } + + var title: String { + switch self { + case .mins15: return "15 mins" + case .mins30: return "30 mins" + case .hour1: return "1 hour" + case .hours3: return "3 hours" + case .hours6: return "6 hours" + case .hours12: return "12 hours" + case .restOfDay: return "Rest of the day" + case .allTime: return "All time" + } + } + } + + private enum ReminderAlignmentOption: String, CaseIterable, Identifiable { + case leading + case center + case trailing + + var id: String { rawValue } + + var title: String { + switch self { + case .leading: return "Left" + case .center: return "Center" + case .trailing: return "Right" + } + } + } + + private var musicVariantBinding: Binding { + Binding( + get: { Double(lockScreenMusicLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + lockScreenMusicLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } + + private var timerVariantBinding: Binding { + Binding( + get: { Double(lockScreenTimerLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + lockScreenTimerLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } + + private var timerSurfaceBinding: Binding { + Binding( + get: { timerGlassModeIsGlass ? .glass : .classic }, + set: { mode in timerGlassModeIsGlass = (mode == .glass) } + ) + } + + var body: some View { + Form { + Section { + Defaults.Toggle(key: .enableLockScreenLiveActivity) { + Text("Enable lock screen live activity") + } + .settingsHighlight(id: highlightID("Enable lock screen live activity")) + Defaults.Toggle(key: .enableLockSounds) { + Text("Play lock/unlock sounds") + } + .settingsHighlight(id: highlightID("Play lock/unlock sounds")) + } header: { + Text("Live Activity & Feedback") + } footer: { + Text("Controls whether Dynamic Island mirrors lock/unlock events with its own live activity and audible chimes.") + } + + Section { + Button(previewManager.isPreviewVisible ? "Hide lock screen preview" : "Preview lock screen widgets") { + previewManager.togglePreview() + } + .buttonStyle(.borderedProminent) + .settingsHighlight(id: highlightID("Preview lock screen widgets")) + } header: { + Text("Preview") + } footer: { + Text("Opens a transparent preview window with mock data that mirrors the current lock screen widget configuration.") + } + + Section { + if #available(macOS 26.0, *) { + Picker("Material", selection: $lockScreenGlassStyle) { + ForEach(LockScreenGlassStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .settingsHighlight(id: highlightID("Material")) + } else { + Picker("Material", selection: $lockScreenGlassStyle) { + ForEach(LockScreenGlassStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .disabled(true) + .settingsHighlight(id: highlightID("Material")) + Text("Liquid Glass requires macOS 26 or later.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if lockScreenGlassStyle == .liquid { + Picker("Glass mode", selection: $lockScreenGlassCustomizationMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .settingsHighlight(id: highlightID("Glass mode")) + + if lockScreenGlassCustomizationMode == .customLiquid { + Text("Use the sliders below to pick unique Apple liquid-glass variants for each widget.") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Text("Custom Liquid settings require the Liquid Glass material.") + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("Lock Screen Glass") + } footer: { + Text("Choose the global material mode for lock screen widgets. Custom Liquid unlocks per-widget variant sliders while Standard sticks to the classic frosted/liquid options.") + } + + Section { + Defaults.Toggle(key: .enableLockScreenMediaWidget) { + Text("Show lock screen media panel") + } + .settingsHighlight(id: highlightID("Show lock screen media panel")) + Defaults.Toggle(key: .lockScreenShowAppIcon) { + Text("Show media app icon") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Show media app icon")) + if isAppleMusicActive { + Defaults.Toggle(key: .lockScreenMusicMergedAirPlayOutput) { + Text("Show merged AirPlay and output devices") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Show merged AirPlay and output devices")) + } + Defaults.Toggle(key: .lockScreenPanelShowsBorder) { + Text("Show panel border") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Show panel border")) + if lockScreenGlassCustomizationMode == .customLiquid { + Defaults.Toggle(key: .lockScreenMusicUsesEnhancedLiquidBorder) { + Text("Use enhanced liquid border") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Use enhanced liquid border")) + } + if lockScreenGlassCustomizationMode == .customLiquid { + variantSlider( + title: "Music panel variant", + value: musicVariantBinding, + currentValue: lockScreenMusicLiquidGlassVariant.rawValue, + isEnabled: enableLockScreenMediaWidget, + highlight: highlightID("Music panel variant") + ) + } else if lockScreenGlassStyle == .frosted { + Defaults.Toggle(key: .lockScreenPanelUsesBlur) { + Text("Enable media panel blur") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Enable media panel blur")) + } else { + blurSettingUnavailableRow + .opacity(enableLockScreenMediaWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Enable media panel blur")) + } + VStack(alignment: .leading, spacing: 4) { + Defaults.Toggle(key: .lockScreenMusicFullscreenArtworkEnabled) { + Text("Fullscreen artwork on right-click") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Fullscreen artwork on right-click")) + Defaults.Toggle(key: .lockScreenUseArtworkLayoutOverFullscreenCanvas) { + Text("Use album art layout over fullscreen canvas") + } + .disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled) + .settingsHighlight(id: highlightID("Use album art layout over fullscreen canvas")) + Defaults.Toggle(key: .lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) { + Text("Keep album art visible during fullscreen artwork") + } + .disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled) + .settingsHighlight(id: highlightID("Keep album art visible during fullscreen artwork")) + Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if !showStandardMediaControls { + Text("Enable Dynamic Island media controls to manage the lock screen panel.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } header: { + Text("Media Panel") + } footer: { + Text("Enable and style the media controls that appear above the system clock when the screen is locked.") + } + .disabled(!showStandardMediaControls) + .opacity(showStandardMediaControls ? 1 : 0.5) + + Section { + Defaults.Toggle(key: .enableLockScreenTimerWidget) { + Text("Show lock screen timer") + } + .settingsHighlight(id: highlightID("Show lock screen timer")) + Picker("Timer surface", selection: timerSurfaceBinding) { + ForEach(LockScreenTimerSurfaceMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Timer surface")) + + if timerGlassModeIsGlass { + Picker("Timer glass material", selection: $lockScreenTimerGlassStyle) { + ForEach(LockScreenGlassStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Timer glass material")) + + if lockScreenTimerGlassStyle == .liquid { + Picker("Timer liquid mode", selection: $lockScreenTimerGlassCustomizationMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Timer liquid mode")) + + if lockScreenTimerGlassCustomizationMode == .customLiquid { + variantSlider( + title: "Timer widget variant", + value: timerVariantBinding, + currentValue: lockScreenTimerLiquidGlassVariant.rawValue, + isEnabled: enableLockScreenTimerWidget, + highlight: highlightID("Timer widget variant") + ) + } + } else { + Text("Uses the frosted blur treatment while glass mode is enabled.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + Text("Classic mode keeps the original translucent black background.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + } + } header: { + Text("Timer Widget") + } footer: { + Text("Controls the optional timer widget that floats above the media panel, including its classic, frosted, or liquid glass surface independent of the global material setting.") + } + + Section { + Defaults.Toggle(key: .enableLockScreenWeatherWidget) { + Text("Show lock screen weather") + } + .settingsHighlight(id: highlightID("Show lock screen weather")) + + if enableLockScreenWeatherWidget { + Picker("Layout", selection: $lockScreenWeatherWidgetStyle) { + ForEach(LockScreenWeatherWidgetStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .pickerStyle(.segmented) + .settingsHighlight(id: highlightID("Layout")) + + Picker("Weather data provider", selection: $lockScreenWeatherProviderSource) { + ForEach(LockScreenWeatherProviderSource.allCases) { source in + Text(source.displayName).tag(source) + } + } + .pickerStyle(.segmented) + .settingsHighlight(id: highlightID("Weather data provider")) + + Picker("Temperature unit", selection: $lockScreenWeatherTemperatureUnit) { + ForEach(LockScreenWeatherTemperatureUnit.allCases) { unit in + Text(unit.rawValue).tag(unit) + } + } + .pickerStyle(.segmented) + .settingsHighlight(id: highlightID("Temperature unit")) + + Defaults.Toggle(key: .lockScreenWeatherShowsLocation) { + Text("Show location label") + } + .disabled(lockScreenWeatherWidgetStyle == .circular) + .settingsHighlight(id: highlightID("Show location label")) + + Defaults.Toggle(key: .lockScreenWeatherShowsSunrise) { + Text("Show sunrise time") + } + .disabled(lockScreenWeatherWidgetStyle != .inline) + .settingsHighlight(id: highlightID("Show sunrise time")) + + Defaults.Toggle(key: .lockScreenWeatherShowsAQI) { + Text("Show AQI widget") + } + .disabled(!lockScreenWeatherProviderSource.supportsAirQuality) + .settingsHighlight(id: highlightID("Show AQI widget")) + + if lockScreenWeatherShowsAQI && lockScreenWeatherProviderSource.supportsAirQuality { + Picker("Air quality scale", selection: $lockScreenWeatherAQIScale) { + ForEach(LockScreenWeatherAirQualityScale.allCases) { scale in + Text(scale.displayName).tag(scale) + } + } + .pickerStyle(.segmented) + .settingsHighlight(id: highlightID("Air quality scale")) + } + + if !lockScreenWeatherProviderSource.supportsAirQuality { + Text("Air quality requires the Open Meteo provider.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Defaults.Toggle(key: .lockScreenWeatherUsesGaugeTint) { + Text("Use colored gauges") + } + .settingsHighlight(id: highlightID("Use colored gauges")) + } + } header: { + Text("Weather Widget") + } footer: { + Text("Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators.") + } + + Section { + Defaults.Toggle(key: .enableLockScreenReminderWidget) { + Text("Show lock screen reminder") + } + .settingsHighlight(id: highlightID("Show lock screen reminder")) + + Picker("Chip color", selection: $lockScreenReminderChipStyle) { + ForEach(LockScreenReminderChipStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .pickerStyle(.segmented) + .disabled(!enableLockScreenReminderWidget) + .settingsHighlight(id: highlightID("Chip color")) + + Picker("Alignment", selection: $lockScreenReminderWidgetHorizontalAlignment) { + ForEach(ReminderAlignmentOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .pickerStyle(.segmented) + .disabled(!enableLockScreenReminderWidget) + .settingsHighlight(id: highlightID("Reminder alignment")) + + HStack { + Text("Vertical offset") + Slider( + value: $lockScreenReminderWidgetVerticalOffset, + in: -160...160, + step: 2 + ) + .disabled(!enableLockScreenReminderWidget) + Text("\(Int(lockScreenReminderWidgetVerticalOffset)) px") + .foregroundStyle(.secondary) + .frame(width: 70, alignment: .trailing) + } + .settingsHighlight(id: highlightID("Reminder vertical offset")) + } header: { + Text("Reminder Widget") + } footer: { + Text("Controls the lock screen reminder chip and its positioning.") + } + + if BatteryActivityManager.shared.hasBattery() { + Section { + Defaults.Toggle(key: .lockScreenBatteryShowsBatteryGauge) { + Text("Show battery indicator") + } + .settingsHighlight(id: highlightID("Show battery indicator")) + + if lockScreenWeatherShowsBatteryGauge { + Defaults.Toggle(key: .lockScreenBatteryUsesLaptopSymbol) { + Text("Use MacBook icon when on battery") + } + .settingsHighlight(id: highlightID("Use MacBook icon when on battery")) + + Defaults.Toggle(key: .lockScreenBatteryShowsCharging) { + Text("Show charging status") + } + .settingsHighlight(id: highlightID("Show charging status")) + + if lockScreenWeatherShowsCharging { + Defaults.Toggle(key: .lockScreenBatteryShowsChargingPercentage) { + Text("Show charging percentage") + } + .settingsHighlight(id: highlightID("Show charging percentage")) + } + + Defaults.Toggle(key: .lockScreenBatteryShowsBluetooth) { + Text("Show Bluetooth battery") + } + .settingsHighlight(id: highlightID("Show Bluetooth battery")) + } + } header: { + Text("Battery Widget") + } footer: { + Text("Enable the battery capsule and configure its layout.") + } + } + + Section { + Defaults.Toggle(key: .enableLockScreenFocusWidget) { + Text("Show focus widget") + } + .settingsHighlight(id: highlightID("Show focus widget")) + } header: { + Text("Focus Widget") + } footer: { + Text("Displays the current Focus state above the weather capsule whenever Focus detection is enabled.") + } + + Section { + Defaults.Toggle(key: .lockScreenShowCalendarEvent) { + Text("Show next calendar event") + } + .settingsHighlight(id: highlightID("Show next calendar event")) + + LabeledContent("Show events within the next") { + HStack { + Spacer(minLength: 0) + Picker("", selection: $lockScreenCalendarEventLookaheadWindow) { + ForEach(CalendarLookaheadOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show events within the next")) + + Toggle("Show events from all calendars", isOn: Binding( + get: { lockScreenCalendarSelectionMode == "all" }, + set: { useAll in + if useAll { + lockScreenCalendarSelectionMode = "all" + } else { + lockScreenCalendarSelectionMode = "selected" + lockScreenSelectedCalendarIDs = Set(calendarManager.eventCalendars.map { $0.id }) + } + } + )) + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show events from all calendars")) + + if lockScreenCalendarSelectionMode != "all" { + HStack { + Spacer() + Button("Deselect All") { + lockScreenSelectedCalendarIDs = [] + } + .buttonStyle(.link) + } + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 8) { + ForEach(calendarManager.eventCalendars, id: \.id) { calendar in + Toggle(isOn: Binding( + get: { lockScreenSelectedCalendarIDs.contains(calendar.id) }, + set: { isOn in + if isOn { + lockScreenSelectedCalendarIDs.insert(calendar.id) + } else { + lockScreenSelectedCalendarIDs.remove(calendar.id) + } + } + )) { + HStack(spacing: 8) { + Circle() + .fill(Color(calendar.color)) + .frame(width: 8, height: 8) + Text(calendar.title) + } + } + } + } + .padding(.top, 4) + .padding(.leading, 2) + .disabled(!lockScreenShowCalendarEvent) + } + + Defaults.Toggle(key: .lockScreenShowCalendarCountdown) { + Text("Show countdown") + } + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show countdown")) + + Defaults.Toggle(key: .lockScreenShowCalendarEventEntireDuration) { + Text("Show event for entire duration") + } + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show event for entire duration")) + .onChange(of: Defaults[.lockScreenShowCalendarEventEntireDuration]) { _, newValue in + if newValue { + Defaults[.lockScreenShowCalendarEventAfterStartEnabled] = false + } + } + + Defaults.Toggle( + "Hide active event and show next upcoming event", + key: .lockScreenShowCalendarEventAfterStartEnabled + ) + .disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration) + .settingsHighlight(id: highlightID("Hide active event and show next upcoming event")) + + LabeledContent("Show event after it starts") { + HStack { + Spacer(minLength: 0) + Picker("", selection: $lockScreenShowCalendarEventAfterStartWindow) { + Text("1 min").tag("1m") + Text("5 mins").tag("5m") + Text("10 mins").tag("10m") + Text("15 mins").tag("15m") + Text("30 mins").tag("30m") + Text("45 mins").tag("45m") + Text("1 hour").tag("1h") + Text("2 hours").tag("2h") + } + .labelsHidden() + .pickerStyle(.menu) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration || !lockScreenShowCalendarEventAfterStartEnabled) + + Text("Turn off 'Show event for entire duration' to use the post-start duration option.") + .font(.caption) + .foregroundStyle(.secondary) + + Defaults.Toggle(key: .lockScreenShowCalendarTimeRemaining) { + Text("Show time remaining") + } + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show time remaining")) + + Defaults.Toggle(key: .lockScreenShowCalendarStartTimeAfterBegins) { + Text("Show start time after event begins") + } + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show start time after event begins")) + } header: { + Text("Calendar Widget") + } footer: { + Text("Displays your next upcoming calendar event above or below the weather capsule. Calendar selection here is independent from the Dynamic Island calendar filter.") + } + + LockScreenPositioningControls() + + Section { + Button("Copy Latest Crash Report") { + copyLatestCrashReport() + } + } header: { + Text("Diagnostics") + } footer: { + Text("Collect the latest crash report to share with the developer when reporting lock screen or overlay issues.") + } + } + .onAppear(perform: enforceLockScreenGlassConsistency) + .onChange(of: lockScreenGlassStyle) { _, _ in enforceLockScreenGlassConsistency() } + .onChange(of: lockScreenGlassCustomizationMode) { _, _ in enforceLockScreenGlassConsistency() } + .navigationTitle("Lock Screen") + } +} + +extension LockScreenSettings { + private func enforceLockScreenGlassConsistency() { + if lockScreenGlassStyle == .frosted && lockScreenGlassCustomizationMode != .standard { + lockScreenGlassCustomizationMode = .standard + } + if lockScreenGlassCustomizationMode == .customLiquid && lockScreenGlassStyle != .liquid { + lockScreenGlassStyle = .liquid + } + } + + private var blurSettingUnavailableRow: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Enable media panel blur") + .foregroundStyle(.secondary) + Text("Only available when Material is set to Frosted Glass.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func variantSlider( + title: String, + value: Binding, + currentValue: Int, + isEnabled: Bool, + highlight: String, + preview: AnyView? = nil + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title) + Spacer() + Text("v\(currentValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + Slider(value: value, in: liquidVariantRange, step: 1) + + if let preview { + preview + .padding(.top, 6) + } + } + .settingsHighlight(id: highlight) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.4) + } +} + +private struct LockScreenGlassVariantPreviewCell: View { + @Binding var variant: LiquidGlassVariant + + private let cornerRadius: CGFloat = 16 + private let previewCornerRadius: CGFloat = 14 + private let previewSize = CGSize(width: 190, height: 96) + + var body: some View { + ZStack { + Image("glassdesktop") + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + + liquidGlassPreview + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity) + .frame(height: 120) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + .padding(.vertical, 6) + .allowsHitTesting(false) + .onAppear { + Logger.log("Lock screen glass preview appeared (variant v\(variant.rawValue))", category: .performance) + } + .onDisappear { + Logger.log("Lock screen glass preview disappeared", category: .performance) + } + .onChange(of: variant) { _, newValue in + Logger.log("Lock screen glass preview variant changed to v\(newValue.rawValue)", category: .performance) + } + } + + private var liquidGlassPreview: some View { + LiquidGlassBackground( + variant: variant, + cornerRadius: previewCornerRadius + ) { + Color.white.opacity(0.04) + } + .frame(width: previewSize.width, height: previewSize.height) + .shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 6) + } +} + +private struct LockScreenPositioningControls: View { + @Default(.lockScreenWeatherVerticalOffset) private var weatherOffset + @Default(.lockScreenMusicVerticalOffset) private var musicOffset + @Default(.lockScreenTimerVerticalOffset) private var timerOffset + @Default(.lockScreenMusicPanelWidth) private var musicWidth + @Default(.lockScreenTimerWidgetWidth) private var timerWidth + private let offsetRange: ClosedRange = -160...160 + private let musicWidthRange: ClosedRange = 320...Double(LockScreenMusicPanel.defaultCollapsedWidth) + private let timerWidthRange: ClosedRange = 320...LockScreenTimerWidget.defaultWidth + + var body: some View { + Section { + let weatherBinding = Binding( + get: { weatherOffset }, + set: { newValue in + let clampedValue = clampOffset(newValue) + if weatherOffset != clampedValue { + weatherOffset = clampedValue + } + propagateWeatherOffsetChange(animated: false) + } + ) + + let timerBinding = Binding( + get: { timerOffset }, + set: { newValue in + let clampedValue = clampOffset(newValue) + if timerOffset != clampedValue { + timerOffset = clampedValue + } + propagateTimerOffsetChange(animated: false) + } + ) + + let musicBinding = Binding( + get: { musicOffset }, + set: { newValue in + let clampedValue = clampOffset(newValue) + if musicOffset != clampedValue { + musicOffset = clampedValue + } + propagateMusicOffsetChange(animated: false) + } + ) + + let musicWidthBinding = Binding( + get: { musicWidth }, + set: { newValue in + let clampedValue = clamp(newValue, within: musicWidthRange) + if musicWidth != clampedValue { + musicWidth = clampedValue + propagateMusicWidthChange(animated: false) + } + } + ) + + let timerWidthBinding = Binding( + get: { timerWidth }, + set: { newValue in + let clampedValue = clamp(newValue, within: timerWidthRange) + if timerWidth != clampedValue { + timerWidth = clampedValue + propagateTimerWidthChange(animated: false) + } + } + ) + + LockScreenPositioningPreview( + weatherOffset: weatherBinding, + timerOffset: timerBinding, + musicOffset: musicBinding, + musicWidth: musicWidthBinding, + timerWidth: timerWidthBinding + ) + .frame(height: 260) + .padding(.vertical, 8) + + HStack(alignment: .top, spacing: 24) { + offsetColumn( + title: String(localized: "Weather"), + value: weatherOffset, + resetTitle: String(localized: "Reset Weather"), + resetAction: resetWeatherOffset + ) + + Divider() + .frame(height: 64) + + offsetColumn( + title: String(localized: "Timer"), + value: timerOffset, + resetTitle: String(localized: "Reset Timer"), + resetAction: resetTimerOffset + ) + + Divider() + .frame(height: 64) + + offsetColumn( + title: String(localized: "Music"), + value: musicOffset, + resetTitle: String(localized: "Reset Music"), + resetAction: resetMusicOffset + ) + + Spacer() + } + + Divider() + .padding(.vertical, 8) + + VStack(alignment: .leading, spacing: 16) { + widthSlider( + title: String(localized: "Media Panel Width"), + value: musicWidthBinding, + range: musicWidthRange, + resetTitle: String(localized: "Reset Media Width"), + resetAction: resetMusicWidth, + helpText: String(localized: "Shrinks the lock screen media panel while keeping the expanded view full width.") + ) + + widthSlider( + title: String(localized: "Timer Widget Width"), + value: timerWidthBinding, + range: timerWidthRange, + resetTitle: String(localized: "Reset Timer Width"), + resetAction: resetTimerWidth, + helpText: String(localized: "Adjusts the lock screen timer widget width without affecting button sizing.") + ) + } + } header: { + Text("Lock Screen Positioning") + } footer: { + Text("Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Use the width sliders below to narrow the media and timer widgets without exceeding their default size. Changes apply instantly while the widgets are visible.") + .textCase(nil) + } + } + + private func clampOffset(_ value: Double) -> Double { + min(max(value, offsetRange.lowerBound), offsetRange.upperBound) + } + + private func clamp(_ value: Double, within range: ClosedRange) -> Double { + min(max(value, range.lowerBound), range.upperBound) + } + + private func resetWeatherOffset() { + weatherOffset = 0 + propagateWeatherOffsetChange(animated: true) + } + + private func resetTimerOffset() { + timerOffset = 0 + propagateTimerOffsetChange(animated: true) + } + + private func resetMusicOffset() { + musicOffset = 0 + propagateMusicOffsetChange(animated: true) + } + + private func resetMusicWidth() { + musicWidth = Double(LockScreenMusicPanel.defaultCollapsedWidth) + propagateMusicWidthChange(animated: true) + } + + private func resetTimerWidth() { + timerWidth = LockScreenTimerWidget.defaultWidth + propagateTimerWidthChange(animated: true) + } + + private func propagateWeatherOffsetChange(animated: Bool) { + Task { @MainActor in + LockScreenWeatherPanelManager.shared.refreshPositionForOffsets(animated: animated) + } + } + + private func propagateTimerOffsetChange(animated: Bool) { + Task { @MainActor in + LockScreenTimerWidgetManager.shared.refreshPositionForOffsets(animated: animated) + } + } + + private func propagateMusicOffsetChange(animated: Bool) { + Task { @MainActor in + LockScreenPanelManager.shared.applyOffsetAdjustment(animated: animated) + } + } + + private func propagateMusicWidthChange(animated: Bool) { + Task { @MainActor in + LockScreenPanelManager.shared.applyOffsetAdjustment(animated: animated) + } + } + + private func propagateTimerWidthChange(animated: Bool) { + Task { @MainActor in + LockScreenTimerWidgetPanelManager.shared.refreshPosition(animated: animated) + } + } + + @ViewBuilder + private func offsetColumn(title: String, value: Double, resetTitle: String, resetAction: @escaping () -> Void) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("\(title) Offset") + .font(.subheadline.weight(.semibold)) + + Text("\(formattedPoints(value)) pt") + .font(.caption) + .foregroundStyle(.secondary) + + Button(resetTitle) { + resetAction() + } + .buttonStyle(.bordered) + } + } + + @ViewBuilder + private func widthSlider( + title: String, + value: Binding, + range: ClosedRange, + resetTitle: String, + resetAction: @escaping () -> Void, + helpText: String + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title) + Spacer() + Text(formattedWidth(value.wrappedValue)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Slider(value: value, in: range) + + HStack(alignment: .top) { + Button(resetTitle) { + resetAction() + } + .buttonStyle(.bordered) + + Spacer() + + Text(helpText) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) + } + } + } + + private func formattedPoints(_ value: Double) -> String { + String(format: "%+.0f", value) + } + + private func formattedWidth(_ value: Double) -> String { + String(format: "%.0f pt", value) + } +} + +private struct LockScreenPositioningPreview: View { + @Binding var weatherOffset: Double + @Binding var timerOffset: Double + @Binding var musicOffset: Double + @Binding var musicWidth: Double + @Binding var timerWidth: Double + + @State private var weatherStartOffset: Double = 0 + @State private var timerStartOffset: Double = 0 + @State private var musicStartOffset: Double = 0 + @State private var isWeatherDragging = false + @State private var isTimerDragging = false + @State private var isMusicDragging = false + + private let offsetRange: ClosedRange = -160...160 + + var body: some View { + GeometryReader { geometry in + let screenPadding: CGFloat = 26 + let screenCornerRadius: CGFloat = 28 + let screenRect = CGRect( + x: screenPadding, + y: screenPadding, + width: geometry.size.width - (screenPadding * 2), + height: geometry.size.height - (screenPadding * 2) + ) + let centerX = screenRect.midX + let weatherBaseY = screenRect.minY + (screenRect.height * 0.28) + let timerBaseY = screenRect.minY + (screenRect.height * 0.5) + let musicBaseY = screenRect.minY + (screenRect.height * 0.78) + let weatherSize = CGSize(width: screenRect.width * 0.42, height: screenRect.height * 0.22) + let defaultMusicWidth = Double(LockScreenMusicPanel.defaultCollapsedWidth) + let musicWidthScale = CGFloat(musicWidth / defaultMusicWidth) + let timerWidthScale = CGFloat(timerWidth / LockScreenTimerWidget.defaultWidth) + let timerSize = CGSize( + width: (screenRect.width * 0.5) * timerWidthScale, + height: screenRect.height * 0.2 + ) + let musicSize = CGSize( + width: (screenRect.width * 0.56) * musicWidthScale, + height: screenRect.height * 0.34 + ) + + ZStack { + RoundedRectangle(cornerRadius: screenCornerRadius, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.55)) + .frame(width: screenRect.width, height: screenRect.height) + .overlay( + RoundedRectangle(cornerRadius: screenCornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.22), radius: 20, x: 0, y: 18) + .position(x: screenRect.midX, y: screenRect.midY) + + weatherPanel(size: weatherSize) + .position(x: centerX, y: weatherBaseY - CGFloat(weatherOffset)) + .gesture(weatherDragGesture(in: screenRect, baseY: weatherBaseY, panelSize: weatherSize)) + + timerPanel(size: timerSize) + .position(x: centerX, y: timerBaseY - CGFloat(timerOffset)) + .gesture(timerDragGesture(in: screenRect, baseY: timerBaseY, panelSize: timerSize)) + + musicPanel(size: musicSize) + .position(x: centerX, y: musicBaseY - CGFloat(musicOffset)) + .gesture(musicDragGesture(in: screenRect, baseY: musicBaseY, panelSize: musicSize)) + } + } + .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: weatherOffset) + .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: musicOffset) + .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: timerOffset) + .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: musicWidth) + .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: timerWidth) + } + + private func weatherPanel(size: CGSize) -> some View { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill( + LinearGradient( + colors: [Color.blue.opacity(0.78), Color.blue.opacity(0.52)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: size.width, height: size.height) + .overlay(alignment: .leading) { + VStack(alignment: .leading, spacing: 4) { + Label("Weather", systemImage: "cloud.sun.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.white) + Text("Inline snapshot preview") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.72)) + } + .padding(.horizontal, 16) + } + .shadow(color: Color.blue.opacity(0.22), radius: 10, x: 0, y: 8) + } + + private func musicPanel(size: CGSize) -> some View { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill( + LinearGradient( + colors: [Color.purple.opacity(0.68), Color.pink.opacity(0.5)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: size.width, height: size.height) + .overlay(alignment: .leading) { + VStack(alignment: .leading, spacing: 6) { + Label("Media", systemImage: "play.square.stack") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.white) + Text("Lock screen panel preview") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.72)) + } + .padding(.horizontal, 18) + } + .shadow(color: Color.purple.opacity(0.24), radius: 12, x: 0, y: 9) + } + + private func timerPanel(size: CGSize) -> some View { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill( + LinearGradient( + colors: [Color.orange.opacity(0.75), Color.purple.opacity(0.55)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: size.width, height: size.height) + .overlay { + VStack(spacing: 6) { + Text("Timer") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + Text("00:05:00") + .font(.system(size: 18, weight: .bold, design: .monospaced)) + .foregroundStyle(.white) + } + } + .shadow(color: Color.orange.opacity(0.3), radius: 12, x: 0, y: 8) + } + + private func weatherDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { value in + if !isWeatherDragging { + isWeatherDragging = true + weatherStartOffset = weatherOffset + } + + let proposed = weatherStartOffset - Double(value.translation.height) + weatherOffset = clampedOffset( + proposed, + baseCenterY: baseY, + panelHeight: panelSize.height, + screenRect: screenRect + ) + } + .onEnded { _ in + isWeatherDragging = false + } + } + + private func musicDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { value in + if !isMusicDragging { + isMusicDragging = true + musicStartOffset = musicOffset + } + + let proposed = musicStartOffset - Double(value.translation.height) + musicOffset = clampedOffset( + proposed, + baseCenterY: baseY, + panelHeight: panelSize.height, + screenRect: screenRect + ) + } + .onEnded { _ in + isMusicDragging = false + } + } + + private func timerDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { value in + if !isTimerDragging { + isTimerDragging = true + timerStartOffset = timerOffset + } + + let proposed = timerStartOffset - Double(value.translation.height) + timerOffset = clampedOffset( + proposed, + baseCenterY: baseY, + panelHeight: panelSize.height, + screenRect: screenRect + ) + } + .onEnded { _ in + isTimerDragging = false + } + } + + private func clampedOffset( + _ proposed: Double, + baseCenterY: CGFloat, + panelHeight: CGFloat, + screenRect: CGRect + ) -> Double { + let halfHeight = panelHeight / 2 + let minCenterY = screenRect.minY + halfHeight + let maxCenterY = screenRect.maxY - halfHeight + let proposedCenter = baseCenterY - CGFloat(proposed) + let clampedCenter = min(max(proposedCenter, minCenterY), maxCenterY) + let derivedOffset = Double(baseCenterY - clampedCenter) + return min(max(derivedOffset, offsetRange.lowerBound), offsetRange.upperBound) + } +} + +private func copyLatestCrashReport() { + let crashReportsPath = NSString(string: "~/Library/Logs/DiagnosticReports").expandingTildeInPath + let fileManager = FileManager.default + + do { + let files = try fileManager.contentsOfDirectory(atPath: crashReportsPath) + let crashFiles = files.filter { $0.contains("DynamicIsland") && $0.hasSuffix(".crash") } + + guard let latestCrash = crashFiles.sorted(by: >).first else { + let alert = NSAlert() + alert.messageText = "No Crash Reports Found" + alert.informativeText = "No crash reports found for DynamicIsland" + alert.alertStyle = .informational + alert.runModal() + return + } + + let crashPath = (crashReportsPath as NSString).appendingPathComponent(latestCrash) + let crashContent = try String(contentsOfFile: crashPath, encoding: .utf8) + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(crashContent, forType: .string) + + let alert = NSAlert() + alert.messageText = "Crash Report Copied" + alert.informativeText = "Crash report '\(latestCrash)' has been copied to clipboard" + alert.alertStyle = .informational + alert.runModal() + } catch { + let alert = NSAlert() + alert.messageText = "Error" + alert.informativeText = "Failed to read crash reports: \(error.localizedDescription)" + alert.alertStyle = .warning + alert.runModal() + } +} + +struct Shortcuts: View { + @Default(.enableTimerFeature) var enableTimerFeature + @Default(.enableClipboardManager) var enableClipboardManager + @Default(.enableShortcuts) var enableShortcuts + @Default(.enableStatsFeature) var enableStatsFeature + @Default(.enableColorPickerFeature) var enableColorPickerFeature + + private func highlightID(_ title: String) -> String { + SettingsTab.shortcuts.highlightID(for: title) + } + + var body: some View { + Form { + Section { + Defaults.Toggle(key: .enableShortcuts) { + Text("Enable global keyboard shortcuts") + } + .settingsHighlight(id: highlightID("Enable global keyboard shortcuts")) + } header: { + Text("General") + } footer: { + Text("When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + + if enableShortcuts { + Section { + KeyboardShortcuts.Recorder("Toggle Sneak Peek:", name: .toggleSneakPeek) + .disabled(!enableShortcuts) + } header: { + Text("Media") + } footer: { + Text("Sneak Peek shows the media title and artist under the notch for a few seconds.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + + Section { + KeyboardShortcuts.Recorder("Toggle Notch Open:", name: .toggleNotchOpen) + .disabled(!enableShortcuts) + } header: { + Text("Navigation") + } footer: { + Text("Toggle the Dynamic Island open or closed from anywhere.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + + Section { + HStack { + VStack(alignment: .leading) { + KeyboardShortcuts.Recorder("Start Demo Timer:", name: .startDemoTimer) + .disabled(!enableShortcuts || !enableTimerFeature) + if !enableTimerFeature { + Text("Timer feature is disabled") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + } + Spacer() + } + } header: { + Text("Timer") + } footer: { + Text("Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + + Section { + HStack { + VStack(alignment: .leading) { + KeyboardShortcuts.Recorder("Clipboard History:", name: .clipboardHistoryPanel) + .disabled(!enableShortcuts || !enableClipboardManager) + if !enableClipboardManager { + Text("Clipboard feature is disabled") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + } + Spacer() + } + } header: { + Text("Clipboard") + } footer: { + Text("Opens the clipboard history panel. Default is Cmd+Shift+V (similar to Windows+V on PC). Only works when clipboard feature is enabled.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + + Section { + HStack { + VStack(alignment: .leading) { + KeyboardShortcuts.Recorder("Screen Assistant:", name: .screenAssistantPanel) + .disabled(!enableShortcuts || !Defaults[.enableScreenAssistant]) + if !Defaults[.enableScreenAssistant] { + Text("Screen Assistant feature is disabled") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + } + Spacer() + } + } header: { + Text("AI Assistant") + } footer: { + Text("Opens the AI assistant panel for file analysis and conversation. Default is Cmd+Shift+A. Only works when screen assistant feature is enabled.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + + Section { + HStack { + VStack(alignment: .leading) { + KeyboardShortcuts.Recorder("Toggle Terminal Tab:", name: .toggleTerminalTab) + .disabled(!enableShortcuts || !Defaults[.enableTerminalFeature]) + if !Defaults[.enableTerminalFeature] { + Text("Terminal feature is disabled") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + } + Spacer() + } + } header: { + Text("Terminal") + } footer: { + Text("Opens the terminal tab in the notch. Default is Ctrl+`. Only works when terminal feature is enabled.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + + Section { + HStack { + VStack(alignment: .leading) { + KeyboardShortcuts.Recorder("Color Picker Panel:", name: .colorPickerPanel) + .disabled(!enableShortcuts || !enableColorPickerFeature) + if !enableColorPickerFeature { + Text("Color Picker feature is disabled") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + } + Spacer() + } + } header: { + Text("Color Picker") + } footer: { + Text("Opens the color picker panel for screen color capture. Default is Cmd+Shift+P. Only works when color picker feature is enabled.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + } else { + Section { + VStack(alignment: .leading, spacing: 8) { + Text("Keyboard shortcuts are disabled") + .font(.headline) + .foregroundStyle(.secondary) + + Text("Enable global keyboard shortcuts above to customize your shortcuts.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 8) + } + } + } + .navigationTitle("Shortcuts") + } +} + +func proFeatureBadge() -> some View { + Text("Upgrade to Pro") + .foregroundStyle(Color(red: 0.545, green: 0.196, blue: 0.98)) + .font(.footnote.bold()) + .padding(.vertical, 3) + .padding(.horizontal, 6) + .background(RoundedRectangle(cornerRadius: 4).stroke(Color(red: 0.545, green: 0.196, blue: 0.98), lineWidth: 1)) +} + +func comingSoonTag() -> some View { + Text("Coming soon") + .foregroundStyle(.secondary) + .font(.footnote.bold()) + .padding(.vertical, 3) + .padding(.horizontal, 6) + .background(Color(nsColor: .secondarySystemFill)) + .clipShape(.capsule) +} + +func customBadge(text: String) -> some View { + Text(text) + .foregroundStyle(.secondary) + .font(.footnote.bold()) + .padding(.vertical, 3) + .padding(.horizontal, 6) + .background(Color(nsColor: .secondarySystemFill)) + .clipShape(.capsule) +} + +func alphaBadge() -> some View { + Text("ALPHA") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Color.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color.orange.opacity(0.9)) + ) +} + +func warningBadge(_ text: String, _ description: String) -> some View { + Section { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 22)) + .foregroundStyle(.yellow) + VStack(alignment: .leading) { + Text(text) + .font(.headline) + Text(description) + .foregroundStyle(.secondary) + } + Spacer() + } + } +} + +struct TimerSettings: View { + @ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared + @Default(.enableTimerFeature) var enableTimerFeature + @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(.showTimerPresetsInNotchTab) private var showTimerPresetsInNotchTab + @Default(.timerControlWindowEnabled) private var controlWindowEnabled + @Default(.mirrorSystemTimer) private var mirrorSystemTimer + @Default(.timerDisplayMode) private var timerDisplayMode + @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget + @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass + @Default(.lockScreenTimerGlassStyle) private var lockScreenTimerGlassStyle + @Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode + @Default(.lockScreenTimerLiquidGlassVariant) private var lockScreenTimerLiquidGlassVariant + @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 + + private func highlightID(_ title: String) -> String { + SettingsTab.timer.highlightID(for: title) + } + + private var liquidVariantRange: ClosedRange { + Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound) + } + + private var timerVariantBinding: Binding { + Binding( + get: { Double(lockScreenTimerLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + lockScreenTimerLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } + + private var timerSurfaceBinding: Binding { + Binding( + get: { timerGlassModeIsGlass ? .glass : .classic }, + set: { mode in timerGlassModeIsGlass = (mode == .glass) } + ) + } + + var body: some View { + Form { + timerFeatureSection + + if enableTimerFeature { + timerConfigurationSections + } + } + .navigationTitle("Timer") + .onAppear { syncCustomDuration() } + .onChange(of: customTimerDuration) { _, newValue in syncCustomDuration(newValue) } + } + + @ViewBuilder + private var timerFeatureSection: some View { + Section { + Defaults.Toggle(key: .enableTimerFeature) { + Text("Enable timer feature") + } + .settingsHighlight(id: highlightID("Enable timer feature")) + + if enableTimerFeature { + Toggle("Enable timer live activity", isOn: $coordinator.timerLiveActivityEnabled) + .animation(.easeInOut, value: coordinator.timerLiveActivityEnabled) + Defaults.Toggle(key: .mirrorSystemTimer) { + HStack(spacing: 8) { + Text("Mirror macOS Clock timers") + alphaBadge() + } + } + .help("Shows the system Clock timer in the notch when available. Requires Accessibility permission to read the status item.") + .settingsHighlight(id: highlightID("Mirror macOS Clock timers")) + + Picker("Timer controls appear as", selection: $timerDisplayMode) { + ForEach(TimerDisplayMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .help(timerDisplayMode.description) + .settingsHighlight(id: highlightID("Timer controls appear as")) + } + } header: { + Text("Timer Feature") + } footer: { + Text("Control timer availability, live activity behaviour, and whether the app mirrors timers started from the macOS Clock app.") + } + } + + @ViewBuilder + private var timerConfigurationSections: some View { + Group { + lockScreenIntegrationSection + customTimerSection + appearanceSection + timerPresetsSection + timerSoundSection + } + .onAppear { + if showsLabel { + controlWindowEnabled = false + } + } + .onChange(of: showsLabel) { _, show in + if show { + controlWindowEnabled = false + } + } + } + + @ViewBuilder + private var lockScreenIntegrationSection: some View { + Section { + Defaults.Toggle(key: .enableLockScreenTimerWidget) { + Text("Show lock screen timer widget") + } + .settingsHighlight(id: highlightID("Show lock screen timer widget")) + Picker("Timer surface", selection: timerSurfaceBinding) { + ForEach(LockScreenTimerSurfaceMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Timer surface")) + + if timerGlassModeIsGlass { + Picker("Timer glass material", selection: $lockScreenTimerGlassStyle) { + ForEach(LockScreenGlassStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Timer glass material")) + + if lockScreenTimerGlassStyle == .liquid { + Picker("Timer liquid mode", selection: $lockScreenTimerGlassCustomizationMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Timer liquid mode")) + + if lockScreenTimerGlassCustomizationMode == .customLiquid { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Timer widget variant") + Spacer() + Text("v\(lockScreenTimerLiquidGlassVariant.rawValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + Slider(value: timerVariantBinding, in: liquidVariantRange, step: 1) + } + .settingsHighlight(id: highlightID("Timer widget variant")) + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.4) + } + } else { + Text("Uses the frosted blur treatment while glass mode is enabled.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + Text("Classic mode keeps the original translucent black background.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + } + } header: { + Text("Lock Screen Integration") + } footer: { + Text("Mirrors the toggle found under Lock Screen settings so timer-specific workflows can enable or disable the widget without switching tabs.") + } + } + + @ViewBuilder + private var customTimerSection: some View { + Section { + VStack(alignment: .leading, spacing: 12) { + Text("Default Custom Timer") + .font(.headline) + + TimerDurationStepperRow(title: String(localized: "Hours"), value: $customHours, range: 0...23) + TimerDurationStepperRow(title: String(localized: "Minutes"), value: $customMinutes, range: 0...59) + TimerDurationStepperRow(title: String(localized: "Seconds"), value: $customSeconds, range: 0...59) + + HStack { + Text("Current default:") + .foregroundStyle(.secondary) + Text(customDurationDisplay) + .font(.system(.body, design: .monospaced)) + .fontWeight(.medium) + Spacer() + } + } + .padding(.vertical, 4) + .onChange(of: customHours) { _, _ in updateCustomDuration() } + .onChange(of: customMinutes) { _, _ in updateCustomDuration() } + .onChange(of: customSeconds) { _, _ in updateCustomDuration() } + } header: { + Text("Custom Timer") + } footer: { + Text("This duration powers the \"Custom\" option inside the timer popover for quick access.") + } + } + + @ViewBuilder + private var appearanceSection: some View { + Section { + Picker("Timer tint", selection: $colorMode) { + ForEach(TimerIconColorMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .settingsHighlight(id: highlightID("Timer tint")) + + if colorMode == .solid { + ColorPicker("Solid colour", selection: $solidColor, supportsOpacity: false) + .settingsHighlight(id: highlightID("Solid colour")) + } + + Toggle("Show timer name", isOn: $showsLabel) + Toggle("Show countdown", isOn: $showsCountdown) + Toggle("Show progress", isOn: $showsProgress) + Toggle("Show preset list in timer tab", isOn: $showTimerPresetsInNotchTab) + .settingsHighlight(id: highlightID("Show preset list in timer tab")) + + 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) + .settingsHighlight(id: highlightID("Progress style")) + } 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.") + } + } + + @ViewBuilder + private var timerPresetsSection: some View { + 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 { + TimerPresetListView( + presets: $timerPresets, + highlightProvider: highlightID, + moveUp: movePresetUp, + moveDown: movePresetDown, + remove: removePreset + ) + } + + HStack { + 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) + } + } + } 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.") + } + } + + @ViewBuilder + private var timerSoundSection: some View { + 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.") + } + } + + 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 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 + } + } + + private func selectCustomTimerSound() { + let panel = NSOpenPanel() + panel.title = "Select Timer Sound" + panel.allowedContentTypes = [.audio] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + + if panel.runModal() == .OK { + if let url = panel.url { + UserDefaults.standard.set(url.path, forKey: "customTimerSoundPath") + } + } + } +} + +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 TimerPresetListView: View { + @Binding var presets: [TimerPreset] + let highlightProvider: (String) -> String + let moveUp: (Int) -> Void + let moveDown: (Int) -> Void + let remove: (Int) -> Void + + var body: some View { + ForEach(presets.indices, id: \.self) { index in + presetRow(at: index) + } + } + + @ViewBuilder + private func presetRow(at index: Int) -> some View { + TimerPresetEditorRow( + preset: $presets[index], + isFirst: index == presets.startIndex, + isLast: index == presets.index(before: presets.endIndex), + highlightID: highlightID(for: index), + moveUp: { moveUp(index) }, + moveDown: { moveDown(index) }, + remove: { remove(index) } + ) + } + + private func highlightID(for index: Int) -> String? { + index == presets.startIndex ? highlightProvider("Accent colour") : nil + } +} + +private struct TimerPresetEditorRow: View { + @Binding var preset: TimerPreset + let isFirst: Bool + let isLast: Bool + let moveUp: () -> Void + let moveDown: () -> Void + let remove: () -> Void + let highlightID: String? + + init( + preset: Binding, + isFirst: Bool, + isLast: Bool, + highlightID: String? = nil, + moveUp: @escaping () -> Void, + moveDown: @escaping () -> Void, + remove: @escaping () -> Void + ) { + _preset = preset + self.isFirst = isFirst + self.isLast = isLast + self.highlightID = highlightID + self.moveUp = moveUp + self.moveDown = moveDown + self.remove = remove + } + + 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: String(localized: "Hours"), value: hoursBinding, range: 0...23) + TimerPresetComponentControl(title: String(localized: "Minutes"), value: minutesBinding, range: 0...59) + TimerPresetComponentControl(title: String(localized: "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) + .settingsHighlightIfPresent(highlightID) + } + + 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 + @Default(.statsStopWhenNotchCloses) var statsStopWhenNotchCloses + @Default(.statsUpdateInterval) var statsUpdateInterval + @Default(.showCpuGraph) var showCpuGraph + @Default(.showMemoryGraph) var showMemoryGraph + @Default(.showGpuGraph) var showGpuGraph + @Default(.showNetworkGraph) var showNetworkGraph + @Default(.showDiskGraph) var showDiskGraph + @Default(.cpuTemperatureUnit) var cpuTemperatureUnit + + private func highlightID(_ title: String) -> String { + SettingsTab.stats.highlightID(for: title) + } + + var enabledGraphsCount: Int { + [showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph].filter { $0 }.count + } + + private var formattedUpdateInterval: String { + let seconds = Int(statsUpdateInterval.rounded()) + if seconds >= 60 { + return "60 s (1 min)" + } else if seconds == 1 { + return "1 s" + } else { + return "\(seconds) s" + } + } + + private var shouldShowStatsBatteryWarning: Bool { + !statsStopWhenNotchCloses && statsUpdateInterval <= 5 + } + + var body: some View { + Form { + Section { + Defaults.Toggle(key: .enableStatsFeature) { + Text("Enable system stats monitoring") + } + .settingsHighlight(id: highlightID("Enable system stats monitoring")) + .onChange(of: enableStatsFeature) { _, newValue in + if !newValue { + statsManager.stopMonitoring() + } + // Note: Smart monitoring will handle starting when switching to stats tab + } + + } header: { + Text("General") + } footer: { + Text("When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + + if enableStatsFeature { + Section { + Defaults.Toggle(key: .statsStopWhenNotchCloses) { + Text("Stop monitoring after closing the notch") + } + .settingsHighlight(id: highlightID("Stop monitoring after closing the notch")) + .help("When enabled, stats monitoring stops a few seconds after the notch closes.") + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Update interval") + Spacer() + Text(formattedUpdateInterval) + .foregroundStyle(.secondary) + } + + Slider(value: $statsUpdateInterval, in: 1...60, step: 1) + .accessibilityLabel("Stats update interval") + + Text("Controls how often system metrics refresh while monitoring is active.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if shouldShowStatsBatteryWarning { + Label { + Text("High-frequency updates without a timeout can increase battery usage.") + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.caption) + .foregroundStyle(.orange) + .padding(.top, 4) + } + } header: { + Text("Monitoring Behavior") + } footer: { + Text("Sampling can continue while the notch is closed when the timeout is disabled.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + + Section { + Defaults.Toggle(key: .showCpuGraph) { + Text("CPU Usage") + } + .settingsHighlight(id: highlightID("CPU Usage")) + + if showCpuGraph { + Picker("Temperature unit", selection: $cpuTemperatureUnit) { + ForEach(LockScreenWeatherTemperatureUnit.allCases) { unit in + Text(unit.rawValue).tag(unit) + } + } + .pickerStyle(.segmented) + .settingsHighlight(id: highlightID("Temperature unit")) + } + Defaults.Toggle(key: .showMemoryGraph) { + Text("Memory Usage") + } + .settingsHighlight(id: highlightID("Memory Usage")) + Defaults.Toggle(key: .showGpuGraph) { + Text("GPU Usage") + } + .settingsHighlight(id: highlightID("GPU Usage")) + Defaults.Toggle(key: .showNetworkGraph) { + Text("Network Activity") + } + .settingsHighlight(id: highlightID("Network Activity")) + Defaults.Toggle(key: .showDiskGraph) { + Text("Disk I/O") + } + .settingsHighlight(id: highlightID("Disk I/O")) + } header: { + Text("Graph Visibility") + } footer: { + if enabledGraphsCount >= 4 { + Text("With \(enabledGraphsCount) graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } else { + Text("Each graph can be individually enabled or disabled. Network activity shows download/upload speeds, and disk I/O shows read/write speeds.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + } + + Section { + HStack { + Text("Monitoring Status") + Spacer() + HStack(spacing: 4) { + Circle() + .fill(statsManager.isMonitoring ? .green : .red) + .frame(width: 8, height: 8) + Text(statsManager.isMonitoring ? "Active" : "Stopped") + .foregroundStyle(.secondary) + } + } + + if statsManager.isMonitoring { + if showCpuGraph { + HStack { + Text("CPU Usage") + Spacer() + Text(statsManager.cpuUsageString) + .foregroundStyle(.secondary) + } + } + + if showMemoryGraph { + HStack { + Text("Memory Usage") + Spacer() + Text(statsManager.memoryUsageString) + .foregroundStyle(.secondary) + } + } + + if showGpuGraph { + HStack { + Text("GPU Usage") + Spacer() + Text(statsManager.gpuUsageString) + .foregroundStyle(.secondary) + } + } + + if showNetworkGraph { + HStack { + Text("Network Download") + Spacer() + Text(String(format: "%.1f MB/s", statsManager.networkDownload)) + .foregroundStyle(.secondary) + } + + HStack { + Text("Network Upload") + Spacer() + Text(String(format: "%.1f MB/s", statsManager.networkUpload)) + .foregroundStyle(.secondary) + } + } + + if showDiskGraph { + HStack { + Text("Disk Read") + Spacer() + Text(String(format: "%.1f MB/s", statsManager.diskRead)) + .foregroundStyle(.secondary) + } + + HStack { + Text("Disk Write") + Spacer() + Text(String(format: "%.1f MB/s", statsManager.diskWrite)) + .foregroundStyle(.secondary) + } + } + + HStack { + Text("Last Updated") + Spacer() + Text(statsManager.lastUpdated, style: .relative) + .foregroundStyle(.secondary) + } + } + } header: { + Text("Live Performance Data") + } + + Section { + HStack { + Button(statsManager.isMonitoring ? "Stop Monitoring" : "Start Monitoring") { + if statsManager.isMonitoring { + statsManager.stopMonitoring() + } else { + statsManager.startMonitoring() + } + } + .buttonStyle(.borderedProminent) + .foregroundColor(statsManager.isMonitoring ? .red : .blue) + + Spacer() + + Button("Clear Data") { + statsManager.clearHistory() + } + .buttonStyle(.bordered) + .disabled(statsManager.isMonitoring) + } + } header: { + Text("Controls") + } + } + } + .navigationTitle("Stats") + } +} + +struct ClipboardSettings: View { + @ObservedObject var clipboardManager = ClipboardManager.shared + @Default(.enableClipboardManager) var enableClipboardManager + @Default(.clipboardHistorySize) var clipboardHistorySize + @Default(.showClipboardIcon) var showClipboardIcon + @Default(.clipboardDisplayMode) var clipboardDisplayMode + + private func highlightID(_ title: String) -> String { + SettingsTab.clipboard.highlightID(for: title) + } + + var body: some View { + Form { + Section { + Defaults.Toggle(key: .enableClipboardManager) { + Text("Enable Clipboard Manager") + } + .settingsHighlight(id: highlightID("Enable Clipboard Manager")) + .onChange(of: enableClipboardManager) { _, enabled in + if enabled { + clipboardManager.startMonitoring() + } else { + clipboardManager.stopMonitoring() + } + } + } header: { + Text("Clipboard Manager") + } footer: { + Text("Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history.") + } + + if enableClipboardManager { + Section { + Defaults.Toggle(key: .showClipboardIcon) { + Text("Show Clipboard Icon") + } + .settingsHighlight(id: highlightID("Show Clipboard Icon")) + + HStack { + Text("Display Mode") + Spacer() + Picker("", selection: $clipboardDisplayMode) { + ForEach(ClipboardDisplayMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.menu) + .frame(minWidth: 100) + } + .settingsHighlight(id: highlightID("Display Mode")) + + HStack { + Text("History Size") + Spacer() + Picker("", selection: $clipboardHistorySize) { + Text("3 items").tag(3) + Text("5 items").tag(5) + Text("7 items").tag(7) + Text("10 items").tag(10) + } + .pickerStyle(.menu) + .frame(minWidth: 100) + } + .settingsHighlight(id: highlightID("History Size")) + + HStack { + Text("Current Items") + Spacer() + Text("\(clipboardManager.clipboardHistory.count)") + .foregroundColor(.secondary) + } + + HStack { + Text("Pinned Items") + Spacer() + Text("\(clipboardManager.pinnedItems.count)") + .foregroundColor(.secondary) + } + + HStack { + Text("Monitoring Status") + Spacer() + Text(clipboardManager.isMonitoring ? "Active" : "Stopped") + .foregroundColor(clipboardManager.isMonitoring ? .green : .secondary) + } + } header: { + Text("Settings") + } footer: { + switch clipboardDisplayMode { + case .popover: + Text("Popover mode shows clipboard as a dropdown attached to the clipboard button.") + case .panel: + Text("Panel mode shows clipboard in a floating window near the notch.") + case .separateTab: + Text("Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left.") + } + } + + Section { + Button("Clear Clipboard History") { + clipboardManager.clearHistory() + } + .foregroundColor(.red) + .disabled(clipboardManager.clipboardHistory.isEmpty) + + Button("Clear Pinned Items") { + clipboardManager.pinnedItems.removeAll() + clipboardManager.savePinnedItemsToDefaults() + } + .foregroundColor(.red) + .disabled(clipboardManager.pinnedItems.isEmpty) + } header: { + Text("Actions") + } footer: { + Text("Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent.") + } + + if !clipboardManager.clipboardHistory.isEmpty { + Section { + ForEach(clipboardManager.clipboardHistory) { item in + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: item.type.icon) + .foregroundColor(.blue) + .frame(width: 16) + Text(item.type.displayName) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(timeAgoString(from: item.timestamp)) + .font(.caption2) + .foregroundColor(.secondary) + } + Text(item.preview) + .font(.system(.body, design: .monospaced)) + .lineLimit(2) + } + .padding(.vertical, 2) } + } header: { + Text("Current History") } } - .controlSize(.small) - .buttonStyle(PlainButtonStyle()) - .overlay { - if customVisualizers.isEmpty { - Text("No custom visualizer") - .foregroundStyle(Color(.secondaryLabelColor)) - .padding(.bottom, 22) - } + } + } + .formStyle(.grouped) + .navigationTitle("Clipboard") + .onAppear { + if enableClipboardManager && !clipboardManager.isMonitoring { + clipboardManager.startMonitoring() + } + } + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return "Just now" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "\(minutes)m ago" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "\(hours)h ago" + } else { + let days = Int(interval / 86400) + return "\(days)d ago" + } + } +} + +struct ScreenAssistantSettings: View { + @ObservedObject var screenAssistantManager = ScreenAssistantManager.shared + @Default(.enableScreenAssistant) var enableScreenAssistant + @Default(.screenAssistantDisplayMode) var screenAssistantDisplayMode + @Default(.geminiApiKey) var geminiApiKey + @State private var apiKeyText = "" + @State private var showingApiKey = false + + private func highlightID(_ title: String) -> String { + SettingsTab.screenAssistant.highlightID(for: title) + } + + var body: some View { + Form { + Section { + Defaults.Toggle(key: .enableScreenAssistant) { + Text("Enable Screen Assistant") } - .sheet(isPresented: $isPresented) { - VStack(alignment: .leading) { - Text("Add new visualizer") - .font(.largeTitle.bold()) - .padding(.vertical) - TextField("Name", text: $name) - TextField("Lottie JSON URL", text: $url) - HStack { - Text("Speed") - Spacer(minLength: 80) - Text("\(speed, specifier: "%.1f")s") - .multilineTextAlignment(.trailing) - .foregroundStyle(.secondary) - Slider(value: $speed, in: 0...2, step: 0.1) + .settingsHighlight(id: highlightID("Enable Screen Assistant")) + } header: { + Text("AI Assistant") + } footer: { + Text("AI-powered assistant that can analyze files, images, and provide conversational help. Use Cmd+Shift+A to quickly access the assistant.") + } + + if enableScreenAssistant { + Section { + HStack { + Text("Gemini API Key") + Spacer() + if geminiApiKey.isEmpty { + Text("Not Set") + .foregroundColor(.red) + } else { + Text("••••••••") + .foregroundColor(.green) } - .padding(.vertical) - HStack { - Button { - isPresented.toggle() - } label: { - Text("Cancel") - .frame(maxWidth: .infinity, alignment: .center) + + Button(showingApiKey ? "Hide" : (geminiApiKey.isEmpty ? "Set" : "Change")) { + if showingApiKey { + showingApiKey = false + if !apiKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Defaults[.geminiApiKey] = apiKeyText + } + apiKeyText = "" + } else { + showingApiKey = true + apiKeyText = geminiApiKey } - - Button { - let visualizer: CustomVisualizer = .init( - UUID: UUID(), - name: name, - url: URL(string: url)!, - speed: speed - ) - - if !customVisualizers.contains(visualizer) { - customVisualizers.append(visualizer) + } + } + + if showingApiKey { + VStack(alignment: .leading, spacing: 8) { + SecureField("Enter your Gemini API Key", text: $apiKeyText) + .textFieldStyle(.roundedBorder) + + Text("Get your free API key from Google AI Studio") + .font(.caption) + .foregroundColor(.secondary) + + HStack { + Button("Open Google AI Studio") { + NSWorkspace.shared.open(URL(string: "https://aistudio.google.com/app/apikey")!) } - - isPresented.toggle() - } label: { - Text("Add") - .frame(maxWidth: .infinity, alignment: .center) + .buttonStyle(.link) + + Spacer() + + Button("Save") { + Defaults[.geminiApiKey] = apiKeyText + showingApiKey = false + apiKeyText = "" + } + .disabled(apiKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - .buttonStyle(BorderedProminentButtonStyle()) } } - .textFieldStyle(RoundedBorderTextFieldStyle()) - .controlSize(.extraLarge) - .padding() + + HStack { + Text("Display Mode") + Spacer() + Picker("", selection: $screenAssistantDisplayMode) { + ForEach(ScreenAssistantDisplayMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.menu) + .frame(minWidth: 100) + } + .settingsHighlight(id: highlightID("Display Mode")) + + HStack { + Text("Attached Files") + Spacer() + Text("\(screenAssistantManager.attachedFiles.count)") + .foregroundColor(.secondary) + } + + HStack { + Text("Recording Status") + Spacer() + Text(screenAssistantManager.isRecording ? "Recording" : "Ready") + .foregroundColor(screenAssistantManager.isRecording ? .red : .secondary) + } + } header: { + Text("Configuration") + } footer: { + switch screenAssistantDisplayMode { + case .popover: + Text("Popover mode shows the assistant as a dropdown attached to the AI button. Panel mode shows the assistant in a floating window near the notch.") + case .panel: + Text("Panel mode shows the assistant in a floating window near the notch. Popover mode shows the assistant as a dropdown attached to the AI button.") + } } - } header: { - HStack(spacing: 0) { - Text("Custom vizualizers (Lottie)") - if !Defaults[.customVisualizers].isEmpty { - Text(" – \(Defaults[.customVisualizers].count)") - .foregroundStyle(.secondary) + + Section { + Button("Clear All Files") { + screenAssistantManager.clearAllFiles() + } + .foregroundColor(.red) + .disabled(screenAssistantManager.attachedFiles.isEmpty) + } header: { + Text("Actions") + } footer: { + Text("Clear all files removes all attached files and audio recordings. This action is permanent.") + } + + if !screenAssistantManager.attachedFiles.isEmpty { + Section { + ForEach(screenAssistantManager.attachedFiles) { file in + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: file.type.iconName) + .foregroundColor(.blue) + .frame(width: 16) + Text(file.type.displayName) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(timeAgoString(from: file.timestamp)) + .font(.caption2) + .foregroundColor(.secondary) + } + Text(file.name) + .font(.system(.body, design: .monospaced)) + .lineLimit(2) + } + .padding(.vertical, 2) + } + } header: { + Text("Attached Files") } } } - + } + .formStyle(.grouped) + .navigationTitle("Screen Assistant") + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return "Just now" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "\(minutes)m ago" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "\(hours)h ago" + } else { + let days = Int(interval / 86400) + return "\(days)d ago" + } + } +} + +struct ColorPickerSettings: View { + @ObservedObject var colorPickerManager = ColorPickerManager.shared + @Default(.enableColorPickerFeature) var enableColorPickerFeature + @Default(.showColorFormats) var showColorFormats + @Default(.colorPickerDisplayMode) var colorPickerDisplayMode + @Default(.colorHistorySize) var colorHistorySize + @Default(.showColorPickerIcon) var showColorPickerIcon + + private func highlightID(_ title: String) -> String { + SettingsTab.colorPicker.highlightID(for: title) + } + + var body: some View { + Form { Section { - Defaults.Toggle("Enable boring mirror", key: .showMirror) - .disabled(!checkVideoInput()) - Picker("Mirror shape", selection: $mirrorShape) { - Text("Circle") - .tag(MirrorShapeEnum.circle) - Text("Square") - .tag(MirrorShapeEnum.rectangle) + Defaults.Toggle(key: .enableColorPickerFeature) { + Text("Enable Color Picker") } - Defaults.Toggle("Show cool face animation while inactivity", key: .showNotHumanFace) + .settingsHighlight(id: highlightID("Enable Color Picker")) } header: { - HStack { - Text("Additional features") + Text("Color Picker") + } footer: { + Text("Enable screen color picking functionality. Use Cmd+Shift+P to quickly access the color picker.") + } + + if enableColorPickerFeature { + Section { + Defaults.Toggle(key: .showColorPickerIcon) { + Text("Show Color Picker Icon") + } + .settingsHighlight(id: highlightID("Show Color Picker Icon")) + + HStack { + Text("Display Mode") + Spacer() + Picker("", selection: $colorPickerDisplayMode) { + ForEach(ColorPickerDisplayMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.menu) + .frame(minWidth: 100) + } + .settingsHighlight(id: highlightID("Display Mode")) + + HStack { + Text("History Size") + Spacer() + Picker("", selection: $colorHistorySize) { + Text("5 colors").tag(5) + Text("10 colors").tag(10) + Text("15 colors").tag(15) + Text("20 colors").tag(20) + } + .pickerStyle(.menu) + .frame(minWidth: 100) + } + .settingsHighlight(id: highlightID("History Size")) + + Defaults.Toggle(key: .showColorFormats) { + Text("Show All Color Formats") + } + .settingsHighlight(id: highlightID("Show All Color Formats")) + + } header: { + Text("Settings") + } footer: { + switch colorPickerDisplayMode { + case .popover: + Text("Popover mode shows color picker as a dropdown attached to the color picker button. Panel mode shows color picker in a floating window.") + case .panel: + Text("Panel mode shows color picker in a floating window. Popover mode shows color picker as a dropdown attached to the color picker button.") + } + } + + Section { + HStack { + Text("Color History") + Spacer() + Text("\(colorPickerManager.colorHistory.count)") + .foregroundColor(.secondary) + } + + HStack { + Text("Picking Status") + Spacer() + Text(colorPickerManager.isPickingColor ? "Active" : "Ready") + .foregroundColor(colorPickerManager.isPickingColor ? .green : .secondary) + } + + Button("Show Color Picker Panel") { + ColorPickerPanelManager.shared.showColorPickerPanel() + } + .disabled(!enableColorPickerFeature) + + } header: { + Text("Status & Actions") + } + + Section { + Button("Clear Color History") { + colorPickerManager.clearHistory() + } + .foregroundColor(.red) + .disabled(colorPickerManager.colorHistory.isEmpty) + + Button("Start Color Picking") { + colorPickerManager.startColorPicking() + } + .disabled(!enableColorPickerFeature || colorPickerManager.isPickingColor) + + } header: { + Text("Quick Actions") + } footer: { + Text("Clear color history removes all picked colors. Start color picking begins screen color capture mode.") } } - - Section { - HStack { - ForEach(icons, id: \.self) { icon in + } + .navigationTitle("Color Picker") + } +} + +struct CustomOSDSettings: View { + @Default(.enableCustomOSD) var enableCustomOSD + @Default(.hasSeenOSDAlphaWarning) var hasSeenOSDAlphaWarning + @Default(.enableOSDVolume) var enableOSDVolume + @Default(.enableOSDBrightness) var enableOSDBrightness + @Default(.enableOSDKeyboardBacklight) var enableOSDKeyboardBacklight + @Default(.enableThirdPartyDDCIntegration) var enableThirdPartyDDCIntegration + @Default(.osdMaterial) var osdMaterial + @Default(.osdLiquidGlassCustomizationMode) var osdLiquidGlassCustomizationMode + @Default(.osdLiquidGlassVariant) var osdLiquidGlassVariant + @Default(.osdIconColorStyle) var osdIconColorStyle + @Default(.enableSystemHUD) var enableSystemHUD + @ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared + + @State private var showAlphaWarning = false + @State private var previewValue: CGFloat = 0.65 + @State private var previewType: SneakContentType = .volume + + private func highlightID(_ title: String) -> String { + SettingsTab.hudAndOSD.highlightID(for: title) + } + + private var hasAccessibilityPermission: Bool { + accessibilityPermission.isAuthorized + } + + private var availableOSDMaterials: [OSDMaterial] { + if #available(macOS 26.0, *) { + return OSDMaterial.allCases + } + return OSDMaterial.allCases.filter { $0 != .liquid } + } + + private var liquidVariantRange: ClosedRange { + Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound) + } + + private var osdLiquidVariantBinding: Binding { + Binding( + get: { Double(osdLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + osdLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } + + var body: some View { + Form { + if !hasAccessibilityPermission && !enableThirdPartyDDCIntegration { + Section { + SettingsPermissionCallout( + message: "Accessibility permission is needed to intercept system controls for the Custom OSD.", + requestAction: { accessibilityPermission.requestAuthorizationPrompt() }, + openSettingsAction: { accessibilityPermission.openSystemSettings() } + ) + } header: { + Text("Accessibility") + } + } + + if hasAccessibilityPermission || enableThirdPartyDDCIntegration { + Section { + Toggle("Volume OSD", isOn: $enableOSDVolume) + .settingsHighlight(id: highlightID("Volume OSD")) + Toggle("Brightness OSD", isOn: $enableOSDBrightness) + .settingsHighlight(id: highlightID("Brightness OSD")) + Toggle("Keyboard Backlight OSD", isOn: $enableOSDKeyboardBacklight) + .settingsHighlight(id: highlightID("Keyboard Backlight OSD")) + .disabled(enableThirdPartyDDCIntegration) + .help(enableThirdPartyDDCIntegration ? "Disabled while external display integration is active \u{2014} brightness keys are handled by the external app." : "") + } header: { + Text("Controls") + } footer: { + Text("Choose which system controls should display custom OSD windows.") + .foregroundStyle(.secondary) + .font(.caption) + } + + Section { + Picker("Material", selection: $osdMaterial) { + ForEach(availableOSDMaterials, id: \.self) { material in + Text(material.rawValue).tag(material) + } + } + .settingsHighlight(id: highlightID("Material")) + .onChange(of: osdMaterial) { _, _ in + previewValue = previewValue == 0.65 ? 0.651 : 0.65 + } + + if osdMaterial == .liquid { + if #available(macOS 26.0, *) { + Picker("Glass mode", selection: $osdLiquidGlassCustomizationMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + + if osdLiquidGlassCustomizationMode == .customLiquid { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Custom liquid variant") + Spacer() + Text("v\(osdLiquidGlassVariant.rawValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + Slider(value: osdLiquidVariantBinding, in: liquidVariantRange, step: 1) + } + } + } else { + Text("Custom Liquid is available on macOS 26 or later.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Picker("Icon & Progress Color", selection: $osdIconColorStyle) { + ForEach(OSDIconColorStyle.allCases, id: \.self) { style in + Text(style.rawValue).tag(style) + } + } + .settingsHighlight(id: highlightID("Icon & Progress Color")) + .onChange(of: osdIconColorStyle) { _, _ in + previewValue = previewValue == 0.65 ? 0.651 : 0.65 + } + } header: { + Text("Appearance") + } footer: { + VStack(alignment: .leading, spacing: 4) { + Text("Material Options:") + Text("• Frosted Glass: Translucent blur effect") + Text("• Liquid Glass: Modern glass effect (macOS 26+)") + Text("• Solid Dark/Light/Auto: Opaque backgrounds") + Text("") + Text("Color options control the icon and progress bar appearance. Auto adapts to system theme.") + } + .foregroundStyle(.secondary) + .font(.caption) + } + + Section { + HStack { Spacer() - VStack { - Image(icon) - .resizable() - .frame(width: 80, height: 80) - .background( - RoundedRectangle(cornerRadius: 20, style: .circular) - .strokeBorder( - icon == selectedIcon ? Color.accentColor : .clear, - lineWidth: 2.5 - ) - ) - - Text("Default") - .fontWeight(.medium) + VStack(spacing: 16) { + Text("Live Preview") .font(.caption) - .foregroundStyle(icon == selectedIcon ? .white : .secondary) - .padding(.horizontal, 10) - .padding(.vertical, 3) - .background( - Capsule() - .fill(icon == selectedIcon ? Color.accentColor : .clear) - ) - } - .onTapGesture { - withAnimation { - selectedIcon = icon + .foregroundStyle(.secondary) + + CustomOSDView( + type: .constant(previewType), + value: .constant(previewValue), + icon: .constant("") + ) + .frame(width: 200, height: 200) + + HStack(spacing: 8) { + Button("Volume") { + previewType = .volume + } + .buttonStyle(.bordered) + + Button("Brightness") { + previewType = .brightness + } + .buttonStyle(.bordered) + + Button("Backlight") { + previewType = .backlight + } + .buttonStyle(.bordered) } - NSApp.applicationIconImage = NSImage(named: icon) + .controlSize(.small) + + Slider(value: $previewValue, in: 0...1) + .frame(width: 160) } + .padding(.vertical, 12) Spacer() } - } - .disabled(true) - } header: { - HStack { - Text("App icon") - customBadge(text: "Coming soon") + } header: { + Text("Preview") + } footer: { + Text("Adjust settings above to see changes in real-time. The actual OSD appears at the bottom center of your screen.") + .foregroundStyle(.secondary) + .font(.caption) } } } - .navigationTitle("Appearance") + .navigationTitle("Custom OSD") + .onAppear { + accessibilityPermission.refreshStatus() + if #unavailable(macOS 26.0), osdMaterial == .liquid { + osdMaterial = .frosted + osdLiquidGlassCustomizationMode = .standard + } + } + .onChange(of: accessibilityPermission.isAuthorized) { _, granted in + if !granted { + enableCustomOSD = false + } + } } - - func checkVideoInput() -> Bool { - if let _ = AVCaptureDevice.default(for: .video) { - return true +} + +struct SettingsPermissionCallout: View { + let title: String + let message: String + let icon: String + let iconColor: Color + let requestButtonTitle: String + let openSettingsButtonTitle: String + let requestAction: () -> Void + let openSettingsAction: () -> Void + + init( + title: String = "Accessibility permission required", + message: String, + icon: String = "exclamationmark.triangle.fill", + iconColor: Color = .orange, + requestButtonTitle: String = "Request Access", + openSettingsButtonTitle: String = "Open Settings", + requestAction: @escaping () -> Void, + openSettingsAction: @escaping () -> Void + ) { + self.title = title + self.message = message + self.icon = icon + self.iconColor = iconColor + self.requestButtonTitle = requestButtonTitle + self.openSettingsButtonTitle = openSettingsButtonTitle + self.requestAction = requestAction + self.openSettingsAction = openSettingsAction + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label(title, systemImage: icon) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(iconColor) + + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Button(requestButtonTitle) { + requestAction() + } + .buttonStyle(.borderedProminent) + + Button(openSettingsButtonTitle) { + openSettingsAction() + } + .buttonStyle(.bordered) + } + .controlSize(.small) } - - return false + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } } -struct Shortcuts: View { +#Preview { + HUD() +} + +struct NotesSettingsView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + + private func highlightID(_ title: String) -> String { + SettingsTab.notes.highlightID(for: title) + } + var body: some View { Form { Section { - KeyboardShortcuts.Recorder("Toggle Sneak Peek:", name: .toggleSneakPeek) + Defaults.Toggle(key: .enableNotes) { + Text("Enable Notes") + } + if Defaults[.enableNotes] { + Defaults.Toggle(key: .enableNotePinning) { + Text("Enable Note Pinning") + } + Defaults.Toggle(key: .enableNoteSearch) { + Text("Enable Note Search") + } + Defaults.Toggle(key: .enableNoteColorFiltering) { + Text("Enable Color Filtering") + } + Defaults.Toggle(key: .enableCreateFromClipboard) { + Text("Enable Create from Clipboard") + } + Defaults.Toggle(key: .enableNoteCharCount) { + Text("Show Character Count") + } + } } header: { - Text("Media") + Text("General") } footer: { - Text("Sneak Peek shows the media title and artist under the notch for a few seconds.") - .multilineTextAlignment(.trailing) - .foregroundStyle(.secondary) + Text("Customize how you organize and create notes. Enabling color filtering and search helps manage large lists.") .font(.caption) + .foregroundStyle(.secondary) } + } + .navigationTitle("Notes") + } +} + +// MARK: - Terminal Settings + +struct TerminalSettings: View { + @ObservedObject var terminalManager = TerminalManager.shared + @Default(.enableTerminalFeature) var enableTerminalFeature + @Default(.terminalShellPath) var terminalShellPath + @Default(.terminalFontFamily) var terminalFontFamily + @Default(.terminalFontSize) var terminalFontSize + @Default(.terminalOpacity) var terminalOpacity + @Default(.terminalMaxHeightFraction) var terminalMaxHeightFraction + @Default(.terminalCursorStyle) var terminalCursorStyle + @Default(.terminalScrollbackLines) var terminalScrollbackLines + @Default(.terminalOptionAsMeta) var terminalOptionAsMeta + @Default(.terminalMouseReporting) var terminalMouseReporting + @Default(.terminalBoldAsBright) var terminalBoldAsBright + @Default(.terminalBackgroundColor) var terminalBackgroundColor + @Default(.terminalForegroundColor) var terminalForegroundColor + @Default(.terminalCursorColor) var terminalCursorColor + + private func highlightID(_ title: String) -> String { + SettingsTab.terminal.highlightID(for: title) + } + + private var formattedMaxHeight: String { + "\(Int(terminalMaxHeightFraction * 100))% of screen" + } + + /// All monospaced font families available on the system. + private var monospacedFontFamilies: [String] { + NSFontManager.shared.availableFontFamilies.filter { family in + guard let font = NSFont(name: family, size: 12) else { return false } + return font.isFixedPitch + || font.fontDescriptor.symbolicTraits.contains(.monoSpace) + } + .sorted() + } + + /// Display name for the font picker — shows "System Monospaced" when no custom font is set. + private var fontDisplayName: String { + terminalFontFamily.isEmpty ? "System Monospaced" : terminalFontFamily + } + + private var cursorStyleBinding: Binding { + Binding( + get: { TerminalCursorStyleOption(rawValue: terminalCursorStyle) ?? .blinkBlock }, + set: { terminalCursorStyle = $0.rawValue } + ) + } + + var body: some View { + Form { Section { - KeyboardShortcuts.Recorder("Toggle Notch Open:", name: .toggleNotchOpen) - } - Section { - KeyboardShortcuts.Recorder("Start Demo Timer:", name: .startDemoTimer) + Defaults.Toggle(key: .enableTerminalFeature) { + Text("Enable terminal") + } + .settingsHighlight(id: highlightID("Enable terminal")) + + if enableTerminalFeature { + Defaults.Toggle(key: .terminalStickyMode) { + VStack(alignment: .leading, spacing: 2) { + Text("Keep terminal open until clicked outside") + Text("Prevents the terminal from closing when the cursor accidentally leaves the notch area.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .settingsHighlight(id: highlightID("Keep terminal open")) + } } header: { - Text("Timer") + Text("General") } footer: { - Text("Starts a 5-minute demo timer to test the timer live activity feature.") - .multilineTextAlignment(.trailing) - .foregroundStyle(.secondary) + Text("Adds a Guake-style dropdown terminal tab. The terminal session persists across notch open/close cycles.") .font(.caption) + .foregroundStyle(.secondary) + } + + if enableTerminalFeature { + // MARK: Shell + Section { + HStack { + Text("Shell path") + Spacer() + TextField("", text: $terminalShellPath) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + .multilineTextAlignment(.trailing) + } + .settingsHighlight(id: highlightID("Shell path")) + } header: { + Text("Shell") + } + + // MARK: Appearance + Section { + Picker("Font family", selection: $terminalFontFamily) { + Text("System Monospaced").tag("") + Divider() + ForEach(monospacedFontFamilies, id: \.self) { family in + Text(family) + .font(.custom(family, size: 13)) + .tag(family) + } + } + .onChange(of: terminalFontFamily) { _, newValue in + terminalManager.applyFontFamily(newValue) + } + .settingsHighlight(id: highlightID("Font family")) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Font size") + Spacer() + Text("\(Int(terminalFontSize)) pt") + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider(value: $terminalFontSize, in: 8...24, step: 1) + .onChange(of: terminalFontSize) { _, newValue in + terminalManager.applyFontSize(newValue) + } + } + .settingsHighlight(id: highlightID("Font size")) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Terminal opacity") + Spacer() + Text("\(Int(terminalOpacity * 100))%") + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider(value: $terminalOpacity, in: 0.3...1.0, step: 0.05) + .onChange(of: terminalOpacity) { _, newValue in + terminalManager.applyOpacity(newValue) + } + } + .settingsHighlight(id: highlightID("Terminal opacity")) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Maximum height") + Spacer() + Text(formattedMaxHeight) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider(value: $terminalMaxHeightFraction, in: 0.2...0.5, step: 0.05) + } + .settingsHighlight(id: highlightID("Maximum height")) + } header: { + Text("Appearance") + } footer: { + Text("Terminal opacity only affects the terminal backdrop; text stays fully opaque. Blur uses the system material behind the window.") + .font(.caption) + .foregroundStyle(.secondary) + } + + // MARK: Colors + Section { + ColorPicker("Background", selection: $terminalBackgroundColor, supportsOpacity: false) + .onChange(of: terminalBackgroundColor) { _, newValue in + terminalManager.applyBackgroundColor(newValue) + } + .settingsHighlight(id: highlightID("Background color")) + + ColorPicker("Foreground", selection: $terminalForegroundColor, supportsOpacity: false) + .onChange(of: terminalForegroundColor) { _, newValue in + terminalManager.applyForegroundColor(newValue) + } + .settingsHighlight(id: highlightID("Foreground color")) + + ColorPicker("Cursor", selection: $terminalCursorColor, supportsOpacity: false) + .onChange(of: terminalCursorColor) { _, newValue in + terminalManager.applyCursorColor(newValue) + } + .settingsHighlight(id: highlightID("Cursor color")) + + Toggle("Bold text as bright colors", isOn: $terminalBoldAsBright) + .onChange(of: terminalBoldAsBright) { _, newValue in + terminalManager.applyBoldAsBright(newValue) + } + .settingsHighlight(id: highlightID("Bold as bright")) + } header: { + Text("Colors") + } footer: { + Text("When bold-as-bright is off, bold text uses a heavier font weight instead of bright ANSI colors.") + .font(.caption) + .foregroundStyle(.secondary) + } + + // MARK: Cursor + Section { + Picker("Cursor style", selection: cursorStyleBinding) { + ForEach(TerminalCursorStyleOption.allCases, id: \.self) { style in + Text(style.displayName).tag(style) + } + } + .onChange(of: terminalCursorStyle) { _, newValue in + if let style = TerminalCursorStyleOption(rawValue: newValue) { + terminalManager.applyCursorStyle(style) + } + } + .settingsHighlight(id: highlightID("Cursor style")) + } header: { + Text("Cursor") + } + + // MARK: Scrollback + Section { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Scrollback lines") + Spacer() + Text("\(terminalScrollbackLines)") + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider( + value: Binding( + get: { Double(terminalScrollbackLines) }, + set: { terminalScrollbackLines = Int($0) } + ), + in: 100...10000, + step: 100 + ) + .onChange(of: terminalScrollbackLines) { _, newValue in + terminalManager.applyScrollback(newValue) + } + } + .settingsHighlight(id: highlightID("Scrollback lines")) + } header: { + Text("Scrollback") + } footer: { + Text("Number of lines kept in the scrollback buffer. Higher values use more memory.") + .font(.caption) + .foregroundStyle(.secondary) + } + + // MARK: Input + Section { + Toggle("Option as Meta key", isOn: $terminalOptionAsMeta) + .onChange(of: terminalOptionAsMeta) { _, newValue in + terminalManager.applyOptionAsMeta(newValue) + } + .settingsHighlight(id: highlightID("Option as Meta")) + + Toggle("Allow mouse reporting", isOn: $terminalMouseReporting) + .onChange(of: terminalMouseReporting) { _, newValue in + terminalManager.applyMouseReporting(newValue) + } + .settingsHighlight(id: highlightID("Mouse reporting")) + } header: { + Text("Input") + } footer: { + Text("Option as Meta sends Esc+key instead of macOS special characters. Mouse reporting forwards mouse events to terminal applications like vim or tmux.") + .font(.caption) + .foregroundStyle(.secondary) + } + + // MARK: Actions + Section { + Button("Restart Shell") { + terminalManager.restartShell() + } + .disabled(!terminalManager.isProcessRunning) + } header: { + Text("Actions") + } footer: { + Text("Restarts the shell process. Any unsaved work in the terminal will be lost.") + .font(.caption) + .foregroundStyle(.secondary) + } } } - .navigationTitle("Shortcuts") + .navigationTitle("Terminal") } } -func proFeatureBadge() -> some View { - Text("Upgrade to Pro") - .foregroundStyle(Color(red: 0.545, green: 0.196, blue: 0.98)) - .font(.footnote.bold()) - .padding(.vertical, 3) - .padding(.horizontal, 6) - .background(RoundedRectangle(cornerRadius: 4).stroke(Color(red: 0.545, green: 0.196, blue: 0.98), lineWidth: 1)) -} +// MARK: - Reusable App Icon View -func comingSoonTag() -> some View { - Text("Coming soon") - .foregroundStyle(.secondary) - .font(.footnote.bold()) - .padding(.vertical, 3) - .padding(.horizontal, 6) - .background(Color(nsColor: .secondarySystemFill)) - .clipShape(.capsule) -} +/// Fetches the real app icon from the system using bundle identifiers, +/// falling back to an asset catalog image or an SF Symbol. +struct AppIconImage: View { + let bundleIdentifiers: [String] + var assetFallback: String? = nil + var symbolFallback: String = "app.fill" + var symbolColor: Color = .accentColor + var size: CGFloat = 16 -func customBadge(text: String) -> some View { - Text(text) - .foregroundStyle(.secondary) - .font(.footnote.bold()) - .padding(.vertical, 3) - .padding(.horizontal, 6) - .background(Color(nsColor: .secondarySystemFill)) - .clipShape(.capsule) + var body: some View { + Group { + if let nsImage = resolvedIcon() { + Image(nsImage: nsImage) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: size * 0.2)) + } else if let assetFallback, let nsImage = NSImage(named: NSImage.Name(assetFallback)) { + Image(nsImage: nsImage) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: size * 0.2)) + } else { + Image(systemName: symbolFallback) + .foregroundColor(symbolColor) + } + } + .frame(width: size, height: size) + } + + private func resolvedIcon() -> NSImage? { + for bundleID in bundleIdentifiers { + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + let icon = NSWorkspace.shared.icon(forFile: appURL.path) + // NSWorkspace returns a valid icon even for generic apps; + // resize to keep memory low. + let thumb = NSImage(size: NSSize(width: 32, height: 32)) + thumb.lockFocus() + icon.draw(in: NSRect(origin: .zero, size: NSSize(width: 32, height: 32)), + from: NSRect(origin: .zero, size: icon.size), + operation: .copy, fraction: 1.0) + thumb.unlockFocus() + return thumb + } + } + return nil + } } -func warningBadge(_ text: String, _ description: String) -> some View { - Section { - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 22)) - .foregroundStyle(.yellow) - VStack(alignment: .leading) { - Text(text) - .font(.headline) - Text(description) - .foregroundStyle(.secondary) +private struct QuickShareProviderIconImage: View { + let provider: QuickShareProvider + var size: CGFloat = 16 + + var body: some View { + Group { + if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) { + Image(nsImage: nsImg) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: size * 0.2)) + } else { + AppIconImage( + bundleIdentifiers: provider.bundleIdentifiersFallback, + assetFallback: provider.assetFallbackName, + symbolFallback: provider.symbolFallbackName, + symbolColor: .accentColor, + size: size + ) } - Spacer() } + .frame(width: size, height: size) } } -#Preview { - HUD() +private extension QuickShareProvider { + var bundleIdentifiersFallback: [String] { + switch id { + case "LocalSend": + return ["org.localsend.localsend_app", "org.localsend.localsend"] + case "AirDrop": + return ["com.apple.finder"] + case "Mail": + return ["com.apple.mail"] + case "Messages": + return ["com.apple.MobileSMS", "com.apple.iChat"] + case "Notes": + return ["com.apple.Notes"] + case "Reminders": + return ["com.apple.reminders"] + case "Add to Safari Reading List": + return ["com.apple.Safari"] + default: + return [] + } + } + + var assetFallbackName: String? { + id == "LocalSend" ? "LocalSend" : nil + } + + var symbolFallbackName: String { + id == "System Share Menu" ? "square.and.arrow.up.on.square" : "square.and.arrow.up" + } } diff --git a/DynamicIsland/components/Settings/SettingsWindowController.swift b/DynamicIsland/components/Settings/SettingsWindowController.swift index c7e99dae..ca37d37f 100644 --- a/DynamicIsland/components/Settings/SettingsWindowController.swift +++ b/DynamicIsland/components/Settings/SettingsWindowController.swift @@ -1,13 +1,27 @@ -// -// SettingsWindowController.swift -// DynamicIsland -// -// Created by Alexander on 2025-06-14. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import AppKit import SwiftUI -import Defaults import Sparkle class SettingsWindowController: NSWindowController { @@ -40,14 +54,15 @@ class SettingsWindowController: NSWindowController { private func setupWindow() { guard let window = window else { return } - window.title = "Dynamic Island Settings" + window.title = "Atoll Settings" window.titlebarAppearsTransparent = false window.titleVisibility = .visible window.toolbarStyle = .unified window.isMovableByWindowBackground = true + window.level = .normal // Make it behave like a regular app window with proper Spaces support - window.collectionBehavior = [.managed, .participatesInCycle, .fullScreenAuxiliary] + window.collectionBehavior = [.managed, .participatesInCycle] // Ensure proper window behavior window.hidesOnDeactivate = false @@ -64,11 +79,17 @@ class SettingsWindowController: NSWindowController { // Handle window closing window.delegate = self + + ScreenCaptureVisibilityManager.shared.register(window, scope: .panelsOnly) } func showWindow() { - // Set app to regular mode first - NSApp.setActivationPolicy(.regular) + // Ensure window exists + _ = window + + // Reassert regular window semantics in case any prior state mutated this window. + window?.level = .normal + window?.collectionBehavior = [.managed, .participatesInCycle] // If window is already visible, bring it to front properly if window?.isVisible == true { @@ -103,6 +124,12 @@ class SettingsWindowController: NSWindowController { // Set app back to accessory mode immediately NSApp.setActivationPolicy(.accessory) } + + deinit { + if let window = window { + ScreenCaptureVisibilityManager.shared.unregister(window) + } + } } extension SettingsWindowController: NSWindowDelegate { diff --git a/DynamicIsland/components/Settings/SoftwareUpdater.swift b/DynamicIsland/components/Settings/SoftwareUpdater.swift index 22aabc16..584cbcd5 100644 --- a/DynamicIsland/components/Settings/SoftwareUpdater.swift +++ b/DynamicIsland/components/Settings/SoftwareUpdater.swift @@ -1,9 +1,24 @@ -// -// SoftwareUpdater.swift -// DynamicIsland -// -// Created by Richard Kunkli on 09/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import Sparkle diff --git a/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift new file mode 100644 index 00000000..890e14a2 --- /dev/null +++ b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift @@ -0,0 +1,129 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Defaults +import SwiftUI + +struct SpotifyAuthSettingsSection: View { + @Default(.spotifySPDCCookie) private var spotifySPDCCookie + @ObservedObject private var spotifyAuthManager = SpotifyAuthManager.shared + @State private var showingLoginSheet = false + + private var hasCookie: Bool { + !SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie).isEmpty + } + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text("Sign in to Spotify to capture the `sp_dc` cookie automatically, or paste it in below.") + .font(.caption) + .foregroundStyle(.secondary) + + Button { + showingLoginSheet = true + } label: { + Label("Sign in with Spotify", systemImage: "person.crop.circle.badge.checkmark") + } + .buttonStyle(.borderedProminent) + + TextField("sp_dc cookie", text: $spotifySPDCCookie, axis: .vertical) + .textFieldStyle(.roundedBorder) + .font(.caption.monospaced()) + .lineLimit(2...4) + .textSelection(.enabled) + } + + HStack(spacing: 10) { + Circle() + .fill(spotifyAuthManager.isAuthenticated ? Color.green : (hasCookie ? Color.orange : Color.secondary)) + .frame(width: 8, height: 8) + + Text(spotifyAuthManager.sessionStatusText) + .foregroundStyle(.secondary) + } + + if let authErrorMessage = spotifyAuthManager.authErrorMessage, !authErrorMessage.isEmpty { + Text(authErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Button("Paste from Clipboard") { + pasteCookieFromClipboard() + } + + Button(spotifyAuthManager.isAuthorizing ? "Validating..." : "Validate Cookie") { + spotifySPDCCookie = SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie) + Task { + await spotifyAuthManager.validateSession() + } + } + .disabled(!hasCookie || spotifyAuthManager.isAuthorizing) + + Button("Clear") { + spotifySPDCCookie = "" + spotifyAuthManager.clearSession() + } + .disabled(!hasCookie && !spotifyAuthManager.isAuthenticated) + } + + DisclosureGroup("Get the cookie manually") { + VStack(alignment: .leading, spacing: 6) { + Text("1. Open Spotify in a browser and log in") + .font(.caption) + Text("2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com") + .font(.caption) + Text("3. Copy the value of `sp_dc` and paste it here") + .font(.caption) + + HStack(spacing: 12) { + Link("Open Spotify Web Player", destination: URL(string: "https://open.spotify.com")!) + Link("Method source", destination: URL(string: "https://github.com/Paxsenix0/Spotify-Canvas-API")!) + } + .font(.caption) + } + .padding(.top, 4) + } + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("Spotify Canvas Session") + } footer: { + Text("Atoll uses the local `sp_dc` cookie only to request Spotify's internal web-player token and fetch the matching Canvas for the current track.") + .foregroundStyle(.secondary) + .font(.caption) + } + .sheet(isPresented: $showingLoginSheet) { + SpotifyLoginSheet { capturedValue in + let sanitized = SpotifyAuthManager.sanitizeCookie(capturedValue) + spotifySPDCCookie = sanitized + Task { + await spotifyAuthManager.validateSession() + } + } + } + } + + private func pasteCookieFromClipboard() { + guard let clipboardText = NSPasteboard.general.string(forType: .string) else { return } + spotifySPDCCookie = SpotifyAuthManager.sanitizeCookie(clipboardText) + } +} diff --git a/DynamicIsland/components/Settings/SpotifyLoginSheet.swift b/DynamicIsland/components/Settings/SpotifyLoginSheet.swift new file mode 100644 index 00000000..7eecc8d7 --- /dev/null +++ b/DynamicIsland/components/Settings/SpotifyLoginSheet.swift @@ -0,0 +1,272 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import os +import SwiftUI +@preconcurrency import WebKit + +private enum SpotifyLoginConstants { + static let loginURL = URL(string: "https://accounts.spotify.com/en/login")! + static let externalLoginURL = URL(string: "https://open.spotify.com/")! + static let cookieInstructionsURL = URL( + string: "https://github.com/Paxsenix0/Spotify-Canvas-API#how-to-get-sp_dc-cookie" + )! + static let cookieDomainSuffix = "spotify.com" + static let cookieName = "sp_dc" + static let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" + static let blockedOAuthHostSuffixes = ["accounts.google.com", "accounts.youtube.com"] + static let googleSignInRedirectURL = URL(string: "https://accounts.spotify.com/en/login")! +} + +private let spotifyLoginLogger = os.Logger(subsystem: "com.Ebullioscopic.Atoll", category: "SpotifyLogin") + +struct SpotifyLoginSheet: View { + @Environment(\.dismiss) private var dismiss + + let onCapture: (String) -> Void + + @State private var statusText: String = + "Sign in with email & password, Apple, or Facebook. Google blocks in-app sign-in — use “Open in Browser” for Google accounts." + @State private var didCapture = false + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Connect Spotify") + .font(.headline) + Text(statusText) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + NSWorkspace.shared.open(SpotifyLoginConstants.externalLoginURL) + } label: { + Label("Open in Browser", systemImage: "safari") + } + .help("Spotify often blocks in-app logins. Sign in via your normal browser, then paste sp_dc in Settings.") + Button("Reset Session") { + NotificationCenter.default.post(name: .spotifyLoginSheetReset, object: nil) + statusText = "Session cleared. Sign in again to capture the cookie." + } + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + } + .padding(12) + Divider() + SpotifyLoginWebView { value in + guard !didCapture else { return } + didCapture = true + statusText = "Captured sp_dc cookie. Validating…" + onCapture(value) + dismiss() + } onStatus: { message in + statusText = message + } + } + .frame(minWidth: 520, minHeight: 640) + } +} + +extension Notification.Name { + static let spotifyLoginSheetReset = Notification.Name("SpotifyLoginSheetReset") +} + +struct SpotifyLoginWebView: NSViewRepresentable { + let onCapture: (String) -> Void + let onStatus: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onCapture: onCapture, onStatus: onStatus) + } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.preferences.javaScriptCanOpenWindowsAutomatically = false + + let webView = FirstClickWebView(frame: .zero, configuration: configuration) + webView.customUserAgent = SpotifyLoginConstants.userAgent + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + webView.allowsBackForwardNavigationGestures = true + + context.coordinator.attach(webView: webView) + webView.load(URLRequest(url: SpotifyLoginConstants.loginURL)) + DispatchQueue.main.async { [weak webView] in + webView?.window?.makeFirstResponder(webView) + } + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) {} + + static func dismantleNSView(_ nsView: WKWebView, coordinator: Coordinator) { + coordinator.detach() + } + + private final class FirstClickWebView: WKWebView { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + } + + @MainActor + final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKHTTPCookieStoreObserver { + private let onCapture: (String) -> Void + private let onStatus: (String) -> Void + private weak var webView: WKWebView? + private var resetObserver: NSObjectProtocol? + + init(onCapture: @escaping (String) -> Void, onStatus: @escaping (String) -> Void) { + self.onCapture = onCapture + self.onStatus = onStatus + super.init() + resetObserver = NotificationCenter.default.addObserver( + forName: .spotifyLoginSheetReset, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.resetSession() } + } + } + + deinit { + if let resetObserver { + NotificationCenter.default.removeObserver(resetObserver) + } + } + + func attach(webView: WKWebView) { + self.webView = webView + webView.configuration.websiteDataStore.httpCookieStore.add(self) + } + + func detach() { + webView?.configuration.websiteDataStore.httpCookieStore.remove(self) + webView = nil + } + + private func resetSession() { + guard let webView else { return } + let store = webView.configuration.websiteDataStore + let types = WKWebsiteDataStore.allWebsiteDataTypes() + store.removeData(ofTypes: types, modifiedSince: .distantPast) { [weak self] in + Task { @MainActor in + self?.webView?.load(URLRequest(url: SpotifyLoginConstants.loginURL)) + } + } + } + + func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { + cookieStore.getAllCookies { [weak self] cookies in + Task { @MainActor in + self?.handle(cookies: cookies) + } + } + } + + // MARK: - WKNavigationDelegate + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + if let url = navigationAction.request.url, Self.isBlockedOAuthHost(url.host) { + decisionHandler(.cancel) + spotifyLoginLogger.debug( + "Blocked embedded OAuth navigation to host=\(url.host ?? "", privacy: .public)" + ) + onStatus( + "Google blocks in-app sign-in. Use email & password, Apple, or tap “Open in Browser” to sign in there and paste sp_dc in Settings." + ) + webView.load(URLRequest(url: SpotifyLoginConstants.googleSignInRedirectURL)) + return + } + decisionHandler(.allow) + } + + private static func isBlockedOAuthHost(_ host: String?) -> Bool { + guard let host else { return false } + return SpotifyLoginConstants.blockedOAuthHostSuffixes.contains { host.hasSuffix($0) } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + let host = webView.url?.host ?? "" + spotifyLoginLogger.debug("didFinish host=\(host, privacy: .public)") + if host.contains("accounts.spotify.com") { + onStatus("Sign in to your Spotify account.") + } else if host.contains("open.spotify.com") { + onStatus("Open Spotify shouldn't load here — capture should fire before this. Falling back.") + } else if host.contains("www.spotify.com") || host.contains("spotify.com") { + onStatus("Logged in. Finishing capture…") + } + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { [weak self] cookies in + let names = cookies + .filter { $0.domain.hasSuffix("spotify.com") } + .map { "\($0.name)@\($0.domain)" } + .joined(separator: ", ") + spotifyLoginLogger.debug("cookies after load: \(names, privacy: .public)") + Task { @MainActor in + self?.handle(cookies: cookies) + } + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + onStatus("Load failed: \(error.localizedDescription)") + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + onStatus("Load failed: \(error.localizedDescription)") + } + + // Allow pop-ups (e.g. SSO providers) to load inline in the same webview. + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if navigationAction.targetFrame == nil, let url = navigationAction.request.url { + if Self.isBlockedOAuthHost(url.host) { + onStatus( + "Google blocks in-app sign-in. Use email & password, Apple, or tap “Open in Browser” to sign in there and paste sp_dc in Settings." + ) + return nil + } + webView.load(URLRequest(url: url)) + } + return nil + } + + // MARK: - Capture + + private func handle(cookies: [HTTPCookie]) { + let match = cookies.first { cookie in + cookie.name == SpotifyLoginConstants.cookieName + && cookie.domain.hasSuffix(SpotifyLoginConstants.cookieDomainSuffix) + } + guard let match, !match.value.isEmpty else { return } + onCapture(match.value) + } + } +} diff --git a/DynamicIsland/components/Shelf/AirDrop.swift b/DynamicIsland/components/Shelf/AirDrop.swift index a46f7c53..f506a4af 100644 --- a/DynamicIsland/components/Shelf/AirDrop.swift +++ b/DynamicIsland/components/Shelf/AirDrop.swift @@ -1,9 +1,20 @@ -// -// AirDrop.swift -// NotchDrop -// -// Created by 秋星桥 on 2024/7/7. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Cocoa diff --git a/DynamicIsland/components/Shelf/AirDropView.swift b/DynamicIsland/components/Shelf/AirDropView.swift index d6e3b2dd..a65bc3a4 100644 --- a/DynamicIsland/components/Shelf/AirDropView.swift +++ b/DynamicIsland/components/Shelf/AirDropView.swift @@ -1,10 +1,23 @@ -// -// AirDrop+View.swift -// NotchDrop -// -// Created by 秋星桥 on 2024/7/8. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import AppKit +import Defaults import SwiftUI import UniformTypeIdentifiers @@ -13,6 +26,10 @@ struct AirDropView: View { @State var trigger: UUID = .init() @State var targeting = false + @Default(.quickShareProvider) var quickShareProvider + @StateObject private var quickShareService = QuickShareService.shared + @State private var showShareSettings = false + @State private var isSwitchHover = false var body: some View { dropArea @@ -29,7 +46,103 @@ struct AirDropView: View { .fill(.white.opacity(0.1)) .opacity(0.5) .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay { dropLabel } + .overlay { + ZStack { + dropLabel + + // Switch button in the top-right for quick-share settings + VStack { + HStack { + Spacer() + Button { + quickShareService.ensureDiscovered() + showShareSettings.toggle() + } label: { + Image(systemName: "switch.2") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSwitchHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear) + ) + .foregroundColor(isSwitchHover ? .accentColor : .gray) + .contentShape(Rectangle()) + .onHover { over in isSwitchHover = over } + .help("Quick Share settings") + } + .buttonStyle(.plain) + .popover(isPresented: $showShareSettings) { + VStack(alignment: .leading, spacing: 8) { + Text("Quick Share") + .font(.headline) + + Picker("Quick Share Service", selection: $quickShareProvider) { + ForEach(quickShareService.availableProviders, id: \.id) { provider in + HStack(spacing: 8) { + Group { + if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) { + Image(nsImage: nsImg) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } else { + Image(systemName: "square.and.arrow.up") + .frame(width: 16, height: 16) + } + } + .foregroundColor(.accentColor) + + Text(provider.id) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + .tag(provider.id) + } + } + .pickerStyle(.menu) + .frame(minWidth: 260) + + if let selected = quickShareService.availableProviders.first(where: { $0.id == quickShareProvider }) { + HStack(alignment: .top, spacing: 8) { + Group { + if let imgData = selected.imageData, let nsImg = NSImage(data: imgData) { + Image(nsImage: nsImg) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + Image(systemName: "square.and.arrow.up") + .frame(width: 20, height: 20) + } + } + .foregroundColor(.accentColor) + + VStack(alignment: .leading, spacing: 2) { + Text("Currently: \(selected.id)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + Text("Files dropped here will be shared via this service") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + .padding() + } + .padding(.trailing, 8) + .padding(.top, 8) + } + Spacer() + } + } + } .aspectRatio(1, contentMode: .fit) .contentShape(Rectangle()) } diff --git a/DynamicIsland/components/Shelf/DragDropView.swift b/DynamicIsland/components/Shelf/DragDropView.swift index 99576067..4ec5ceef 100644 --- a/DynamicIsland/components/Shelf/DragDropView.swift +++ b/DynamicIsland/components/Shelf/DragDropView.swift @@ -1,9 +1,20 @@ -// -// DragDropView.swift -// DynamicIsland -// -// Created by Richard Kunkli on 2024. 10. 19.. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import AppKit diff --git a/DynamicIsland/components/Shelf/DropItem.swift b/DynamicIsland/components/Shelf/DropItem.swift index 0748cdd4..12ec4259 100644 --- a/DynamicIsland/components/Shelf/DropItem.swift +++ b/DynamicIsland/components/Shelf/DropItem.swift @@ -1,9 +1,20 @@ -// -// TrayDrop+DropItem.swift -// TrayDrop -// -// Created by 秋星桥 on 2024/7/8. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Cocoa import Foundation diff --git a/DynamicIsland/components/Shelf/DropItemView.swift b/DynamicIsland/components/Shelf/DropItemView.swift index 131d70c2..0ec95ad1 100644 --- a/DynamicIsland/components/Shelf/DropItemView.swift +++ b/DynamicIsland/components/Shelf/DropItemView.swift @@ -1,9 +1,20 @@ -// -// TrayDrop+DropItemView.swift -// NotchDrop -// -// Created by 秋星桥 on 2024/7/8. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation import SwiftUI @@ -13,7 +24,7 @@ struct DropItemView: View { let item: TrayDrop.DropItem @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared - @StateObject var tvm = TrayDrop.shared + @ObservedObject var tvm = TrayDrop.shared @State var hover = false diff --git a/DynamicIsland/components/Shelf/Ext+FileProvider.swift b/DynamicIsland/components/Shelf/Ext+FileProvider.swift index c7550794..b4c7f0ef 100644 --- a/DynamicIsland/components/Shelf/Ext+FileProvider.swift +++ b/DynamicIsland/components/Shelf/Ext+FileProvider.swift @@ -1,9 +1,20 @@ -// -// Ext+FileProvider.swift -// NotchDrop -// -// Created by 秋星桥 on 2024/7/8. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Cocoa import Foundation diff --git a/DynamicIsland/components/Shelf/Ext+NSAlert.swift b/DynamicIsland/components/Shelf/Ext+NSAlert.swift index 4c72ab74..dc8c5b36 100644 --- a/DynamicIsland/components/Shelf/Ext+NSAlert.swift +++ b/DynamicIsland/components/Shelf/Ext+NSAlert.swift @@ -1,9 +1,20 @@ -// -// Ext+NSAlert.swift -// NotchDrop -// -// Created by 秋星桥 on 2024/7/9. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Cocoa diff --git a/DynamicIsland/components/Shelf/Ext+NSImage.swift b/DynamicIsland/components/Shelf/Ext+NSImage.swift index 32d357ae..6254127a 100644 --- a/DynamicIsland/components/Shelf/Ext+NSImage.swift +++ b/DynamicIsland/components/Shelf/Ext+NSImage.swift @@ -1,9 +1,20 @@ -// -// Ext+NSImage.swift -// NotchDrop -// -// Created by 秋星桥 on 2024/7/8. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Cocoa diff --git a/DynamicIsland/components/Shelf/Ext+URL.swift b/DynamicIsland/components/Shelf/Ext+URL.swift index 0eaf4420..65081f0e 100644 --- a/DynamicIsland/components/Shelf/Ext+URL.swift +++ b/DynamicIsland/components/Shelf/Ext+URL.swift @@ -1,9 +1,20 @@ -// -// Ext+URL.swift -// NotchDrop -// -// Created by 秋星桥 on 2024/7/8. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Cocoa import Foundation diff --git a/DynamicIsland/components/Shelf/Models/Bookmark.swift b/DynamicIsland/components/Shelf/Models/Bookmark.swift new file mode 100644 index 00000000..076ffe0e --- /dev/null +++ b/DynamicIsland/components/Shelf/Models/Bookmark.swift @@ -0,0 +1,109 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import AppKit + +struct Bookmark: Sendable, Equatable, Codable { + let data: Data + + init(data: Data) { + self.data = data + } + + init(url: URL) throws { + guard url.isFileURL, FileManager.default.fileExists(atPath: url.path) else { + throw NSError(domain: "Bookmark", code: 1, userInfo: [NSLocalizedDescriptionKey: "Not a valid file URL or file does not exist at \(url.path)"]) + } + do { + let bookmark = try url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + NSLog("✅ Successfully created bookmark for \(url.path)") + self.data = bookmark + } catch { + NSLog("❌ Failed to create bookmark for \(url.path): \(error.localizedDescription)") + throw error + } + } + + func resolve() -> (url: URL?, refreshedData: Data?) { + guard !data.isEmpty else { return (nil, nil) } + var isStale = false + do { + let url = try URL( + resolvingBookmarkData: data, + options: [.withSecurityScope], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + if isStale, let newData = try? url.bookmarkData(options: [.withSecurityScope]) { + NSLog("⚠️ Bookmark was stale for \(url.path), refreshed") + return (url, newData) + } + return (url, nil) + } catch { + NSLog("❌ Failed to resolve bookmark: \(error.localizedDescription)") + return (nil, nil) + } + } + + func resolveURL() -> URL? { + return resolve().url + } + + var refreshedData: Data? { + return resolve().refreshedData + } + + static func update(in items: inout [ShelfItem], for item: ShelfItem, newBookmark: Data) { + guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return } + guard case .file = items[idx].kind else { return } + items[idx].kind = ShelfItemKind.file(bookmark: newBookmark) + } + + func validate() async -> Bool { + let (url, _) = resolve() + guard let url = url else { return false } + return url.accessSecurityScopedResource { url in + FileManager.default.fileExists(atPath: url.path) + } + } + + func withAccess(_ block: @Sendable (URL) async throws -> T) async rethrows -> T? { + let url = resolveURL() + guard let url = url else { return nil } + return try await url.accessSecurityScopedResource { url in + try await block(url) + } + } + + func withAccess(_ block: (URL) throws -> T) rethrows -> T? { + let url = resolveURL() + guard let url = url else { return nil } + return try url.accessSecurityScopedResource { url in + try block(url) + } + } +} diff --git a/DynamicIsland/components/Shelf/Models/ShelfItem.swift b/DynamicIsland/components/Shelf/Models/ShelfItem.swift new file mode 100644 index 00000000..f5408295 --- /dev/null +++ b/DynamicIsland/components/Shelf/Models/ShelfItem.swift @@ -0,0 +1,243 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Foundation + +enum ShelfItemKind: Codable, Equatable, Sendable { + case file(bookmark: Data) + case text(string: String) + case link(url: URL) + + enum CodingKeys: String, CodingKey { case type, value } + + enum KindTag: String, Codable { case file, text, link } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(KindTag.self, forKey: .type) + switch type { + case .file: + let data = try container.decode(Data.self, forKey: .value) + self = .file(bookmark: data) + case .text: + self = .text(string: try container.decode(String.self, forKey: .value)) + case .link: + self = .link(url: try container.decode(URL.self, forKey: .value)) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .file(let bookmark): + try container.encode(KindTag.file, forKey: .type) + try container.encode(bookmark, forKey: .value) + case .text(let string): + try container.encode(KindTag.text, forKey: .type) + try container.encode(string, forKey: .value) + case .link(let url): + try container.encode(KindTag.link, forKey: .type) + try container.encode(url, forKey: .value) + } + } + +} + +@MainActor +struct ShelfItem: Identifiable, Codable, Equatable, Sendable { + let id: UUID + var kind: ShelfItemKind + var isTemporary: Bool + init(id: UUID = UUID(), kind: ShelfItemKind, isTemporary: Bool = false) { + self.id = id + self.kind = kind + self.isTemporary = isTemporary + } + + var displayName: String { + switch kind { + case .file(let bookmarkData): + let bookmark = Bookmark(data: bookmarkData) + guard let resolvedURL = bookmark.resolveURL() else { return "" } + + // Check for stored data files (text blocks, weblocs, etc.) to provide friendly names + if resolvedURL.pathExtension.lowercased() == "json" && resolvedURL.path.contains("TextBlocks") { + do { + let data = try Data(contentsOf: resolvedURL) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + struct TextBlockData: Codable { + let content: String + let title: String? + var displayTitle: String { + if let title = title, !title.isEmpty { + return title + } + let firstLine = content.components(separatedBy: .newlines).first ?? content + if firstLine.count > 50 { + return String(firstLine.prefix(47)) + "..." + } + return firstLine + } + } + if let textData = try? decoder.decode(TextBlockData.self, from: data) { + return textData.displayTitle + } + } catch { + // Fall through to default naming + } + } else if resolvedURL.pathExtension.lowercased() == "webloc" && resolvedURL.path.contains("WebLocs") { + do { + let data = try Data(contentsOf: resolvedURL) + if let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let urlString = plist["URL"] as? String { + let title = plist["Title"] as? String + return title ?? urlString + } + } catch { + // Fall through to default naming + } + } + return (try? resolvedURL.resourceValues(forKeys: [.localizedNameKey]).localizedName) ?? resolvedURL.lastPathComponent + case .text(let string): + return string.trimmingCharacters(in: .whitespacesAndNewlines) + case .link(let url): + let s = url.absoluteString + if s.hasPrefix("https://") { + return String(s.dropFirst("https://".count)) + } else if s.hasPrefix("http://") { + return String(s.dropFirst("http://".count)) + } else { + return s + } + } + } + + var fileURL: URL? { + guard case .file = kind else { return nil } + return ShelfStateViewModel.shared.resolveFileURL(for: self) + } + + var URL: URL? { + if case let .file(bookmark) = kind { return resolvedContext(for: bookmark)?.url } + else if case let .link(url) = kind { return url } + else { return nil } + } + + var icon: NSImage { + guard case .file = kind else { + return Self.thumbnailSymbolImage(systemName: kind.iconSymbolName) ?? NSImage() + } + if let resolvedURL = ShelfStateViewModel.shared.resolveFileURL(for: self) { + return NSWorkspace.shared.icon(forFile: resolvedURL.path) + } + return NSImage() + } + + + func cleanupStoredData() { + guard case let .file(bookmark) = kind, + let context = resolvedContext(for: bookmark) else { return } + + let url = context.url + + // Handle temporary files + if isTemporary { + TemporaryFileStorageService.shared.removeTemporaryFileIfNeeded(at: url) + return + } + } +} + +private extension ShelfItem { + static func thumbnailSymbolImage( + systemName: String, + size: CGSize = CGSize(width: 64, height: 80), + symbolPointSize: CGFloat = 38, + backgroundColor: NSColor = NSColor.white, + symbolColor: NSColor = NSColor.labelColor + ) -> NSImage? { + let image = NSImage(size: size) + image.lockFocus() + defer { image.unlockFocus() } + + let rect = CGRect(origin: .zero, size: size) + let cornerRadius = min(size.width, size.height) * 0.06 + let path = NSBezierPath(roundedRect: rect.insetBy(dx: 2, dy: 2), xRadius: cornerRadius, yRadius: cornerRadius) + backgroundColor.setFill() + path.fill() + + if let symbol = NSImage(systemSymbolName: systemName, accessibilityDescription: nil) { + let symbolSize = CGSize(width: symbolPointSize, height: symbolPointSize) + let symbolOrigin = CGPoint( + x: (size.width - symbolSize.width) / 2, + y: (size.height - symbolSize.height) / 2 + ) + let symbolRect = CGRect(origin: symbolOrigin, size: symbolSize) + symbol.draw(in: symbolRect) + } + + return image + } +} + +// MARK: - Identity key for deduplication +extension ShelfItem { + var identityKey: String { + switch kind { + case .file(let bookmark): + if let url = resolvedContext(for: bookmark)?.url { + return "file://" + url.standardizedFileURL.path + } + return "file://missing/" + bookmark.base64EncodedString() + case .link(let u): + return "link://" + u.absoluteString + case .text(let s): + return "text://" + s + } + } +} + +// MARK: - Private helpers +private extension ShelfItemKind { + var iconSymbolName: String { + switch self { + case .file: + return "questionmark.circle" + case .text: + return "text.justifyleft" + case .link: + return "link" + } + } +} + +private extension ShelfItem { + func resolvedContext(for bookmarkData: Data) -> (url: URL, bookmark: Data)? { + let bookmark = Bookmark(data: bookmarkData) + if let url = bookmark.resolveURL() { + return (url, bookmark.refreshedData ?? bookmarkData) + } + return nil + } +} diff --git a/DynamicIsland/components/Shelf/Services/ImageProcessingService.swift b/DynamicIsland/components/Shelf/Services/ImageProcessingService.swift new file mode 100644 index 00000000..57cdbcfb --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/ImageProcessingService.swift @@ -0,0 +1,335 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import AppKit +import CoreImage +import CoreGraphics +import Vision +import PDFKit +import UniformTypeIdentifiers +import ImageIO + +/// Options for image conversion +struct ImageConversionOptions { + enum ImageFormat { + case png, jpeg, heic, tiff, bmp + + var utType: UTType { + switch self { + case .png: return .png + case .jpeg: return .jpeg + case .heic: return .heic + case .tiff: return .tiff + case .bmp: return .bmp + } + } + + var fileExtension: String { + switch self { + case .png: return "png" + case .jpeg: return "jpg" + case .heic: return "heic" + case .tiff: return "tiff" + case .bmp: return "bmp" + } + } + } + + let format: ImageFormat + let compressionQuality: Double // 0.0 to 1.0, only applies to JPEG/HEIC + let maxDimension: CGFloat? // Max width or height, nil for no scaling + let removeMetadata: Bool +} + +/// Service for processing images (background removal, conversion, PDF creation) +@MainActor +final class ImageProcessingService { + static let shared = ImageProcessingService() + + private init() {} + private let ciContext = CIContext(options: nil) + + // MARK: - Remove Background + + /// Removes the background from an image using Vision framework + func removeBackground(from url: URL) async throws -> URL? { + guard let inputImage = NSImage(contentsOf: url) else { + throw ImageProcessingError.invalidImage + } + + guard let cgImage = inputImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + throw ImageProcessingError.invalidImage + } + + let request = VNGenerateForegroundInstanceMaskRequest() + let handler = VNImageRequestHandler(cgImage: cgImage) + + try handler.perform([request]) + + guard let result = request.results?.first else { + throw ImageProcessingError.backgroundRemovalFailed + } + + let mask = try result.generateScaledMaskForImage(forInstances: result.allInstances, from: handler) + + let output = try await applyMask(mask, to: cgImage) + + let processedImage = NSImage(cgImage: output, size: inputImage.size) + + // Create temporary file + let originalName = url.deletingPathExtension().lastPathComponent + let newName = "\(originalName)_no_bg.png" + + guard let pngData = processedImage.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: pngData), + let finalData = bitmap.representation(using: .png, properties: [:]) else { + throw ImageProcessingError.saveFailed + } + + guard let tempURL = await TemporaryFileStorageService.shared.createTempFile( + for: .data(finalData, suggestedName: newName) + ) else { + throw ImageProcessingError.saveFailed + } + + return tempURL + } + + private func applyMask(_ mask: CVPixelBuffer, to image: CGImage) async throws -> CGImage { + let ciImage = CIImage(cgImage: image) + let maskImage = CIImage(cvPixelBuffer: mask) + + let filter = CIFilter.blendWithMask() + filter.inputImage = ciImage + filter.maskImage = maskImage + filter.backgroundImage = CIImage.empty() + + guard let output = filter.outputImage else { + throw ImageProcessingError.backgroundRemovalFailed + } + + let context = CIContext() + guard let result = context.createCGImage(output, from: output.extent) else { + throw ImageProcessingError.backgroundRemovalFailed + } + + return result + } + + // MARK: - Convert Image + + /// Converts an image with specified options + func convertImage(from url: URL, options: ImageConversionOptions) async throws -> URL? { + guard var inputImage = NSImage(contentsOf: url) else { + throw ImageProcessingError.invalidImage + } + + // Scale image if needed + if let maxDim = options.maxDimension { + inputImage = scaleImage(inputImage, maxDimension: maxDim) + } + + // Get image data based on format + let imageData: Data? + + if options.removeMetadata { + // Create new image without metadata + guard let cgImage = inputImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + throw ImageProcessingError.invalidImage + } + + let newImage = NSImage(cgImage: cgImage, size: inputImage.size) + imageData = try convertToFormat(newImage, format: options.format, quality: options.compressionQuality) + } else { + imageData = try convertToFormat(inputImage, format: options.format, quality: options.compressionQuality) + } + + guard let data = imageData else { + throw ImageProcessingError.conversionFailed + } + + // Create temporary file + let originalName = url.deletingPathExtension().lastPathComponent + let newName = "\(originalName)_converted.\(options.format.fileExtension)" + + guard let tempURL = await TemporaryFileStorageService.shared.createTempFile( + for: .data(data, suggestedName: newName) + ) else { + throw ImageProcessingError.saveFailed + } + + return tempURL + } + + private func convertToFormat(_ image: NSImage, format: ImageConversionOptions.ImageFormat, quality: Double) throws -> Data? { + guard let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData) else { + return nil + } + + switch format { + case .png: + return bitmap.representation(using: .png, properties: [:]) + case .jpeg: + let properties: [NSBitmapImageRep.PropertyKey: Any] = [ + .compressionFactor: quality + ] + return bitmap.representation(using: .jpeg, properties: properties) + case .tiff: + let properties: [NSBitmapImageRep.PropertyKey: Any] = [ + .compressionMethod: NSNumber(value: NSBitmapImageRep.TIFFCompression.lzw.rawValue) + ] + return bitmap.representation(using: .tiff, properties: properties) + case .bmp: + return bitmap.representation(using: .bmp, properties: [:]) + case .heic: + // HEIC requires using CIContext + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + let ciImage = CIImage(cgImage: cgImage) + let context = CIContext() + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)! + let options: [CIImageRepresentationOption: Any] = [ + CIImageRepresentationOption(rawValue: kCGImageDestinationLossyCompressionQuality as String): quality + ] + return try? context.heifRepresentation(of: ciImage, format: .RGBA8, colorSpace: colorSpace, options: options) + } + } + + private func scaleImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage { + guard maxDimension > 0 else { return image } + + guard let srcCG = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return image + } + + let srcMax = max(srcCG.width, srcCG.height) + if CGFloat(srcMax) <= maxDimension { + return image // no downscaling needed + } + + let scale = maxDimension / CGFloat(srcMax) + + let ciImage = CIImage(cgImage: srcCG) + let lanczos = CIFilter.lanczosScaleTransform() + lanczos.inputImage = ciImage + lanczos.scale = Float(scale) + lanczos.aspectRatio = 1.0 + + guard let output = lanczos.outputImage else { + return image + } + + // Preserve the source color space for exact color matching + let colorSpace = srcCG.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)! + let ciContext = CIContext(options: [.workingColorSpace: colorSpace]) + + // Render using the CIContext with matching color space + guard let dstCG = ciContext.createCGImage(output, from: output.extent, format: .RGBA8, colorSpace: colorSpace) else { + return image + } + + return NSImage(cgImage: dstCG, size: NSSize(width: dstCG.width, height: dstCG.height)) + } + + // MARK: - Create PDF + + /// Creates a PDF from multiple image URLs + func createPDF(from imageURLs: [URL], outputName: String? = nil) async throws -> URL? { + guard !imageURLs.isEmpty else { + throw ImageProcessingError.noImagesProvided + } + + let pdfDocument = PDFDocument() + + for (index, url) in imageURLs.enumerated() { + guard let image = NSImage(contentsOf: url) else { + continue + } + + let pdfPage = PDFPage(image: image) + if let page = pdfPage { + pdfDocument.insert(page, at: index) + } + } + + guard pdfDocument.pageCount > 0 else { + throw ImageProcessingError.pdfCreationFailed + } + + // Create temporary file + let name = outputName ?? "images_\(Date().timeIntervalSince1970).pdf" + let pdfName = name.hasSuffix(".pdf") ? name : "\(name).pdf" + + guard let pdfData = pdfDocument.dataRepresentation() else { + throw ImageProcessingError.pdfCreationFailed + } + + guard let tempURL = await TemporaryFileStorageService.shared.createTempFile( + for: .data(pdfData, suggestedName: pdfName) + ) else { + throw ImageProcessingError.saveFailed + } + + return tempURL + } + + // MARK: - Helper Methods + + /// Checks if a URL is an image file + func isImageFile(_ url: URL) -> Bool { + guard let contentType = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType else { + return false + } + return contentType.conforms(to: .image) + } +} + +// MARK: - Errors + +enum ImageProcessingError: LocalizedError { + case invalidImage + case backgroundRemovalFailed + case conversionFailed + case pdfCreationFailed + case noImagesProvided + case saveFailed + + var errorDescription: String? { + switch self { + case .invalidImage: + return "The file is not a valid image" + case .backgroundRemovalFailed: + return "Failed to remove background from image" + case .conversionFailed: + return "Failed to convert image format" + case .pdfCreationFailed: + return "Failed to create PDF from images" + case .noImagesProvided: + return "No images were provided" + case .saveFailed: + return "Failed to save processed file" + } + } +} diff --git a/DynamicIsland/components/Shelf/Services/LocalSendService.swift b/DynamicIsland/components/Shelf/Services/LocalSendService.swift new file mode 100644 index 00000000..e1d5d422 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/LocalSendService.swift @@ -0,0 +1,1117 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +import Foundation +import Network +import Defaults +import UniformTypeIdentifiers +import Darwin + +struct LocalSendDeviceInfo: Identifiable, Hashable, Sendable { + let id: String + let alias: String + let ip: String + let port: Int + let https: Bool + let model: String? + + var displayName: String { + if let model, !model.isEmpty { + return "\(alias) (\(model))" + } + return alias + } + + var baseURL: String { + let scheme = https ? "https" : "http" + return "\(scheme)://\(ip):\(port)" + } +} + +enum LocalSendTransferState: Equatable { + case idle + case sending + case completed + case failed(String) + case rejected(deviceID: String) +} + +@MainActor +final class LocalSendService: NSObject, ObservableObject { + static let shared = LocalSendService() + + @Published private(set) var devices: [LocalSendDeviceInfo] = [] + @Published private(set) var isRefreshing = false + @Published private(set) var isSending = false + @Published private(set) var sendProgress: Double = 0 + @Published private(set) var transferState: LocalSendTransferState = .idle + @Published private(set) var rejectedDeviceIDs: Set = [] + @Published var selectedDeviceID: String { + didSet { Defaults[.localSendSelectedDeviceID] = selectedDeviceID } + } + + private let multicastGroupHost = "224.0.0.167" + private let defaultPort = 53317 + private var connectionGroup: NWConnectionGroup? + private var registerListener: NWListener? + private var cleanupTask: Task? + private var announceTask: Task? + private var activeRefreshTask: Task? + private var refreshSessionID = UUID() + private var discoveredByID: [String: (device: LocalSendDeviceInfo, lastSeen: Date)] = [:] + private var recentProbeIPs: [String] = [] + private var knownPeerIPs: [String] = [] + private var isStarted = false + private var completionDismissTask: Task? + + private override init() { + selectedDeviceID = Defaults[.localSendSelectedDeviceID] + super.init() + } + + func clearRejectedStatus(for deviceID: String) { + rejectedDeviceIDs.remove(deviceID) + } + + func clearAllRejectedStatuses() { + rejectedDeviceIDs.removeAll() + } + + func startDiscovery() { + guard !isStarted else { return } + isStarted = true + + startRegisterListenerIfNeeded() + + do { + let group = try NWMulticastGroup(for: [ + .hostPort( + host: .init(multicastGroupHost), + port: .init(integerLiteral: NWEndpoint.Port.IntegerLiteralType(defaultPort)) + ), + ]) + let params = NWParameters.udp + params.allowLocalEndpointReuse = true + params.includePeerToPeer = true + + let connectionGroup = NWConnectionGroup(with: group, using: params) + connectionGroup.setReceiveHandler(maximumMessageSize: 65_536) { [weak self] message, content, _ in + guard let content, let self else { return } + Task { @MainActor in + self.handleIncoming(content: content, endpoint: message.remoteEndpoint) + } + } + + connectionGroup.stateUpdateHandler = { _ in } + connectionGroup.start(queue: .global(qos: .utility)) + self.connectionGroup = connectionGroup + + sendAnnouncement() + + cleanupTask = Task { [weak self] in + while let self, !Task.isCancelled { + try? await Task.sleep(nanoseconds: 15_000_000_000) + self.cleanupStale() + } + } + + announceTask = Task { [weak self] in + while let self, !Task.isCancelled { + try? await Task.sleep(nanoseconds: 8_000_000_000) + self.sendAnnouncement() + } + } + } catch { + Logger.log("LocalSend discovery start failed: \(error.localizedDescription)", category: .extensions) + } + } + + func refreshDeviceScan() { + activeRefreshTask?.cancel() + startDiscovery() + + let sessionID = UUID() + refreshSessionID = sessionID + isRefreshing = true + + // Keep listener state and run a LocalSend-like refresh sequence: + // multicast announce burst first, then targeted/fallback HTTP discovery. + activeRefreshTask = Task { [weak self] in + guard let self, !Task.isCancelled else { return } + + let startedAt = Date() + defer { + if self.refreshSessionID == sessionID { + self.isRefreshing = false + self.activeRefreshTask = nil + } + } + + let burstDelays: [UInt64] = [100_000_000, 500_000_000, 2_000_000_000] + for delay in burstDelays { + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled else { return } + self.sendAnnouncement() + } + + guard !Task.isCancelled else { return } + await self.probeNearbyDevicesDirectly(limit: 20, timeout: 0.14) + + guard !Task.isCancelled else { return } + if !self.hasFreshDiscovery(since: startedAt), + let localIP = self.ownIPv4Address() { + await self.probeLocalSubnetLegacy(localIP: localIP, timeout: 0.12) + } + + guard !Task.isCancelled else { return } + self.pruneUnavailableDevices(since: startedAt) + } + } + + private func pruneUnavailableDevices(since date: Date) { + let previousCount = discoveredByID.count + discoveredByID = discoveredByID.filter { $0.value.lastSeen >= date } + if discoveredByID.count != previousCount { + refreshDevices() + } + } + + private func probeNearbyDevicesDirectly(limit: Int = 12, timeout: TimeInterval = 0.14) async { + let port = defaultPort + let candidates = candidateIPsForActiveProbe(limit: max(1, limit)) + guard !candidates.isEmpty else { return } + let deadline = Date().addingTimeInterval(1.0) + + await withTaskGroup(of: LocalSendDeviceInfo?.self) { group in + let maxConcurrent = 4 + var iterator = candidates.makeIterator() + + for _ in 0 ..< min(maxConcurrent, candidates.count) { + guard let ip = iterator.next() else { break } + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: 0.14) + } + } + + while let result = await group.next() { + if Date() > deadline { + group.cancelAll() + return + } + + guard !Task.isCancelled else { + group.cancelAll() + return + } + + if let device = result { + discoveredByID[device.id] = (device, Date()) + rememberKnownPeerIP(device.ip) + rememberRecentProbeIP(device.ip) + } + + if let ip = iterator.next() { + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: timeout) + } + } + } + } + + refreshDevices() + } + + private func candidateIPsForActiveProbe(limit: Int) -> [String] { + var ordered: [String] = [] + var seen = Set() + + if let selected = devices.first(where: { $0.id == selectedDeviceID })?.ip, + seen.insert(selected).inserted { + ordered.append(selected) + } + + for ip in recentProbeIPs where seen.insert(ip).inserted { + ordered.append(ip) + } + + for ip in discoveredByID.values.map(\.device.ip) where seen.insert(ip).inserted { + ordered.append(ip) + } + + for ip in knownPeerIPs where seen.insert(ip).inserted { + ordered.append(ip) + } + + if ordered.count > limit { + return Array(ordered.prefix(limit)) + } + return ordered + } + + private func probeLocalSubnetLegacy(localIP: String, timeout: TimeInterval) async { + guard isValidIPv4(localIP) else { return } + let parts = localIP.split(separator: ".") + guard parts.count == 4 else { return } + + let prefix = parts.prefix(3).joined(separator: ".") + let host = Int(parts[3]) ?? 0 + let candidates = (1 ... 254) + .filter { $0 != host } + .map { "\(prefix).\($0)" } + + await probeExactIPs(candidates, timeout: timeout, concurrency: 50) + } + + private func probeExactIPs(_ ips: [String], timeout: TimeInterval, concurrency: Int) async { + let port = defaultPort + let unique = Array(NSOrderedSet(array: ips).compactMap { $0 as? String }) + guard !unique.isEmpty else { return } + + await withTaskGroup(of: LocalSendDeviceInfo?.self) { group in + var iterator = unique.makeIterator() + + for _ in 0 ..< min(max(1, concurrency), unique.count) { + guard let ip = iterator.next() else { break } + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: timeout) + } + } + + while let result = await group.next() { + guard !Task.isCancelled else { + group.cancelAll() + return + } + + if let device = result { + discoveredByID[device.id] = (device, Date()) + rememberKnownPeerIP(device.ip) + rememberRecentProbeIP(device.ip) + } + + if let ip = iterator.next() { + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: timeout) + } + } + } + } + + refreshDevices() + } + + private func hasFreshDiscovery(since date: Date) -> Bool { + discoveredByID.values.contains { $0.lastSeen >= date } + } + + private nonisolated static func probeDeviceInfo(at ip: String, port: Int, timeout: TimeInterval) async -> LocalSendDeviceInfo? { + let schemes = ["http", "https"] + let paths = ["/api/localsend/v2/info"] + + for scheme in schemes { + for path in paths { + guard var components = URLComponents(string: "\(scheme)://\(ip):\(port)\(path)") else { continue } + components.queryItems = [ + URLQueryItem(name: "fingerprint", value: "atoll.localsend.bridge"), + ] + guard let url = components.url else { continue } + + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + + do { + let (data, response) = try await probeSession.data(for: request) + guard let http = response as? HTTPURLResponse, + (200 ... 299).contains(http.statusCode), + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let fingerprint = json["fingerprint"] as? String, + let alias = json["alias"] as? String, + fingerprint != "atoll.localsend.bridge" + else { + continue + } + + let model = json["deviceModel"] as? String + let usesHTTPS = (json["protocol"] as? String) == "https" || scheme == "https" + + return LocalSendDeviceInfo( + id: fingerprint, + alias: alias, + ip: ip, + port: port, + https: usesHTTPS, + model: model + ) + } catch { + continue + } + } + } + + return nil + } + + private nonisolated static let probeSession: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 0.2 + config.timeoutIntervalForResource = 0.2 + config.waitsForConnectivity = false + return URLSession(configuration: config, delegate: LocalSendTLSDelegate(), delegateQueue: nil) + }() + + private func rememberRecentProbeIP(_ ip: String) { + guard isValidIPv4(ip) else { return } + recentProbeIPs.removeAll { $0 == ip } + recentProbeIPs.insert(ip, at: 0) + if recentProbeIPs.count > 12 { + recentProbeIPs = Array(recentProbeIPs.prefix(12)) + } + } + + private func rememberKnownPeerIP(_ ip: String) { + guard isValidIPv4(ip) else { return } + knownPeerIPs.removeAll { $0 == ip } + knownPeerIPs.insert(ip, at: 0) + if knownPeerIPs.count > 48 { + knownPeerIPs = Array(knownPeerIPs.prefix(48)) + } + } + + private func arpNeighborIPs() -> [String] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/arp") + process.arguments = ["-an"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return [] } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return [] } + + let regex = try NSRegularExpression(pattern: #"\((\d{1,3}(?:\.\d{1,3}){3})\)"#) + let nsrange = NSRange(output.startIndex..() + for match in matches { + guard match.numberOfRanges > 1, + let range = Range(match.range(at: 1), in: output) + else { continue } + let ip = String(output[range]) + guard isValidIPv4(ip) else { continue } + if ip.hasPrefix("169.254.") { continue } + if ip == "255.255.255.255" { continue } + + if let ownPrefix { + let ipPrefix = ip.split(separator: ".").prefix(3).joined(separator: ".") + guard ipPrefix == ownPrefix else { continue } + } + + if seen.insert(ip).inserted { + ips.append(ip) + } + } + + return ips + } catch { + return [] + } + } + + private func localSubnetSweepIPs(radius: Int) -> [String] { + guard let own = ownIPv4Address(), isValidIPv4(own) else { return [] } + let parts = own.split(separator: ".").compactMap { Int($0) } + guard parts.count == 4 else { return [] } + + let prefix = "\(parts[0]).\(parts[1]).\(parts[2])" + let myHost = parts[3] + let start = max(1, myHost - radius) + let end = min(254, myHost + radius) + + return (start ... end) + .filter { $0 != myHost } + .map { "\(prefix).\($0)" } + } + + private func ownIPv4Address() -> String? { + var ifaddr: UnsafeMutablePointer? + guard getifaddrs(&ifaddr) == 0, let first = ifaddr else { return nil } + defer { freeifaddrs(ifaddr) } + + var ptr: UnsafeMutablePointer? = first + while let current = ptr { + defer { ptr = current.pointee.ifa_next } + + let family = current.pointee.ifa_addr.pointee.sa_family + let flags = Int32(current.pointee.ifa_flags) + guard family == UInt8(AF_INET), + (flags & IFF_UP) == IFF_UP, + (flags & IFF_LOOPBACK) == 0 + else { continue } + + var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + var addr = current.pointee.ifa_addr.pointee + let result = getnameinfo( + &addr, + socklen_t(current.pointee.ifa_addr.pointee.sa_len), + &hostBuffer, + socklen_t(hostBuffer.count), + nil, + 0, + NI_NUMERICHOST + ) + guard result == 0 else { continue } + + let ip = String(cString: hostBuffer) + if isValidIPv4(ip) { + return ip + } + } + + return nil + } + + private func isValidIPv4(_ ip: String) -> Bool { + let parts = ip.split(separator: ".") + guard parts.count == 4 else { return false } + for p in parts { + guard let v = Int(p), (0 ... 255).contains(v) else { return false } + } + return true + } + + func send(items: [Any]) async throws { + let startedAt = Date() + isSending = true + sendProgress = 0 + transferState = .sending + completionDismissTask?.cancel() + + do { + guard let target = devices.first(where: { $0.id == selectedDeviceID }) ?? devices.first else { + throw LocalSendServiceError.noDeviceSelected + } + + let files = try await buildTransferFiles(from: items) + guard !files.isEmpty else { throw LocalSendServiceError.noTransferableItems } + + let prepare = try await prepareUpload(files: files, to: target) + guard !prepare.fileTokens.isEmpty else { + sendProgress = 1 + await finishSending(startedAt: startedAt, success: true) + return + } + + let uploads: [(TransferFile, String)] = files.compactMap { file in + guard let token = prepare.fileTokens[file.id] else { return nil } + return (file, token) + } + + // Compute total bytes to provide smooth overall progress + let totalBytes = uploads.reduce(Int64(0)) { acc, entry in acc + Int64(entry.0.data.count) } + var bytesCompleted: Int64 = 0 + + for (index, entry) in uploads.enumerated() { + let file = entry.0 + let fileSize = Int64(file.data.count) + + try await upload(file: file, sessionID: prepare.sessionID, token: entry.1, to: target) { fileFraction in + if totalBytes > 0 { + let currentSent = Int64(Double(fileSize) * fileFraction) + let overall = Double(bytesCompleted + currentSent) / Double(totalBytes) + Task { @MainActor in self.sendProgress = overall } + } else { + // Fallback to file-index-based progress when sizes unknown + Task { @MainActor in self.sendProgress = Double(index) / Double(max(uploads.count, 1)) } + } + } + + bytesCompleted += fileSize + } + + sendProgress = 1 + await finishSending(startedAt: startedAt, success: true) + } catch let error as LocalSendServiceError { + if case .transferRejected = error { + rejectedDeviceIDs.insert(selectedDeviceID) + transferState = .rejected(deviceID: selectedDeviceID) + } else { + transferState = .failed(error.localizedDescription) + } + await finishSending(startedAt: startedAt, success: false) + throw error + } catch { + transferState = .failed(error.localizedDescription) + await finishSending(startedAt: startedAt, success: false) + throw error + } + } + + private func finishSending(startedAt: Date, success: Bool) async { + let elapsed = Date().timeIntervalSince(startedAt) + let minimumVisibleDuration: TimeInterval = 0.8 + if elapsed < minimumVisibleDuration { + let remaining = minimumVisibleDuration - elapsed + try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) + } + isSending = false + sendProgress = 0 + + if success { + transferState = .completed + // Auto-dismiss completed state after 3 seconds + completionDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 3_000_000_000) + if case .completed = self.transferState { + self.transferState = .idle + } + } + } else { + // Auto-dismiss failed/rejected state after 4 seconds + completionDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 4_000_000_000) + if case .failed = self.transferState { + self.transferState = .idle + } else if case .rejected = self.transferState { + self.transferState = .idle + } + } + } + } + + private func sendAnnouncement() { + let payload: [String: Any] = [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "fingerprint": "atoll.localsend.bridge", + "port": defaultPort, + // Use HTTP here so LocalSend peers can quickly fail over to UDP response + // when Atoll is not serving LocalSend register endpoint. + "protocol": "http", + "download": false, + "announcement": true, + "announce": true, + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + connectionGroup?.send(content: data, completion: { _ in }) + } + + private func startRegisterListenerIfNeeded() { + guard registerListener == nil else { return } + + do { + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + params.includePeerToPeer = true + + let listener = try NWListener( + using: params, + on: NWEndpoint.Port(integerLiteral: NWEndpoint.Port.IntegerLiteralType(defaultPort)) + ) + + listener.stateUpdateHandler = { state in + if case let .failed(error) = state { + Task { @MainActor [weak self] in + Logger.log("LocalSend register listener failed: \(error.localizedDescription)", category: .extensions) + self?.registerListener = nil + } + } + } + + listener.newConnectionHandler = { [weak self] connection in + Task { @MainActor in + self?.handleRegisterConnection(connection) + } + } + + listener.start(queue: .global(qos: .utility)) + registerListener = listener + } catch { + Logger.log("LocalSend register listener start failed: \(error.localizedDescription)", category: .extensions) + } + } + + private func handleRegisterConnection(_ connection: NWConnection) { + connection.start(queue: .global(qos: .utility)) + connection.receive(minimumIncompleteLength: 1, maximumLength: 131_072) { [weak self] content, _, _, _ in + guard let self, let data = content, !data.isEmpty else { + connection.cancel() + return + } + + Task { @MainActor in + let response = self.processRegisterRequest(data: data, from: connection.endpoint) + connection.send(content: response, completion: .contentProcessed { _ in + connection.cancel() + }) + } + } + } + + private func processRegisterRequest(data: Data, from endpoint: NWEndpoint) -> Data { + guard let request = String(data: data, encoding: .utf8) else { + return httpResponse(status: 400, json: ["error": "invalid-request"]) + } + + let parts = request.components(separatedBy: "\r\n\r\n") + guard let head = parts.first, + let firstLine = head.components(separatedBy: "\r\n").first + else { + return httpResponse(status: 400, json: ["error": "invalid-request"]) + } + + let tokens = firstLine.split(separator: " ") + guard tokens.count >= 2 else { + return httpResponse(status: 400, json: ["error": "invalid-request"]) + } + + let method = String(tokens[0]).uppercased() + let rawPath = String(tokens[1]) + let path = rawPath.components(separatedBy: "?").first ?? rawPath + + if method == "POST", path == "/api/localsend/v2/register" || path == "/api/localsend/v3/register" { + let callerIP = endpointIPv4(endpoint) + let bodyText = parts.dropFirst().joined(separator: "\r\n\r\n") + let bodyData = bodyText.data(using: .utf8) + let json = bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + + if let json, + let fingerprint = json["fingerprint"] as? String, + let alias = json["alias"] as? String, + let callerIP, + fingerprint != "atoll.localsend.bridge" { + let device = LocalSendDeviceInfo( + id: fingerprint, + alias: alias, + ip: callerIP, + port: (json["port"] as? Int) ?? defaultPort, + https: (json["protocol"] as? String) == "https", + model: json["deviceModel"] as? String + ) + discoveredByID[fingerprint] = (device, Date()) + rememberKnownPeerIP(callerIP) + rememberRecentProbeIP(callerIP) + refreshDevices() + } else if let callerIP { + // Be tolerant to partial HTTP payloads: still ACK register and probe caller directly. + Task { @MainActor [weak self] in + guard let self else { return } + if let device = await Self.probeDeviceInfo(at: callerIP, port: defaultPort, timeout: 0.35) { + self.discoveredByID[device.id] = (device, Date()) + self.rememberKnownPeerIP(device.ip) + self.rememberRecentProbeIP(device.ip) + self.refreshDevices() + } + } + } + + let responseJSON: [String: Any] = [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "token": "atoll.localsend.bridge", + "fingerprint": "atoll.localsend.bridge", + "download": false, + "hasWebInterface": false, + ] + return httpResponse(status: 200, json: responseJSON) + } + + if method == "GET", path == "/api/localsend/v2/info" { + let responseJSON: [String: Any] = [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "fingerprint": "atoll.localsend.bridge", + "port": defaultPort, + "protocol": "http", + "download": false, + ] + return httpResponse(status: 200, json: responseJSON) + } + + return httpResponse(status: 404, json: ["error": "not-found"]) + } + + private func endpointIPv4(_ endpoint: NWEndpoint) -> String? { + guard case let .hostPort(host, _) = endpoint else { return nil } + let ip = host.debugDescription.replacingOccurrences(of: "\"", with: "") + return isValidIPv4(ip) ? ip : nil + } + + private func httpResponse(status: Int, json: [String: Any]) -> Data { + let body = (try? JSONSerialization.data(withJSONObject: json)) ?? Data("{}".utf8) + let headers = [ + "HTTP/1.1 \(status) \(status == 200 ? "OK" : "Error")", + "Content-Type: application/json", + "Content-Length: \(body.count)", + "Connection: close", + "", + "", + ].joined(separator: "\r\n") + + var response = Data(headers.utf8) + response.append(body) + return response + } + + private func handleIncoming(content: Data, endpoint: NWEndpoint?) { + guard let json = try? JSONSerialization.jsonObject(with: content) as? [String: Any], + let fingerprint = json["fingerprint"] as? String, + let alias = json["alias"] as? String + else { return } + + if fingerprint == "atoll.localsend.bridge" { return } + + let ip: String + if let announced = json["ip"] as? String { + ip = announced + } else if case let .hostPort(host, _) = endpoint { + ip = host.debugDescription.replacingOccurrences(of: "\"", with: "") + } else { + return + } + + let port = (json["port"] as? Int) ?? defaultPort + let https = (json["protocol"] as? String) == "https" + let model = json["deviceModel"] as? String + + let device = LocalSendDeviceInfo( + id: fingerprint, + alias: alias, + ip: ip, + port: port, + https: https, + model: model + ) + + discoveredByID[fingerprint] = (device, Date()) + rememberKnownPeerIP(ip) + rememberRecentProbeIP(ip) + refreshDevices() + } + + private func cleanupStale() { + let cutoff = Date().addingTimeInterval(-30) + discoveredByID = discoveredByID.filter { $0.value.lastSeen > cutoff } + refreshDevices() + } + + private func refreshDevices() { + devices = discoveredByID.values.map(\.device).sorted { $0.alias.localizedCaseInsensitiveCompare($1.alias) == .orderedAscending } + if !devices.contains(where: { $0.id == selectedDeviceID }), let first = devices.first { + selectedDeviceID = first.id + } + } + + private struct TransferFile { + let id: String + let name: String + let mimeType: String + let data: Data + } + + private func buildTransferFiles(from items: [Any]) async throws -> [TransferFile] { + var result: [TransferFile] = [] + for item in items { + if let url = item as? URL, url.isFileURL { + if let data = try? Data(contentsOf: url) { + result.append(TransferFile( + id: UUID().uuidString, + name: url.lastPathComponent, + mimeType: preferredTransferMimeType(for: url), + data: data + )) + } + } else if let url = item as? URL { + let string = url.absoluteString + result.append(TransferFile( + id: UUID().uuidString, + name: "link.url", + mimeType: "text/uri-list", + data: Data(string.utf8) + )) + } else if let text = item as? String { + result.append(TransferFile( + id: UUID().uuidString, + name: "text.txt", + mimeType: "text/plain", + data: Data(text.utf8) + )) + } + } + return result + } + + private func preferredTransferMimeType(for url: URL) -> String { + let ext = url.pathExtension.lowercased() + if ext == "svg" { + // Some LocalSend receivers try gallery/image-specific write paths for SVG and fail. + return "application/octet-stream" + } + return url.mimeType ?? "application/octet-stream" + } + + private func prepareUpload(files: [TransferFile], to device: LocalSendDeviceInfo) async throws -> (sessionID: String, fileTokens: [String: String]) { + var filesMap: [String: Any] = [:] + for file in files { + filesMap[file.id] = [ + "id": file.id, + "fileName": file.name, + "size": file.data.count, + "fileType": file.mimeType, + ] + } + + let payload: [String: Any] = [ + "info": [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "fingerprint": "atoll.localsend.bridge", + "token": "atoll.localsend.bridge", + "port": defaultPort, + "protocol": device.https ? "https" : "http", + "download": false, + ], + "files": filesMap, + ] + + var lastError: Error? + for baseURL in candidateBaseURLs(for: device) { + do { + return try await prepareUpload(payload: payload, baseURL: baseURL) + } catch { + lastError = error + Logger.log("LocalSend prepare-upload failed via \(baseURL): \(error.localizedDescription)", category: .extensions) + if !shouldRetryAcrossSchemes(error) { + throw error + } + } + } + + throw lastError ?? LocalSendServiceError.invalidResponse + } + + private func prepareUpload(payload: [String: Any], baseURL: String) async throws -> (sessionID: String, fileTokens: [String: String]) { + guard let url = URL(string: "\(baseURL)/api/localsend/v2/prepare-upload") else { + throw LocalSendServiceError.invalidTarget + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let (data, response) = try await trustedSession.data(for: request) + guard let http = response as? HTTPURLResponse else { throw LocalSendServiceError.invalidResponse } + + if http.statusCode == 204 { + return ("", [:]) + } + // HTTP 403 means the transfer was rejected by the recipient + if http.statusCode == 403 { + throw LocalSendServiceError.transferRejected + } + guard (200 ... 299).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) + throw LocalSendServiceError.server(status: http.statusCode, body: body) + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let sessionID = json["sessionId"] as? String, + let rawFileTokens = json["files"] as? [String: Any] + else { throw LocalSendServiceError.invalidResponse } + + var fileTokens: [String: String] = [:] + for (k, v) in rawFileTokens { + if let s = v as? String { + fileTokens[k] = s + } + } + + return (sessionID, fileTokens) + } + + private func upload(file: TransferFile, sessionID: String, token: String, to device: LocalSendDeviceInfo, progress: @escaping (Double) -> Void) async throws { + var lastError: Error? + for baseURL in candidateBaseURLs(for: device) { + do { + try await upload(file: file, sessionID: sessionID, token: token, baseURL: baseURL, progress: progress) + return + } catch { + lastError = error + Logger.log("LocalSend upload failed via \(baseURL) for \(file.name): \(error.localizedDescription)", category: .extensions) + if !shouldRetryAcrossSchemes(error) { + throw error + } + } + } + throw lastError ?? LocalSendServiceError.invalidResponse + } + + private func upload(file: TransferFile, sessionID: String, token: String, baseURL: String, progress: @escaping (Double) -> Void) async throws { + var components = URLComponents(string: "\(baseURL)/api/localsend/v2/upload") + components?.queryItems = [ + URLQueryItem(name: "sessionId", value: sessionID), + URLQueryItem(name: "fileId", value: file.id), + URLQueryItem(name: "token", value: token), + ] + + guard let url = components?.url else { throw LocalSendServiceError.invalidTarget } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue(file.mimeType, forHTTPHeaderField: "Content-Type") + request.setValue("\(file.data.count)", forHTTPHeaderField: "Content-Length") + + // Use uploadTask to receive per-byte progress via delegate + let delegate = UploadProgressDelegate { sent, expected in + guard expected > 0 else { return } + let fraction = Double(sent) / Double(expected) + progress(fraction) + } + + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + + let (data, response) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in + let task = session.uploadTask(with: request, from: file.data) { data, response, error in + if let error = error { + continuation.resume(throwing: error) + return + } + guard let data = data, let response = response else { + continuation.resume(throwing: LocalSendServiceError.invalidResponse) + return + } + continuation.resume(returning: (data, response)) + } + task.resume() + } + + session.finishTasksAndInvalidate() + + guard let http = response as? HTTPURLResponse, (200 ... 299).contains(http.statusCode) else { + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + let body = String(data: data, encoding: .utf8) + throw LocalSendServiceError.server(status: status, body: body) + } + } + + private func candidateBaseURLs(for device: LocalSendDeviceInfo) -> [String] { + let primary = device.baseURL + let alternateScheme = device.https ? "http" : "https" + let alternate = "\(alternateScheme)://\(device.ip):\(device.port)" + return primary == alternate ? [primary] : [primary, alternate] + } + + private func shouldRetryAcrossSchemes(_ error: Error) -> Bool { + if case LocalSendServiceError.server = error { + // A peer responded with a concrete HTTP error; fallback to another scheme is usually noise. + return false + } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + let code = URLError.Code(rawValue: nsError.code) + switch code { + case .timedOut, .networkConnectionLost, .notConnectedToInternet, + .cannotFindHost, .cannotConnectToHost, .secureConnectionFailed, + .dnsLookupFailed: + return true + default: + return false + } + } + + return false + } + + private lazy var trustedSession: URLSession = { + URLSession(configuration: .default, delegate: LocalSendTLSDelegate(), delegateQueue: nil) + }() +} + +private class LocalSendTLSDelegate: NSObject, URLSessionDelegate { + func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + if let trust = challenge.protectionSpace.serverTrust { + return (.useCredential, URLCredential(trust: trust)) + } + return (.performDefaultHandling, nil) + } +} + +private final class UploadProgressDelegate: LocalSendTLSDelegate, URLSessionTaskDelegate { + private let onProgress: (Int64, Int64) -> Void + + init(onProgress: @escaping (Int64, Int64) -> Void) { + self.onProgress = onProgress + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + Task { @MainActor in + onProgress(totalBytesSent, totalBytesExpectedToSend) + } + } +} + +private extension URL { + var mimeType: String? { + guard !pathExtension.isEmpty, + let type = UTType(filenameExtension: pathExtension) + else { return nil } + return type.preferredMIMEType + } +} + +enum LocalSendServiceError: LocalizedError { + case noDeviceSelected + case noTransferableItems + case invalidTarget + case invalidResponse + case server(status: Int, body: String?) + case transferRejected + + var errorDescription: String? { + switch self { + case .noDeviceSelected: + return "No LocalSend device selected" + case .noTransferableItems: + return "No transferable files or text found" + case .invalidTarget: + return "Invalid LocalSend target" + case .invalidResponse: + return "Invalid response from LocalSend peer" + case .server(let status, let body): + if let body, !body.isEmpty { + return "LocalSend peer error (\(status)): \(body)" + } + return "LocalSend peer error (\(status))" + case .transferRejected: + return "Transfer was rejected by the recipient" + } + } +} \ No newline at end of file diff --git a/DynamicIsland/components/Shelf/Services/QuickLookService.swift b/DynamicIsland/components/Shelf/Services/QuickLookService.swift new file mode 100644 index 00000000..0d88c111 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/QuickLookService.swift @@ -0,0 +1,149 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import UniformTypeIdentifiers +import SwiftUI +import QuickLookUI +import AppKit + +@MainActor +final class QuickLookService: ObservableObject { + @Published var urls: [URL] = [] + @Published var selectedURL: URL? + + @Published var isQuickLookOpen: Bool = false + + private var previewPanel: QLPreviewPanel? + private var dataSource: QuickLookDataSource? + private var accessingURLs: [URL] = [] + private var previewPanelObserver: Any? + + func show(urls: [URL], selectFirst: Bool = true, slideshow: Bool = false) { + guard !urls.isEmpty else { return } + stopAccessingCurrentURLs() + accessingURLs = urls.filter { url in + if url.isFileURL { + return url.startAccessingSecurityScopedResource() + } + return true + } + self.urls = accessingURLs + self.isQuickLookOpen = true + if selectFirst { + self.selectedURL = accessingURLs.first + } + // Observe the shared Quick Look preview panel closing so we can relinquish security scope + let panel = QLPreviewPanel.shared() + // Remove any existing observer for previous panel + if let prev = previewPanel { + NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: prev) + } + previewPanel = panel + NotificationCenter.default.addObserver(self, selector: #selector(previewPanelWillClose(_:)), name: NSWindow.willCloseNotification, object: panel) + } + + func hide() { + stopAccessingCurrentURLs() + selectedURL = nil + urls.removeAll() + isQuickLookOpen = false + if let panel = previewPanel, panel.isVisible { + panel.orderOut(nil) + } + if let panel = previewPanel { + NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: panel) + previewPanel = nil + } + } + + private func stopAccessingCurrentURLs() { + NSLog("Stopping access to \(accessingURLs.count) URLs") + for url in accessingURLs where url.isFileURL { + url.stopAccessingSecurityScopedResource() + } + accessingURLs.removeAll() + // If Quick Look panel was closed externally, also remove observer and clear reference + if let panel = previewPanel { + NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: panel) + previewPanel = nil + } + } + + func showQuickLook(urls: [URL]) { + show(urls: urls, selectFirst: true, slideshow: false) + } + + func updateSelection(urls: [URL]) { + guard isQuickLookOpen else { return } + show(urls: urls, selectFirst: true) + } +} + +extension QuickLookService { + @objc private func previewPanelWillClose(_ notification: Notification) { + guard let panel = notification.object as? QLPreviewPanel, panel === previewPanel else { return } + // Ensure cleanup happens on main actor + Task { @MainActor in + stopAccessingCurrentURLs() + selectedURL = nil + urls.removeAll() + isQuickLookOpen = false + // Remove observer and clear reference + NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: panel) + previewPanel = nil + } + } +} + +struct QuickLookPresenter: ViewModifier { + @ObservedObject var service: QuickLookService + + func body(content: Content) -> some View { + content + .quickLookPreview($service.selectedURL, in: service.urls) + } +} + +extension View { + func quickLookPresenter(using service: QuickLookService) -> some View { + self.modifier(QuickLookPresenter(service: service)) + } +} + + +final class QuickLookDataSource: NSObject, QLPreviewPanelDataSource, QLPreviewPanelDelegate { + private let urls: [URL] + + init(urls: [URL]) { + self.urls = urls + super.init() + } + + nonisolated func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { + return urls.count + } + nonisolated func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> QLPreviewItem! { + guard index >= 0 && index < urls.count else { return nil } + return urls[index] as QLPreviewItem + } +} diff --git a/DynamicIsland/components/Shelf/Services/QuickShareService.swift b/DynamicIsland/components/Shelf/Services/QuickShareService.swift new file mode 100644 index 00000000..b77f925e --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/QuickShareService.swift @@ -0,0 +1,333 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Foundation +import UniformTypeIdentifiers + +/// Dynamic representation of a sharing provider discovered at runtime +struct QuickShareProvider: Identifiable, Hashable, Sendable { + var id: String + var imageData: Data? + var supportsRawText: Bool +} + +class QuickShareService: ObservableObject { + static let shared = QuickShareService() + + @Published var availableProviders: [QuickShareProvider] = [] + @Published var isPickerOpen = false + private var cachedServices: [String: NSSharingService] = [:] + // Hold security-scoped URLs during sharing + private var sharingAccessingURLs: [URL] = [] + private var lifecycleDelegate: SharingLifecycleDelegate? + private var hasDiscovered = false + private var discoveryTask: Task? + + init() { + // Lazy discovery — don't block app launch. + // Discovery runs on first access (shelf tab / settings Shelf section). + } + + /// Ensures providers are discovered exactly once. Safe to call multiple times. + @MainActor + func ensureDiscovered() { + guard !hasDiscovered, discoveryTask == nil else { return } + discoveryTask = Task { + await discoverAvailableProviders() + } + } + + // MARK: - Provider Discovery + + @MainActor + func discoverAvailableProviders() async { + // Move the heavy NSSharingService enumeration off the main thread + let result: (providers: [QuickShareProvider], services: [String: NSSharingService]) = await Task.detached(priority: .userInitiated) { + let testItems: [Any] = [ + URL(string: "https://apple.com")! as NSURL, + "Test Text" as NSString + ] + + var nativeServices = NSSharingService.sharingServices(forItems: testItems) + + // Manually inject essential system services that the static list may omit + let manualServiceNames: [NSSharingService.Name] = [ + .composeEmail, + .sendViaAirDrop, + .composeMessage, + .addToSafariReadingList + ] + for name in manualServiceNames { + if let service = NSSharingService(named: name), + !nativeServices.contains(where: { $0.title == service.title }) { + nativeServices.append(service) + } + } + + var providers: [QuickShareProvider] = [] + var services: [String: NSSharingService] = [:] + + // Process each service inside an autoreleasepool so that + // the large intermediate TIFF / bitmap buffers are freed + // immediately instead of accumulating across the loop. + for svc in nativeServices { + let (provider, shouldCache) = autoreleasepool { () -> (QuickShareProvider, Bool) in + let title = svc.title + + // Downscale to a small thumbnail and store as compressed PNG + // to keep total memory footprint minimal. + let imgData: Data? = { + let src = svc.image + let thumbSize = NSSize(width: 32, height: 32) + let thumb = NSImage(size: thumbSize) + thumb.lockFocus() + src.draw(in: NSRect(origin: .zero, size: thumbSize), + from: NSRect(origin: .zero, size: src.size), + operation: .copy, fraction: 1.0) + thumb.unlockFocus() + + guard let tiff = thumb.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff) else { return nil } + return bitmap.representation(using: .png, properties: [:]) + }() + + let supportsRawText = svc.canPerform(withItems: ["Test Text"]) + let prov = QuickShareProvider(id: title, imageData: imgData, supportsRawText: supportsRawText) + let isNew = !providers.contains(prov) + return (prov, isNew) + } + + if shouldCache { + providers.append(provider) + services[provider.id] = svc + } + } + + // Move AirDrop to the top + if let idx = providers.firstIndex(where: { $0.id == "AirDrop" }) { + let ad = providers.remove(at: idx) + providers.insert(ad, at: 0) + } + + if !providers.contains(where: { $0.id == "LocalSend" }) { + providers.insert(QuickShareProvider(id: "LocalSend", imageData: nil, supportsRawText: true), at: min(1, providers.count)) + } + + // System Share Menu fallback + if !providers.contains(where: { $0.id == "System Share Menu" }) { + providers.append(QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true)) + } + + return (providers, services) + }.value + + var providers = result.providers + if let idx = providers.firstIndex(where: { $0.id == "LocalSend" }) { + providers[idx].imageData = localSendIconData() + } + + self.cachedServices = result.services + self.availableProviders = providers + self.hasDiscovered = true + self.discoveryTask = nil + } + + private func localSendIconData() -> Data? { + guard let icon = NSImage(named: NSImage.Name("LocalSend")) else { + return nil + } + let thumbSize = NSSize(width: 32, height: 32) + let thumb = NSImage(size: thumbSize) + thumb.lockFocus() + icon.draw(in: NSRect(origin: .zero, size: thumbSize), + from: NSRect(origin: .zero, size: icon.size), + operation: .copy, + fraction: 1.0) + thumb.unlockFocus() + + guard let tiff = thumb.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff) + else { return nil } + + return bitmap.representation(using: .png, properties: [:]) + } + + // MARK: - File Picker + @MainActor + func showFilePicker(for provider: QuickShareProvider, from view: NSView?) async { + guard !isPickerOpen else { + print("⚠️ QuickShareService: File picker already open") + return + } + + isPickerOpen = true + SharingStateManager.shared.beginInteraction() + + // Improve interaction in dev/test builds where app activation can be flaky. + NSApp.activate(ignoringOtherApps: true) + + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = true + panel.canChooseFiles = true + panel.title = "Select Files for \(provider.id)" + panel.message = "Choose files to share via \(provider.id)" + + let completion: (NSApplication.ModalResponse) -> Void = { [weak self] response in + defer { + self?.isPickerOpen = false + SharingStateManager.shared.endInteraction() + } + + if response == .OK && !panel.urls.isEmpty { + Task { + await self?.shareFilesOrText(panel.urls, using: provider, from: view) + } + } + } + + if let window = view?.window { + panel.beginSheetModal(for: window, completionHandler: completion) + } else { + let response = panel.runModal() + completion(response) + } + } + + // MARK: - Sharing + @MainActor + func shareFilesOrText(_ items: [Any], using provider: QuickShareProvider, from view: NSView?) async { + let fileURLs = items.compactMap { $0 as? URL }.filter { $0.isFileURL } + // Stop any previous sharing access + stopSharingAccessingURLs() + // Start security-scoped access for all file URLs + sharingAccessingURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() } + + if provider.id == "LocalSend" { + SharingStateManager.shared.beginInteraction() + defer { + SharingStateManager.shared.endInteraction() + } + + do { + try await LocalSendService.shared.send(items: items) + } catch { + NSAlert.popError(error) + } + stopSharingAccessingURLs() + return + } + + // Setup lifecycle delegate to keep notch open during picker/service + let delegate = SharingStateManager.shared.makeDelegate { [weak self] in + self?.lifecycleDelegate = nil + self?.stopSharingAccessingURLs() + } + lifecycleDelegate = delegate + + if let svc = cachedServices[provider.id], svc.canPerform(withItems: items) { + // For direct service path, explicitly mark service interaction start + delegate.markServiceBegan() + svc.delegate = delegate + svc.perform(withItems: items) + } else { + let picker = NSSharingServicePicker(items: items) + picker.delegate = delegate + delegate.markPickerBegan() + if let view { + picker.show(relativeTo: .zero, of: view, preferredEdge: .minY) + } + } + } + + private func stopSharingAccessingURLs() { + guard !sharingAccessingURLs.isEmpty else { return } + NSLog("Stopping sharing access to URLs") + for url in sharingAccessingURLs { + url.stopAccessingSecurityScopedResource() + } + sharingAccessingURLs.removeAll() + } +// MARK: - SharingServiceDelegate + +private class SharingServiceDelegate: NSObject {} + + func shareDroppedFiles(_ providers: [NSItemProvider], using shareProvider: QuickShareProvider, from view: NSView?) async { + var itemsToShare: [Any] = [] + var foundText: String? + + for provider in providers { + if let webURL = await provider.extractURL() { + itemsToShare.append(webURL) + } else if foundText == nil, let text = await provider.extractText() { + foundText = text + } else if let itemFileURL = await provider.extractItem() { + let resolvedURL = await resolveShelfItemBookmark(for: itemFileURL) ?? itemFileURL + itemsToShare.append(resolvedURL) + } + } + + // If text was found, prioritize sharing it. + if let text = foundText { + if shareProvider.supportsRawText { + await shareFilesOrText([text], using: shareProvider, from: view) + } else { + if let tempTextURL = await TemporaryFileStorageService.shared.createTempFile(for: .text(text)) { + await shareFilesOrText([tempTextURL], using: shareProvider, from: view) + TemporaryFileStorageService.shared.removeTemporaryFileIfNeeded(at: tempTextURL) + } else { + await shareFilesOrText([text], using: shareProvider, from: view) + } + } + } else if !itemsToShare.isEmpty { + await shareFilesOrText(itemsToShare, using: shareProvider, from: view) + } + } + + private func resolveShelfItemBookmark(for fileURL: URL) async -> URL? { + let items = await ShelfStateViewModel.shared.items + + for itm in items { + if let resolved = await ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: itm) { + if resolved.standardizedFileURL.path == fileURL.standardizedFileURL.path { + return resolved + } + } + } + print("❌ Failed to resolve bookmark for shelf item") + return nil + } +} + +// MARK: - App Storage Extension for Provider Selection + +extension QuickShareProvider { + static var defaultProvider: QuickShareProvider { + let svc = QuickShareService.shared + + if let airdrop = svc.availableProviders.first(where: { $0.id == "AirDrop" }) { + return airdrop + } + return svc.availableProviders.first ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true) + } +} diff --git a/DynamicIsland/components/Shelf/Services/ShareServiceFinder.swift b/DynamicIsland/components/Shelf/Services/ShareServiceFinder.swift new file mode 100644 index 00000000..d9289ece --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/ShareServiceFinder.swift @@ -0,0 +1,74 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Cocoa + +class ShareServiceFinder: NSObject, NSSharingServicePickerDelegate { + + @MainActor + private var onServicesCaptured: (([NSSharingService]) -> Void)? + + /// Returns share services asynchronously without blocking the UI + @MainActor + func findApplicableServices(for items: [Any], timeout: TimeInterval = 2.0) async -> [NSSharingService] { + + let dummyView = NSView(frame: .zero) + let picker = NSSharingServicePicker(items: items) + picker.delegate = self + + return await withCheckedContinuation { continuation in + var didResume = false + + // Capture services callback + Task { @MainActor in + self.onServicesCaptured = { services in + guard !didResume else { return } + didResume = true + continuation.resume(returning: services) + } + } + + picker.show(relativeTo: dummyView.bounds, of: dummyView, preferredEdge: .minY) + + + // Timeout task + Task { @MainActor in + try? await Task.sleep(for: .seconds(timeout)) + guard !didResume else { return } + didResume = true + print("Warning: timed out waiting for sharing services") + continuation.resume(returning: []) + } + } + } + + // MARK: NSSharingServicePickerDelegate + + func sharingServicePicker(_ picker: NSSharingServicePicker, + sharingServicesForItems items: [Any], + proposedSharingServices proposed: [NSSharingService]) -> [NSSharingService] { + Task { @MainActor in + self.onServicesCaptured?(proposed) + } + return proposed + } +} diff --git a/DynamicIsland/components/Shelf/Services/ShelfActionService.swift b/DynamicIsland/components/Shelf/Services/ShelfActionService.swift new file mode 100644 index 00000000..bfb2f6a4 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/ShelfActionService.swift @@ -0,0 +1,74 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Foundation + +/// A service providing common actions for `ShelfItem`s, such as opening, revealing, or copying paths. +@MainActor +enum ShelfActionService { + + static func open(_ item: ShelfItem) { + switch item.kind { + case .file(let bookmark): + handleBookmarkedFile(bookmark) { url in + NSWorkspace.shared.open(url) + } + case .link(let url): + NSWorkspace.shared.open(url) + case .text(let string): + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(string, forType: .string) + } + } + + static func reveal(_ item: ShelfItem) { + guard case .file(let bookmark) = item.kind else { return } + handleBookmarkedFile(bookmark) { url in + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + } + + static func copyPath(_ item: ShelfItem) { + guard case .file(let bookmark) = item.kind else { return } + handleBookmarkedFile(bookmark) { url in + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url.path, forType: .string) + } + } + + static func remove(_ item: ShelfItem) { + ShelfStateViewModel.shared.remove(item) + } + + private static func handleBookmarkedFile(_ bookmarkData: Data, action: @escaping @Sendable (URL) -> Void) { + Task { + let bookmark = Bookmark(data: bookmarkData) + if let url = bookmark.resolveURL() { + url.accessSecurityScopedResource { accessibleURL in + action(accessibleURL) + } + } + } + } +} + diff --git a/DynamicIsland/components/Shelf/Services/ShelfDropService.swift b/DynamicIsland/components/Shelf/Services/ShelfDropService.swift new file mode 100644 index 00000000..066a7c0c --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/ShelfDropService.swift @@ -0,0 +1,103 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Foundation +import UniformTypeIdentifiers + +struct ShelfDropService { + static func items(from providers: [NSItemProvider]) async -> [ShelfItem] { + var results: [ShelfItem] = [] + + for provider in providers { + if let item = await processProvider(provider) { + results.append(item) + } + } + + return results + } + + static func items(from fileURLs: [URL]) async -> [ShelfItem] { + var results: [ShelfItem] = [] + let fileManager = FileManager.default + + for fileURL in fileURLs { + let resolvedURL = fileURL.standardizedFileURL + guard resolvedURL.isFileURL else { continue } + guard fileManager.fileExists(atPath: resolvedURL.path) else { + NSLog("⚠️ Ignoring non-existent file URL: \(resolvedURL.path)") + continue + } + + if let bookmark = createBookmark(for: resolvedURL) { + results.append(await ShelfItem(kind: .file(bookmark: bookmark), isTemporary: false)) + } + } + + return results + } + + private static func processProvider(_ provider: NSItemProvider) async -> ShelfItem? { + if let actualFileURL = await provider.extractFileURL() { + if let bookmark = createBookmark(for: actualFileURL) { + return await ShelfItem(kind: .file(bookmark: bookmark), isTemporary: false) + } + return nil + } + + if let url = await provider.extractURL() { + if url.isFileURL { + if let bookmark = createBookmark(for: url) { + return await ShelfItem(kind: .file(bookmark: bookmark), isTemporary: false) + } + } else { + return await ShelfItem(kind: .link(url: url), isTemporary: false) + } + return nil + } + + if let text = await provider.extractText() { + return await ShelfItem(kind: .text(string: text), isTemporary: false) + } + + if let data = await provider.loadData() { + if let tempDataURL = await TemporaryFileStorageService.shared.createTempFile(for: .data(data, suggestedName: provider.suggestedName)), + let bookmark = createBookmark(for: tempDataURL) { + return await ShelfItem(kind: .file(bookmark: bookmark), isTemporary: true) + } + return nil + } + + if let fileURL = await provider.extractItem() { + if let bookmark = createBookmark(for: fileURL) { + return await ShelfItem(kind: .file(bookmark: bookmark), isTemporary: false) + } + } + + return nil + } + + private static func createBookmark(for url: URL) -> Data? { + return (try? Bookmark(url: url))?.data + } +} diff --git a/DynamicIsland/components/Shelf/Services/ShelfPersistenceService.swift b/DynamicIsland/components/Shelf/Services/ShelfPersistenceService.swift new file mode 100644 index 00000000..a0b6a9f3 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/ShelfPersistenceService.swift @@ -0,0 +1,96 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +// Access model types +@_exported import struct Foundation.URL + + +final class ShelfPersistenceService { + static let shared = ShelfPersistenceService() + + private let fileURL: URL + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + private init() { + let fm = FileManager.default + let support = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let dir = (support ?? fm.temporaryDirectory).appendingPathComponent("DynamicIsland", isDirectory: true).appendingPathComponent("Shelf", isDirectory: true) + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + fileURL = dir.appendingPathComponent("items.json") + encoder.outputFormatting = [.prettyPrinted] + decoder.dateDecodingStrategy = .iso8601 + encoder.dateEncodingStrategy = .iso8601 + } + + func load() -> [ShelfItem] { + guard let data = try? Data(contentsOf: fileURL) else { return [] } + + // Try to decode as array first (normal case) + if let items = try? decoder.decode([ShelfItem].self, from: data) { + return items + } + + // If array decoding fails, try to decode individual items + do { + // Parse as JSON array to get individual item data + guard let jsonArray = try JSONSerialization.jsonObject(with: data) as? [Any] else { + print("⚠️ Shelf persistence file is not a valid JSON array") + return [] + } + + var validItems: [ShelfItem] = [] + var failedCount = 0 + + for (index, jsonItem) in jsonArray.enumerated() { + do { + let itemData = try JSONSerialization.data(withJSONObject: jsonItem) + let item = try decoder.decode(ShelfItem.self, from: itemData) + validItems.append(item) + } catch { + failedCount += 1 + print("⚠️ Failed to decode shelf item at index \(index): \(error.localizedDescription)") + } + } + + if failedCount > 0 { + print("📦 Successfully loaded \(validItems.count) shelf items, discarded \(failedCount) corrupted items") + } + + return validItems + } catch { + print("❌ Failed to parse shelf persistence file: \(error.localizedDescription)") + return [] + } + } + + func save(_ items: [ShelfItem]) { + do { + let data = try encoder.encode(items) + try data.write(to: fileURL, options: Data.WritingOptions.atomic) + } catch { + print("Failed to save shelf items: \(error.localizedDescription)") + } + } +} diff --git a/DynamicIsland/components/Shelf/Services/TemporaryFileStorageService.swift b/DynamicIsland/components/Shelf/Services/TemporaryFileStorageService.swift new file mode 100644 index 00000000..639765fa --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/TemporaryFileStorageService.swift @@ -0,0 +1,259 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import AppKit +import UniformTypeIdentifiers + +enum TempFileType { + case data(Data, suggestedName: String?) + case text(String) + case url(URL) +} + +class TemporaryFileStorageService { + static let shared = TemporaryFileStorageService() + + // MARK: - Public Interface + + /// Creates a temporary file and tracks it for manual cleanup + func createTempFile(for type: TempFileType) async -> URL? { + return await withCheckedContinuation { continuation in + let result = createTempFile(for: type) + continuation.resume(returning: result) + } + } + + func removeTemporaryFileIfNeeded(at url: URL) { + let tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) + + guard url.path.hasPrefix(tempDirectory.path) else { + print("Attempted to remove temporary file outside temp directory: \(url.path)") + return + } + + let folderURL = url.deletingLastPathComponent() + + do { + try FileManager.default.removeItem(at: url) + print("Deleted file: \(url.path)") + + let contents = try FileManager.default.contentsOfDirectory(atPath: folderURL.path) + if contents.isEmpty { + try FileManager.default.removeItem(at: folderURL) + print("Folder was empty, deleted folder: \(folderURL.path)") + } else { + print("Folder not deleted — it still contains \(contents.count) item(s).") + } + + } catch { + print("Error: \(error.localizedDescription)") + } + } + + // MARK: - Private Implementation + + private func createTempFile(for type: TempFileType) -> URL? { + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()) + let uuid = UUID().uuidString + + switch type { + case .data(let data, let suggestedName): + let filename = suggestedName ?? ".dat" + let dirURL = tempDir.appendingPathComponent(uuid, isDirectory: true) + let fileURL = dirURL.appendingPathComponent(filename) + + do { + try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) + try data.write(to: fileURL) + return fileURL + } catch { + print("Error: \(error)") + return nil + } + + case .text(let string): + let filename = "\(uuid).txt" + let dirURL = tempDir.appendingPathComponent(uuid, isDirectory: true) + let fileURL = dirURL.appendingPathComponent(filename) + + guard let data = string.data(using: .utf8) else { + print("❌ Failed to convert text to data") + return nil + } + + do { + try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) + try data.write(to: fileURL) + return fileURL + } catch { + print("Error: \(error)") + return nil + } + + case .url(let url): + let filename = "\(url.host ?? uuid).webloc" + let dirURL = tempDir.appendingPathComponent(uuid, isDirectory: true) + let fileURL = dirURL.appendingPathComponent(filename) + + let weblocContent = createWeblocContent(for: url) + guard let data = weblocContent.data(using: String.Encoding.utf8) else { + print("❌ Failed to create webloc data") + return nil + } + + do { + try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) + try data.write(to: fileURL) + return fileURL + } catch { + print("Error: \(error)") + return nil + } + } + } + + private func createFile(at url: URL, data: Data) -> URL? { + do { + try data.write(to: url) + return url + } catch { + print("❌ Failed to create temp file at \(url.path): \(error)") + return nil + } + } + func createZip(from urls: [URL], suggestedName: String? = nil) async -> URL? { + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()) + let uuid = UUID().uuidString + let workingDir = tempDir.appendingPathComponent("zip_\(uuid)", isDirectory: true) + + do { + try FileManager.default.createDirectory(at: workingDir, withIntermediateDirectories: true) + } catch { + print("❌ Failed to create zip working directory: \(error)") + return nil + } + + // Helper to run zip process + func runZip(arguments: [String], currentDirectory: URL) -> Bool { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/zip") + proc.arguments = arguments + proc.currentDirectoryURL = currentDirectory + do { + try proc.run() + proc.waitUntilExit() + return proc.terminationStatus == 0 + } catch { + print("❌ Failed to run zip: \(error)") + return false + } + } + + // Single-item optimization: do not copy contents into the working dir. + if urls.count == 1, let src = urls.first { + let isDir = (try? src.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + let baseName = src.lastPathComponent + let archiveName: String + if isDir { + // Folder: name as FolderName.zip and include the folder itself in the archive + archiveName = "\(baseName).zip" + let archiveURL = workingDir.appendingPathComponent(archiveName) + // Run zip from the parent directory so the folder is stored as top-level entry + let parent = src.deletingLastPathComponent() + let args = ["-r", "-q", archiveURL.path, baseName] + let ok = runZip(arguments: args, currentDirectory: parent) + if ok { + return archiveURL + } else { + return nil + } + } else { + // File: include the file only (no parent folders). Name should include original extension. + archiveName = "\(baseName).zip" + let archiveURL = workingDir.appendingPathComponent(archiveName) + let parent = src.deletingLastPathComponent() + // -j to junk paths and store only the file + let args = ["-j", "-q", archiveURL.path, baseName] + let ok = runZip(arguments: args, currentDirectory: parent) + if ok { + return archiveURL + } else { + return nil + } + } + } + + // Multi-item: copy items into working dir (so their relative structure is preserved), zip, then remove copies. + for src in urls { + let dest = workingDir.appendingPathComponent(src.lastPathComponent) + do { + if FileManager.default.fileExists(atPath: dest.path) { + // Avoid collision by appending a suffix + let unique = "\(UUID().uuidString)_\(src.lastPathComponent)" + try FileManager.default.copyItem(at: src, to: workingDir.appendingPathComponent(unique)) + } else { + try FileManager.default.copyItem(at: src, to: dest) + } + } catch { + print("⚠️ Failed to copy \(src.path) to working dir: \(error)") + } + } + + let archiveName = suggestedName ?? "Archive.zip" + let archiveURL = workingDir.appendingPathComponent(archiveName) + let args = ["-r", "-q", archiveURL.path, "."] + let ok = runZip(arguments: args, currentDirectory: workingDir) + if ok { + // Remove the copied (uncompressed) items so the temp folder contains only the archive + do { + let contents = try FileManager.default.contentsOfDirectory(at: workingDir, includingPropertiesForKeys: nil) + for file in contents { + if file.standardizedFileURL != archiveURL.standardizedFileURL { + try FileManager.default.removeItem(at: file) + } + } + } catch { + print("⚠️ Failed to cleanup working directory after zip: \(error)") + } + return archiveURL + } else { + return nil + } + } + + // MARK: - Content Creation Helpers + + + private func createWeblocContent(for url: URL) -> String { + return """ + + + + + URL + \(url.absoluteString) + + + """ + } +} diff --git a/DynamicIsland/components/Shelf/Services/ThumbnailService.swift b/DynamicIsland/components/Shelf/Services/ThumbnailService.swift new file mode 100644 index 00000000..8b206531 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/ThumbnailService.swift @@ -0,0 +1,113 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import AppKit +import QuickLookThumbnailing +import UniformTypeIdentifiers + +actor ThumbnailService { + static let shared = ThumbnailService() + + private var cache: [String: NSImage] = [:] + private var pendingRequests: [String: Task] = [:] + private let thumbnailGenerator = QLThumbnailGenerator.shared + + private init() {} + + func thumbnail(for url: URL, size: CGSize) async -> NSImage? { + let cacheKey = "\(url.path)_\(size.width)x\(size.height)" + + if let cached = cache[cacheKey] { + return cached + } + + if let pending = pendingRequests[cacheKey] { + return await pending.value + } + + let task = Task { + let thumbnail = await generateQuickLookThumbnail(for: url, size: size) + if let thumbnail = thumbnail { + cache[cacheKey] = thumbnail + } + pendingRequests[cacheKey] = nil + return thumbnail + } + + pendingRequests[cacheKey] = task + return await task.value + } + + func clearCache() { + cache.removeAll() + } + + func clearCache(for url: URL) { + cache = cache.filter { !$0.key.starts(with: url.path) } + } + + // MARK: - Private Methods + + private func generateQuickLookThumbnail(for url: URL, size: CGSize) async -> NSImage? { + let scale = await MainActor.run { NSScreen.main?.backingScaleFactor ?? 2.0 } + + return await url.accessSecurityScopedResource { scopedURL in + NSLog("🔐 ThumbnailService: obtaining security scope for \(scopedURL.path)") + let request = QLThumbnailGenerator.Request( + fileAt: scopedURL, + size: size, + scale: scale, + representationTypes: .all + ) + request.iconMode = true + + return await withCheckedContinuation { (continuation: CheckedContinuation) in + thumbnailGenerator.generateBestRepresentation(for: request) { representation, error in + if let rep = representation { + NSLog("🔍 ThumbnailService: generated thumbnail for \(scopedURL.path)") + continuation.resume(returning: rep.nsImage) + } else { + if let err = error { + NSLog("⚠️ ThumbnailService: thumbnail error for \(scopedURL.path): \(err.localizedDescription)") + } + continuation.resume(returning: nil) + } + } + } + } + } +} + +// MARK: - Extensions + +extension QLThumbnailRepresentation { + var nsImage: NSImage { + return NSImage(cgImage: self.cgImage, size: self.cgImage.size) + } +} + +extension CGImage { + var size: NSSize { + return NSSize(width: self.width, height: self.height) + } +} diff --git a/DynamicIsland/components/Shelf/TrayDrop.swift b/DynamicIsland/components/Shelf/TrayDrop.swift index b701b6a3..a7481a7b 100644 --- a/DynamicIsland/components/Shelf/TrayDrop.swift +++ b/DynamicIsland/components/Shelf/TrayDrop.swift @@ -1,3 +1,21 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + import Cocoa import Combine import Foundation diff --git a/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift b/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift new file mode 100644 index 00000000..ca57d624 --- /dev/null +++ b/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift @@ -0,0 +1,1127 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import CoreServices +import ObjectiveC + +@MainActor +final class ShelfItemViewModel: ObservableObject { + @Published private(set) var item: ShelfItem + @Published var thumbnail: NSImage? + @Published var isDropTargeted: Bool = false + @Published var isRenaming: Bool = false + @Published var draftTitle: String = "" + private var sharingLifecycle: SharingLifecycleDelegate? + private var quickShareLifecycle: SharingLifecycleDelegate? + private var sharingAccessingURLs: [URL] = [] + private static var copiedURLs: [URL] = [] + + private let selection = ShelfSelectionModel.shared + + init(item: ShelfItem) { + self.item = item + self.draftTitle = item.displayName + Task { await loadThumbnail() } + } + + var isSelected: Bool { selection.isSelected(item.id) } + + func loadThumbnail() async { + guard let url = item.fileURL else { return } + if let image = await ThumbnailService.shared.thumbnail(for: url, size: CGSize(width: 56, height: 56)) { + self.thumbnail = image + } + } + + // MARK: - Drag & Drop helpers + func dragItemProvider() -> NSItemProvider { + let selectedItems = selection.selectedItems(in: ShelfStateViewModel.shared.items) + if selectedItems.count > 1 && selectedItems.contains(where: { $0.id == item.id }) { + return createMultiItemProvider(for: selectedItems) + } + return createItemProvider(for: item) + } + + private func createItemProvider(for item: ShelfItem) -> NSItemProvider { + switch item.kind { + case .file: + let provider = NSItemProvider() + if let url = ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) { + provider.registerObject(url as NSURL, visibility: .all) + } else { + provider.registerObject(item.displayName as NSString, visibility: .all) + } + return provider + case .text(let string): + return NSItemProvider(object: string as NSString) + case .link(let url): + return NSItemProvider(object: url as NSURL) + } + } + + private func createMultiItemProvider(for items: [ShelfItem]) -> NSItemProvider { + let provider = NSItemProvider() + var urls: [URL] = [] + var textItems: [String] = [] + for item in items { + switch item.kind { + case .file: + if let url = ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) { + urls.append(url) + } else { + textItems.append(item.displayName) + } + case .text(let string): + textItems.append(string) + case .link: + break + } + } + if !urls.isEmpty { + for url in urls { + provider.registerObject(url as NSURL, visibility: .all) + } + } + if !textItems.isEmpty { + provider.registerObject(textItems.joined(separator: "\n") as NSString, visibility: .all) + } + return provider + } + + // MARK: - Actions + func handleClick(event: NSEvent, view: NSView) { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags.contains(.shift) { + selection.shiftSelect(to: item, in: ShelfStateViewModel.shared.items) + } else if flags.contains(.command) { + selection.toggle(item) + } else if flags.contains(.control) { + handleRightClick(event: event, view: view) + } else { + if !selection.isSelected(item.id) { selection.selectSingle(item) } + } + if event.clickCount == 2 { handleDoubleClick() } + } + + func handleRightClick(event: NSEvent, view: NSView) { + if !selection.isSelected(item.id) { selection.selectSingle(item) } + presentContextMenu(event: event, in: view) + } + + func handleDoubleClick() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + for it in selected { ShelfActionService.open(it) } + } + + func shareItem(from view: NSView?) { + Task { + var itemsToShare: [Any] = [] + var fileURLs: [URL] = [] + if case .text(let text) = item.kind { + itemsToShare.append(text) + } else { + for item in ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) { + switch item.kind { + case .file: + // Use immediate update for user-initiated share action + if let url = ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) { + itemsToShare.append(url) + fileURLs.append(url) + } + case .text(let string): + itemsToShare.append(string) + case .link(let url): + itemsToShare.append(url) + } + } + } + + guard !itemsToShare.isEmpty else { return } + + stopSharingAccessingURLs() + // Start security-scoped access for all file URLs and keep it active during sharing + sharingAccessingURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() } + + // Create and retain lifecycle delegate for the entire share operation + let lifecycle = SharingStateManager.shared.makeDelegate { [weak self] in + self?.sharingLifecycle = nil + self?.stopSharingAccessingURLs() + } + self.sharingLifecycle = lifecycle + + let picker = NSSharingServicePicker(items: itemsToShare) + picker.delegate = lifecycle + lifecycle.markPickerBegan() + if let view { + picker.show(relativeTo: .zero, of: view, preferredEdge: .minY) + } + } + } + + private func stopSharingAccessingURLs() { + for url in sharingAccessingURLs { + url.stopAccessingSecurityScopedResource() + } + sharingAccessingURLs.removeAll() + } + + /// Call this closure to request a QuickLook preview for the given URLs. + var onQuickLookRequest: (([URL]) -> Void)? + + // MARK: - Context Menu helpers (extracted from view) + func loadOpenWithApps() -> [URL] { + // Support both files and link items. For link items we ask NSWorkspace for apps that can open the URL (browsers). + if let fileURL = item.fileURL { + var results: [URL] = NSWorkspace.shared.urlsForApplications(toOpen: fileURL) + if results.isEmpty { + if let uti = try? fileURL.resourceValues(forKeys: [.contentTypeKey]).contentType { + results = NSWorkspace.shared.urlsForApplications(toOpen: uti) + } + } + let unique = Array(Set(results)) + let sorted = unique.sorted { appDisplayName(for: $0) < appDisplayName(for: $1) } + return sorted + } else if case .link(let url) = item.kind { + var results: [URL] = NSWorkspace.shared.urlsForApplications(toOpen: url) + if results.isEmpty { + if let uti = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType { + results = NSWorkspace.shared.urlsForApplications(toOpen: uti) + } + } + let unique = Array(Set(results)) + let sorted = unique.sorted { appDisplayName(for: $0) < appDisplayName(for: $1) } + return sorted + } + return [] + } + + private func ensureContextMenuSelection() { + if !selection.isSelected(item.id) { selection.selectSingle(item) } + } + + func presentContextMenu(event: NSEvent, in view: NSView) { + ensureContextMenuSelection() + let menu = NSMenu() + + func addMenuItem(title: String) { + let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "") + menu.addItem(mi) + } + + let selectedItems = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let selectedFileURLs = selectedItems.compactMap { $0.fileURL } + let selectedLinkURLs: [URL] = selectedItems.compactMap { itm in + if case .link(let url) = itm.kind { return url } + return nil + } + let selectedFolderURLs = selectedFileURLs.filter { isDirectory($0) } + // URLs valid for Open/Open With (exclude folders) + let selectedOpenableURLs = selectedItems.compactMap { itm -> URL? in + if let u = itm.fileURL { return isDirectory(u) ? nil : u } + if case .link(let url) = itm.kind { return url } + return nil + } + + if !selectedOpenableURLs.isEmpty { + addMenuItem(title: "Open") + } + + if !selectedOpenableURLs.isEmpty { + let openWith = NSMenuItem(title: "Open With", action: nil, keyEquivalent: "") + let submenu = NSMenu() + + // Choose a representative URL to compute apps (prefer current item if not a folder) + let baseURLForApps: URL? = { + if let u = item.fileURL, !isDirectory(u) { return u } + if case .link(let u) = item.kind { return u } + return selectedOpenableURLs.first + }() + + let openWithApps: [URL] = { + guard let u = baseURLForApps else { return [] } + if u.isFileURL { + var results = NSWorkspace.shared.urlsForApplications(toOpen: u) + if results.isEmpty, let uti = try? u.resourceValues(forKeys: [.contentTypeKey]).contentType { + results = NSWorkspace.shared.urlsForApplications(toOpen: uti) + } + return Array(Set(results)) + } else { + return Array(Set(NSWorkspace.shared.urlsForApplications(toOpen: u))) + } + }() + let defaultApp = defaultAppURL() + + if openWithApps.isEmpty { + let noApps = NSMenuItem(title: "No Compatible Apps Found", action: nil, keyEquivalent: "") + noApps.isEnabled = false + submenu.addItem(noApps) + } else { + if let defaultApp = defaultApp { + let appName = appDisplayName(for: defaultApp) + let def = NSMenuItem(title: appName, action: nil, keyEquivalent: "") + def.representedObject = defaultApp + def.image = nsAppIcon(for: defaultApp, size: 16) + + let title = NSMutableAttributedString(string: appName, attributes: [ + .font: NSFont.menuFont(ofSize: 0), + .foregroundColor: NSColor.labelColor + ]) + let defaultPart = NSAttributedString(string: " (default)", attributes: [ + .font: NSFont.menuFont(ofSize: 0), + .foregroundColor: NSColor.secondaryLabelColor + ]) + title.append(defaultPart) + def.attributedTitle = title + submenu.addItem(def) + + if openWithApps.count > 1 || !openWithApps.contains(defaultApp) { + submenu.addItem(NSMenuItem.separator()) + } + } + for appURL in openWithApps where appURL != defaultApp { + let mi = NSMenuItem(title: appDisplayName(for: appURL), action: nil, keyEquivalent: "") + mi.representedObject = appURL + mi.image = nsAppIcon(for: appURL, size: 16) + submenu.addItem(mi) + } + } + + submenu.addItem(NSMenuItem.separator()) + let other = NSMenuItem(title: "Other…", action: nil, keyEquivalent: "") + other.representedObject = "__OTHER__" + submenu.addItem(other) + + openWith.submenu = submenu + menu.addItem(openWith) + } + + if !selectedFileURLs.isEmpty { addMenuItem(title: "Show in Finder") } + // Allow Quick Look for files and link URLs + if !selectedFileURLs.isEmpty || !selectedLinkURLs.isEmpty { + // Add Quick Look menu item + let quickLookItem = NSMenuItem(title: "Quick Look", action: nil, keyEquivalent: "") + menu.addItem(quickLookItem) + + // Add Slideshow as alternate menu item (shown when Option key is held) + let slideshowItem = NSMenuItem(title: "Quick Look", action: nil, keyEquivalent: "") + slideshowItem.isAlternate = true + slideshowItem.keyEquivalentModifierMask = [.option] + menu.addItem(slideshowItem) + } + + menu.addItem(NSMenuItem.separator()) + addMenuItem(title: "Share…") + + // Add image processing options for image files grouped under "Image Actions" + let imageURLs = selectedFileURLs.filter { ImageProcessingService.shared.isImageFile($0) } + if !imageURLs.isEmpty { + menu.addItem(NSMenuItem.separator()) + + let imageActions = NSMenuItem(title: "Image Actions", action: nil, keyEquivalent: "") + let imageSubmenu = NSMenu() + + // Remove Background - only for single images + if imageURLs.count == 1 { + let removeBg = NSMenuItem(title: "Remove Background", action: nil, keyEquivalent: "") + imageSubmenu.addItem(removeBg) + } + + // Convert Image - only for single images + if imageURLs.count == 1 { + let convertItem = NSMenuItem(title: "Convert Image…", action: nil, keyEquivalent: "") + imageSubmenu.addItem(convertItem) + } + + // Create PDF - for one or more images + let createPDF = NSMenuItem(title: "Create PDF", action: nil, keyEquivalent: "") + imageSubmenu.addItem(createPDF) + + imageActions.submenu = imageSubmenu + menu.addItem(imageActions) + menu.addItem(NSMenuItem.separator()) + } + + // Add compression option for files/folders (single or multiple) + if !selectedFileURLs.isEmpty { + let compressItem = NSMenuItem(title: "Compress", action: nil, keyEquivalent: "") + menu.addItem(compressItem) + } + + if selectedItems.count == 1, case .file(_) = item.kind { addMenuItem(title: "Rename") } + + // Always show "Copy" for all item types + addMenuItem(title: "Copy") + // If there are file URLs, add "Copy Path" as an alternate menu item (Option key) + if !selectedFileURLs.isEmpty { + let copyPathItem = NSMenuItem(title: "Copy Path", action: nil, keyEquivalent: "") + copyPathItem.isAlternate = true + copyPathItem.keyEquivalentModifierMask = [.option] + menu.addItem(copyPathItem) + } + + menu.addItem(NSMenuItem.separator()) + addMenuItem(title: "Remove") + + let actionTarget = MenuActionTarget(item: item, view: view, viewModel: self) + + for menuItem in menu.items { + if menuItem.isSeparatorItem { continue } + menuItem.target = actionTarget + menuItem.action = #selector(MenuActionTarget.handle(_:)) + + if let submenu = menuItem.submenu { + for subItem in submenu.items { + if !subItem.isSeparatorItem { + subItem.target = actionTarget + subItem.action = #selector(MenuActionTarget.handle(_:)) + } + } + } + } + + menu.retainActionTarget(actionTarget) + + NSMenu.popUpContextMenu(menu, with: event, for: view) + } + + private func isDirectory(_ url: URL) -> Bool { + return url.accessSecurityScopedResource { scoped in + (try? scoped.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + } + } + + private final class MenuActionTarget: NSObject { + let item: ShelfItem + weak var view: NSView? + unowned let viewModel: ShelfItemViewModel + + // Keep associated objects (like accessory view handlers) without magic keys + private static var sliderHandlerAssoc = AssociatedObject() + + init(item: ShelfItem, view: NSView, viewModel: ShelfItemViewModel) { + self.item = item + self.view = view + self.viewModel = viewModel + } + + @MainActor @objc func handle(_ sender: NSMenuItem) { + let title = sender.title + + if let marker = sender.representedObject as? String, marker == "__OTHER__" { + openWithPanel() + return + } + + if let appURL = sender.representedObject as? URL { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + + Task { + var allSelectedURLs: [URL] = [] + + for itm in selected { + if let fileURL = itm.fileURL { + allSelectedURLs.append(fileURL) + } else if case .link(let url) = itm.kind { + allSelectedURLs.append(url) + } + } + + guard !allSelectedURLs.isEmpty else { return } + + let config = NSWorkspace.OpenConfiguration() + + let fileURLs = allSelectedURLs.filter { $0.isFileURL } + do { + if !fileURLs.isEmpty { + _ = try await fileURLs.accessSecurityScopedResources { _ in + try await NSWorkspace.shared.open(allSelectedURLs, withApplicationAt: appURL, configuration: config) + } + } else { + try await NSWorkspace.shared.open(allSelectedURLs, withApplicationAt: appURL, configuration: config) + } + } catch { + print("❌ Failed to open with application: \(error.localizedDescription)") + } + } + return + } + + switch title { + case "Quick Look": + // Handle all selected items for Quick Look, not just the clicked item + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let urls: [URL] = selected.compactMap { item in + if let fileURL = item.fileURL { + return fileURL + } + if case .link(let url) = item.kind { + return url + } + return nil + } + if !urls.isEmpty { + viewModel.onQuickLookRequest?(urls) + } + + case "Open": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + for it in selected { ShelfActionService.open(it) } + + case "Share…": + viewModel.shareItem(from: view) + + case "Rename": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + if selected.count == 1, let single = selected.first { showRenameDialog(for: single) } + + case "Show in Finder": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + Task { + let urls = await selected.asyncCompactMap { item -> URL? in + if case .file = item.kind { + // Use immediate update for user-initiated menu action + return await ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) + } + return nil + } + if !urls.isEmpty { + await urls.accessSecurityScopedResources { accessibleURLs in + NSWorkspace.shared.activateFileViewerSelecting(accessibleURLs) + } + } + } + + case "Copy Path": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let paths = selected.compactMap { $0.fileURL?.path } + if !paths.isEmpty { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(paths.joined(separator: "\n"), forType: .string) + } + + case "Copy": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let pb = NSPasteboard.general + + // Stop accessing previously copied URLs + for url in ShelfItemViewModel.copiedURLs { + url.stopAccessingSecurityScopedResource() + } + ShelfItemViewModel.copiedURLs.removeAll() + + pb.clearContents() + Task { + let fileURLs = await selected.asyncCompactMap { item -> URL? in + if case .file = item.kind { + return ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) + } + return nil + } + if !fileURLs.isEmpty { + // Start security-scoped access for all URLs and keep them active + ShelfItemViewModel.copiedURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() } + NSLog("🔐 Started security-scoped access for \(ShelfItemViewModel.copiedURLs.count) copied files") + + // Write to pasteboard + pb.writeObjects(fileURLs as [NSURL]) + } else { + let strings = selected.map { $0.displayName } + if !strings.isEmpty { + pb.setString(strings.joined(separator: "\n"), forType: .string) + } + } + } + + case "Remove": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + for it in selected { ShelfActionService.remove(it) } + + case "Remove Background": + handleRemoveBackground() + + case "Convert Image…": + showConvertImageDialog() + + case "Create PDF": + handleCreatePDF() + + case "Compress": + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let fileURLs = selected.compactMap { $0.fileURL } + guard !fileURLs.isEmpty else { break } + + Task { + do { + // Create ZIP in a temporary location while holding access to selected resources + if let zipTempURL = try await fileURLs.accessSecurityScopedResources(accessor: { urls in + await TemporaryFileStorageService.shared.createZip(from: urls) + }) { + if let bookmark = try? Bookmark(url: zipTempURL) { + let newItem = ShelfItem(kind: .file(bookmark: bookmark.data), isTemporary: true) + ShelfStateViewModel.shared.add([newItem]) + } else { + // Fallback: reveal the temporary file in Finder + NSWorkspace.shared.activateFileViewerSelecting([zipTempURL]) + } + } + } catch { + print("❌ Compress failed: \(error)") + } + } + + default: + break + } + } + + @MainActor + private func openWithPanel() { + // Support both file items and link items + let targetURL: URL? + let needsSecurityScope: Bool + + if let fileURL = item.fileURL { + targetURL = fileURL + needsSecurityScope = true + } else if case .link(let url) = item.kind { + targetURL = url + needsSecurityScope = false + } else { + targetURL = nil + needsSecurityScope = false + } + guard let fileURL = targetURL else { return } + + let panel = NSOpenPanel() + panel.title = "Choose Application" + panel.message = "Choose an application to open the document \"\(item.displayName)\"." + panel.prompt = "Open" + panel.allowsMultipleSelection = false + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.resolvesAliases = true + if #available(macOS 12.0, *) { + panel.allowedContentTypes = [.application] + } + panel.directoryURL = URL(fileURLWithPath: "/Applications") + + // Compute recommended applications for the selected target + let recommendedApps: Set = { + let apps: [URL] + if let uti = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType { + apps = NSWorkspace.shared.urlsForApplications(toOpen: uti) + } else { + apps = NSWorkspace.shared.urlsForApplications(toOpen: fileURL) + } + return Set(apps.map { $0.standardizedFileURL }) + }() + + // Delegate to filter entries when in "Recommended Applications" mode + final class AppChooserDelegate: NSObject, NSOpenSavePanelDelegate { + enum Mode { case recommended, all } + var mode: Mode = .recommended + let recommended: Set + init(recommended: Set) { self.recommended = recommended } + + func panel(_ sender: Any, shouldEnable url: URL) -> Bool { + let ext = url.pathExtension.lowercased() + if ext == "app" { + switch mode { + case .all: + return true + case .recommended: + // Standardize URLs for reliable comparison + let std = url.standardizedFileURL + return recommended.contains(std) + } + } + + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue { + return true + } + + return false + } + } + + let chooserDelegate = AppChooserDelegate(recommended: recommendedApps) + panel.delegate = chooserDelegate + + let enableLabel = NSTextField(labelWithString: "Enable:") + enableLabel.font = .systemFont(ofSize: NSFont.systemFontSize) + enableLabel.alignment = .natural + enableLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + let popup = NSPopUpButton(frame: .zero, pullsDown: false) + popup.addItems(withTitles: ["Recommended Applications", "All Applications"]) + popup.font = .systemFont(ofSize: NSFont.systemFontSize) + popup.selectItem(at: 0) + + popup.setContentHuggingPriority(.defaultLow, for: .horizontal) + popup.widthAnchor.constraint(greaterThanOrEqualToConstant: 200).isActive = true + + let alwaysCheckbox = NSButton(checkboxWithTitle: "Always Open With", target: nil, action: nil) + alwaysCheckbox.font = .systemFont(ofSize: NSFont.systemFontSize) + alwaysCheckbox.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let row = NSStackView(views: [enableLabel, popup]) + row.orientation = .horizontal + row.spacing = 8 + row.alignment = .centerY + row.distribution = .fill + + let column = NSStackView(views: [row, alwaysCheckbox]) + column.orientation = .vertical + column.spacing = 12 + column.alignment = .centerX + column.distribution = .fill + column.edgeInsets = NSEdgeInsets(top: 16, left: 20, bottom: 16, right: 20) + + panel.accessoryView = column + panel.isAccessoryViewDisclosed = true + + // Wire up popup to switch filter mode + class PopupBinder: NSObject { + weak var popup: NSPopUpButton? + weak var chooserDelegate: AppChooserDelegate? + weak var panel: NSOpenPanel? + init(popup: NSPopUpButton, chooserDelegate: AppChooserDelegate, panel: NSOpenPanel) { + self.popup = popup + self.chooserDelegate = chooserDelegate + self.panel = panel + } + @objc func changed(_ sender: Any?) { + if popup?.indexOfSelectedItem == 1 { + chooserDelegate?.mode = .all + } else { + chooserDelegate?.mode = .recommended + } + if let panel = panel { + panel.validateVisibleColumns() + let currentDir = panel.directoryURL + panel.directoryURL = currentDir + } + } + } + let binder = PopupBinder(popup: popup, chooserDelegate: chooserDelegate, panel: panel) + popup.target = binder + popup.action = #selector(PopupBinder.changed(_:)) + + panel.begin { response in + if response == .OK, let appURL = panel.url { + Task { + do { + let config = NSWorkspace.OpenConfiguration() + if alwaysCheckbox.state == .on, let bundleID = Bundle(url: appURL)?.bundleIdentifier { + if let contentType = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType { + let status = LSSetDefaultRoleHandlerForContentType(contentType.identifier as CFString, LSRolesMask.all, bundleID as CFString) + if status != noErr { print("⚠️ Failed to set default handler for \(contentType.identifier): \(status)") } + } else if let scheme = fileURL.scheme { + let status = LSSetDefaultHandlerForURLScheme(scheme as CFString, bundleID as CFString) + if status != noErr { print("⚠️ Failed to set default handler for scheme \(scheme): \(status)") } + } + } + + if needsSecurityScope { + _ = try await fileURL.accessSecurityScopedResource { accessibleURL in + try await NSWorkspace.shared.open([accessibleURL], withApplicationAt: appURL, configuration: config) + } + } else { + try await NSWorkspace.shared.open([fileURL], withApplicationAt: appURL, configuration: config) + } + } catch { + print("❌ Failed to open with application: \(error.localizedDescription)") + } + } + } + // Keep binder/delegate alive until panel finishes + _ = binder + _ = chooserDelegate + } + } + + @MainActor + private func showRenameDialog(for item: ShelfItem) { + guard case let .file(bookmarkData) = item.kind else { return } + Task { + let bookmark = Bookmark(data: bookmarkData) + if let fileURL = bookmark.resolveURL() { + // Start security-scoped access and keep it active until rename completes. + let didStart = fileURL.startAccessingSecurityScopedResource() + + let savePanel = NSSavePanel() + savePanel.title = "Rename File" + savePanel.prompt = "Rename" + savePanel.nameFieldStringValue = fileURL.lastPathComponent + savePanel.directoryURL = fileURL.deletingLastPathComponent() + savePanel.begin { response in + if response == .OK, let newURL = savePanel.url { + Task { + do { + NSLog("🔐 Rename: moving from \(fileURL.path) to \(newURL.path) (securityScope=\(didStart))") + + try FileManager.default.moveItem(at: fileURL, to: newURL) + + if let newBookmark = try? Bookmark(url: newURL) { + ShelfStateViewModel.shared.updateBookmark(for: item, bookmark: newBookmark.data) + } + } catch { + print("❌ Failed to rename file: \(error.localizedDescription)") + } + if didStart { fileURL.stopAccessingSecurityScopedResource() } + } + } else { + if didStart { fileURL.stopAccessingSecurityScopedResource() } + } + } + } + } + } + + @MainActor + private func handleRemoveBackground() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } + + guard let imageURL = imageURLs.first else { return } + + Task { + do { + let resultURL = try await imageURL.accessSecurityScopedResource { url in + try await ImageProcessingService.shared.removeBackground(from: url) + } + + if let resultURL = resultURL { + // Create bookmark and add to shelf as temporary item + if let bookmark = try? Bookmark(url: resultURL) { + let newItem = ShelfItem( + kind: .file(bookmark: bookmark.data), + isTemporary: true + ) + ShelfStateViewModel.shared.add([newItem]) + } + } + } catch { + print("❌ Failed to remove background: \(error.localizedDescription)") + await showErrorAlert(title: "Background Removal Failed", message: error.localizedDescription) + } + } + } + + @MainActor + private func handleCreatePDF() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } + + guard !imageURLs.isEmpty else { return } + + Task { + do { + let resultURL = try await imageURLs.accessSecurityScopedResources { urls in + try await ImageProcessingService.shared.createPDF(from: urls) + } + + if let resultURL = resultURL { + // Create bookmark and add to shelf as temporary item + if let bookmark = try? Bookmark(url: resultURL) { + let newItem = ShelfItem( + kind: .file(bookmark: bookmark.data), + isTemporary: true + ) + ShelfStateViewModel.shared.add([newItem]) + } + } + } catch { + print("❌ Failed to create PDF: \(error.localizedDescription)") + await showErrorAlert(title: "PDF Creation Failed", message: error.localizedDescription) + } + } + } + + @MainActor + private func showConvertImageDialog() { + let selected = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let imageURLs = selected.compactMap { $0.fileURL }.filter { ImageProcessingService.shared.isImageFile($0) } + + guard let imageURL = imageURLs.first else { return } + + // Create and show conversion options dialog with better layout + let alert = NSAlert() + alert.messageText = "Convert Image" + alert.alertStyle = .informational + alert.addButton(withTitle: "Convert") + alert.addButton(withTitle: "Cancel") + + // Create accessory view with better spacing and organization + let accessoryView = NSView(frame: NSRect(x: 0, y: 0, width: 380, height: 180)) + accessoryView.wantsLayer = true + + // MARK: Format Row + let formatLabel = NSTextField(labelWithString: "Format:") + formatLabel.frame = NSRect(x: 0, y: 145, width: 100, height: 20) + formatLabel.font = .systemFont(ofSize: 12, weight: .medium) + accessoryView.addSubview(formatLabel) + + let formatPopup = NSPopUpButton(frame: NSRect(x: 120, y: 140, width: 250, height: 28)) + formatPopup.addItems(withTitles: ["PNG", "JPEG", "HEIC", "TIFF", "BMP"]) + formatPopup.selectItem(at: 0) + formatPopup.font = .systemFont(ofSize: 12) + accessoryView.addSubview(formatPopup) + + // MARK: Image Size Row + let imageSizeLabel = NSTextField(labelWithString: "Image Size:") + imageSizeLabel.frame = NSRect(x: 0, y: 105, width: 100, height: 20) + imageSizeLabel.font = .systemFont(ofSize: 12, weight: .medium) + accessoryView.addSubview(imageSizeLabel) + + let imageSizePopup = NSPopUpButton(frame: NSRect(x: 120, y: 100, width: 160, height: 28)) + imageSizePopup.addItems(withTitles: ["Actual Size", "Large", "Medium", "Small", "Custom..."]) + imageSizePopup.selectItem(at: 0) + imageSizePopup.font = .systemFont(ofSize: 12) + accessoryView.addSubview(imageSizePopup) + + // Custom size field (initially hidden) + let customSizeField = NSTextField(frame: NSRect(x: 285, y: 103, width: 85, height: 22)) + customSizeField.placeholderString = "e.g., 1920" + customSizeField.font = .systemFont(ofSize: 12) + customSizeField.isHidden = true + accessoryView.addSubview(customSizeField) + + // MARK: Preserve Metadata Checkbox + let metadataCheckbox = NSButton(checkboxWithTitle: "Preserve Metadata", target: nil, action: nil) + metadataCheckbox.frame = NSRect(x: 120, y: 65, width: 200, height: 20) + metadataCheckbox.font = .systemFont(ofSize: 12) + metadataCheckbox.state = .on + accessoryView.addSubview(metadataCheckbox) + + // MARK: Separator line + let separatorLine = NSView(frame: NSRect(x: 0, y: 50, width: 380, height: 1)) + separatorLine.wantsLayer = true + separatorLine.layer?.backgroundColor = NSColor.separatorColor.cgColor + accessoryView.addSubview(separatorLine) + + // MARK: Format-specific options (shown/hidden based on format selection) + let qualityRow = NSView(frame: NSRect(x: 0, y: 15, width: 380, height: 30)) + qualityRow.wantsLayer = true + + let qualityLabel = NSTextField(labelWithString: "Compression:") + qualityLabel.frame = NSRect(x: 0, y: 7, width: 100, height: 20) + qualityLabel.font = .systemFont(ofSize: 12, weight: .medium) + qualityRow.addSubview(qualityLabel) + + let qualitySlider = NSSlider(frame: NSRect(x: 120, y: 12, width: 200, height: 20)) + qualitySlider.minValue = 0.0 + qualitySlider.maxValue = 1.0 + qualitySlider.doubleValue = 0.85 + accessoryView.addSubview(qualitySlider) + + let qualityValueLabel = NSTextField(labelWithString: "85%") + qualityValueLabel.frame = NSRect(x: 325, y: 7, width: 55, height: 20) + qualityValueLabel.font = .systemFont(ofSize: 12) + qualityValueLabel.alignment = .left + accessoryView.addSubview(qualityValueLabel) + + // Update quality label and hide/show compression row based on format + let updateQualityLabel = { + let value = Int(qualitySlider.doubleValue * 100) + qualityValueLabel.stringValue = "\(value)%" + } + + let updateCompressionVisibility = { + let formatIndex = formatPopup.indexOfSelectedItem + let showCompression = formatIndex == 1 || formatIndex == 2 // JPEG or HEIC + qualitySlider.isHidden = !showCompression + qualityValueLabel.isHidden = !showCompression + qualityLabel.isHidden = !showCompression + } + + let updateCustomSizeVisibility = { + let sizeIndex = imageSizePopup.indexOfSelectedItem + customSizeField.isHidden = sizeIndex != 4 // Show only for "Custom..." + } + + // Create a target object to handle slider value changes + class SliderHandler: NSObject { + let updateLabel: () -> Void + let updateVisibility: () -> Void + let updateCustomSize: () -> Void + init(updateLabel: @escaping () -> Void, updateVisibility: @escaping () -> Void, updateCustomSize: @escaping () -> Void) { + self.updateLabel = updateLabel + self.updateVisibility = updateVisibility + self.updateCustomSize = updateCustomSize + } + @objc func sliderChanged(_ sender: NSSlider) { + updateLabel() + } + @objc func formatChanged(_ sender: NSPopUpButton) { + updateVisibility() + } + @objc func sizeChanged(_ sender: NSPopUpButton) { + updateCustomSize() + } + } + + let handler = SliderHandler(updateLabel: updateQualityLabel, updateVisibility: updateCompressionVisibility, updateCustomSize: updateCustomSizeVisibility) + qualitySlider.target = handler + qualitySlider.action = #selector(SliderHandler.sliderChanged(_:)) + qualitySlider.isContinuous = true + + formatPopup.target = handler + formatPopup.action = #selector(SliderHandler.formatChanged(_:)) + + imageSizePopup.target = handler + imageSizePopup.action = #selector(SliderHandler.sizeChanged(_:)) + + updateCompressionVisibility() + updateQualityLabel() + updateCustomSizeVisibility() + + // Keep the handler alive using the `AssociatedObject` helper instead of a magic string key + MenuActionTarget.sliderHandlerAssoc[accessoryView] = handler + + alert.accessoryView = accessoryView + + let response = alert.runModal() + + if response == .alertFirstButtonReturn { + // Get selected options + let formatIndex = formatPopup.indexOfSelectedItem + let format: ImageConversionOptions.ImageFormat + switch formatIndex { + case 0: format = .png + case 1: format = .jpeg + case 2: format = .heic + case 3: format = .tiff + case 4: format = .bmp + default: format = .png + } + + let quality = qualitySlider.doubleValue + + // Get max dimension based on image size selection + let maxDimension: CGFloat? = { + let sizeIndex = imageSizePopup.indexOfSelectedItem + switch sizeIndex { + case 0: return nil // Actual Size + case 1: return 1280 // Large + case 2: return 640 // Medium + case 3: return 320 // Small + case 4: // Custom (user-specified) + let text = customSizeField.stringValue.trimmingCharacters(in: .whitespaces) + guard !text.isEmpty, let value = Double(text), value > 0 else { return nil } + return CGFloat(value) + default: return nil + } + }() + + let removeMetadata = metadataCheckbox.state == .off // Note: we invert this + + let options = ImageConversionOptions( + format: format, + compressionQuality: quality, + maxDimension: maxDimension, + removeMetadata: removeMetadata + ) + + Task { + do { + let resultURL = try await imageURL.accessSecurityScopedResource { url in + try await ImageProcessingService.shared.convertImage(from: url, options: options) + } + + if let resultURL = resultURL { + // Create bookmark and add to shelf as temporary item + if let bookmark = try? Bookmark(url: resultURL) { + let newItem = ShelfItem( + kind: .file(bookmark: bookmark.data), + isTemporary: true + ) + ShelfStateViewModel.shared.add([newItem]) + } + } + } catch { + print("❌ Failed to convert image: \(error.localizedDescription)") + showErrorAlert(title: "Image Conversion Failed", message: error.localizedDescription) + } + } + } + } + + @MainActor + private func showErrorAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.runModal() + } + } + + // MARK: - Private helpers + private func appDisplayName(for appURL: URL) -> String { + (try? appURL.resourceValues(forKeys: [.localizedNameKey]).localizedName) ?? appURL.lastPathComponent + } + + private func nsAppIcon(for appURL: URL, size: CGFloat) -> NSImage? { + let baseIcon = NSWorkspace.shared.icon(forFile: appURL.path) + baseIcon.isTemplate = false + + let targetSize = NSSize(width: size, height: size) + let rendered = NSImage(size: targetSize, flipped: false) { rect in + NSGraphicsContext.current?.imageInterpolation = .high + baseIcon.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1.0, respectFlipped: true, hints: [ + .interpolation: NSImageInterpolation.high.rawValue + ]) + return true + } + + rendered.size = targetSize + return rendered + } + + private func defaultAppURL() -> URL? { + if let fileURL = item.fileURL { + return NSWorkspace.shared.urlForApplication(toOpen: fileURL) + } else if case .link(let url) = item.kind { + return NSWorkspace.shared.urlForApplication(toOpen: url) + } + return nil + } +} + +fileprivate extension Sequence { + func asyncCompactMap(_ transform: (Element) async -> T?) async -> [T] { + var result: [T] = [] + for element in self { + if let transformed = await transform(element) { + result.append(transformed) + } + } + return result + } +} diff --git a/DynamicIsland/components/Shelf/ViewModels/ShelfSelectionModel.swift b/DynamicIsland/components/Shelf/ViewModels/ShelfSelectionModel.swift new file mode 100644 index 00000000..815029c7 --- /dev/null +++ b/DynamicIsland/components/Shelf/ViewModels/ShelfSelectionModel.swift @@ -0,0 +1,115 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Combine + +private let _shelfTypeAnchor: Bool = { + _ = String(describing: ShelfItem.self) + return true +}() + +@MainActor +final class ShelfSelectionModel: ObservableObject { + static let shared = ShelfSelectionModel() + + @Published private(set) var selectedIDs: Set = [] + + // Anchor for shift-range selection + private var lastAnchorID: UUID? = nil + + func isSelected(_ id: UUID) -> Bool { selectedIDs.contains(id) } + + var hasSelection: Bool { !selectedIDs.isEmpty } + + var firstSelectedItem: ShelfItem? { + guard let firstID = selectedIDs.first else { return nil } + return ShelfStateViewModel.shared.items.first(where: { $0.id == firstID }) + } + + func selectedItems(in allItems: [ShelfItem]) -> [ShelfItem] { + allItems.filter { selectedIDs.contains($0.id) } + } + + func selectSingle(_ item: ShelfItem) { + selectedIDs = [item.id] + lastAnchorID = item.id + } + + func toggle(_ item: ShelfItem) { + if selectedIDs.contains(item.id) { + selectedIDs.remove(item.id) + } else { + selectedIDs.insert(item.id) + } + lastAnchorID = item.id + } + + func shiftSelect(to item: ShelfItem, in allItems: [ShelfItem]) { + // Determine anchor + let anchorID = lastAnchorID ?? selectedIDs.first ?? item.id + guard let startIndex = allItems.firstIndex(where: { $0.id == anchorID }), + let endIndex = allItems.firstIndex(where: { $0.id == item.id }) else { + // Fallback to single select if indices not found + return selectSingle(item) + } + let lower = min(startIndex, endIndex) + let upper = max(startIndex, endIndex) + let rangeIDs = allItems[lower...upper].map { $0.id } + selectedIDs = Set(rangeIDs) + } + + func clear() { + selectedIDs.removeAll() + lastAnchorID = nil + } + + func reconcileSelection(with allItems: [ShelfItem]) { + let validIDs = Set(allItems.map(\.id)) + let filteredSelection = selectedIDs.intersection(validIDs) + + if filteredSelection != selectedIDs { + selectedIDs = filteredSelection + } + + if let anchor = lastAnchorID, !validIDs.contains(anchor) { + lastAnchorID = selectedIDs.first + } + } + + // Keep anchor sane if items array changed drastically (optional helper) + func ensureValidAnchor(in allItems: [ShelfItem]) { + if let anchor = lastAnchorID, !allItems.contains(where: { $0.id == anchor }) { + lastAnchorID = selectedIDs.first + } + } + + @Published private(set) var isDragging: Bool = false + + func beginDrag() { + isDragging = true + } + + func endDrag() { + isDragging = false + } +} diff --git a/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift b/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift new file mode 100644 index 00000000..8c69de00 --- /dev/null +++ b/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift @@ -0,0 +1,168 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import AppKit + +@MainActor +final class ShelfStateViewModel: ObservableObject { + static let shared = ShelfStateViewModel() + + @Published private(set) var items: [ShelfItem] = [] { + didSet { + ShelfPersistenceService.shared.save(items) + ShelfSelectionModel.shared.reconcileSelection(with: items) + } + } + + @Published var isLoading: Bool = false + + var isEmpty: Bool { items.isEmpty } + + // Queue for deferred bookmark updates to avoid publishing during view updates + private var pendingBookmarkUpdates: [ShelfItem.ID: Data] = [:] + private var updateTask: Task? + + private init() { + items = ShelfPersistenceService.shared.load() + } + + + func add(_ newItems: [ShelfItem]) { + guard !newItems.isEmpty else { return } + var merged = items + // Deduplicate by identityKey while preserving order (existing first) + var seen: Set = Set(merged.map { $0.identityKey }) + var addedIDs: [String] = [] + for it in newItems { + let key = it.identityKey + if !seen.contains(key) { + merged.append(it) + seen.insert(key) + addedIDs.append(it.id.uuidString) + } + } + items = merged + if !addedIDs.isEmpty { + ExtensionRPCServer.shared.notifyShelfItemsChanged(itemIDs: addedIDs, action: "added") + } + } + + func remove(_ item: ShelfItem) { + item.cleanupStoredData() + items.removeAll { $0.id == item.id } + ExtensionRPCServer.shared.notifyShelfItemsChanged(itemIDs: [item.id.uuidString], action: "removed") + } + + func updateBookmark(for item: ShelfItem, bookmark: Data) { + guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return } + if case .file = items[idx].kind { + items[idx].kind = .file(bookmark: bookmark) + } + } + + private func scheduleDeferredBookmarkUpdate(for item: ShelfItem, bookmark: Data) { + pendingBookmarkUpdates[item.id] = bookmark + + // Cancel existing task and schedule a new one + updateTask?.cancel() + updateTask = Task { @MainActor [weak self] in + await Task.yield() + + guard let self = self else { return } + + for (itemID, bookmarkData) in self.pendingBookmarkUpdates { + if let idx = self.items.firstIndex(where: { $0.id == itemID }), + case .file = self.items[idx].kind { + self.items[idx].kind = .file(bookmark: bookmarkData) + } + } + + self.pendingBookmarkUpdates.removeAll() + } + } + + + func load(_ providers: [NSItemProvider]) { + guard !providers.isEmpty else { return } + isLoading = true + Task { [weak self] in + let dropped = await ShelfDropService.items(from: providers) + await MainActor.run { + self?.add(dropped) + self?.isLoading = false + } + } + } + + func cleanupInvalidItems() { + Task { [weak self] in + guard let self else { return } + var keep: [ShelfItem] = [] + for item in self.items { + switch item.kind { + case .file(let data): + let bookmark = Bookmark(data: data) + if await bookmark.validate() { + keep.append(item) + } else { + item.cleanupStoredData() + } + default: + keep.append(item) + } + } + await MainActor.run { self.items = keep } + } + } + + + func resolveFileURL(for item: ShelfItem) -> URL? { + guard case .file(let bookmarkData) = item.kind else { return nil } + let bookmark = Bookmark(data: bookmarkData) + let result = bookmark.resolve() + if let refreshed = result.refreshedData, refreshed != bookmarkData { + NSLog("Bookmark for \(item) stale; refreshing") + scheduleDeferredBookmarkUpdate(for: item, bookmark: refreshed) + } + return result.url + } + + func resolveAndUpdateBookmark(for item: ShelfItem) -> URL? { + guard case .file(let bookmarkData) = item.kind else { return nil } + let bookmark = Bookmark(data: bookmarkData) + let result = bookmark.resolve() + if let refreshed = result.refreshedData, refreshed != bookmarkData { + NSLog("Bookmark for \(item) stale; refreshing") + updateBookmark(for: item, bookmark: refreshed) + } + return result.url + } + + func resolveFileURLs(for items: [ShelfItem]) -> [URL] { + var urls: [URL] = [] + for it in items { + if let u = resolveFileURL(for: it) { urls.append(u) } + } + return urls + } +} diff --git a/DynamicIsland/components/Shelf/Views/DragPreviewView.swift b/DynamicIsland/components/Shelf/Views/DragPreviewView.swift new file mode 100644 index 00000000..dc69c75c --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/DragPreviewView.swift @@ -0,0 +1,51 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import AppKit + +struct DragPreviewView: View { + let thumbnail: NSImage? + let displayName: String + + var body: some View { + VStack(alignment: .center, spacing: 4) { + Image(nsImage: thumbnail ?? NSImage()) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + Text(displayName) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .lineLimit(2) + .truncationMode(.middle) + .multilineTextAlignment(.center) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 4).fill(Color.accentColor)) + .frame(alignment: .top) + } + .frame(width: 105) + } +} diff --git a/DynamicIsland/components/Shelf/Views/FileShareView.swift b/DynamicIsland/components/Shelf/Views/FileShareView.swift new file mode 100644 index 00000000..13e23166 --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/FileShareView.swift @@ -0,0 +1,338 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Defaults +import SwiftUI +import UniformTypeIdentifiers + +struct FileShareView: View { + @EnvironmentObject private var vm: DynamicIslandViewModel + @StateObject private var quickShare = QuickShareService.shared + @StateObject private var localSend = LocalSendService.shared + @Default(.quickShareProvider) var quickShareProvider: String + @State private var showQuickSharePopover = false + @State private var isSwitchHover = false + @State private var autoCloseToken = UUID() + + @State private var hostView: NSView? + @State private var interactionNonce: UUID = .init() + @State private var isProcessing = false + @State private var pendingDropProviders: [NSItemProvider]? + @State private var showLocalSendPicker = false + + private var selectedProvider: QuickShareProvider { + quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true) + } + + private var notchToggleProviders: [QuickShareProvider] { + quickShare.availableProviders.filter { $0.id == "AirDrop" || $0.id == "LocalSend" } + } + + var body: some View { + dropArea + .background(NSViewHost(view: $hostView)) + .onAppear { + quickShare.ensureDiscovered() + localSend.startDiscovery() + } + .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data, .image], isTargeted: $vm.dropZoneTargeting) { providers in + interactionNonce = .init() + vm.dropEvent = true + if selectedProvider.id == "LocalSend" { + pendingDropProviders = providers + showLocalSendPicker = true + } else { + Task { await handleDrop(providers) } + } + return true + } + .onTapGesture { + guard quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) != nil else { return } + // Only open picker on taps when AirDrop or LocalSend is selected + if quickShareProvider == "AirDrop" || quickShareProvider == "LocalSend" { + Task { await handleClick() } + } + } + } + + private var dropArea: some View { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill( + LinearGradient(colors: [Color.black.opacity(0.35), Color.black.opacity(0.20)], startPoint: .topLeading, endPoint: .bottomTrailing) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke( + vm.dropZoneTargeting + ? Color.accentColor.opacity(0.9) + : Color.white.opacity(0.1), + style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10]) + ) + ) + .shadow(color: Color.black.opacity(0.6), radius: 6, x: 0, y: 2) + + // Content + VStack(spacing: 5) { + ZStack { + Circle() + .fill(Color.white.opacity( + vm.dropZoneTargeting ? 0.11 : 0.09 + )) + .frame(width: 55, height: 55) + Image(systemName: "square.and.arrow.up") + Group { + if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) { + Image(nsImage: nsImg) + .resizable() + .scaledToFit() + .frame(width: 34, height: 34) + .clipped() + } else { + Image(systemName: "square.and.arrow.up") + .resizable() + .scaledToFit() + .frame(width: 34, height: 34) + } + } + .foregroundStyle( + vm.dropZoneTargeting ? Color.accentColor : Color.gray + ) + .scaleEffect( + vm.dropZoneTargeting ? 1.06 : 1.0 + ) + .animation(.spring(response: 0.36, dampingFraction: 0.7), value: vm.dropZoneTargeting) + } + + Text(selectedProvider.id) + .font(.system(.headline, design: .rounded)) + .foregroundColor(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity) + + } + .padding(18) + .frame(maxWidth: .infinity) + + // Switch button pinned to top-right corner + VStack { + HStack { + Spacer() + Button { + vm.setAutoCloseSuppression(true, token: autoCloseToken) + quickShare.ensureDiscovered() + showQuickSharePopover.toggle() + } label: { + Image(systemName: "switch.2") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .padding(8) + .background(RoundedRectangle(cornerRadius: 8).fill(isSwitchHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear)) + .foregroundColor(isSwitchHover ? .accentColor : .gray) + } + .buttonStyle(PlainButtonStyle()) + .onHover { hovering in + isSwitchHover = hovering + vm.setAutoCloseSuppression(hovering, token: autoCloseToken) + } + .popover(isPresented: $showQuickSharePopover, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 8) { + Text("Quick Share") + .font(.headline) + + Picker("Quick Share Service", selection: $quickShareProvider) { + ForEach(quickShare.availableProviders, id: \.id) { provider in + HStack(spacing: 8) { + Group { + if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) { + Image(nsImage: nsImg) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } else { + Image(systemName: "square.and.arrow.up") + .frame(width: 16, height: 16) + } + } + .foregroundColor(.accentColor) + + Text(provider.id) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + .fixedSize(horizontal: true, vertical: false) + } + .tag(provider.id) + } + } + .pickerStyle(.menu) + .frame(minWidth: 260) + + if let selected = quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) { + HStack(alignment: .top, spacing: 8) { + Group { + if let imgData = selected.imageData, let nsImg = NSImage(data: imgData) { + Image(nsImage: nsImg) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + Image(systemName: "square.and.arrow.up") + .frame(width: 20, height: 20) + } + } + .foregroundColor(.accentColor) + + VStack(alignment: .leading, spacing: 2) { + Text("Currently: \(selected.id)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + .fixedSize(horizontal: true, vertical: false) + Text("Files shared from the shelf will use this service") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + .padding() + .onAppear { vm.setAutoCloseSuppression(true, token: autoCloseToken) } + .onDisappear { + // Delay clearing suppression slightly so the close click doesn't immediately close the notch + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + vm.setAutoCloseSuppression(false, token: autoCloseToken) + } + } + .onHover { hovering in vm.setAutoCloseSuppression(hovering, token: autoCloseToken) } + } + .padding(.trailing, 8) + .padding(.top, 8) + } + Spacer() + } + + // Loading overlay + if isProcessing || quickShare.isPickerOpen { + RoundedRectangle(cornerRadius: 12) + .fill(.black.opacity(0.3)) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + ) + } + + if selectedProvider.id == "LocalSend" && localSend.isSending { + VStack { + HStack { + Spacer() + ZStack { + Circle() + .stroke(Color.white.opacity(0.16), lineWidth: 3) + + Circle() + .trim(from: 0, to: min(max(localSend.sendProgress, 0), 1)) + .stroke( + Color.white, + style: StrokeStyle(lineWidth: 3, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: localSend.sendProgress) + + if localSend.sendProgress > 0.99 { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(.white) + } + } + .frame(width: 24, height: 24) + .padding(.top, 10) + .padding(.trailing, 10) + } + Spacer() + } + .transition(.opacity) + } + } + .contentShape(RoundedRectangle(cornerRadius: 12)) + .onChange(of: showLocalSendPicker) { _, show in + if show { + LocalSendDevicePickerWindowManager.shared.show( + onDeviceSelected: { device in + localSend.selectedDeviceID = device.id + if let providers = pendingDropProviders { + // Close the picker first since handleDrop will use quickShare properly + showLocalSendPicker = false + Task { + await handleDrop(providers) + pendingDropProviders = nil + } + } + }, + onDismiss: { + showLocalSendPicker = false + pendingDropProviders = nil + } + ) + } else { + LocalSendDevicePickerWindowManager.shared.hide() + } + } + } + + // MARK: - Actions + + private func handleDrop(_ providers: [NSItemProvider]) async { + isProcessing = true + defer { isProcessing = false } + await quickShare.shareDroppedFiles(providers, using: selectedProvider, from: hostView) + } + + private func handleClick() async { + await quickShare.showFilePicker(for: selectedProvider, from: hostView) + } +} + +// MARK: - Host NSView extractor for anchoring share sheet + +private struct NSViewHost: NSViewRepresentable { + @Binding var view: NSView? + + func makeNSView(context: Context) -> NSView { + let v = NSView(frame: .zero) + DispatchQueue.main.async { self.view = v } + return v + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { self.view = nsView } + } +} diff --git a/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift b/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift new file mode 100644 index 00000000..15a72a21 --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift @@ -0,0 +1,173 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct LocalSendLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @StateObject private var localSend = LocalSendService.shared + + @State private var isHovering: Bool = false + @State private var isExpanded: Bool = false + + private var tint: Color { + switch localSend.transferState { + case .completed: + return .green + case .failed, .rejected: + return .red + default: + return .accentColor + } + } + + private var isActive: Bool { + localSend.isSending || localSend.transferState == .completed || + isFailedOrRejected + } + + private var isFailedOrRejected: Bool { + if case .failed = localSend.transferState { return true } + if case .rejected = localSend.transferState { return true } + return false + } + + var body: some View { + HStack(spacing: 0) { + // Left side: upload icon capsule + Color.clear + .background { + if isExpanded { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(tint.opacity(0.14)) + + Image(systemName: leftIcon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + } + .frame( + width: vm.effectiveClosedNotchHeight - 12, + height: vm.effectiveClosedNotchHeight - 12 + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + } + .frame( + width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + + // Center: closed notch body (slightly wider during transfers) + Rectangle() + .fill(.black) + .frame( + width: vm.closedNotchSize.width + + (isHovering ? 8 : 0) + + (isActive ? 40 : 0) + ) + + // Right side: progress ring or status icon + Color.clear + .background { + if isExpanded { + HStack { + rightContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + } + .frame( + width: isExpanded ? max(60, vm.effectiveClosedNotchHeight) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .onAppear { + withAnimation(.smooth(duration: 0.35)) { + isExpanded = true + } + } + .onChange(of: isActive) { _, newValue in + withAnimation(.smooth(duration: 0.35)) { + isExpanded = newValue + } + } + } + + private var leftIcon: String { + // Always show document upload icon on left side + return "document.badge.arrow.up.fill" + } + + @ViewBuilder + private var rightContent: some View { + switch localSend.transferState { + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .failed: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .rejected: + Image(systemName: "hand.raised.fill") + .foregroundStyle(.orange) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .sending, .idle: + LocalSendProgressRing(progress: localSend.sendProgress) + .padding(.trailing, 6) + } + } +} + +// MARK: - Progress Ring + +private struct LocalSendProgressRing: View { + let progress: Double + + var body: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.2), lineWidth: 2.5) + + Circle() + .trim(from: 0, to: min(max(progress, 0), 1)) + .stroke( + Color.accentColor, + style: StrokeStyle(lineWidth: 2.5, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: progress) + + if progress > 0.99 { + Image(systemName: "checkmark") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(.accentColor) + } + } + .frame(width: 20, height: 20) + } +} diff --git a/DynamicIsland/components/Shelf/Views/ShelfInlineLiveActivity.swift b/DynamicIsland/components/Shelf/Views/ShelfInlineLiveActivity.swift new file mode 100644 index 00000000..b190182b --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/ShelfInlineLiveActivity.swift @@ -0,0 +1,56 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +/// Standalone inline HUD shown in the closed Dynamic Island when the +/// File Tray (Shelf) has items and no higher-priority live activity is active. +/// +/// Layout mirrors the battery inline HUD: +/// [tray icon] ─── [ notch ] ─── [bold green count] +struct ShelfInlineLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject private var shelfState = ShelfStateViewModel.shared + + private let sideWidth: CGFloat = 24 + + var body: some View { + HStack(spacing: 0) { + + // LEFT of notch — tray icon (white, same style as the screenshot) + Image(systemName: "tray.fill") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + .frame(width: sideWidth, alignment: .leading) + + // Physical notch / pill space + Rectangle() + .fill(Color.black) + .frame(width: vm.closedNotchSize.width) + + // RIGHT of notch — file count in white + Text("\(shelfState.items.count)") + .font(.system(.callout, design: .rounded, weight: .bold)) + .foregroundStyle(.white) + .contentTransition(.numericText(countsDown: false)) + .animation(.smooth(duration: 0.3), value: shelfState.items.count) + .frame(width: sideWidth, alignment: .trailing) + } + .frame(height: vm.effectiveClosedNotchHeight, alignment: .center) + } +} diff --git a/DynamicIsland/components/Shelf/Views/ShelfItemView.swift b/DynamicIsland/components/Shelf/Views/ShelfItemView.swift new file mode 100644 index 00000000..e266e087 --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/ShelfItemView.swift @@ -0,0 +1,415 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI +import Defaults + +import QuickLook + +struct ShelfItemView: View { + let item: ShelfItem + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var selection = ShelfSelectionModel.shared + @StateObject private var viewModel: ShelfItemViewModel + @EnvironmentObject private var quickLookService: QuickLookService + @State private var showStack = false + @State private var cachedPreviewImage: NSImage? + @State private var debouncedDropTarget = false + + private var isSelected: Bool { viewModel.isSelected } + private var shouldHideDuringDrag: Bool { selection.isDragging && selection.isSelected(item.id) && false } + + init(item: ShelfItem) { + self.item = item + _viewModel = StateObject(wrappedValue: ShelfItemViewModel(item: item)) + } + + var body: some View { + ZStack { + if !shouldHideDuringDrag { + VStack(alignment: .center, spacing: 2) { + iconView + textView + } + .frame(width: 105) + .padding(.vertical, 10) + .padding(.horizontal, 5) + .background(backgroundView) + .contentShape(Rectangle()) + .animation(.easeInOut(duration: 0.1), value: debouncedDropTarget) + .animation(.easeInOut(duration: 0.1), value: isSelected) + + DraggableClickHandler( + item: item, + viewModel: viewModel, + cachedPreviewImage: $cachedPreviewImage, + dragPreviewContent: { + DragPreviewView(thumbnail: viewModel.thumbnail ?? item.icon, displayName: item.displayName) + }, + onRightClick: viewModel.handleRightClick, + onClick: { event, nsview in + viewModel.handleClick(event: event, view: nsview) + } + ) + } else { + Color.clear + .frame(width: 105) + .padding(.vertical, 10) + .padding(.horizontal, 5) + } + } + .onChange(of: viewModel.isDropTargeted) { _, targeted in + vm.dragDetectorTargeting = targeted + // Debounce drop target state changes + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) + debouncedDropTarget = targeted + } + } + .onAppear { + Task { + await viewModel.loadThumbnail() + // Pre-render drag preview once on appear + if cachedPreviewImage == nil { + cachedPreviewImage = await renderDragPreview() + } + } + viewModel.onQuickLookRequest = { urls in + quickLookService.show(urls: urls, selectFirst: true) + } + } + .onChange(of: viewModel.thumbnail) { _, _ in + // Invalidate cached preview when thumbnail changes + Task { + cachedPreviewImage = await renderDragPreview() + } + } + .quickLookPresenter(using: quickLookService) + } + + // MARK: - View Components + + private var iconView: some View { + Image(nsImage: viewModel.thumbnail ?? item.icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.15), radius: 3, x: 0, y: 2) + } + + private var textView: some View { + Text(item.displayName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.white) + .lineLimit(2) + .truncationMode(.middle) + .multilineTextAlignment(.center) + .frame(height: 30, alignment: .top) + } + + private var backgroundView: some View { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + strokeColor, + lineWidth: strokeWidth + ) + ) + } + + private var backgroundColor: Color { + if debouncedDropTarget { + return Color.accentColor.opacity(0.25) + } else if isSelected { + return Color.accentColor.opacity(0.15) + } else { + return Color.clear + } + } + + private var strokeColor: Color { + if debouncedDropTarget { + return Color.accentColor.opacity(0.9) + } else if isSelected { + return Color.accentColor.opacity(0.8) + } else { + return Color.clear + } + } + + private var strokeWidth: CGFloat { + if debouncedDropTarget { + return 3 + } else if isSelected { + return 2 + } else { + return 1 + } + } + + // MARK: - Drag Preview Rendering + + @MainActor + private func renderDragPreview() async -> NSImage { + let content = DragPreviewView(thumbnail: viewModel.thumbnail ?? item.icon, displayName: item.displayName) + let renderer = ImageRenderer(content: content) + renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0 + return renderer.nsImage ?? (viewModel.thumbnail ?? item.icon) + } + + +} + +// MARK: - Draggable Click Handler with NSDraggingSource +private struct DraggableClickHandler: NSViewRepresentable { + let item: ShelfItem + let viewModel: ShelfItemViewModel + @Binding var cachedPreviewImage: NSImage? + @ViewBuilder let dragPreviewContent: () -> Content + let onRightClick: (NSEvent, NSView) -> Void + let onClick: (NSEvent, NSView) -> Void + + func makeNSView(context: Context) -> DraggableClickView { + let view = DraggableClickView() + view.item = item + view.viewModel = viewModel + view.dragPreviewImage = cachedPreviewImage ?? renderDragPreview() + view.onRightClick = onRightClick + view.onClick = onClick + return view + } + + func updateNSView(_ nsView: DraggableClickView, context: Context) { + nsView.item = item + nsView.viewModel = viewModel + // Only update preview if cached version is available + if let cached = cachedPreviewImage { + nsView.dragPreviewImage = cached + } + nsView.onRightClick = onRightClick + nsView.onClick = onClick + } + + private func renderDragPreview() -> NSImage { + let content = dragPreviewContent() + let renderer = ImageRenderer(content: content) + renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0 + + if let nsImage = renderer.nsImage { + return nsImage + } + + // Fallback to icon if rendering fails + return viewModel.thumbnail ?? item.icon + } + + final class DraggableClickView: NSView, NSDraggingSource { + var item: ShelfItem! + weak var viewModel: ShelfItemViewModel? + var dragPreviewImage: NSImage? + var onRightClick: ((NSEvent, NSView) -> Void)? + var onClick: ((NSEvent, NSView) -> Void)? + + private var mouseDownEvent: NSEvent? + private let dragThreshold: CGFloat = 3.0 + private var draggedURLs: [URL] = [] + private var draggedItems: [ShelfItem] = [] + private var didStartDragSession = false + + override func rightMouseDown(with event: NSEvent) { + onRightClick?(event, self) + } + + override func mouseDown(with event: NSEvent) { + mouseDownEvent = event + didStartDragSession = false + } + + override func mouseUp(with event: NSEvent) { + defer { + mouseDownEvent = nil + didStartDragSession = false + } + + guard let mouseDownEvent, !didStartDragSession else { + super.mouseUp(with: event) + return + } + + onClick?(mouseDownEvent, self) + } + + override func mouseDragged(with event: NSEvent) { + guard let mouseDownEvent = mouseDownEvent else { + super.mouseDragged(with: event) + return + } + + let dragDistance = hypot( + event.locationInWindow.x - mouseDownEvent.locationInWindow.x, + event.locationInWindow.y - mouseDownEvent.locationInWindow.y + ) + + if dragDistance > dragThreshold { + didStartDragSession = true + prepareSelectionForDrag(using: mouseDownEvent) + startDragSession(with: event) + self.mouseDownEvent = nil + } else { + super.mouseDragged(with: event) + } + } + + private func prepareSelectionForDrag(using initialEvent: NSEvent) { + let flags = initialEvent.modifierFlags.intersection(.deviceIndependentFlagsMask) + + if flags.contains(.shift) || flags.contains(.command) || flags.contains(.control) { + return + } + + if !ShelfSelectionModel.shared.isSelected(item.id) { + ShelfSelectionModel.shared.selectSingle(item) + } + } + + private func startDragSession(with event: NSEvent) { + // Prepare dragging items + let selectedItems = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + let itemsToDrag: [ShelfItem] + + if selectedItems.count > 1 && selectedItems.contains(where: { $0.id == item.id }) { + itemsToDrag = selectedItems + } else { + itemsToDrag = [item] + } + + // Store items being dragged for auto-remove feature + draggedItems = itemsToDrag + + // Create dragging items for AppKit + var draggingItems: [NSDraggingItem] = [] + + for dragItem in itemsToDrag { + if let pasteboardItem = createPasteboardItem(for: dragItem) { + let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem) + + // Use the drag preview image + let image = dragPreviewImage ?? dragItem.icon + let imageFrame = NSRect( + x: 0, + y: 0, + width: image.size.width, + height: image.size.height + ) + draggingItem.setDraggingFrame(imageFrame, contents: image) + + draggingItems.append(draggingItem) + } + } + + guard !draggingItems.isEmpty else { return } + + beginDraggingSession(with: draggingItems, event: event, source: self) + } + + private func createPasteboardItem(for item: ShelfItem) -> NSPasteboardItem? { + let pasteboardItem = NSPasteboardItem() + + switch item.kind { + case .file: + guard let url = ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) else { + pasteboardItem.setString(item.displayName, forType: .string) + return pasteboardItem + } + + // Start accessing security-scoped resource and keep it active during drag + if url.startAccessingSecurityScopedResource() { + draggedURLs.append(url) + NSLog("🔐 Started security-scoped access for drag: \(url.path)") + } + + pasteboardItem.setString(url.absoluteString, forType: .fileURL) + pasteboardItem.setString(url.path, forType: .string) + return pasteboardItem + + case .text(let string): + pasteboardItem.setString(string, forType: .string) + return pasteboardItem + + case .link(let url): + pasteboardItem.setString(url.absoluteString, forType: .URL) + pasteboardItem.setString(url.absoluteString, forType: .string) + return pasteboardItem + } + } + + // MARK: - NSDraggingSource + + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + // When copyOnDrag is enabled, only allow copy operations + if Defaults[.copyOnDrag] { + return [.copy] + } + + switch context { + case .outsideApplication: + return [.copy, .move] + case .withinApplication: + return [.copy, .move, .generic] + @unknown default: + return [.copy] + } + } + + func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) { + ShelfSelectionModel.shared.beginDrag() + } + + + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + ShelfSelectionModel.shared.endDrag() + + // Stop accessing security-scoped resources after drag completes + for url in draggedURLs { + url.stopAccessingSecurityScopedResource() + NSLog("🔐 Stopped security-scoped access after drag: \(url.path)") + } + draggedURLs.removeAll() + + // Auto-remove items from shelf if enabled and drag succeeded + if Defaults[.autoRemoveShelfItems] && !operation.isEmpty { + for item in draggedItems { + ShelfStateViewModel.shared.remove(item) + } + } + draggedItems.removeAll() + } + + func ignoreModifierKeys(for session: NSDraggingSession) -> Bool { + return false + } + } +} diff --git a/DynamicIsland/components/Shelf/Views/ShelfView.swift b/DynamicIsland/components/Shelf/Views/ShelfView.swift new file mode 100644 index 00000000..f53cb3a9 --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/ShelfView.swift @@ -0,0 +1,158 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import AppKit + +private struct ShelfBackgroundClickCatcher: NSViewRepresentable { + let onClick: () -> Void + + func makeNSView(context: Context) -> BackgroundClickView { + let view = BackgroundClickView() + view.onClick = onClick + return view + } + + func updateNSView(_ nsView: BackgroundClickView, context: Context) { + nsView.onClick = onClick + } + + final class BackgroundClickView: NSView { + var onClick: (() -> Void)? + + override func mouseUp(with event: NSEvent) { + onClick?() + } + } +} + +struct ShelfView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @StateObject var tvm = ShelfStateViewModel.shared + @StateObject var selection = ShelfSelectionModel.shared + @StateObject private var quickLookService = QuickLookService() + private let spacing: CGFloat = 8 + + var body: some View { + HStack(spacing: 12) { + FileShareView() + .aspectRatio(1, contentMode: .fit) + .environmentObject(vm) + panel + .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], isTargeted: $vm.dragDetectorTargeting) { providers in + handleDrop(providers: providers) + } + } + // Bind Quick Look to shelf selection + .onChange(of: selection.selectedIDs) { + updateQuickLookSelection() + } + .quickLookPresenter(using: quickLookService) + } + + private func handleDrop(providers: [NSItemProvider]) -> Bool { + guard !selection.isDragging else { return false } + vm.dropEvent = true + ShelfStateViewModel.shared.load(providers) + return true + } + + private func updateQuickLookSelection() { + guard quickLookService.isQuickLookOpen && !selection.selectedIDs.isEmpty else { return } + + let selectedItems = selection.selectedItems(in: tvm.items) + let urls: [URL] = selectedItems.compactMap { item in + if let fileURL = item.fileURL { + return fileURL + } + if case .link(let url) = item.kind { + return url + } + return nil + } + + if !urls.isEmpty { + quickLookService.updateSelection(urls: urls) + } + } + + var panel: some View { + RoundedRectangle(cornerRadius: 16) + .stroke( + vm.dragDetectorTargeting + ? Color.accentColor.opacity(0.9) + : Color.white.opacity(0.1), + style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10]) + ) + .overlay { + ZStack { + ShelfBackgroundClickCatcher { + guard !selection.isDragging else { return } + selection.clear() + } + + content + .padding() + } + } + .transaction { transaction in + transaction.animation = vm.animation + } + .contentShape(Rectangle()) + } + + var content: some View { + Group { + if tvm.isEmpty { + VStack(spacing: 10) { + Image(systemName: "tray.and.arrow.down") + .symbolVariant(.fill) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.white, .gray) + .imageScale(.large) + + Text("Drop files here") + .foregroundStyle(.gray) + .font(.system(.title3, design: .rounded)) + .fontWeight(.medium) + } + } else { + ScrollView(.horizontal) { + HStack(spacing: spacing) { + ForEach(tvm.items) { item in + ShelfItemView(item: item) + .environmentObject(quickLookService) + } + } + } + .padding(-spacing) + .scrollIndicators(.never) + .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], isTargeted: $vm.dragDetectorTargeting) { providers in + handleDrop(providers: providers) + } + } + } + .onAppear { + ShelfStateViewModel.shared.cleanupInvalidItems() + } + } +} diff --git a/DynamicIsland/components/Stats/CPUStatsDetailView.swift b/DynamicIsland/components/Stats/CPUStatsDetailView.swift new file mode 100644 index 00000000..08b11783 --- /dev/null +++ b/DynamicIsland/components/Stats/CPUStatsDetailView.swift @@ -0,0 +1,65 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct CPUStatsDetailView: View { + @ObservedObject private var statsManager = StatsManager.shared + @State private var topProcesses: [ProcessStats] = [] + + private let systemColor = Color(red: 0.94, green: 0.32, blue: 0.28) + private let userColor = Color(red: 0.27, green: 0.52, blue: 0.97) + private let idleColor = Color.gray.opacity(0.28) + + var body: some View { + ScrollView { + VStack(spacing: 16) { + StatsCard(title: String(localized: "CPU Overview")) { + CPUUsageDashboard( + breakdown: statsManager.cpuBreakdown, + loadAverage: statsManager.cpuLoadAverage, + uptime: statsManager.cpuUptime, + coreCount: statsManager.cpuCoreUsage.count, + systemColor: systemColor, + userColor: userColor, + idleColor: idleColor, + temperature: statsManager.cpuTemperature, + frequency: statsManager.cpuFrequency + ) + } + + StatsCard(title: String(localized: "Top Processes")) { + CPUProcessList(processes: topProcesses, accentColor: userColor) + } + + if !statsManager.cpuCoreUsage.isEmpty { + StatsCard(title: String(localized: "Per-Core Usage")) { + CPUCoreUsageGrid(cores: statsManager.cpuCoreUsage, accentColor: userColor) + } + } + } + .padding(16) + } + .onReceive(statsManager.$topCPUProcesses) { processes in + topProcesses = Array(processes.prefix(8)) + } + .onAppear { + topProcesses = Array(statsManager.topCPUProcesses.prefix(8)) + } + } +} diff --git a/DynamicIsland/components/Stats/CPUStatsSharedViews.swift b/DynamicIsland/components/Stats/CPUStatsSharedViews.swift new file mode 100644 index 00000000..773a7880 --- /dev/null +++ b/DynamicIsland/components/Stats/CPUStatsSharedViews.swift @@ -0,0 +1,562 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Defaults +import SwiftUI + +struct StatsCard: View { + let title: String + let padding: CGFloat + let background: Color + let cornerRadius: CGFloat + let content: Content + + init( + title: String, + padding: CGFloat = 16, + background: Color = Color(nsColor: .windowBackgroundColor).opacity(0.65), + cornerRadius: CGFloat = 12, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.padding = padding + self.background = background + self.cornerRadius = cornerRadius + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + + content + } + .padding(padding) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(background) + ) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.05), lineWidth: 0.5) + ) + } +} + +struct CPUUsageDashboard: View { + let breakdown: CPULoadBreakdown + let loadAverage: LoadAverage + let uptime: TimeInterval + let coreCount: Int + let systemColor: Color + let userColor: Color + let idleColor: Color + let temperature: CPUTemperatureMetrics + let frequency: CPUFrequencyMetrics? + + var body: some View { + let usageSection = VStack(alignment: .leading, spacing: 12) { + Text("Usage") + .font(.caption) + .foregroundColor(.secondary) + DetailRow(color: systemColor.opacity(0.95), label: String(localized: "System"), value: StatsFormatting.percentage(breakdown.system)) + DetailRow(color: userColor.opacity(0.95), label: String(localized: "User"), value: StatsFormatting.percentage(breakdown.user)) + DetailRow(color: idleColor.opacity(0.7), label: String(localized: "Idle"), value: StatsFormatting.percentage(breakdown.idle)) + DetailRow(color: nil, label: String(localized: "stats_resource_active", defaultValue: "Active"), value: StatsFormatting.percentage(breakdown.activeUsage)) + } + .frame(maxWidth: .infinity, alignment: .leading) + + let systemSection = VStack(alignment: .leading, spacing: 10) { + Text("System") + .font(.caption) + .foregroundColor(.secondary) + DetailRow(color: nil, label: String(localized: "Logical cores"), value: "\(max(coreCount, 1))") + DetailRow(color: nil, label: String(localized: "Uptime"), value: StatsFormatting.abbreviatedDuration(uptime)) + Divider().padding(.vertical, 4) + DetailRow(color: nil, label: String(localized: "Load 1m"), value: String(format: "%.2f", loadAverage.oneMinute)) + DetailRow(color: nil, label: String(localized: "Load 5m"), value: String(format: "%.2f", loadAverage.fiveMinutes)) + DetailRow(color: nil, label: String(localized: "Load 15m"), value: String(format: "%.2f", loadAverage.fifteenMinutes)) + } + .frame(maxWidth: .infinity, alignment: .leading) + + let primaryGauges = CPUSensorGaugeGroup( + breakdown: breakdown, + temperature: temperature, + frequency: frequency, + systemColor: systemColor, + userColor: userColor, + idleColor: idleColor, + usageTint: userColor, + temperatureTint: Color(red: 0.98, green: 0.53, blue: 0.18) + ) + .frame(maxWidth: .infinity, alignment: .center) + + return ViewThatFits(in: .horizontal) { + VStack(alignment: .leading, spacing: 20) { + primaryGauges + usageSection + Divider().padding(.vertical, 4) + systemSection + } + HStack(alignment: .top, spacing: 28) { + primaryGauges + .frame(maxWidth: .infinity, alignment: .center) + usageSection + .frame(maxWidth: .infinity, alignment: .leading) + systemSection + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} + +private struct CPUSensorGaugeGroup: View { + let breakdown: CPULoadBreakdown + let temperature: CPUTemperatureMetrics + let frequency: CPUFrequencyMetrics? + let systemColor: Color + let userColor: Color + let idleColor: Color + let usageTint: Color + let temperatureTint: Color + + var body: some View { + HStack(alignment: .center, spacing: 18) { + CPUTemperatureGauge(temperature: temperature.celsius, tint: temperatureTint, size: 78, lineWidth: 8) + .frame(maxWidth: .infinity) + CPUSegmentDonut( + breakdown: breakdown, + systemColor: systemColor, + userColor: userColor, + idleColor: idleColor, + diameter: 118, + lineWidth: 13 + ) + .frame(width: 118, height: 118) + .frame(maxWidth: .infinity) + CPUFrequencyGauge(frequency: frequency, tint: usageTint, size: 78, lineWidth: 8) + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, alignment: .center) + } +} + +private struct CPUTemperatureGauge: View { + let temperature: Double? + let tint: Color + var size: CGFloat = 84 + var lineWidth: CGFloat = 9 + @Default(.cpuTemperatureUnit) private var temperatureUnit + private var maxTemperature: Double { + temperatureUnit == .celsius ? 110 : 230 + } + + var body: some View { + CircularGaugeView( + title: String(localized: "Temperature"), + value: normalizedValue, + tint: tint, + centerPrimaryText: centerPrimary, + subtitle: nil, + size: size, + lineWidth: lineWidth + ) + } + + private var displayTemperature: Double? { + guard let temperature else { return nil } + return temperatureUnit == .celsius ? temperature : temperature * 9.0 / 5.0 + 32.0 + } + + private var normalizedValue: Double { + guard let displayTemperature, maxTemperature > 0 else { return 0 } + return min(max(displayTemperature / maxTemperature, 0), 1) + } + + private var centerPrimary: String { + guard let displayTemperature else { return "—" } + return String(format: "%.0f%@", displayTemperature, temperatureUnit.symbol) + } +} + +private struct CPUFrequencyGauge: View { + let frequency: CPUFrequencyMetrics? + let tint: Color + var size: CGFloat = 84 + var lineWidth: CGFloat = 9 + + var body: some View { + CircularGaugeView( + title: String(localized: "Frequency"), + value: normalizedValue, + tint: tint, + centerPrimaryText: centerPrimary, + centerSecondaryText: frequency != nil ? "GHz" : nil, + subtitle: subtitle, + size: size, + lineWidth: lineWidth + ) + } + + private var normalizedValue: Double { + guard let frequency else { return 0 } + let candidateMax = frequency.maxOverallGHz > 0 ? frequency.maxOverallGHz : frequency.overallGHz + let denominator = max(candidateMax, 1.0) + return min(max(frequency.overallGHz / denominator, 0), 1) + } + + private var centerPrimary: String { + guard let frequency else { return "—" } + return String(format: "%.1f", frequency.overallGHz) + } + + private var subtitle: String? { + guard let frequency else { return nil } + var components: [String] = [] + if let eValue = frequency.eCoreGHz { + components.append("E \(String(format: "%.1f", eValue))") + } + if let pValue = frequency.pCoreGHz { + components.append("P \(String(format: "%.1f", pValue))") + } + return components.isEmpty ? nil : components.joined(separator: " • ") + } +} + +private struct CPUSegmentDonut: View { + let breakdown: CPULoadBreakdown + let systemColor: Color + let userColor: Color + let idleColor: Color + var diameter: CGFloat = 132 + var lineWidth: CGFloat = 14 + + var body: some View { + ZStack { + Circle() + .stroke(idleColor.opacity(0.3), lineWidth: lineWidth) + .frame(width: diameter, height: diameter) + CPUUsageRing( + breakdown: breakdown, + systemColor: systemColor, + userColor: userColor, + lineWidth: lineWidth + ) + .frame(width: diameter, height: diameter) + VStack(spacing: 4) { + Text(StatsFormatting.percentage(breakdown.activeUsage)) + .font(.system(size: 30, weight: .bold, design: .rounded)) + Text(String(localized: "stats_resource_active", defaultValue: "Active")) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } +} + +struct CPUUsageRing: View { + let breakdown: CPULoadBreakdown + let systemColor: Color + let userColor: Color + var lineWidth: CGFloat = 14 + + var body: some View { + let segments = breakdown.normalizedSegments + ZStack { + if segments.system > 0 { + RingArc(start: 0, end: segments.system, color: systemColor, lineWidth: lineWidth) + } + if segments.user > 0 { + RingArc(start: segments.system, end: segments.system + segments.user, color: userColor, lineWidth: lineWidth) + } + } + } +} + +struct RingArc: View { + let start: Double + let end: Double + let color: Color + var lineWidth: CGFloat = 14 + + var body: some View { + Circle() + .trim(from: CGFloat(start), to: CGFloat(end)) + .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } +} + +struct LegendRow: View { + let color: Color + let title: String + let value: String + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(color) + .frame(width: 10, height: 10) + Text(title) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + Spacer() + Text(value) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(.primary) + } + } +} + +struct LoadAverageRow: View { + let loadAverage: LoadAverage + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Load Average") + .font(.caption) + .foregroundColor(.secondary) + HStack(spacing: 12) { + LoadAverageValue(label: String(localized: "1m"), value: loadAverage.oneMinute) + LoadAverageValue(label: String(localized: "5m"), value: loadAverage.fiveMinutes) + LoadAverageValue(label: String(localized: "15m"), value: loadAverage.fifteenMinutes) + } + } + } +} + +struct LoadAverageValue: View { + let label: String + let value: Double + + var body: some View { + VStack(spacing: 2) { + Text(label) + .font(.caption2) + .foregroundColor(.secondary) + Text(String(format: "%.2f", value)) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(.primary) + } + } +} + +struct DetailRow: View { + let color: Color? + let label: String + let value: String + + var body: some View { + HStack { + if let color { + Circle() + .fill(color) + .frame(width: 8, height: 8) + } + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .lineLimit(1) + .layoutPriority(0.5) + Spacer() + Text(value) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(.primary) + .lineLimit(1) + .minimumScaleFactor(0.85) + .layoutPriority(1) + } + } +} + +struct CPUCoreUsageGrid: View { + let cores: [CPUCoreUsage] + let accentColor: Color + + private var columns: [GridItem] { + [GridItem(.adaptive(minimum: 90), spacing: 14, alignment: .top)] + } + + var body: some View { + LazyVGrid(columns: columns, spacing: 14) { + ForEach(cores) { core in + CPUCoreUsageCell(core: core, accentColor: accentColor) + } + } + } +} + +private struct CPUCoreUsageCell: View { + let core: CPUCoreUsage + let accentColor: Color + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Core \(core.id + 1)") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(StatsFormatting.percentage(core.usage)) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundColor(accentColor) + } + GeometryReader { proxy in + let width = proxy.size.width + let progress = max(0, min(core.usage / 100, 1)) + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.08)) + Capsule() + .fill(accentColor) + .frame(width: width * progress) + } + } + .frame(height: 8) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.6)) + ) + } +} + +struct CPUDetailsGrid: View { + let breakdown: CPULoadBreakdown + let loadAverage: LoadAverage + let uptime: TimeInterval + let coreCount: Int + let systemColor: Color + let userColor: Color + var showLoadAverage: Bool = true + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 8) { + Text("Usage Breakdown") + .font(.caption) + .foregroundColor(.secondary) + DetailRow(color: nil, label: String(localized: "Total"), value: StatsFormatting.percentage(breakdown.activeUsage)) + DetailRow(color: systemColor, label: String(localized: "System"), value: StatsFormatting.percentage(breakdown.system)) + DetailRow(color: userColor, label: String(localized: "User"), value: StatsFormatting.percentage(breakdown.user)) + DetailRow(color: Color.gray.opacity(0.4), label: String(localized: "Idle"), value: StatsFormatting.percentage(breakdown.idle)) + } + + Divider().padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 8) { + Text("System") + .font(.caption) + .foregroundColor(.secondary) + DetailRow(color: nil, label: String(localized: "Logical cores"), value: "\(max(coreCount, 1))") + DetailRow(color: nil, label: String(localized: "Uptime"), value: StatsFormatting.abbreviatedDuration(uptime)) + } + + if showLoadAverage { + Divider().padding(.vertical, 4) + LoadAverageRow(loadAverage: loadAverage) + } + } + } +} + + +struct CPUProcessList: View { + let processes: [ProcessStats] + let accentColor: Color + var displayLimit: Int? = nil + + private var visibleProcesses: [ProcessStats] { + if let limit = displayLimit { + return Array(processes.prefix(limit)) + } + return processes + } + + var body: some View { + VStack(spacing: 8) { + if visibleProcesses.isEmpty { + Text("No process data yet. Keep stats open for a moment.") + .font(.footnote) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ForEach(visibleProcesses) { process in + CPUProcessRow(process: process, accentColor: accentColor) + if process.id != visibleProcesses.last?.id { + Divider().padding(.leading, 32) + } + } + } + } + } +} + +struct CPUProcessRow: View { + let process: ProcessStats + let accentColor: Color + + var body: some View { + HStack(spacing: 12) { + iconView + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 0.5) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(process.name) + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + Text(process.memoryUsageString) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + + Spacer() + + Text(process.cpuUsageString) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(accentColor) + } + } + + private var iconView: some View { + Group { + if let icon = process.icon { + Image(nsImage: icon) + .resizable() + .scaledToFit() + } else { + ZStack { + accentColor.opacity(0.15) + Image(systemName: "app") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(accentColor) + } + } + } + } +} diff --git a/DynamicIsland/components/Stats/CircularGaugeView.swift b/DynamicIsland/components/Stats/CircularGaugeView.swift new file mode 100644 index 00000000..f0a92bc6 --- /dev/null +++ b/DynamicIsland/components/Stats/CircularGaugeView.swift @@ -0,0 +1,66 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct CircularGaugeView: View { + let title: String + let value: Double + let tint: Color + let centerPrimaryText: String + var centerSecondaryText: String? = nil + var subtitle: String? = nil + var size: CGFloat = 72 + var lineWidth: CGFloat = 8 + var backgroundTint: Color = Color.primary.opacity(0.08) + + var body: some View { + VStack(spacing: 8) { + ZStack { + Circle() + .stroke(backgroundTint, lineWidth: lineWidth) + .frame(width: size, height: size) + if value > 0 { + RingArc(start: 0, end: min(max(value, 0), 1), color: tint, lineWidth: lineWidth) + .frame(width: size, height: size) + } + VStack(spacing: 2) { + Text(centerPrimaryText) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundColor(tint) + if let centerSecondaryText, !centerSecondaryText.isEmpty { + Text(centerSecondaryText) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + } + } + } + VStack(spacing: 2) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.footnote) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + } + } +} diff --git a/DynamicIsland/components/Stats/DetailedTimelineGraph.swift b/DynamicIsland/components/Stats/DetailedTimelineGraph.swift new file mode 100644 index 00000000..9553cde4 --- /dev/null +++ b/DynamicIsland/components/Stats/DetailedTimelineGraph.swift @@ -0,0 +1,18 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + diff --git a/DynamicIsland/components/Stats/DiskStatsDetailView.swift b/DynamicIsland/components/Stats/DiskStatsDetailView.swift new file mode 100644 index 00000000..4edc8124 --- /dev/null +++ b/DynamicIsland/components/Stats/DiskStatsDetailView.swift @@ -0,0 +1,229 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct DiskStatsDetailView: View { + @ObservedObject private var statsManager = StatsManager.shared + + private let readColor = Color.cyan + private let writeColor = Color.yellow + private let cardBackground = Color(nsColor: .windowBackgroundColor).opacity(0.65) + + var body: some View { + ScrollView { + VStack(spacing: 16) { + StatsCard(title: String(localized: "Disk Overview"), padding: 16, background: cardBackground, cornerRadius: 12) { + DiskOverview( + readSpeed: statsManager.diskRead, + writeSpeed: statsManager.diskWrite, + peakRead: statsManager.diskReadHistory.max() ?? 0, + peakWrite: statsManager.diskWriteHistory.max() ?? 0, + readColor: readColor, + writeColor: writeColor + ) + } + + StatsCard(title: String(localized: "Storage Devices"), padding: 16, background: cardBackground, cornerRadius: 12) { + DiskDevicesCard(devices: statsManager.diskDevices, accentColor: readColor) + } + + StatsCard(title: String(localized: "Session Totals"), padding: 16, background: cardBackground, cornerRadius: 12) { + DiskTotalsView(totals: statsManager.diskTotals, readColor: readColor, writeColor: writeColor) + } + } + .padding(16) + } + .frame(minWidth: 360, minHeight: 340) + } +} + +private struct DiskOverview: View { + let readSpeed: Double + let writeSpeed: Double + let peakRead: Double + let peakWrite: Double + let readColor: Color + let writeColor: Color + + var body: some View { + HStack(alignment: .center, spacing: 24) { + VStack(alignment: .leading, spacing: 10) { + Text("Read") + .font(.headline) + .foregroundColor(readColor) + Text(StatsFormatting.mbPerSecond(readSpeed)) + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(.primary) + DetailRow(color: readColor.opacity(0.6), label: "Peak", value: StatsFormatting.mbPerSecond(peakRead)) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Divider() + .frame(height: 110) + .overlay(Color.white.opacity(0.06)) + + VStack(alignment: .leading, spacing: 10) { + Text("Write") + .font(.headline) + .foregroundColor(writeColor) + Text(StatsFormatting.mbPerSecond(writeSpeed)) + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(.primary) + DetailRow(color: writeColor.opacity(0.6), label: "Peak", value: StatsFormatting.mbPerSecond(peakWrite)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private struct DiskTotalsView: View { + let totals: DiskTotals + let readColor: Color + let writeColor: Color + + var body: some View { + VStack(spacing: 10) { + DetailRow(color: readColor.opacity(0.75), label: String(localized: "Read"), value: formattedBytes(totals.readMB)) + DetailRow(color: writeColor.opacity(0.75), label: String(localized: "Written"), value: formattedBytes(totals.writtenMB)) + } + } + + private func formattedBytes(_ megabytes: Double) -> String { + let bytes = UInt64(megabytes * 1_048_576) + return StatsFormatting.bytes(bytes) + } +} + +private struct DiskDevicesCard: View { + let devices: [DiskDeviceMetrics] + let accentColor: Color + + private var sortedDevices: [DiskDeviceMetrics] { + devices.sorted { lhs, rhs in + if lhs.isRoot != rhs.isRoot { + return lhs.isRoot && !rhs.isRoot + } + if lhs.isRemovable != rhs.isRemovable { + return !lhs.isRemovable && rhs.isRemovable + } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + + var body: some View { + if sortedDevices.isEmpty { + Text("No storage devices detected yet.") + .font(.footnote) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + VStack(spacing: 12) { + ForEach(sortedDevices) { device in + DiskDeviceRow(device: device, accentColor: accentColor) + if device.id != sortedDevices.last?.id { + Divider().padding(.leading, 36) + } + } + } + } + } +} + +private struct DiskDeviceRow: View { + let device: DiskDeviceMetrics + let accentColor: Color + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(accentColor.opacity(0.16)) + .overlay( + Image(systemName: device.isRemovable ? "externaldrive" : "internaldrive") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(accentColor) + ) + .frame(width: 32, height: 32) + VStack(alignment: .leading, spacing: 2) { + Text(device.name) + .font(.system(size: 13, weight: .semibold)) + Text(device.path.path) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + Spacer() + if device.isRoot { + Text("System") + .font(.caption2) + .foregroundColor(accentColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(accentColor.opacity(0.16)) + .clipShape(Capsule()) + } else if device.isRemovable { + Text("External") + .font(.caption2) + .foregroundColor(accentColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(accentColor.opacity(0.16)) + .clipShape(Capsule()) + } + } + + DiskUsageBar(usedBytes: device.usedBytes, totalBytes: device.totalBytes, tint: accentColor) + .frame(height: 8) + + HStack { + Text("Used · \(StatsFormatting.bytes(device.usedBytes))") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Text("Free · \(StatsFormatting.bytes(device.freeBytes))") + .font(.caption2) + .foregroundColor(.secondary) + } + + Text("Total · \(StatsFormatting.bytes(device.totalBytes))") + .font(.caption2) + .foregroundColor(.secondary) + } + } +} + +private struct DiskUsageBar: View { + let usedBytes: UInt64 + let totalBytes: UInt64 + let tint: Color + + var body: some View { + GeometryReader { proxy in + let width = proxy.size.width + let progress = totalBytes > 0 ? min(max(Double(usedBytes) / Double(totalBytes), 0), 1) : 0 + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.08)) + Capsule() + .fill(tint.opacity(0.8)) + .frame(width: width * progress) + } + } + } +} diff --git a/DynamicIsland/components/Stats/DualTimelineGraph.swift b/DynamicIsland/components/Stats/DualTimelineGraph.swift new file mode 100644 index 00000000..9553cde4 --- /dev/null +++ b/DynamicIsland/components/Stats/DualTimelineGraph.swift @@ -0,0 +1,18 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + diff --git a/DynamicIsland/components/Stats/GPUStatsDetailView.swift b/DynamicIsland/components/Stats/GPUStatsDetailView.swift new file mode 100644 index 00000000..1cac62ae --- /dev/null +++ b/DynamicIsland/components/Stats/GPUStatsDetailView.swift @@ -0,0 +1,314 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct GPUStatsDetailView: View { + @ObservedObject private var statsManager = StatsManager.shared + @State private var topProcesses: [ProcessStats] = [] + + private let accentColor = Color.purple + private let cardBackground = Color(nsColor: .windowBackgroundColor).opacity(0.65) + private let processDisplayLimit = 8 + + var body: some View { + ScrollView { + VStack(spacing: 16) { + StatsCard(title: String(localized: "GPU Overview"), padding: 16, background: cardBackground, cornerRadius: 12) { + GPUUsageDashboard( + usage: statsManager.gpuUsage, + breakdown: statsManager.gpuBreakdown, + averageUsage: statsManager.avgGpuUsage, + lastUpdated: statsManager.lastUpdated, + accentColor: accentColor, + primaryDevice: statsManager.gpuDevices.first + ) + } + + StatsCard(title: String(localized: "Top Processes"), padding: 16, background: cardBackground, cornerRadius: 12) { + CPUProcessList(processes: topProcesses, accentColor: accentColor, displayLimit: processDisplayLimit) + } + + if !statsManager.gpuDevices.isEmpty { + StatsCard(title: String(localized: "Devices"), padding: 16, background: cardBackground, cornerRadius: 12) { + GPUDeviceList(devices: statsManager.gpuDevices, accentColor: accentColor) + } + } + } + .padding(16) + } + .frame(minWidth: 360, minHeight: 380) + .onAppear(perform: refreshProcesses) + .onReceive(statsManager.$lastUpdated) { _ in + refreshProcesses() + } + } + + private func refreshProcesses() { + let processes = statsManager.getProcessesRankedByGPU() + topProcesses = Array(processes.prefix(processDisplayLimit)) + } +} + +private struct GPUUsageDashboard: View { + let usage: Double + let breakdown: GPUBreakdown + let averageUsage: Double + let lastUpdated: Date + let accentColor: Color + let primaryDevice: GPUDeviceMetrics? + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 20) { + leftColumn + breakdownAndMeta + } + VStack(alignment: .leading, spacing: 20) { + leftColumn + breakdownAndMeta + } + } + } + + private func formattedTimestamp(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .medium + return formatter.string(from: date) + } + + private var leftColumn: some View { + GPUEngineGauges( + usage: usage, + render: renderEngineUtilization, + tiler: tilerUtilization, + accentColor: accentColor + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var breakdownAndMeta: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 20) { + breakdownSection + metaSection + } + VStack(alignment: .leading, spacing: 16) { + breakdownSection + Divider().padding(.vertical, 4) + metaSection + } + } + } + + private var breakdownSection: some View { + VStack(alignment: .leading, spacing: 12) { + DetailRow(color: accentColor.opacity(0.9), label: String(localized: "Render"), value: StatsFormatting.percentage(breakdown.render)) + DetailRow(color: accentColor.opacity(0.7), label: String(localized: "Compute"), value: StatsFormatting.percentage(breakdown.compute)) + DetailRow(color: accentColor.opacity(0.55), label: String(localized: "Video"), value: StatsFormatting.percentage(breakdown.video)) + DetailRow(color: accentColor.opacity(0.45), label: String(localized: "Other"), value: StatsFormatting.percentage(breakdown.other)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var metaSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Averages") + .font(.caption) + .foregroundColor(.secondary) + DetailRow(color: nil, label: String(localized: "Session Avg"), value: StatsFormatting.percentage(averageUsage)) + DetailRow(color: nil, label: String(localized: "Last Update"), value: formattedTimestamp(lastUpdated)) + if let device = primaryDevice { + Divider().padding(.vertical, 4) + DetailRow(color: nil, label: String(localized: "Active GPU"), value: device.formattedVendorModel) + DetailRow(color: nil, label: String(localized: "Status"), value: device.isActive ? String(localized: "Active") : String(localized: "Idle")) + DetailRow(color: nil, label: String(localized: "Temperature"), value: device.temperatureText) + if let cores = device.cores { + DetailRow(color: nil, label: String(localized: "Cores"), value: "\(cores)") + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var renderEngineUtilization: Double? { + primaryDevice?.renderUtilization ?? breakdown.render + } + + private var tilerUtilization: Double? { + primaryDevice?.tilerUtilization + } +} + +private struct GPUEngineGauges: View { + let usage: Double + let render: Double? + let tiler: Double? + let accentColor: Color + private let ringDiameter: CGFloat = 112 + private let ringLineWidth: CGFloat = 12 + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Engine Utilization") + .font(.caption) + .foregroundColor(.secondary) + HStack(alignment: .center, spacing: 18) { + EngineGaugeView(title: String(localized: "Render"), value: render, tint: accentColor, size: 72) + GPUUsageRing(usage: usage, accentColor: accentColor, diameter: ringDiameter, lineWidth: ringLineWidth) + EngineGaugeView(title: String(localized: "Tiler"), value: tiler, tint: accentColor.opacity(0.75), size: 72) + } + .frame(maxWidth: .infinity, alignment: .center) + } + } +} + +private struct GPUUsageRing: View { + let usage: Double + let accentColor: Color + var diameter: CGFloat = 108 + var lineWidth: CGFloat = 12 + + var body: some View { + ZStack { + Circle() + .stroke(accentColor.opacity(0.25), lineWidth: lineWidth) + RingArc(start: 0, end: CGFloat(min(max(usage / 100, 0), 1)), color: accentColor, lineWidth: lineWidth) + VStack(spacing: 4) { + Text(StatsFormatting.percentage(usage)) + .font(.system(size: 26, weight: .bold, design: .rounded)) + Text(String(localized: "stats_resource_active", defaultValue: "Active")) + .font(.footnote) + .foregroundColor(.secondary) + } + } + .frame(width: diameter, height: diameter) + } +} + +private struct EngineGaugeView: View { + let title: String + let value: Double? + let tint: Color + var size: CGFloat = 68 + + var body: some View { + if let value { + CircularGaugeView( + title: title, + value: min(max(value / 100, 0), 1), + tint: tint, + centerPrimaryText: StatsFormatting.percentage(value), + centerSecondaryText: nil, + subtitle: nil, + size: size, + lineWidth: 7, + backgroundTint: tint.opacity(0.18) + ) + } else { + placeholder + } + } + + private var placeholder: some View { + VStack(spacing: 8) { + ZStack { + Circle() + .stroke(Color.primary.opacity(0.08), lineWidth: 7) + .frame(width: size, height: size) + Text("—") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.secondary) + } + Text(title) + .font(.caption) + .foregroundColor(.secondary) + } + } +} + +private struct GPUDeviceList: View { + let devices: [GPUDeviceMetrics] + let accentColor: Color + + var body: some View { + VStack(spacing: 16) { + ForEach(Array(devices.enumerated()), id: \.element.id) { index, device in + GPUDeviceRow(device: device, accentColor: accentColor) + if index < devices.count - 1 { + Divider() + } + } + } + } +} + +private struct GPUDeviceRow: View { + let device: GPUDeviceMetrics + let accentColor: Color + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Circle() + .fill(device.isActive ? Color.green.opacity(0.85) : Color.red.opacity(0.7)) + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 2) { + Text(device.formattedVendorModel) + .font(.system(size: 13, weight: .semibold)) + Text(device.isActive ? String(localized: "stats_resource_active", defaultValue: "Active") : String(localized: "Idle")) + .font(.caption2) + .foregroundColor(device.isActive ? .green : .secondary) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text(device.utilizationText) + .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .foregroundColor(accentColor) + Text("Utilization") + .font(.caption2) + .foregroundColor(.secondary) + } + } + VStack(spacing: 8) { + if let render = device.renderUtilization { + DetailRow(color: accentColor.opacity(0.9), label: String(localized: "Renderer"), value: StatsFormatting.percentage(render)) + } + if let tiler = device.tilerUtilization { + DetailRow(color: accentColor.opacity(0.6), label: String(localized: "Tiler"), value: StatsFormatting.percentage(tiler)) + } + if let temp = device.temperature { + DetailRow(color: nil, label: String(localized: "Temperature"), value: String(format: "%.0f°C", temp)) + } + if let fan = device.fanSpeed { + DetailRow(color: nil, label: String(localized: "Fan Speed"), value: "\(fan)%") + } + if let coreClock = device.coreClock { + DetailRow(color: nil, label: String(localized: "Core Clock"), value: "\(coreClock) MHz") + } + if let memoryClock = device.memoryClock { + DetailRow(color: nil, label: String(localized: "Memory Clock"), value: "\(memoryClock) MHz") + } + if let cores = device.cores { + DetailRow(color: nil, label: String(localized: "Cores"), value: "\(cores)") + } + } + } + } +} diff --git a/DynamicIsland/components/Stats/MemoryStatsDetailView.swift b/DynamicIsland/components/Stats/MemoryStatsDetailView.swift new file mode 100644 index 00000000..bebfb91b --- /dev/null +++ b/DynamicIsland/components/Stats/MemoryStatsDetailView.swift @@ -0,0 +1,255 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct MemoryStatsDetailView: View { + @ObservedObject private var statsManager = StatsManager.shared + @State private var topProcesses: [ProcessStats] = [] + + private let accentColor = Color.green + private let cardBackground = Color(nsColor: .windowBackgroundColor).opacity(0.65) + private let processDisplayLimit = 8 + + var body: some View { + ScrollView { + VStack(spacing: 16) { + StatsCard(title: String(localized: "Memory Overview"), padding: 16, background: cardBackground, cornerRadius: 12) { + MemoryUsageDashboard(breakdown: statsManager.memoryBreakdown, accentColor: accentColor) + } + + StatsCard(title: String(localized: "Top Processes"), padding: 16, background: cardBackground, cornerRadius: 12) { + CPUProcessList(processes: topProcesses, accentColor: accentColor, displayLimit: processDisplayLimit) + } + + StatsCard(title: String(localized: "Pressure & Swap"), padding: 16, background: cardBackground, cornerRadius: 12) { + MemoryHealthView(breakdown: statsManager.memoryBreakdown, accentColor: accentColor) + } + } + .padding(16) + } + .frame(minWidth: 380, minHeight: 420) + .onAppear(perform: refreshProcesses) + .onReceive(statsManager.$lastUpdated) { _ in + refreshProcesses() + } + } + + private func refreshProcesses() { + let processes = statsManager.getProcessesRankedByMemory() + topProcesses = Array(processes.prefix(processDisplayLimit)) + } +} + +private struct MemoryUsageDashboard: View { + let breakdown: MemoryBreakdown + let accentColor: Color + + private let freeColor = Color.gray.opacity(0.28) + + var body: some View { + let totalsSection = VStack(alignment: .leading, spacing: 12) { + DetailRow(color: accentColor.opacity(0.8), label: String(localized: "Used"), value: formattedBytes(breakdown.usedBytes)) + DetailRow(color: freeColor.opacity(0.9), label: String(localized: "Free"), value: formattedBytes(breakdown.freeBytes)) + DetailRow(color: nil, label: String(localized: "Total"), value: formattedBytes(breakdown.totalBytes)) + } + .frame(maxWidth: .infinity, alignment: .leading) + + let breakdownSection = VStack(alignment: .leading, spacing: 8) { + Text("Breakdown") + .font(.caption) + .foregroundColor(.secondary) + MemoryBreakdownRow(label: String(localized: "App"), value: breakdown.appBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.85)) + MemoryBreakdownRow(label: String(localized: "Cached"), value: breakdown.cacheBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.65)) + MemoryBreakdownRow(label: String(localized: "Wired"), value: breakdown.wiredBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.45)) + MemoryBreakdownRow(label: String(localized: "Compressed"), value: breakdown.compressedBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.35)) + } + .frame(maxWidth: .infinity, alignment: .leading) + + return ViewThatFits(in: .horizontal) { + VStack(alignment: .leading, spacing: 20) { + usageRing + totalsSection + Divider().padding(.vertical, 4) + breakdownSection + } + HStack(alignment: .top, spacing: 28) { + usageRing + totalsSection + breakdownSection + } + } + } + + private func formattedBytes(_ value: UInt64) -> String { + StatsFormatting.bytes(value) + } + + private var usageRing: some View { + ZStack { + Circle() + .stroke(freeColor.opacity(0.35), lineWidth: 14) + .frame(width: 128, height: 128) + + MemoryUsageRing(breakdown: breakdown, usedColor: accentColor, freeColor: freeColor) + .frame(width: 128, height: 128) + + VStack(spacing: 4) { + Text(StatsFormatting.percentage(breakdown.usedPercentage)) + .font(.system(size: 30, weight: .bold, design: .rounded)) + Text("Used") + .font(.footnote) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct MemoryUsageRing: View { + let breakdown: MemoryBreakdown + let usedColor: Color + let freeColor: Color + + var body: some View { + ZStack { + let usedFraction = CGFloat(min(max(breakdown.usedPercentage / 100, 0), 1)) + if usedFraction > 0 { + RingArc(start: 0, end: usedFraction, color: usedColor, lineWidth: 14) + } + if usedFraction < 1 { + RingArc(start: usedFraction, end: 1, color: freeColor.opacity(0.65), lineWidth: 14) + } + } + } +} + +private struct MemoryBreakdownRow: View { + let label: String + let value: UInt64 + let total: UInt64 + let color: Color + + var body: some View { + let percent = total > 0 ? Double(value) / Double(total) * 100 : 0 + return DetailRow(color: color, label: label, value: "\(StatsFormatting.bytes(value)) · \(StatsFormatting.percentage(percent))") + } +} + +private struct MemoryHealthView: View { + let breakdown: MemoryBreakdown + let accentColor: Color + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + MemoryPressureIndicator(pressure: breakdown.pressure) + Divider().padding(.vertical, 4) + SwapUsageView(swap: breakdown.swap, accentColor: accentColor) + } + } +} + +private struct MemoryPressureIndicator: View { + let pressure: MemoryPressure + + private var status: (title: String, color: Color, description: String) { + switch pressure.level { + case .normal: + return (String(localized: "Normal"), Color.green, String(localized: "System memory pressure is nominal.")) + case .warning: + return (String(localized: "Warning"), Color.orange, String(localized: "Memory pressure is elevated. Closing apps may help.")) + case .critical: + return (String(localized: "Critical"), Color.red, String(localized: "Memory pressure is critical; the system may purge aggressively.")) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Pressure") + .font(.caption) + .foregroundColor(.secondary) + + HStack { + Capsule() + .fill(status.color.opacity(0.16)) + .overlay( + HStack(spacing: 8) { + Circle() + .fill(status.color) + .frame(width: 10, height: 10) + Text(status.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(status.color) + } + .padding(.horizontal, 12) + ) + .frame(height: 28) + Spacer() + } + + Text(status.description) + .font(.footnote) + .foregroundColor(.secondary) + } + } +} + +private struct SwapUsageView: View { + let swap: MemorySwap + let accentColor: Color + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Swap") + .font(.caption) + .foregroundColor(.secondary) + + if swap.totalBytes > 0 { + DetailRow(color: accentColor.opacity(0.85), label: String(localized: "Used"), value: StatsFormatting.bytes(swap.usedBytes)) + DetailRow(color: accentColor.opacity(0.55), label: String(localized: "Free"), value: StatsFormatting.bytes(swap.freeBytes)) + DetailRow(color: nil, label: String(localized: "Total"), value: StatsFormatting.bytes(swap.totalBytes)) + SwapUsageBar(usedBytes: swap.usedBytes, totalBytes: swap.totalBytes, tint: accentColor) + .frame(height: 8) + } else { + Text("Swap is disabled on this system.") + .font(.footnote) + .foregroundColor(.secondary) + } + } + } +} + +private struct SwapUsageBar: View { + let usedBytes: UInt64 + let totalBytes: UInt64 + let tint: Color + + var body: some View { + GeometryReader { proxy in + let width = proxy.size.width + let progress = totalBytes > 0 ? min(max(Double(usedBytes) / Double(totalBytes), 0), 1) : 0 + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.08)) + Capsule() + .fill(tint.opacity(0.8)) + .frame(width: width * progress) + } + } + } +} diff --git a/DynamicIsland/components/Stats/NetworkStatsDetailView.swift b/DynamicIsland/components/Stats/NetworkStatsDetailView.swift new file mode 100644 index 00000000..1d4eee22 --- /dev/null +++ b/DynamicIsland/components/Stats/NetworkStatsDetailView.swift @@ -0,0 +1,273 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct NetworkStatsDetailView: View { + @ObservedObject private var statsManager = StatsManager.shared + + private let downloadColor = Color.orange + private let uploadColor = Color.red + private let cardBackground = Color(nsColor: .windowBackgroundColor).opacity(0.65) + + var body: some View { + ScrollView { + VStack(spacing: 16) { + StatsCard(title: String(localized: "Network Overview"), padding: 16, background: cardBackground, cornerRadius: 12) { + NetworkOverview( + download: statsManager.networkDownload, + upload: statsManager.networkUpload, + avgDownload: average(statsManager.networkDownloadHistory), + avgUpload: average(statsManager.networkUploadHistory), + peakDownload: statsManager.networkDownloadHistory.max() ?? 0, + peakUpload: statsManager.networkUploadHistory.max() ?? 0, + downloadColor: downloadColor, + uploadColor: uploadColor + ) + } + + StatsCard(title: String(localized: "Interfaces"), padding: 16, background: cardBackground, cornerRadius: 12) { + NetworkInterfacesCard( + interfaces: statsManager.networkInterfaces, + downloadColor: downloadColor, + uploadColor: uploadColor + ) + } + + StatsCard(title: String(localized: "Session Totals"), padding: 16, background: cardBackground, cornerRadius: 12) { + NetworkTotalsView(totals: statsManager.networkTotals, downloadColor: downloadColor, uploadColor: uploadColor) + } + } + .padding(16) + } + .frame(minWidth: 360, minHeight: 340) + } + + private func average(_ values: [Double]) -> Double { + guard !values.isEmpty else { return 0 } + let filtered = values.filter { $0 > 0 } + guard !filtered.isEmpty else { return 0 } + return filtered.reduce(0, +) / Double(filtered.count) + } +} + +private struct NetworkOverview: View { + let download: Double + let upload: Double + let avgDownload: Double + let avgUpload: Double + let peakDownload: Double + let peakUpload: Double + let downloadColor: Color + let uploadColor: Color + + var body: some View { + HStack(alignment: .center, spacing: 24) { + VStack(alignment: .leading, spacing: 10) { + Text("Download") + .font(.headline) + .foregroundColor(downloadColor) + Text(StatsFormatting.throughput(download)) + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(.primary) + DetailRow(color: downloadColor.opacity(0.6), label: "Average", value: StatsFormatting.throughput(avgDownload)) + DetailRow(color: downloadColor.opacity(0.45), label: "Peak", value: StatsFormatting.throughput(peakDownload)) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Divider() + .frame(height: 110) + .overlay(Color.white.opacity(0.06)) + + VStack(alignment: .leading, spacing: 10) { + Text("Upload") + .font(.headline) + .foregroundColor(uploadColor) + Text(StatsFormatting.throughput(upload)) + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(.primary) + DetailRow(color: uploadColor.opacity(0.6), label: String(localized: "Average"), value: StatsFormatting.throughput(avgUpload)) + DetailRow(color: uploadColor.opacity(0.45), label: String(localized: "Peak"), value: StatsFormatting.throughput(peakUpload)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private struct NetworkTotalsView: View { + let totals: NetworkTotals + let downloadColor: Color + let uploadColor: Color + + var body: some View { + VStack(spacing: 10) { + DetailRow(color: downloadColor.opacity(0.75), label: String(localized: "Downloaded"), value: formattedBytes(totals.downloadedMB)) + DetailRow(color: uploadColor.opacity(0.75), label: String(localized: "Uploaded"), value: formattedBytes(totals.uploadedMB)) + } + } + + private func formattedBytes(_ megabytes: Double) -> String { + let bytes = UInt64(megabytes * 1_048_576) + return StatsFormatting.bytes(bytes) + } +} + +private struct NetworkInterfacesCard: View { + let interfaces: [NetworkInterfaceMetrics] + let downloadColor: Color + let uploadColor: Color + + private var sortedInterfaces: [NetworkInterfaceMetrics] { + interfaces.sorted { lhs, rhs in + if lhs.isActive != rhs.isActive { + return lhs.isActive && !rhs.isActive + } + return interfacePriority(lhs.type) < interfacePriority(rhs.type) + } + } + + private func interfacePriority(_ type: NetworkInterfaceType) -> Int { + switch type { + case .wifi: + return 0 + case .ethernet: + return 1 + case .cellular: + return 2 + case .other: + return 3 + case .loopback: + return 4 + } + } + + var body: some View { + if sortedInterfaces.isEmpty { + Text("No active interfaces detected yet.") + .font(.footnote) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + VStack(spacing: 12) { + ForEach(sortedInterfaces) { interface in + NetworkInterfaceRow( + interface: interface, + downloadColor: downloadColor, + uploadColor: uploadColor + ) + if interface.id != sortedInterfaces.last?.id { + Divider().padding(.leading, 44) + } + } + } + } + } +} + +private struct NetworkInterfaceRow: View { + let interface: NetworkInterfaceMetrics + let downloadColor: Color + let uploadColor: Color + + var body: some View { + HStack(alignment: .top, spacing: 12) { + icon + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(interface.displayName) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.primary) + if interface.isActive { + Text(String(localized: "stats_resource_active", defaultValue: "Active")) + .font(.caption2) + .foregroundColor(downloadColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(downloadColor.opacity(0.16)) + .clipShape(Capsule()) + } + } + + if let ipv4 = interface.ipv4, !ipv4.isEmpty { + Text("IPv4 · \(ipv4)") + .font(.caption2) + .foregroundColor(.secondary) + } + if let ipv6 = interface.ipv6, !ipv6.isEmpty { + Text("IPv6 · \(ipv6)") + .font(.caption2) + .foregroundColor(.secondary) + } + + Text("Session · ↓ \(formattedBytes(interface.totalDownloaded)) · ↑ \(formattedBytes(interface.totalUploaded))") + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + metricRow(label: String(localized: "Down"), value: StatsFormatting.throughput(interface.currentDownload), color: downloadColor) + metricRow(label: String(localized: "Up"), value: StatsFormatting.throughput(interface.currentUpload), color: uploadColor) + } + } + } + + private var icon: some View { + let isActive = interface.isActive + return RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(isActive ? downloadColor.opacity(0.18) : Color.primary.opacity(0.05)) + .overlay( + Image(systemName: iconName) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(isActive ? downloadColor : .secondary) + ) + .frame(width: 32, height: 32) + } + + private var iconName: String { + switch interface.type { + case .wifi: + return "wifi" + case .ethernet: + return "cable.connector" + case .cellular: + return "antenna.radiowaves.left.and.right" + case .loopback: + return "arrow.triangle.2.circlepath" + case .other: + return "network" + } + } + + private func metricRow(label: String, value: String, color: Color) -> some View { + HStack(spacing: 6) { + Text(label) + .font(.caption2) + .foregroundColor(.secondary) + Text(value) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(color) + } + } + + private func formattedBytes(_ megabytes: Double) -> String { + let bytes = UInt64(max(megabytes, 0) * 1_048_576) + return StatsFormatting.bytes(bytes) + } +} diff --git a/DynamicIsland/components/Stats/ProcessStats.swift b/DynamicIsland/components/Stats/ProcessStats.swift new file mode 100644 index 00000000..1d154e0d --- /dev/null +++ b/DynamicIsland/components/Stats/ProcessStats.swift @@ -0,0 +1,80 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import AppKit +import SwiftUI + +// Process information structure +struct ProcessStats: Identifiable, Hashable { + let id = UUID() + let pid: Int32 + let name: String + let cpuUsage: Double + let memoryUsage: UInt64 // in bytes + let icon: NSImage? + + var memoryUsageString: String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB, .useGB] + formatter.countStyle = .memory + return formatter.string(fromByteCount: Int64(memoryUsage)) + } + + var cpuUsageString: String { + return String(format: "%.1f%%", cpuUsage) + } +} + +enum ProcessRankingType { + case cpu + case memory + case gpu + case network + case disk + + var title: String { + switch self { + case .cpu: return "CPU Usage" + case .memory: return "Memory Usage" + case .gpu: return "GPU Usage" + case .network: return "Network Activity" + case .disk: return "Disk Activity" + } + } + + var icon: String { + switch self { + case .cpu: return "cpu" + case .memory: return "memorychip" + case .gpu: return "display" + case .network: return "network" + case .disk: return "internaldrive" + } + } + + var color: Color { + switch self { + case .cpu: return .blue + case .memory: return .green + case .gpu: return .purple + case .network: return .orange + case .disk: return .cyan + } + } +} \ No newline at end of file diff --git a/DynamicIsland/components/Stats/RankedProcessPopover.swift b/DynamicIsland/components/Stats/RankedProcessPopover.swift new file mode 100644 index 00000000..7ebec0c2 --- /dev/null +++ b/DynamicIsland/components/Stats/RankedProcessPopover.swift @@ -0,0 +1,132 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +#if os(macOS) +import AppKit +#endif + +struct RankedProcessPopover: View { + let rankingType: ProcessRankingType + @Environment(\.dismiss) private var dismiss + + // Callback to notify parent about hover state + var onHoverChange: ((Bool) -> Void)? + + private var configuration: (width: CGFloat, minHeight: CGFloat, padding: CGFloat) { + switch rankingType { + case .cpu: + return (420, 420, 0) + case .memory: + return (400, 380, 0) + case .gpu: + return (380, 360, 0) + case .network, .disk: + return (360, 320, 0) + } + } + + var body: some View { + popoverContent + .padding(configuration.padding) + .frame(width: configuration.width) + .frame(minHeight: configuration.minHeight) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.28), radius: 12, x: 0, y: 8) + .overlay(alignment: .topTrailing) { + Button(action: { dismiss() }) { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundColor(.secondary) + .padding(10) + } + .buttonStyle(PlainButtonStyle()) + } + .overlay(alignment: .center) { + PopoverHoverSensor { hovering in + onHoverChange?(hovering) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + } + } + + @ViewBuilder + private var popoverContent: some View { + switch rankingType { + case .cpu: + CPUStatsDetailView() + case .memory: + MemoryStatsDetailView() + case .gpu: + GPUStatsDetailView() + case .network: + NetworkStatsDetailView() + case .disk: + DiskStatsDetailView() + } + } +} + +#if os(macOS) +private struct PopoverHoverSensor: NSViewRepresentable { + var onHoverChange: ((Bool) -> Void)? + + func makeNSView(context: Context) -> TrackingView { + let view = TrackingView() + view.onHoverChange = onHoverChange + return view + } + + func updateNSView(_ nsView: TrackingView, context: Context) { + nsView.onHoverChange = onHoverChange + } + + final class TrackingView: NSView { + var onHoverChange: ((Bool) -> Void)? + private var trackingArea: NSTrackingArea? + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { + removeTrackingArea(trackingArea) + } + let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways, .inVisibleRect] + let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) + addTrackingArea(area) + trackingArea = area + } + + override func mouseEntered(with event: NSEvent) { + onHoverChange?(true) + } + + override func mouseExited(with event: NSEvent) { + onHoverChange?(false) + } + + deinit { + onHoverChange?(false) + if let trackingArea { + removeTrackingArea(trackingArea) + } + } + } +} +#endif \ No newline at end of file diff --git a/DynamicIsland/components/Stats/StatsFormatting.swift b/DynamicIsland/components/Stats/StatsFormatting.swift new file mode 100644 index 00000000..42ce57a8 --- /dev/null +++ b/DynamicIsland/components/Stats/StatsFormatting.swift @@ -0,0 +1,87 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +enum StatsFormatting { + static func bytes(_ value: UInt64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB, .useGB] + formatter.countStyle = .memory + formatter.includesUnit = true + return formatter.string(fromByteCount: Int64(value)) + } + + static func mbPerSecond(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = value >= 10 ? 1 : 2 + let number = NSNumber(value: value) + let formatted = formatter.string(from: number) ?? String(format: "%.2f", value) + return "\(formatted) MB/s" + } + + static func throughput(_ valueInMegabytesPerSecond: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + + if valueInMegabytesPerSecond >= 1 { + formatter.maximumFractionDigits = valueInMegabytesPerSecond >= 10 ? 1 : 2 + let formatted = formatter.string(from: NSNumber(value: valueInMegabytesPerSecond)) + ?? String(format: "%.2f", valueInMegabytesPerSecond) + return "\(formatted) MB/s" + } + + let valueInKilobytesPerSecond = valueInMegabytesPerSecond * 1024 + if valueInKilobytesPerSecond >= 1 { + formatter.maximumFractionDigits = valueInKilobytesPerSecond >= 10 ? 0 : 1 + let formatted = formatter.string(from: NSNumber(value: valueInKilobytesPerSecond)) + ?? String(format: valueInKilobytesPerSecond >= 10 ? "%.0f" : "%.1f", valueInKilobytesPerSecond) + return "\(formatted) KB/s" + } + + let valueInBytesPerSecond = valueInMegabytesPerSecond * 1_048_576 + if valueInBytesPerSecond >= 1 { + formatter.maximumFractionDigits = 0 + let formatted = formatter.string(from: NSNumber(value: valueInBytesPerSecond)) + ?? String(format: "%.0f", valueInBytesPerSecond) + return "\(formatted) B/s" + } + + return "0 B/s" + } + + static func percentage(_ value: Double) -> String { + String(format: "%.1f%%", value) + } + + static func gigabytes(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = value >= 10 ? 1 : 2 + return (formatter.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value)) + " GB" + } + + static func abbreviatedDuration(_ value: TimeInterval) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.day, .hour, .minute] + formatter.maximumUnitCount = 2 + formatter.unitsStyle = .abbreviated + return formatter.string(from: value) ?? "—" + } +} diff --git a/DynamicIsland/components/Tabs/TabButton.swift b/DynamicIsland/components/Tabs/TabButton.swift index 360658dc..903bfa49 100644 --- a/DynamicIsland/components/Tabs/TabButton.swift +++ b/DynamicIsland/components/Tabs/TabButton.swift @@ -1,9 +1,24 @@ -// -// TabButton.swift -// DynamicIsland -// -// Created by Hugo Persson on 2024-08-24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI @@ -16,7 +31,6 @@ struct TabButton: View { var body: some View { Button(action: onClick) { Image(systemName: icon) - .padding(.horizontal, 15) .contentShape(Capsule()) } .buttonStyle(PlainButtonStyle()) diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index 46b6f756..77eee5d9 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -1,53 +1,187 @@ -// -// TabSelectionView.swift -// DynamicIsland -// -// Created by Hugo Persson on 2024-08-25. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import AtollExtensionKit import SwiftUI +import Defaults +import AppKit struct TabModel: Identifiable { - let id = UUID() + let id: String let label: String let icon: String let view: NotchViews -} + let experienceID: String? + let accentColor: Color? -let tabs = [ - TabModel(label: "Home", icon: "house.fill", view: .home), - TabModel(label: "Shelf", icon: "tray.fill", view: .shelf), - TabModel(label: "Timer", icon: "timer", view: .timer) -] + init(label: String, icon: String, view: NotchViews, experienceID: String? = nil, accentColor: Color? = nil) { + self.id = experienceID.map { "extension-\($0)" } ?? "system-\(view)-\(label)" + self.label = label + self.icon = icon + self.view = view + self.experienceID = experienceID + self.accentColor = accentColor + } +} struct TabSelectionView: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared + @StateObject private var quickShareService = QuickShareService.shared + @Default(.quickShareProvider) private var quickShareProvider + @State private var showQuickSharePopover = false + @Default(.enableTimerFeature) var enableTimerFeature + @Default(.enableStatsFeature) var enableStatsFeature + @Default(.enableColorPickerFeature) var enableColorPickerFeature + @Default(.timerDisplayMode) var timerDisplayMode + @Default(.enableThirdPartyExtensions) private var enableThirdPartyExtensions + @Default(.enableExtensionNotchExperiences) private var enableExtensionNotchExperiences + @Default(.enableExtensionNotchTabs) private var enableExtensionNotchTabs + @Default(.showCalendar) private var showCalendar + @Default(.showMirror) private var showMirror + @Default(.showStandardMediaControls) private var showStandardMediaControls + @Default(.enableMinimalisticUI) private var enableMinimalisticUI @Namespace var animation + + private var tabs: [TabModel] { + var tabsArray: [TabModel] = [] + + if homeTabVisible { + tabsArray.append(TabModel(label: "Home", icon: "house.fill", view: .home)) + } + + if Defaults[.dynamicShelf] { + tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf)) + } + + if enableTimerFeature && timerDisplayMode == .tab { + 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)) + } + + if Defaults[.enableNotes] || (Defaults[.enableClipboardManager] && Defaults[.clipboardDisplayMode] == .separateTab) { + let label = Defaults[.enableNotes] ? "Notes" : "Clipboard" + let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard" + tabsArray.append(TabModel(label: label, icon: icon, view: .notes)) + } + if Defaults[.enableTerminalFeature] { + tabsArray.append(TabModel(label: "Terminal", icon: "apple.terminal", view: .terminal)) + } + if extensionTabsEnabled { + for payload in extensionTabPayloads { + guard let tab = payload.descriptor.tab else { continue } + let accent = payload.descriptor.accentColor.swiftUIColor + let iconName = tab.iconSymbolName ?? "puzzlepiece.extension" + tabsArray.append( + TabModel( + label: tab.title, + icon: iconName, + view: .extensionExperience, + experienceID: payload.descriptor.id, + accentColor: accent + ) + ) + } + } + return tabsArray + } var body: some View { - HStack(spacing: 0) { - ForEach(tabs) { tab in - TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) { - withAnimation(.smooth) { - coordinator.currentView = tab.view - } + HStack(spacing: 24) { + ForEach(Array(tabs.enumerated()), id: \.element.id) { idx, tab in + let isSelected = isSelected(tab) + let activeAccent = tab.accentColor ?? .white + + // Render the tab button + TabButton(label: tab.label, icon: tab.icon, selected: isSelected) { + if tab.view == .extensionExperience { + coordinator.selectedExtensionExperienceID = tab.experienceID } - .frame(height: 26) - .foregroundStyle(tab.view == coordinator.currentView ? .white : .gray) - .background { - if tab.view == coordinator.currentView { - Capsule() - .fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear) - .matchedGeometryEffect(id: "capsule", in: animation) - } else { - Capsule() - .fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear) - .matchedGeometryEffect(id: "capsule", in: animation) - .hidden() - } + coordinator.currentView = tab.view + } + .frame(height: 26) + .foregroundStyle(isSelected ? activeAccent : .gray) + .background { + if isSelected { + Capsule() + .fill((tab.accentColor ?? Color(nsColor: .secondarySystemFill)).opacity(0.25)) + .shadow(color: (tab.accentColor ?? .clear).opacity(0.4), radius: 8) + .matchedGeometryEffect(id: "capsule", in: animation) + } else { + Capsule() + .fill(Color.clear) + .matchedGeometryEffect(id: "capsule", in: animation) + .hidden() } + } + + } } + .animation(.smooth(duration: 0.3), value: coordinator.currentView) .clipShape(Capsule()) + .onAppear { + ensureValidSelection(with: tabs) + } + } + + private var extensionTabsEnabled: Bool { + enableThirdPartyExtensions && enableExtensionNotchExperiences && enableExtensionNotchTabs + } + + private var extensionTabPayloads: [ExtensionNotchExperiencePayload] { + extensionNotchExperienceManager.activeExperiences.filter { $0.descriptor.tab != nil } + } + + private var homeTabVisible: Bool { + if enableMinimalisticUI { + return true + } + return showStandardMediaControls || showCalendar || showMirror + } + + private func isSelected(_ tab: TabModel) -> Bool { + if tab.view == .extensionExperience { + return coordinator.currentView == .extensionExperience + && coordinator.selectedExtensionExperienceID == tab.experienceID + } + return coordinator.currentView == tab.view + } + + private func ensureValidSelection(with tabs: [TabModel]) { + guard !tabs.isEmpty else { return } + if tabs.contains(where: { isSelected($0) }) { + return + } + guard let first = tabs.first else { return } + if first.view == .extensionExperience { + coordinator.selectedExtensionExperienceID = first.experienceID + } else { + coordinator.selectedExtensionExperienceID = nil + } + coordinator.currentView = first.view } } diff --git a/DynamicIsland/components/TestView.swift b/DynamicIsland/components/TestView.swift index f7ffce27..572af666 100644 --- a/DynamicIsland/components/TestView.swift +++ b/DynamicIsland/components/TestView.swift @@ -1,9 +1,24 @@ -// -// TestView.swift -// DynamicIsland -// -// Created by Richard Kunkli on 14/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI diff --git a/DynamicIsland/components/Timer/TimerControlOverlay.swift b/DynamicIsland/components/Timer/TimerControlOverlay.swift new file mode 100644 index 00000000..396df2d4 --- /dev/null +++ b/DynamicIsland/components/Timer/TimerControlOverlay.swift @@ -0,0 +1,142 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +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 secondaryIcon: String { + timerManager.isOvertime ? "stop.fill" : "xmark" + } + + private var secondaryHelp: String { + timerManager.isOvertime ? "Stop" : "Cancel" + } + + 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) { + if !timerManager.isOvertime { + ControlButton( + icon: pauseIcon, + foreground: pauseForeground, + background: Color.white.opacity(0.14), + help: helpText, + action: togglePause + ) + .disabled(!timerManager.allowsManualInteraction) + } + + ControlButton( + icon: secondaryIcon, + foreground: timerManager.isOvertime ? Color.white : Color.white, + background: timerManager.isOvertime ? Color.red.opacity(0.24) : Color.white.opacity(0.14), + help: secondaryHelp, + action: stopTimer + ) + .disabled(!timerManager.allowsManualInteraction) + } + .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() { + guard timerManager.allowsManualInteraction else { return } + if timerManager.isPaused { + timerManager.resumeTimer() + } else { + timerManager.pauseTimer() + } + } + + private func stopTimer() { + guard timerManager.allowsManualInteraction else { + timerManager.endExternalTimer(triggerSmoothClose: false) + return + } +#if os(macOS) + TimerControlWindowManager.shared.hide(animated: true) +#endif + timerManager.stopTimer() + } +} + +private struct ControlButton: View { + let icon: String + let foreground: Color + let background: Color + let help: String + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + .frame(width: 32, height: 32) + .foregroundStyle(foreground) + .background(background.opacity(isHovering ? 1 : 0.7)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .buttonStyle(.plain) + .help(help) + .onHover { hovering in isHovering = hovering } + } +} + +#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 7f83cb7b..4e7cbcc8 100644 --- a/DynamicIsland/components/Timer/TimerIconAnimation.swift +++ b/DynamicIsland/components/Timer/TimerIconAnimation.swift @@ -1,100 +1,59 @@ -// -// TimerIconAnimation.swift -// DynamicIsland -// -// Created by GitHub Copilot on 2025-01-13. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ 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 6595402e..9b740ca1 100644 --- a/DynamicIsland/components/Timer/TimerLiveActivity.swift +++ b/DynamicIsland/components/Timer/TimerLiveActivity.swift @@ -1,96 +1,689 @@ -// -// TimerLiveActivity.swift -// DynamicIsland -// -// Created by GitHub Copilot on 2025-01-13. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ 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 + @ObservedObject var lockScreenManager = LockScreenManager.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 + @State private var pendingControlWindowTask: Task? + @State private var controlWindowSuppressionTask: Task? + @State private var hasPendingControlWindowSync: Bool = false + @State private var pendingControlWindowForceRefresh: Bool = false + @State private var isControlWindowSuppressed: 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 + @Default(.enableMinimalisticUI) private var enableMinimalisticUI + 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 let controlWindowResumeDelay: TimeInterval = 0.22 + + private struct ControlWindowSyncKey: Equatable { + var isTimerActive: Bool + var timerNameSignature: Int + var isFinished: Bool + var isOvertime: Bool + var isPaused: Bool + var closedNotchSize: CGSize + var screenName: String? + var hideOnClosed: Bool + var controlWindowEnabled: Bool + var showsLabel: Bool + var showTransientLabel: Bool + var showsCountdown: Bool + var showsProgress: Bool + var progressStyleIdentifier: String + var activeSourceIdentifier: String + } + + 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 middleSectionWidth: CGFloat { + vm.closedNotchSize.width + (isHovering ? 8 : 0) + } + + private var adjustedNotchHeight: CGFloat { + vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0) + } + + private var controlWindowSyncKey: ControlWindowSyncKey { + ControlWindowSyncKey( + isTimerActive: timerManager.isTimerActive, + timerNameSignature: timerManager.timerName.hashValue, + isFinished: timerManager.isFinished, + isOvertime: timerManager.isOvertime, + isPaused: timerManager.isPaused, + closedNotchSize: vm.closedNotchSize, + screenName: vm.screen, + hideOnClosed: vm.hideOnClosed, + controlWindowEnabled: controlWindowEnabled, + showsLabel: showsLabel, + showTransientLabel: showTransientLabel, + showsCountdown: showsCountdown, + showsProgress: showsProgress, + progressStyleIdentifier: progressStyle.rawValue, + activeSourceIdentifier: timerManager.activeSource.rawValue + ) + } + + private var shouldShowControlWindow: Bool { + if !controlWindowEnabled { return false } + if showsLabel { return false } + + let timerEligible = timerManager.isTimerActive && !timerManager.isExternalTimerActive + if !timerEligible { return false } + + let notchReady = vm.notchState == .closed && !vm.hideOnClosed + if !notchReady { return false } + + if isControlWindowSuppressed { return false } + if lockScreenManager.isLocked { return false } + + return true + } + + 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 + baseTimerLayout + .onHover { hovering in + withAnimation(.smooth(duration: 0.18)) { + isHovering = hovering + } + requestControlWindowSync(forceRefresh: true, delay: hovering ? 0.05 : 0) + } + .onAppear { + requestControlWindowSync(forceRefresh: true) + } + .onDisappear { + hideControlWindow() + cancelControlWindowSync() + cancelControlWindowSuppressionTask() + } + .onChange(of: timerManager.isTimerActive) { _, isActive in + if isActive { + if !timerManager.isFinished && !timerManager.isOvertime { + requestControlWindowSync(forceRefresh: true) + triggerTransientLabel() + } + } else { + hideControlWindow() + cancelTransientLabel() + showTransientLabel = false + isHovering = false + cancelControlWindowSync() + resetPendingControlWindowFlags() + } + } + .onChange(of: timerManager.timerName) { _, _ in + if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime { + requestControlWindowSync(forceRefresh: true) + triggerTransientLabel() + } + } + .onChange(of: timerManager.isFinished) { _, finished in + if finished { + cancelTransientLabel() + withAnimation(.smooth) { + showTransientLabel = true + isHovering = false + } + requestControlWindowSync() + } } - .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) + .onChange(of: timerManager.isOvertime) { _, overtime in + if overtime { + cancelTransientLabel() + withAnimation(.smooth) { + showTransientLabel = true + isHovering = false + } + requestControlWindowSync() + } + } + .onChange(of: timerManager.isPaused) { _, _ in + requestControlWindowSync(forceRefresh: true) + } + .onChange(of: controlWindowSyncKey) { _, _ in + requestControlWindowSync(forceRefresh: true) + } + .onChange(of: vm.notchState) { _, state in + handleControlWindowNotchStateChange(state) + } + .onChange(of: lockScreenManager.isLocked) { _, locked in + handleControlWindowLockStateChange(locked) + } + } + + private var baseTimerLayout: some View { + HStack(spacing: 0) { + leftWingView() + middleSectionView() + rightWingView() + } + .frame(height: adjustedNotchHeight, alignment: .center) + .contentShape(Rectangle()) + } + + @ViewBuilder + private func leftWingView() -> some View { + 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) + } + } + + @ViewBuilder + private func middleSectionView() -> some View { + Rectangle() + .fill(.black) + .frame(width: middleSectionWidth, height: notchContentHeight) + } + + @ViewBuilder + private func rightWingView() -> some View { + 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) + } } - private var timerIconSection: some View { - HStack { - // Simple timer icon with color, no background circle or animations + 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: .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) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(glyphColor) + .frame(width: iconSize, height: iconSize) } - .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), - height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))) + .frame(width: ringWrapsIcon ? ringDiameter : iconWidth, + height: notchContentHeight, + alignment: .center) } - private var timerInfoSection: some View { - Rectangle() + 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) - .overlay( - HStack(alignment: .center) { - if coordinator.expandingView.show && coordinator.expandingView.type == .timer { - // Timer name - MarqueeText( - .constant(timerManager.timerName), - textColor: timerManager.timerColor, - minDuration: 0.4, - frameWidth: 100 - ) - .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) - - Spacer(minLength: vm.closedNotchSize.width) - - // Timer status - Text(timerManager.isPaused ? "Paused" : "Active") - .lineLimit(1) - .truncationMode(.tail) - .foregroundStyle(timerManager.isOvertime ? .red : timerManager.timerColor) - .opacity((coordinator.expandingView.show && coordinator.expandingView.type == .timer && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 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) + } } } - ) - .frame(width: (coordinator.expandingView.show && coordinator.expandingView.type == .timer && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : vm.closedNotchSize.width + (isHovering ? 8 : 0)) + .padding(.leading, 12) + .padding(.trailing, 6) + } + .animation(.smooth, value: timerManager.isFinished) + } + + 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 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 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 requestControlWindowSync(forceRefresh: Bool = false, delay: TimeInterval = 0) { + enqueueControlWindowSync(forceRefresh: forceRefresh, delay: delay) + } + + private func enqueueControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0) { + if shouldDeferControlWindowSync() { + hasPendingControlWindowSync = true + if forceRefresh { + pendingControlWindowForceRefresh = true + } + return + } + + scheduleControlWindowSync(forceRefresh: forceRefresh, delay: delay) + } + + private func scheduleControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0, bypassSuppression: Bool = false) { + cancelControlWindowSync() + + guard shouldShowControlWindow else { + resetPendingControlWindowFlags() + hideControlWindow() + return + } + + if !bypassSuppression && shouldDeferControlWindowSync() { + hasPendingControlWindowSync = true + if forceRefresh { + pendingControlWindowForceRefresh = true + } + return + } + + resetPendingControlWindowFlags() + + let syncDelay = max(0, delay) + + if syncDelay <= 0 { + syncControlWindow(forceRefresh: forceRefresh) + return + } + + pendingControlWindowTask = Task { @MainActor [forceRefresh] in + let nanoseconds = UInt64(syncDelay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + + guard !Task.isCancelled else { return } + + if shouldShowControlWindow { + syncControlWindow(forceRefresh: forceRefresh) + } else { + hideControlWindow() + } + + pendingControlWindowTask = nil + } + } + + private func cancelControlWindowSync() { + pendingControlWindowTask?.cancel() + pendingControlWindowTask = nil + } + + private func resetPendingControlWindowFlags() { + hasPendingControlWindowSync = false + pendingControlWindowForceRefresh = false + } + + private func shouldDeferControlWindowSync() -> Bool { + vm.notchState != .closed || lockScreenManager.isLocked || isControlWindowSuppressed + } + + private func triggerPendingControlWindowSyncIfNeeded() { + guard hasPendingControlWindowSync else { return } + let shouldForce = pendingControlWindowForceRefresh + resetPendingControlWindowFlags() + scheduleControlWindowSync(forceRefresh: shouldForce, delay: 0, bypassSuppression: true) + } + + private func suppressControlWindowUpdates() { + isControlWindowSuppressed = true + cancelControlWindowSuppressionTask() + } + + private func releaseControlWindowUpdates(after delay: TimeInterval) { + cancelControlWindowSuppressionTask() + controlWindowSuppressionTask = Task { [delay] in + if delay > 0 { + let nanoseconds = UInt64(delay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } + + guard !Task.isCancelled else { return } + + await MainActor.run { + if vm.notchState == .closed && !lockScreenManager.isLocked { + isControlWindowSuppressed = false + triggerPendingControlWindowSyncIfNeeded() + } else { + isControlWindowSuppressed = true + } + + controlWindowSuppressionTask = nil + } + } + } + + private func cancelControlWindowSuppressionTask() { + controlWindowSuppressionTask?.cancel() + controlWindowSuppressionTask = nil + } + + private func handleControlWindowNotchStateChange(_ state: NotchState) { + if state == .open { + suppressControlWindowUpdates() + cancelControlWindowSync() + hideControlWindow() + } else { + releaseControlWindowUpdates(after: controlWindowResumeDelay) + requestControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + + private func handleControlWindowLockStateChange(_ locked: Bool) { + if locked { + suppressControlWindowUpdates() + cancelControlWindowSync() + hideControlWindow() + } else { + releaseControlWindowUpdates(after: controlWindowResumeDelay) + requestControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + + private func triggerTransientLabel() { + guard !showsLabel else { return } + guard !enableMinimalisticUI else { return } + cancelTransientLabel() + withAnimation(.smooth) { + showTransientLabel = true + } + let task = DispatchWorkItem { + withAnimation(.smooth) { + showTransientLabel = false + } + requestControlWindowSync(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 + ) } } @@ -100,7 +693,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 00000000..570cc5f3 --- /dev/null +++ b/DynamicIsland/components/Timer/TimerPopover.swift @@ -0,0 +1,382 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +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 String(localized: "Overtime") + } else if timerManager.isPaused { + return String(localized: "Paused") + } else if timerManager.isTimerActive { + return String(localized: "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: String(localized: "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(String(localized: "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) { + if !timerManager.isOvertime { + Button(action: togglePause) { + Label(timerManager.isPaused ? String(localized: "Resume") : String(localized: "Pause"), systemImage: timerManager.isPaused ? "play.fill" : "pause.fill") + .font(.system(size: 12, weight: .medium)) + .labelStyle(.titleAndIcon) + } + .buttonStyle(.bordered) + .disabled(!timerManager.allowsManualInteraction) + } + + Button(role: .destructive, action: stopTimer) { + Label(String(localized: "Stop"), systemImage: "stop.fill") + .font(.system(size: 12, weight: .medium)) + .labelStyle(.titleAndIcon) + } + .buttonStyle(.borderedProminent) + .disabled(!timerManager.allowsManualInteraction) + } + } + .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() { + guard timerManager.allowsManualInteraction else { return } + withAnimation(.smooth) { + if timerManager.isPaused { + timerManager.resumeTimer() + } else { + timerManager.pauseTimer() + } + } + } + + private func stopTimer() { + guard timerManager.allowsManualInteraction else { + timerManager.endExternalTimer(triggerSmoothClose: false) + return + } + 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(String(localized: "Custom Timer")) + .font(.system(size: 14, weight: .semibold)) + + Grid(alignment: .leading, horizontalSpacing: 6, verticalSpacing: 8) { + GridRow { + DurationStepper(title: String(localized: "Hours"), value: $hours, range: 0...23) + DurationStepper(title: String(localized: "Minutes"), value: $minutes, range: 0...59) + DurationStepper(title: String(localized: "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: 8.5, 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(String(localized: "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/components/Tips/TipStore.swift b/DynamicIsland/components/Tips/TipStore.swift index 355159a5..31fc9853 100644 --- a/DynamicIsland/components/Tips/TipStore.swift +++ b/DynamicIsland/components/Tips/TipStore.swift @@ -1,9 +1,24 @@ -// -// TipStore.swift -// DynamicIsland -// -// Created by Richard Kunkli on 15/09/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import TipKit diff --git a/DynamicIsland/components/UI/FocusIndicator.swift b/DynamicIsland/components/UI/FocusIndicator.swift new file mode 100644 index 00000000..ddfe8d56 --- /dev/null +++ b/DynamicIsland/components/UI/FocusIndicator.swift @@ -0,0 +1,70 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct FocusIndicator: View { + @ObservedObject var manager = DoNotDisturbManager.shared + + var body: some View { + Capsule() + .fill(Color.black) + .overlay { + focusIcon + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(accentColor) + .frame(width: 24, height: 24) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + } + + private var focusMode: FocusModeType { + FocusModeType.resolve( + identifier: manager.currentFocusModeIdentifier, + name: manager.currentFocusModeName + ) + } + + private var focusIcon: Image { + focusMode.activeIcon + } + + private var accentColor: Color { + focusMode.accentColor + } + + private var accessibilityLabel: String { + let trimmedName = manager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines) + let baseName: String + if trimmedName.isEmpty { + baseName = focusMode.displayName + } else { + baseName = trimmedName + } + + let finalName = focusMode == .doNotDisturb ? "Focus" : baseName + return "Focus active: \(finalName)" + } +} + +#Preview { + FocusIndicator() + .frame(width: 30, height: 30) + .background(Color.gray.opacity(0.2)) +} diff --git a/DynamicIsland/components/UI/RecordingIndicator.swift b/DynamicIsland/components/UI/RecordingIndicator.swift new file mode 100644 index 00000000..af4640dd --- /dev/null +++ b/DynamicIsland/components/UI/RecordingIndicator.swift @@ -0,0 +1,183 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct RecordingIndicator: View { + @ObservedObject var recordingManager = ScreenRecordingManager.shared + @State private var isPulsing = false + + // MARK: - Configuration + private let indicatorSize: CGFloat = 6 + private let glowSize: CGFloat = 2 + private let animationDuration: Double = 0.8 + + var body: some View { + Group { + if recordingManager.isRecording { + ZStack { + // Glow effect + Circle() + .fill(Color.red.opacity(0.3)) + .frame(width: indicatorSize + glowSize * 2, height: indicatorSize + glowSize * 2) + .blur(radius: glowSize) + .scaleEffect(isPulsing ? 1.2 : 1.0) + + // Main indicator dot + Circle() + .fill(Color.red) + .frame(width: indicatorSize, height: indicatorSize) + .opacity(isPulsing ? 1.0 : 0.6) + } + .onAppear { + startPulsingAnimation() + } + .onDisappear { + stopPulsingAnimation() + } + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .scale.combined(with: .opacity) + )) + } + } + .animation(.easeInOut(duration: 0.3), value: recordingManager.isRecording) + } + + // MARK: - Private Methods + + private func startPulsingAnimation() { + isPulsing = false + withAnimation(.easeInOut(duration: animationDuration).repeatForever(autoreverses: true)) { + isPulsing = true + } + } + + private func stopPulsingAnimation() { + withAnimation(.easeInOut(duration: 0.2)) { + isPulsing = false + } + } +} + +// MARK: - Preview + +struct RecordingIndicator_Previews: PreviewProvider { + static var previews: some View { + Group { + // Recording state preview + VStack { + Text("Recording Active") + .font(.caption) + RecordingIndicator() + .onAppear { + ScreenRecordingManager.shared.isRecording = true + } + } + .padding() + .background(Color.black) + .previewDisplayName("Recording Active") + + // Non-recording state preview + VStack { + Text("Not Recording") + .font(.caption) + RecordingIndicator() + .onAppear { + ScreenRecordingManager.shared.isRecording = false + } + } + .padding() + .background(Color.black) + .previewDisplayName("Not Recording") + } + } +} + +// MARK: - Alternative Indicator Styles + +struct RecordingIndicatorLarge: View { + @ObservedObject var recordingManager = ScreenRecordingManager.shared + @State private var isPulsing = false + + private let indicatorSize: CGFloat = 10 + private let glowSize: CGFloat = 3 + + var body: some View { + Group { + if recordingManager.isRecording { + ZStack { + // Outer glow + Circle() + .fill(Color.red.opacity(0.2)) + .frame(width: indicatorSize + glowSize * 4, height: indicatorSize + glowSize * 4) + .blur(radius: glowSize) + .scaleEffect(isPulsing ? 1.3 : 1.0) + + // Inner glow + Circle() + .fill(Color.red.opacity(0.4)) + .frame(width: indicatorSize + glowSize * 2, height: indicatorSize + glowSize * 2) + .blur(radius: glowSize / 2) + .scaleEffect(isPulsing ? 1.1 : 1.0) + + // Main indicator + Circle() + .fill(Color.red) + .frame(width: indicatorSize, height: indicatorSize) + .opacity(isPulsing ? 1.0 : 0.7) + } + .onAppear { + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + isPulsing = true + } + } + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .scale.combined(with: .opacity) + )) + } + } + .animation(.easeInOut(duration: 0.3), value: recordingManager.isRecording) + } +} + +// MARK: - Subtle Indicator for Closed State + +struct RecordingIndicatorSubtle: View { + @ObservedObject var recordingManager = ScreenRecordingManager.shared + @State private var opacity: Double = 0.5 + + var body: some View { + Group { + if recordingManager.isRecording { + Circle() + .fill(Color.red) + .frame(width: 4, height: 4) + .opacity(opacity) + .onAppear { + withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) { + opacity = 1.0 + } + } + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.2), value: recordingManager.isRecording) + } +} \ No newline at end of file diff --git a/DynamicIsland/components/Webcam/WebcamView.swift b/DynamicIsland/components/Webcam/WebcamView.swift index ca1d8210..097cee82 100644 --- a/DynamicIsland/components/Webcam/WebcamView.swift +++ b/DynamicIsland/components/Webcam/WebcamView.swift @@ -1,9 +1,24 @@ -// -// WebcamView.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 19/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import AVFoundation import Defaults diff --git a/DynamicIsland/components/WhatsNewView.swift b/DynamicIsland/components/WhatsNewView.swift index 6da6f55b..6e2ecb07 100644 --- a/DynamicIsland/components/WhatsNewView.swift +++ b/DynamicIsland/components/WhatsNewView.swift @@ -1,9 +1,24 @@ -// -// WhatsNewView.swift -// DynamicIsland -// -// Created by Richard Kunkli on 09/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI diff --git a/DynamicIsland/enums/TimerDisplayMode.swift b/DynamicIsland/enums/TimerDisplayMode.swift new file mode 100644 index 00000000..b65fd566 --- /dev/null +++ b/DynamicIsland/enums/TimerDisplayMode.swift @@ -0,0 +1,44 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Defaults + +public enum TimerDisplayMode: String, CaseIterable, Defaults.Serializable, Identifiable { + case tab + case popover + + public var id: String { rawValue } + + var displayName: String { + switch self { + case .tab: + return String(localized:"Tab") + case .popover: + return String(localized:"Popover") + } + } + + var description: String { + switch self { + case .tab: + return "Shows timer controls as a dedicated tab inside the open notch." + case .popover: + return "Keeps the current popover button beside the notch instead of adding a tab." + } + } +} diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index 49bf0695..c7d590a2 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -1,18 +1,64 @@ -// -// generic.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 04/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation import Defaults +import CoreGraphics public enum Style { case notch case floating } +/// Controls how Atoll renders on external and non-notched displays. +/// - `notch`: Standard notch shape (concave top corners blending into the screen edge). +/// - `dynamicIsland`: Pill-shaped island with continuously rounded corners, +/// inspired by DynamicNotchKit's floating style. Only applies to screens +/// that do NOT have a physical notch. +enum ExternalDisplayStyle: String, CaseIterable, Defaults.Serializable, Identifiable { + case notch = "Standard Notch" + case dynamicIsland = "Dynamic Island" + + var id: String { rawValue } + + var localizedName: String { + switch self { + case .notch: + return String(localized: "Standard Notch") + case .dynamicIsland: + return String(localized: "Dynamic Island") + } + } + + var description: String { + switch self { + case .notch: + return String(localized: "Classic notch shape that blends into the top screen edge") + case .dynamicIsland: + return String(localized: "Pill-shaped island with rounded corners, similar to iPhone's Dynamic Island") + } + } +} + public enum ContentType: Int, Codable, Hashable, Equatable { case normal case menu @@ -28,6 +74,29 @@ public enum NotchViews { case home case shelf case timer + case stats + case colorPicker + case notes + case clipboard + case terminal + case extensionExperience +} + +enum NotesLayoutState: Equatable { + case list + case split + case editor + + var preferredHeight: CGFloat { + switch self { + case .list: + return 240 + case .split: + return 260 + case .editor: + return 320 + } + } } enum SettingsEnum { @@ -44,6 +113,18 @@ enum SettingsEnum { enum DownloadIndicatorStyle: String, Defaults.Serializable { case progress = "Progress" case percentage = "Percentage" + case circle = "Circle" + + var localizedName: String { + switch self { + case .progress: + return String(localized: "Progress") + case .percentage: + return String(localized: "Percentage") + case .circle: + return String(localized: "Circle") + } + } } enum DownloadIconStyle: String, Defaults.Serializable { @@ -67,4 +148,185 @@ enum SliderColorEnum: String, CaseIterable, Defaults.Serializable { case white = "White" case albumArt = "Match album art" case accent = "Accent color" + + var localizedName: String { + switch self { + case .white: + return String(localized: "Standard") + case .albumArt: + return String(localized: "Custom Liquid") + case .accent: + return String(localized: "Accent color") + } + } +} + +enum LockScreenGlassStyle: String, CaseIterable, Defaults.Serializable, Identifiable { + case liquid = "Liquid Glass" + case frosted = "Frosted Glass" + + var id: String { rawValue } + + var localizedName: String { + switch self { + case .liquid: + return String(localized: "Liquid Glass") + case .frosted: + return String(localized: "Frosted Glass") + } + } +} + +enum LockScreenGlassCustomizationMode: String, CaseIterable, Defaults.Serializable, Identifiable { + case standard = "Standard" + case customLiquid = "Custom Liquid" + + var id: String { rawValue } + + var allowsVariantSelection: Bool { + self == .customLiquid + } + + var localizedName: String { + switch self { + case .standard: + return String(localized: "Standard") + case .customLiquid: + return String(localized: "Custom Liquid") + } + } +} + +enum LockScreenTimerSurfaceMode: String, CaseIterable, Defaults.Serializable, Identifiable { + case classic = "Classic" + case glass = "Glass" + + var id: String { rawValue } + + var localizedName: String { + switch self { + case .classic: + return String(localized: "Classic") + case .glass: + return String(localized: "Glass") + } + } +} + +enum LockScreenWeatherWidgetStyle: String, CaseIterable, Defaults.Serializable, Identifiable { + case inline = "Inline" + case circular = "Circular" + + var id: String { rawValue } + + var localizedName: String { + switch self { + case .inline: + return String(localized: "Inline") + case .circular: + return String(localized: "Circular") + } + } +} + +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" + } + } +} + +enum LockScreenWeatherAirQualityScale: String, CaseIterable, Defaults.Serializable, Identifiable { + case us = "U.S. AQI" + case european = "EAQI" + + var id: String { rawValue } + + var displayName: String { rawValue } + + var compactLabel: String { + switch self { + case .us: + return String(localized: "AQI") + case .european: + return String(localized: "EAQI") + } + } + + var accessibilityLabel: String { + switch self { + case .us: + return String(localized: "AQI") + case .european: + return String(localized: "EAQI") + } + } + + var queryParameter: String { + switch self { + case .us: + return "us_aqi" + case .european: + return "european_aqi" + } + } + + var gaugeRange: ClosedRange { + switch self { + case .us: + return 0...500 + case .european: + return 0...120 + } + } +} + +enum LockScreenReminderChipStyle: String, CaseIterable, Defaults.Serializable, Identifiable { + case eventColor = "Event color" + case monochrome = "White" + + var id: String { rawValue } + + var localizedName: String { + switch self { + case .eventColor: + return String(localized: "Event color") + case .monochrome: + return String(localized: "White") + } + } } diff --git a/DynamicIsland/extensions/ActionBar.swift b/DynamicIsland/extensions/ActionBar.swift index fd72957f..3e87e57f 100644 --- a/DynamicIsland/extensions/ActionBar.swift +++ b/DynamicIsland/extensions/ActionBar.swift @@ -1,9 +1,24 @@ -// -// ActionBar.swift -// DynamicIsland -// -// Created by Richard Kunkli on 15/09/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI diff --git a/DynamicIsland/extensions/AlbumArtFlipModifier.swift b/DynamicIsland/extensions/AlbumArtFlipModifier.swift new file mode 100644 index 00000000..9553cde4 --- /dev/null +++ b/DynamicIsland/extensions/AlbumArtFlipModifier.swift @@ -0,0 +1,18 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + diff --git a/DynamicIsland/extensions/BundleInfos.swift b/DynamicIsland/extensions/BundleInfos.swift index 9d6471a5..941c048c 100644 --- a/DynamicIsland/extensions/BundleInfos.swift +++ b/DynamicIsland/extensions/BundleInfos.swift @@ -1,9 +1,24 @@ -// -// BundleInfos.swift -// DynamicIsland -// -// Created by Richard Kunkli on 08/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI diff --git a/DynamicIsland/extensions/Button+Bouncing.swift b/DynamicIsland/extensions/Button+Bouncing.swift index f435f937..6346cb7d 100644 --- a/DynamicIsland/extensions/Button+Bouncing.swift +++ b/DynamicIsland/extensions/Button+Bouncing.swift @@ -1,9 +1,25 @@ -// -// Button+Bouncing.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 19/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + import SwiftUI import Defaults diff --git a/DynamicIsland/extensions/Color+AccentColor.swift b/DynamicIsland/extensions/Color+AccentColor.swift new file mode 100644 index 00000000..d4840c20 --- /dev/null +++ b/DynamicIsland/extensions/Color+AccentColor.swift @@ -0,0 +1,38 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#if os(macOS) +import SwiftUI +import Defaults + +extension Color { + /// Returns the accent color configured in settings, falling back to the system accent color. + static var effectiveAccent: Color { + Defaults[.accentColor] + } + + /// Returns a subtle background variant of the accent color. + static var effectiveAccentBackground: Color { + Defaults[.accentColor].opacity(0.25) + } +} +#endif diff --git a/DynamicIsland/extensions/ConditionalModifier.swift b/DynamicIsland/extensions/ConditionalModifier.swift index 3d676f26..f947058a 100644 --- a/DynamicIsland/extensions/ConditionalModifier.swift +++ b/DynamicIsland/extensions/ConditionalModifier.swift @@ -1,9 +1,24 @@ -// -// ConditionalModifier.swift -// DynamicIsland -// -// Created by Richard Kunkli on 20/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI diff --git a/DynamicIsland/extensions/DataTypes+Extensions.swift b/DynamicIsland/extensions/DataTypes+Extensions.swift index 8de5f9dd..6f541c4c 100644 --- a/DynamicIsland/extensions/DataTypes+Extensions.swift +++ b/DynamicIsland/extensions/DataTypes+Extensions.swift @@ -1,9 +1,24 @@ - // - // DataTypes+Extensions.swift - // DynamicIsland - // - // Created by Harsh Vardhan Goswami on 27/08/24. - // +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation diff --git a/DynamicIsland/extensions/Image+InternalSystemName.swift b/DynamicIsland/extensions/Image+InternalSystemName.swift new file mode 100644 index 00000000..345d6223 --- /dev/null +++ b/DynamicIsland/extensions/Image+InternalSystemName.swift @@ -0,0 +1,32 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +@_silgen_name("$s7SwiftUI5ImageV19_internalSystemNameACSS_tcfC") +private func _swiftUI_image(internalSystemName: String) -> Image? + +extension Image { + init?(internalSystemName systemName: String) { + guard let systemImage = _swiftUI_image(internalSystemName: systemName) else { + return nil + } + + self = systemImage + } +} diff --git a/DynamicIsland/extensions/KeyboardShortcutsHelper.swift b/DynamicIsland/extensions/KeyboardShortcutsHelper.swift index c1d2ee39..ded99eee 100644 --- a/DynamicIsland/extensions/KeyboardShortcutsHelper.swift +++ b/DynamicIsland/extensions/KeyboardShortcutsHelper.swift @@ -1,9 +1,24 @@ -// -// KeyboardShortcutsHelper.swift -// DynamicIsland -// -// Created by Richard Kunkli on 16/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import KeyboardShortcuts import SwiftUI diff --git a/DynamicIsland/extensions/MouseTracker.swift b/DynamicIsland/extensions/MouseTracker.swift index 798b18f9..f64ee078 100644 --- a/DynamicIsland/extensions/MouseTracker.swift +++ b/DynamicIsland/extensions/MouseTracker.swift @@ -1,9 +1,24 @@ -// -// MouseTracker.swift -// DynamicIsland -// -// Created by Richard Kunkli on 12/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI diff --git a/DynamicIsland/extensions/NSImage+Extensions.swift b/DynamicIsland/extensions/NSImage+Extensions.swift index 0676dffd..284398cc 100644 --- a/DynamicIsland/extensions/NSImage+Extensions.swift +++ b/DynamicIsland/extensions/NSImage+Extensions.swift @@ -1,9 +1,24 @@ -// -// Image2Color.swift -// DynamicIsland -// -// Created by Richard Kunkli on 07/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import AppKit diff --git a/DynamicIsland/extensions/NSItemProvider+LoadHelpers.swift b/DynamicIsland/extensions/NSItemProvider+LoadHelpers.swift new file mode 100644 index 00000000..e0fd11bb --- /dev/null +++ b/DynamicIsland/extensions/NSItemProvider+LoadHelpers.swift @@ -0,0 +1,189 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Foundation +import UniformTypeIdentifiers + +extension NSItemProvider { + func extractItem() async -> URL? { + await loadFileURL(typeIdentifier: UTType.item.identifier) + } + + /// Detects if this is a file dragged from the filesystem + func extractFileURL() async -> URL? { + if hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + return await loadFileURL(typeIdentifier: UTType.fileURL.identifier) + } + return nil + } + + /// Loads raw data for the given type identifier + func loadData() async -> Data? { + guard hasItemConformingToTypeIdentifier(UTType.data.identifier) else { return nil } + return await withCheckedContinuation { (cont: CheckedContinuation) in + loadItem(forTypeIdentifier: UTType.data.identifier, options: nil) { item, error in + if let error { + print("Error loading data for type \(UTType.data.identifier): \(error.localizedDescription)") + cont.resume(returning: nil) + return + } + if let url = item as? URL, let data = try? Data(contentsOf: url) { + if !url.absoluteString.contains("com.apple.SwiftUI.filePromises") { + cont.resume(returning: nil) + return + } + self.suggestedName = self.suggestedName ?? url.lastPathComponent + let fileManager = FileManager.default + let folderURL = url.deletingLastPathComponent() + do { + try fileManager.removeItem(at: url) + let contents = try fileManager.contentsOfDirectory(atPath: folderURL.path) + if contents.isEmpty { + try fileManager.removeItem(at: folderURL) + } + } catch { + print("Error: \(error.localizedDescription)") + } + cont.resume(returning: data) + } else if let data = item as? Data { + cont.resume(returning: data) + } else { + cont.resume(returning: nil) + } + } + } + } + + /// Attempts to extract a URL (web link) from the provider + func extractURL() async -> URL? { + if hasItemConformingToTypeIdentifier(UTType.url.identifier) { + if let url = await loadURL(typeIdentifier: UTType.url.identifier) { + guard url.scheme != nil else { return nil } + return url + } + } + return nil + } + + func extractText() async -> String? { + let textTypes = [UTType.utf8PlainText.identifier, UTType.plainText.identifier] + for typeIdentifier in textTypes where hasItemConformingToTypeIdentifier(typeIdentifier) { + if let text = await loadText(typeIdentifier: typeIdentifier) { + return text + } + } + return nil + } + + /// Loads a file URL from the provider for the given type identifier. + func loadFileURL(typeIdentifier: String) async -> URL? { + await withCheckedContinuation { (cont: CheckedContinuation) in + loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in + if let error { + print("❌ Error loading item for type \(typeIdentifier): \(error.localizedDescription)") + cont.resume(returning: nil) + return + } + var resolvedURL: URL? + if let url = item as? URL { + resolvedURL = url + } else if let data = item as? Data { + if let string = String(data: data, encoding: .utf8) { + if let url = URL(string: string) { + resolvedURL = url + } else if string.hasPrefix("/") { + resolvedURL = URL(fileURLWithPath: string) + } + } + if resolvedURL == nil { + let bookmark = Bookmark(data: data) + resolvedURL = bookmark.resolveURL() + } + } else if let string = item as? String { + if let url = URL(string: string) { + resolvedURL = url + } else if string.hasPrefix("/") { + resolvedURL = URL(fileURLWithPath: string) + } + } + cont.resume(returning: resolvedURL) + } + } + } + + /// Loads a URL from the provider for the given type identifier. + func loadURL(typeIdentifier: String) async -> URL? { + await withCheckedContinuation { (cont: CheckedContinuation) in + loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in + if error != nil { + cont.resume(returning: nil) + return + } + if let url = item as? URL { + cont.resume(returning: url) + } else if let data = item as? Data { + if let string = String(data: data, encoding: .utf8) { + if let url = URL(string: string) { + cont.resume(returning: url) + return + } else if string.hasPrefix("/") { + cont.resume(returning: URL(fileURLWithPath: string)) + return + } + } + cont.resume(returning: nil) + } else if let string = item as? String { + if let url = URL(string: string) { + cont.resume(returning: url) + } else if string.hasPrefix("/") { + cont.resume(returning: URL(fileURLWithPath: string)) + } else { + cont.resume(returning: nil) + } + } else { + cont.resume(returning: nil) + } + } + } + } + + /// Loads text from the provider for the given type identifier. + func loadText(typeIdentifier: String) async -> String? { + await withCheckedContinuation { (cont: CheckedContinuation) in + loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in + if error != nil { + cont.resume(returning: nil) + return + } + if let string = item as? String { + cont.resume(returning: string) + } else if let data = item as? Data, + let string = String(data: data, encoding: .utf8) { + cont.resume(returning: string) + } else { + cont.resume(returning: nil) + } + } + } + } +} diff --git a/DynamicIsland/extensions/NSMenu+AssociatedObject.swift b/DynamicIsland/extensions/NSMenu+AssociatedObject.swift new file mode 100644 index 00000000..a8dbc2e6 --- /dev/null +++ b/DynamicIsland/extensions/NSMenu+AssociatedObject.swift @@ -0,0 +1,36 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit + +private final class MenuActionBox: NSObject { + let target: AnyObject + init(target: AnyObject) { self.target = target } +} + +extension NSMenu { + private static let retainedAction = AssociatedObject() + + func retainActionTarget(_ target: AnyObject) { + NSMenu.retainedAction[self] = MenuActionBox(target: target) + } +} diff --git a/DynamicIsland/extensions/PanGesture.swift b/DynamicIsland/extensions/PanGesture.swift index 4593c840..7eaa2a72 100644 --- a/DynamicIsland/extensions/PanGesture.swift +++ b/DynamicIsland/extensions/PanGesture.swift @@ -1,101 +1,170 @@ -// -// PanGesture.swift -// DynamicIsland -// -// Created by Richard Kunkli on 21/08/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ -import SwiftUI import AppKit +import SwiftUI + +enum PanDirection { + case left, right, up, down + + var isHorizontal: Bool { self == .left || self == .right } + var sign: CGFloat { (self == .right || self == .down) ? 1 : -1 } + + func signed(from translation: CGSize) -> CGFloat { (isHorizontal ? translation.width : translation.height) * sign } + func signed(deltaX: CGFloat, deltaY: CGFloat) -> CGFloat { (isHorizontal ? deltaX : deltaY) * sign } +} extension View { - func panGesture(direction: PanDirection, action: @escaping (CGFloat, NSEvent.Phase) -> Void) -> some View { - background( - PanGestureView(direction: direction, action: action) - .frame(maxWidth: 0, maxHeight: 0) - ) + func panGesture(direction: PanDirection, threshold: CGFloat = 4, action: @escaping (CGFloat, NSEvent.Phase) -> Void) -> some View { + self + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let s = direction.signed(from: value.translation) + guard s > 0, s.magnitude >= threshold else { return } + action(s.magnitude, .changed) + } + .onEnded { _ in action(0, .ended) } + ) + .background(ScrollMonitor(direction: direction, threshold: threshold, action: action)) } } -struct PanGestureView: NSViewRepresentable { +private struct ScrollMonitor: NSViewRepresentable { let direction: PanDirection + let threshold: CGFloat let action: (CGFloat, NSEvent.Phase) -> Void - + func makeNSView(context: Context) -> NSView { let view = NSView() - NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { event in - if event.window == view.window { - context.coordinator.handleEvent(event) - } - return event - } + context.coordinator.installMonitor(on: view) return view } - - func updateNSView(_ nsView: NSView, context: Context) { - - } - - func makeCoordinator() -> Coordinator { - Coordinator(direction: direction, action: action) + func updateNSView(_ nsView: NSView, context: Context) {} + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.removeMonitor() } + + func makeCoordinator() -> Coordinator { + Coordinator(direction: direction, threshold: threshold, action: action) } - - class Coordinator: NSObject { - let direction: PanDirection - let action: (CGFloat, NSEvent.Phase) -> Void - - var accumulatedScrollDeltaX: CGFloat = 0 - var accumulatedScrollDeltaY: CGFloat = 0 - - init(direction: PanDirection, action: @escaping (CGFloat, NSEvent.Phase) -> Void) { + + @MainActor final class Coordinator: NSObject { + private let direction: PanDirection + private let threshold: CGFloat + private let action: (CGFloat, NSEvent.Phase) -> Void + private var monitor: Any? + private var globalMonitor: Any? + private var accumulated: CGFloat = 0 + private var active = false + private let noiseThreshold: CGFloat = 0.2 + private weak var observedView: NSView? + /// Small vertical inset so scroll gestures fire when the cursor + /// "kisses" the very top of the screen inside the physical notch + /// area. Kept at zero horizontally to avoid unwanted hover opens + /// from the sides. + private let verticalEdgeInset: CGFloat = 4 + private var lastEventTimestamp: TimeInterval = 0 + + init(direction: PanDirection, threshold: CGFloat, action: @escaping (CGFloat, NSEvent.Phase) -> Void) { self.direction = direction + self.threshold = threshold self.action = action } - - @objc func handleEvent(_ event: NSEvent) { - if event.type == .scrollWheel { - accumulatedScrollDeltaX += event.scrollingDeltaX - accumulatedScrollDeltaY += event.scrollingDeltaY - - switch direction { - case .down: - if accumulatedScrollDeltaY > 0 { - handle() - } - case .up: - if accumulatedScrollDeltaY < 0 { - handle() - } - case .left: - if accumulatedScrollDeltaX < 0 { - handle() - } - case .right: - if accumulatedScrollDeltaX > 0 { - handle() - } - } - - func handle() { - if (direction == .left || direction == .right) { - action(abs(accumulatedScrollDeltaX), event.phase) - } else { - action(abs(accumulatedScrollDeltaY), event.phase) - } - } - - if event.phase == .ended { - accumulatedScrollDeltaY = 0 - accumulatedScrollDeltaX = 0 + + func installMonitor(on view: NSView) { + removeMonitor() + observedView = view + monitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in + guard let self else { return event } + self.handleScroll(event) + return event + } + globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in + self?.handleScroll(event) + } + } + + func removeMonitor() { + if let monitor = monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + if let globalMonitor = globalMonitor { + NSEvent.removeMonitor(globalMonitor) + self.globalMonitor = nil + } + accumulated = 0 + active = false + observedView = nil + lastEventTimestamp = 0 + } + + private func handleScroll(_ event: NSEvent) { + guard lastEventTimestamp != event.timestamp else { return } + lastEventTimestamp = event.timestamp + guard isCursorNearObservedView(using: event) else { return } + + if event.phase == .ended || event.momentumPhase == .ended { + if active { + action(accumulated.magnitude, .ended) + } else { + action(0, .ended) } + active = false + accumulated = 0 + return + } + + let s = direction.signed(deltaX: event.scrollingDeltaX, deltaY: event.scrollingDeltaY) + guard s.magnitude > noiseThreshold else { return } + accumulated = s > 0 ? accumulated + s : 0 + + if !active && accumulated >= threshold { + active = true + action(accumulated.magnitude, .began) + } else if active { + action(accumulated.magnitude, .changed) } } - } -} -enum PanDirection { - case left - case right - case up - case down + private func isCursorNearObservedView(using event: NSEvent) -> Bool { + guard let view = observedView, let window = view.window else { return false } + + let screenPoint: NSPoint + if let eventWindow = event.window { + let rect = NSRect(origin: event.locationInWindow, size: .zero) + screenPoint = eventWindow.convertToScreen(rect).origin + } else { + screenPoint = NSEvent.mouseLocation + } + + let windowPoint = window.convertPoint(fromScreen: screenPoint) + let localPoint = view.convert(windowPoint, from: nil) + + // Extend the hit area vertically by a few points so the cursor + // at the very top screen edge (kissing the notch) still triggers + // scroll gestures. No horizontal extension to prevent false opens + // from the sides of the notch. + let hitArea = view.bounds.insetBy(dx: 0, dy: -verticalEdgeInset) + return hitArea.contains(localPoint) + } + } } diff --git a/DynamicIsland/extensions/URL+SecurityScoped.swift b/DynamicIsland/extensions/URL+SecurityScoped.swift new file mode 100644 index 00000000..d0a0b124 --- /dev/null +++ b/DynamicIsland/extensions/URL+SecurityScoped.swift @@ -0,0 +1,59 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import AppKit + +extension URL { + func accessSecurityScopedResource(accessor: (URL) throws -> Value) rethrows -> Value { + let didStartAccessing = startAccessingSecurityScopedResource() + defer { + if didStartAccessing { + stopAccessingSecurityScopedResource() + } + } + return try accessor(self) + } + + /// Async version of accessSecurityScopedResource + func accessSecurityScopedResource(accessor: (URL) async throws -> Value) async rethrows -> Value { + let didStartAccessing = startAccessingSecurityScopedResource() + defer { + if didStartAccessing { + stopAccessingSecurityScopedResource() + } + } + return try await accessor(self) + } +} + +extension Array where Element == URL { + func accessSecurityScopedResources(accessor: ([URL]) async throws -> Value) async rethrows -> Value { + let didStart = map { $0.startAccessingSecurityScopedResource() } + defer { + for (url, started) in zip(self, didStart) where started { + url.stopAccessingSecurityScopedResource() + } + } + return try await accessor(self) + } +} diff --git a/DynamicIsland/extensions/View+Parallax3D.swift b/DynamicIsland/extensions/View+Parallax3D.swift new file mode 100644 index 00000000..35d27596 --- /dev/null +++ b/DynamicIsland/extensions/View+Parallax3D.swift @@ -0,0 +1,90 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import AppKit + +import Defaults + +struct ParallaxMotionModifier: ViewModifier { + var enableOverride: Bool? + var isSuspended: Bool + + @Default(.parallaxEffectIntensity) var parallaxEffectIntensity + @State private var offset: CGSize = .zero + @State private var isHovering = false + @State private var viewSize: CGSize = .zero + + func body(content: Content) -> some View { + if isSuspended || !(enableOverride ?? (parallaxEffectIntensity > 0.0)) { + content + } else { + content + .contentShape(Rectangle()) + .overlay( + GeometryReader { proxy in + Color.clear + .allowsHitTesting(false) + .onAppear { + viewSize = proxy.size + } + .onChange(of: proxy.size) { _, newSize in + viewSize = newSize + } + } + ) + .onContinuousHover { phase in + switch phase { + case .active(let location): + guard viewSize.width > 0, viewSize.height > 0 else { return } + guard NSEvent.pressedMouseButtons == 0 else { return } // Skip hover math while clicking to avoid lag + let x = (location.x / viewSize.width) * 2 - 1 + let y = (location.y / viewSize.height) * 2 - 1 + + withAnimation(.interactiveSpring(response: 0.1, dampingFraction: 0.5)) { + offset = CGSize(width: x, height: y) + isHovering = true + } + case .ended: + withAnimation(.spring(response: 0.35, dampingFraction: 0.6)) { + offset = .zero + isHovering = false + } + } + } + .rotation3DEffect( + .degrees(offset.height * parallaxEffectIntensity), // Y movement rotates around X axis + axis: (x: 1, y: 0, z: 0) + ) + .rotation3DEffect( + .degrees(offset.width * -parallaxEffectIntensity), // X movement rotates around Y axis (inverted to look naturally) + axis: (x: 0, y: 1, z: 0) + ) + .scaleEffect(isHovering ? 1.02 : 1.0) // Subtle scale up on hover + } + } +} + +extension View { + func parallax3D( + enableOverride: Bool? = nil, + suspended: Bool = false + ) -> some View { + modifier(ParallaxMotionModifier(enableOverride: enableOverride, isSuspended: suspended)) + } +} diff --git a/DynamicIsland/helpers/AccessibilityPermissionStore.swift b/DynamicIsland/helpers/AccessibilityPermissionStore.swift new file mode 100644 index 00000000..2b5bd80a --- /dev/null +++ b/DynamicIsland/helpers/AccessibilityPermissionStore.swift @@ -0,0 +1,94 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Combine +import AppKit +#if canImport(ApplicationServices) +import ApplicationServices +#endif + +/// Tracks accessibility permission status and exposes helpers to request access. +@MainActor +final class AccessibilityPermissionStore: ObservableObject { + static let shared = AccessibilityPermissionStore() + + @Published private(set) var isAuthorized: Bool = AccessibilityPermissionStore.isAccessibilityAuthorized() + + private var pollingTask: Task? + + private init() {} + + deinit { + pollingTask?.cancel() + } + + func refreshStatus() { + updateAuthorizationStatus(to: Self.isAccessibilityAuthorized()) + } + + func requestAuthorizationPrompt() { +#if canImport(ApplicationServices) + let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String + let options: CFDictionary = [promptKey: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) +#endif + beginPollingForStatusChanges() + } + + func openSystemSettings() { +#if os(macOS) + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } +#endif + } + + private func beginPollingForStatusChanges() { + pollingTask?.cancel() + pollingTask = Task { [weak self] in + for _ in 0..<30 { + try? await Task.sleep(nanoseconds: 500_000_000) + let status = Self.isAccessibilityAuthorized() + await MainActor.run { + guard let self else { return } + self.updateAuthorizationStatus(to: status) + } + if status { + break + } + } + } + } + + private func updateAuthorizationStatus(to newValue: Bool) { + guard newValue != isAuthorized else { return } + isAuthorized = newValue + if !newValue { + beginPollingForStatusChanges() + } + } + + private static func isAccessibilityAuthorized() -> Bool { +#if canImport(ApplicationServices) + return AXIsProcessTrusted() +#else + return true +#endif + } +} diff --git a/DynamicIsland/helpers/AppIcons.swift b/DynamicIsland/helpers/AppIcons.swift index 1123b012..0b563c45 100644 --- a/DynamicIsland/helpers/AppIcons.swift +++ b/DynamicIsland/helpers/AppIcons.swift @@ -1,12 +1,24 @@ -// -// AppIcons.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 16/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import AppKit +import Defaults struct AppIcons { @@ -26,13 +38,6 @@ struct AppIcons { return getIcon(file: path) } - func getIcon(application: String) -> NSImage? { - guard let path = NSWorkspace.shared.fullPath(forApplication: application) - else { return nil } - - return getIcon(file: path) - } - /// Easily read Info.plist as a Dictionary from any bundle by accessing .infoDictionary on Bundle func bundle(forBundleID: String) -> Bundle? { guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: forBundleID) @@ -65,3 +70,18 @@ func AppIconAsNSImage(for bundleID: String) -> NSImage? { return nil } +func applySelectedAppIcon() { + let customIcons = Defaults[.customAppIcons] + if let selectedID = Defaults[.selectedAppIconID], + let icon = customIcons.first(where: { $0.id.uuidString == selectedID }), + let image = NSImage(contentsOf: icon.fileURL) { + NSApp.applicationIconImage = image + return + } + + let fallbackName = Bundle.main.iconFileName ?? "AppIcon" + if let image = NSImage(named: fallbackName) { + NSApp.applicationIconImage = image + } +} + diff --git a/DynamicIsland/helpers/AppleScriptError.swift b/DynamicIsland/helpers/AppleScriptError.swift new file mode 100644 index 00000000..ebb5abd9 --- /dev/null +++ b/DynamicIsland/helpers/AppleScriptError.swift @@ -0,0 +1,25 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +enum AppleScriptError: Error { + case initScriptFailed + case runtimeError + case emptyOutput +} \ No newline at end of file diff --git a/DynamicIsland/helpers/AppleScriptHelper.swift b/DynamicIsland/helpers/AppleScriptHelper.swift index 50312edd..64fbe5a4 100644 --- a/DynamicIsland/helpers/AppleScriptHelper.swift +++ b/DynamicIsland/helpers/AppleScriptHelper.swift @@ -1,9 +1,24 @@ -// -// AppleScriptHelper.swift -// DynamicIsland -// -// Created by Alexander on 2025-03-29. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation diff --git a/DynamicIsland/helpers/AppleScriptRunner.swift b/DynamicIsland/helpers/AppleScriptRunner.swift new file mode 100644 index 00000000..603c2c96 --- /dev/null +++ b/DynamicIsland/helpers/AppleScriptRunner.swift @@ -0,0 +1,42 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +class AppleScriptRunner { + private init() {} + + static func run(script: String) throws -> String { + var error: NSDictionary? + if let scriptObject = NSAppleScript(source: script) { + let output: NSAppleEventDescriptor = scriptObject.executeAndReturnError(&error) + guard error == nil else { + throw AppleScriptError.runtimeError + } + if let outputString = output.stringValue { + return outputString + } + throw AppleScriptError.emptyOutput + } + throw AppleScriptError.initScriptFailed + } +} \ No newline at end of file diff --git a/DynamicIsland/helpers/AssociatedObject.swift b/DynamicIsland/helpers/AssociatedObject.swift new file mode 100644 index 00000000..f69007c2 --- /dev/null +++ b/DynamicIsland/helpers/AssociatedObject.swift @@ -0,0 +1,43 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import ObjectiveC + +public struct AssociatedObject { + private let key: UnsafeRawPointer + private let policy: objc_AssociationPolicy + + public init(_ policy: objc_AssociationPolicy = .OBJC_ASSOCIATION_RETAIN_NONATOMIC) { + self.key = UnsafeRawPointer(Unmanaged.passUnretained(UniqueKey()).toOpaque()) + self.policy = policy + } + + private final class UniqueKey {} + + public subscript(_ owner: Owner) -> Value? { + get { objc_getAssociatedObject(owner, key) as? Value } + nonmutating set { objc_setAssociatedObject(owner, key, newValue, policy) } + } +} + +extension AssociatedObject: @unchecked Sendable where Value: Sendable {} diff --git a/DynamicIsland/helpers/AudioPlayer.swift b/DynamicIsland/helpers/AudioPlayer.swift index b9f4d10f..1b0b56e9 100644 --- a/DynamicIsland/helpers/AudioPlayer.swift +++ b/DynamicIsland/helpers/AudioPlayer.swift @@ -1,9 +1,24 @@ -// -// AudioPlayer.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 09/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation import AppKit diff --git a/DynamicIsland/helpers/Clipboard+Content.swift b/DynamicIsland/helpers/Clipboard+Content.swift index 8b7d02e4..c634e11f 100644 --- a/DynamicIsland/helpers/Clipboard+Content.swift +++ b/DynamicIsland/helpers/Clipboard+Content.swift @@ -1,3 +1,25 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + import AppKit func getAttributedString(content: Any, type: NSPasteboard.PasteboardType) -> NSAttributedString? { diff --git a/DynamicIsland/helpers/CoreBrightnessDisplayClient.swift b/DynamicIsland/helpers/CoreBrightnessDisplayClient.swift new file mode 100644 index 00000000..0a50d07b --- /dev/null +++ b/DynamicIsland/helpers/CoreBrightnessDisplayClient.swift @@ -0,0 +1,108 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import ObjectiveC.runtime + +final class CoreBrightnessDisplayClient { + static let shared = CoreBrightnessDisplayClient() + + private static let builtInDisplayID: UInt64 = 0 + + private var clientInstance: NSObject? + private let getSelector = NSSelectorFromString("brightnessForDisplay:") + private let setSelector = NSSelectorFromString("setBrightness:forDisplay:") + private let notificationSelector = NSSelectorFromString("setNotificationBlock:") + + var onBrightnessChange: ((Float) -> Void)? + private var available = false + + /// Known class names Apple has used across macOS versions for the + /// CoreBrightness display-brightness client. Ordered newest-first so + /// we resolve the current name quickly. + private static let candidateClassNames: [String] = [ + "CBBrightnessProxy", + "CBDisplayBrightnessClient", + "BrightnessSystemClient", + "DisplayBrightnessClient" + ] + + private init() { + let osVersion = ProcessInfo.processInfo.operatingSystemVersionString + var loaded = false + let bundlePaths = [ + "/System/Library/PrivateFrameworks/CoreBrightness.framework", + "/System/Library/PrivateFrameworks/CoreBrightness.framework/CoreBrightness" + ] + for path in bundlePaths where !loaded { + if let bundle = Bundle(path: path) { + loaded = bundle.load() + } + } + guard loaded else { + NSLog("⚠️ CoreBrightnessDisplayClient: CoreBrightness.framework could not be loaded (macOS %@)", osVersion) + return + } + + var resolvedClass: NSObject.Type? + for name in Self.candidateClassNames { + if let cls = NSClassFromString(name) as? NSObject.Type { + resolvedClass = cls + NSLog("✅ CoreBrightnessDisplayClient: Resolved class '%@' (macOS %@)", name, osVersion) + break + } + } + + guard let cls = resolvedClass else { + NSLog("⚠️ CoreBrightnessDisplayClient: None of the known class names found: %@ (macOS %@)", + Self.candidateClassNames.joined(separator: ", "), osVersion) + return + } + clientInstance = cls.init() + available = clientInstance != nil + } + + var isAvailable: Bool { available } + + func currentBrightness() -> Float? { + guard let clientInstance, + let getter: BrightnessGetter = methodIMP(on: clientInstance, selector: getSelector, as: BrightnessGetter.self) + else { return nil } + let value = getter(clientInstance, getSelector, Self.builtInDisplayID) + guard value >= 0, value <= 1 else { return nil } + return value + } + + func setBrightness(_ value: Float) -> Bool { + guard let clientInstance, + let setter: BrightnessSetter = methodIMP(on: clientInstance, selector: setSelector, as: BrightnessSetter.self) + else { return false } + return setter(clientInstance, setSelector, value, Self.builtInDisplayID).boolValue + } + + private typealias BrightnessGetter = @convention(c) (NSObject, Selector, UInt64) -> Float + private typealias BrightnessSetter = @convention(c) (NSObject, Selector, Float, UInt64) -> ObjCBool + + private func methodIMP(on object: NSObject, selector: Selector, as type: T.Type) -> T? { + guard let cls = object_getClass(object), + let method = class_getInstanceMethod(cls, selector) + else { return nil } + let imp = method_getImplementation(method) + return unsafeBitCast(imp, to: T.self) + } +} diff --git a/DynamicIsland/helpers/DisplayServicesDynamic.swift b/DynamicIsland/helpers/DisplayServicesDynamic.swift new file mode 100644 index 00000000..05a63b99 --- /dev/null +++ b/DynamicIsland/helpers/DisplayServicesDynamic.swift @@ -0,0 +1,65 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Darwin + +final class DisplayServicesDynamic { + static let shared = DisplayServicesDynamic() + + private let handle: UnsafeMutableRawPointer? + + private typealias GetBrightnessFn = @convention(c) (UInt32, UnsafeMutablePointer) -> Int32 + private typealias SetBrightnessFn = @convention(c) (UInt32, Float) -> Int32 + + private let getBrightnessFn: GetBrightnessFn? + private let setBrightnessFn: SetBrightnessFn? + + private init() { + handle = dlopen("/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices", RTLD_NOW) + + if handle == nil, let errorPointer = dlerror() { + let message = String(cString: errorPointer) + NSLog("⚠️ DisplayServicesDynamic: dlopen failed - %@", message) + } + + getBrightnessFn = handle.flatMap { dlsym($0, "DisplayServicesGetBrightness") } + .map { unsafeBitCast($0, to: GetBrightnessFn.self) } + + setBrightnessFn = handle.flatMap { dlsym($0, "DisplayServicesSetBrightness") } + .map { unsafeBitCast($0, to: SetBrightnessFn.self) } + } + + func setBrightness(displayID: UInt32, value: Float) -> Int32? { + guard let fn = setBrightnessFn else { return nil } + return fn(displayID, value) + } + + func getBrightness(displayID: UInt32) -> (status: Int32, value: Float)? { + guard let fn = getBrightnessFn else { return nil } + var brightness: Float = 0 + let status = fn(displayID, &brightness) + return (status, brightness) + } + + deinit { + if let handle { + dlclose(handle) + } + } +} diff --git a/DynamicIsland/helpers/FullDiskAccessPermissionStore.swift b/DynamicIsland/helpers/FullDiskAccessPermissionStore.swift new file mode 100644 index 00000000..adab59d5 --- /dev/null +++ b/DynamicIsland/helpers/FullDiskAccessPermissionStore.swift @@ -0,0 +1,257 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Combine +import Foundation + +enum FullDiskAccessAuthorization { + private static let probeURLs: [URL] = [ + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.TCC/TCC.db"), + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/DoNotDisturb/DB/ModeConfigurations.json") + ] + + static func hasPermission() -> Bool { + for url in probeURLs { + if canReadProtectedResource(at: url) { + return true + } + } + return false + } + + private static func canReadProtectedResource(at url: URL) -> Bool { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), !isDirectory.boolValue else { + return false + } + + do { + let handle = try FileHandle(forReadingFrom: url) + try handle.close() + return true + } catch { + return false + } + } +} + +enum ShelfFolderAccessAuthorization { + private static var documentsDirectoryURL: URL? { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + } + + private static var downloadsDirectoryURL: URL? { + FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + } + + static func hasDocumentsAccess() -> Bool { + canReadDirectory(at: documentsDirectoryURL) + } + + static func hasDownloadsAccess() -> Bool { + canReadDirectory(at: downloadsDirectoryURL) + } + + static func hasDocumentsAndDownloadsAccess() -> Bool { + hasDocumentsAccess() && hasDownloadsAccess() + } + + static func requestAccessProbe() { + if let documentsDirectoryURL { + _ = try? FileManager.default.contentsOfDirectory(at: documentsDirectoryURL, includingPropertiesForKeys: nil) + } + + if let downloadsDirectoryURL { + _ = try? FileManager.default.contentsOfDirectory(at: downloadsDirectoryURL, includingPropertiesForKeys: nil) + } + } + + private static func canReadDirectory(at url: URL?) -> Bool { + guard let url else { return false } + + do { + _ = try FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + return true + } catch { + return false + } + } +} + +@MainActor +final class FullDiskAccessPermissionStore: ObservableObject { + static let shared = FullDiskAccessPermissionStore() + + @Published private(set) var isAuthorized: Bool = FullDiskAccessAuthorization.hasPermission() + + private var pollingTask: Task? + + private init() {} + + deinit { + pollingTask?.cancel() + } + + func refreshStatus() { + updateAuthorizationStatus(to: FullDiskAccessAuthorization.hasPermission()) + } + + func requestAccessPrompt() { +#if os(macOS) + let alert = NSAlert() + alert.messageText = "Full Disk Access Required" + alert.informativeText = "Dynamic Island needs Full Disk Access to detect custom Focus indicators and power the Shelf. Click Continue to open Full Disk Access settings, then press the + button and select Dynamic Island (we'll reveal it in Finder for you)." + alert.alertStyle = .informational + alert.addButton(withTitle: "Continue") + alert.addButton(withTitle: "Cancel") + + if alert.runModal() == .alertFirstButtonReturn { + openSystemSettings() + revealAppBundleInFinder() + } +#endif + beginPollingForStatusChanges() + } + + func openSystemSettings() { +#if os(macOS) + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") { + NSWorkspace.shared.open(url) + } +#endif + } + + private func revealAppBundleInFinder() { +#if os(macOS) + let bundleURL = Bundle.main.bundleURL + NSWorkspace.shared.activateFileViewerSelecting([bundleURL]) +#endif + } + + private func beginPollingForStatusChanges() { + pollingTask?.cancel() + pollingTask = Task { [weak self] in + guard let self else { return } + + for _ in 0..<40 { + try? await Task.sleep(nanoseconds: 500_000_000) + let status = FullDiskAccessAuthorization.hasPermission() + + await MainActor.run { + self.updateAuthorizationStatus(to: status) + } + + if status { + break + } + } + } + } + + private func updateAuthorizationStatus(to newValue: Bool) { + guard newValue != isAuthorized else { return } + isAuthorized = newValue + } +} + +@MainActor +final class ShelfFolderAccessPermissionStore: ObservableObject { + static let shared = ShelfFolderAccessPermissionStore() + + @Published private(set) var hasDocumentsAccess: Bool = ShelfFolderAccessAuthorization.hasDocumentsAccess() + @Published private(set) var hasDownloadsAccess: Bool = ShelfFolderAccessAuthorization.hasDownloadsAccess() + + var hasDocumentsAndDownloadsAccess: Bool { + hasDocumentsAccess && hasDownloadsAccess + } + + private var pollingTask: Task? + + private init() {} + + deinit { + pollingTask?.cancel() + } + + func refreshStatus() { + updateStatus( + documents: ShelfFolderAccessAuthorization.hasDocumentsAccess(), + downloads: ShelfFolderAccessAuthorization.hasDownloadsAccess() + ) + } + + func requestAccessPrompt() { + ShelfFolderAccessAuthorization.requestAccessProbe() + beginPollingForStatusChanges() + } + + func openSystemSettings() { +#if os(macOS) + let urls = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders", + "x-apple.systempreferences:com.apple.preference.security" + ] + + for candidate in urls { + guard let url = URL(string: candidate) else { continue } + if NSWorkspace.shared.open(url) { + return + } + } +#endif + } + + private func beginPollingForStatusChanges() { + pollingTask?.cancel() + pollingTask = Task { [weak self] in + guard let self else { return } + + for _ in 0..<40 { + try? await Task.sleep(nanoseconds: 500_000_000) + + let docs = ShelfFolderAccessAuthorization.hasDocumentsAccess() + let downloads = ShelfFolderAccessAuthorization.hasDownloadsAccess() + + await MainActor.run { + self.updateStatus(documents: docs, downloads: downloads) + } + + if docs && downloads { + break + } + } + } + } + + private func updateStatus(documents: Bool, downloads: Bool) { + if hasDocumentsAccess != documents { + hasDocumentsAccess = documents + } + + if hasDownloadsAccess != downloads { + hasDownloadsAccess = downloads + } + } +} diff --git a/DynamicIsland/helpers/LiquidGlassBackground.swift b/DynamicIsland/helpers/LiquidGlassBackground.swift new file mode 100644 index 00000000..924a2f22 --- /dev/null +++ b/DynamicIsland/helpers/LiquidGlassBackground.swift @@ -0,0 +1,271 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import AppKit +import Defaults + +private final class LiquidGlassContainerView: NSView { + weak var glassView: NSView? + var hostingView: NSHostingView? + + private var observedBackdropLayers: [CALayer] = [] + private var hasScheduledBackdropSetup = false + private let windowServerAwareKeyPath = "windowServerAware" + private let scaleKeyPath = "scale" + + deinit { + removeBackdropObservers() + } + + override func removeFromSuperview() { + removeBackdropObservers() + super.removeFromSuperview() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + scheduleBackdropSetup() + } + + override func layout() { + super.layout() + scheduleBackdropSetup() + } + + func scheduleBackdropSetup() { + guard !hasScheduledBackdropSetup else { return } + hasScheduledBackdropSetup = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + guard let self else { return } + self.hasScheduledBackdropSetup = false + self.configureBackdropLayers() + } + } + + private func configureBackdropLayers() { + guard let glassView else { return } + guard let rootLayer = glassView.layer else { + scheduleBackdropSetup() + return + } + + setBackdropProperties(in: rootLayer) + let newBackdropLayers = collectBackdropLayers(in: rootLayer) + + removeBackdropObservers() + observedBackdropLayers = newBackdropLayers + for backdrop in observedBackdropLayers { + backdrop.addObserver(self, forKeyPath: windowServerAwareKeyPath, options: [.old, .new], context: nil) + backdrop.addObserver(self, forKeyPath: scaleKeyPath, options: [.old, .new], context: nil) + } + } + + private func setBackdropProperties(in layer: CALayer) { + if NSStringFromClass(type(of: layer)).contains("CABackdropLayer") { + layer.setValue(true, forKey: windowServerAwareKeyPath) + layer.setValue(1.0, forKey: scaleKeyPath) + } + layer.sublayers?.forEach { setBackdropProperties(in: $0) } + } + + private func collectBackdropLayers(in layer: CALayer) -> [CALayer] { + var results: [CALayer] = [] + if NSStringFromClass(type(of: layer)).contains("CABackdropLayer") { + results.append(layer) + } + layer.sublayers?.forEach { results.append(contentsOf: collectBackdropLayers(in: $0)) } + return results + } + + override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, + context: UnsafeMutableRawPointer? + ) { + if keyPath == windowServerAwareKeyPath { + if change?[.newKey] as? Bool == false { + configureBackdropLayers() + } + } else if keyPath == scaleKeyPath { + guard let layer = object as? CALayer else { return } + if let newScale = (change?[.newKey] as? NSNumber)?.doubleValue, newScale != 1.0 { + layer.setValue(1.0, forKey: scaleKeyPath) + } + } else { + super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + } + } + + private func removeBackdropObservers() { + for layer in observedBackdropLayers { + layer.removeObserver(self, forKeyPath: windowServerAwareKeyPath) + layer.removeObserver(self, forKeyPath: scaleKeyPath) + } + observedBackdropLayers.removeAll() + } +} + +/// All 20 available liquid‑glass variants. +/// Apple does not publicly describe how each value looks so experiment and pick the one you like! +public enum LiquidGlassVariant: Int, CaseIterable, Identifiable, Defaults.Serializable, Sendable { + case v0 = 0, v1 = 1, v2 = 2, v3 = 3, v4 = 4 + case v5 = 5, v6 = 6, v7 = 7, v8 = 8, v9 = 9 + case v10 = 10, v11 = 11, v12 = 12, v13 = 13, v14 = 14 + case v15 = 15, v16 = 16, v17 = 17, v18 = 18, v19 = 19 + + public var id: Int { rawValue } + + public static let supportedRange = 0...19 + + public static var defaultVariant: LiquidGlassVariant { .v11 } + + public static func clamped(_ rawValue: Int) -> LiquidGlassVariant { + let clamped = min(max(rawValue, supportedRange.lowerBound), supportedRange.upperBound) + return LiquidGlassVariant(rawValue: clamped) ?? .defaultVariant + } +} + +/// A SwiftUI view that embeds its content inside Apple’s private liquid‑glass material. +/// +/// ```swift +/// GlassBackground(variant: .v11, cornerRadius: 12) { +/// VStack(spacing: 12) { +/// Image(systemName: "sparkles") +/// .font(.largeTitle) +/// Text("Hello, glass!") +/// .font(.title2) +/// } +/// .padding() +/// } +/// ``` +public struct LiquidGlassBackground: NSViewRepresentable { + private let content: Content + private let cornerRadius: CGFloat + private let variant: LiquidGlassVariant + /// Creates a new liquid‑glass container. + /// - Parameters: + /// - variant: Any ``LiquidGlassVariant`` (0–19). Defaults to `.v11`, which is visually super pleasing + /// - cornerRadius: Corner radius in points. Defaults to `10`. + /// - content: Your SwiftUI hierarchy. + public init( + variant: LiquidGlassVariant = .defaultVariant, + cornerRadius: CGFloat = 10, + @ViewBuilder content: () -> Content + ) { + self.variant = variant + self.cornerRadius = cornerRadius + self.content = content() + } + + + @inline(__always) + private func setterSelector(for key: String, privateVariant: Bool = true) -> Selector? { + guard !key.isEmpty else { return nil } + let name: String + if privateVariant { + let cleaned = key.hasPrefix("_") ? key : "_" + key + name = "set" + cleaned + } else { + let first = String(key.prefix(1)).uppercased() + let rest = String(key.dropFirst()) + name = "set" + first + rest + } + return NSSelectorFromString(name + ":") + } + + private typealias VariantSetterIMP = @convention(c) (AnyObject, Selector, Int) -> Void + + private func callPrivateVariantSetter(on object: AnyObject, value: Int) { + guard + let sel = setterSelector(for: "variant", privateVariant: true), + let m = class_getInstanceMethod(object_getClass(object), sel) + else { + #if DEBUG + print("✗ LiquidGlassBackground: selector set_variant: not found. falling back to default") + #endif + return + } + let imp = method_getImplementation(m) + let f = unsafeBitCast(imp, to: VariantSetterIMP.self) + f(object, sel, value) + } + + + + public func makeNSView(context: Context) -> NSView { + // `NSGlassEffectView` is private. Look it up dynamically to avoid compile‑time coupling. + if let glassType = NSClassFromString("NSGlassEffectView") as? NSView.Type { + let container = LiquidGlassContainerView(frame: .zero) + container.translatesAutoresizingMaskIntoConstraints = false + + let glass = glassType.init(frame: .zero) + glass.translatesAutoresizingMaskIntoConstraints = false + glass.setValue(cornerRadius, forKey: "cornerRadius") + callPrivateVariantSetter(on: glass, value: variant.rawValue) + + let hosting = NSHostingView(rootView: AnyView(content)) + hosting.translatesAutoresizingMaskIntoConstraints = false + glass.setValue(hosting, forKey: "contentView") + + container.addSubview(glass) + NSLayoutConstraint.activate([ + glass.leadingAnchor.constraint(equalTo: container.leadingAnchor), + glass.trailingAnchor.constraint(equalTo: container.trailingAnchor), + glass.topAnchor.constraint(equalTo: container.topAnchor), + glass.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + + container.glassView = glass + container.hostingView = hosting + container.scheduleBackdropSetup() + return container + } + + // Fallback for earlier macOS – use an ordinary blur. + let fallback = NSVisualEffectView() + fallback.material = .underWindowBackground + + let hosting = NSHostingView(rootView: content) + hosting.translatesAutoresizingMaskIntoConstraints = false + fallback.addSubview(hosting) + NSLayoutConstraint.activate([ + hosting.leadingAnchor.constraint(equalTo: fallback.leadingAnchor), + hosting.trailingAnchor.constraint(equalTo: fallback.trailingAnchor), + hosting.topAnchor.constraint(equalTo: fallback.topAnchor), + hosting.bottomAnchor.constraint(equalTo: fallback.bottomAnchor) + ]) + return fallback + } + + public func updateNSView(_ nsView: NSView, context: Context) { + if let container = nsView as? LiquidGlassContainerView, + let glass = container.glassView { + container.hostingView?.rootView = AnyView(content) + glass.setValue(cornerRadius, forKey: "cornerRadius") + callPrivateVariantSetter(on: glass, value: variant.rawValue) + container.scheduleBackdropSetup() + return + } + + if let hosting = nsView.subviews.first as? NSHostingView { + hosting.rootView = content + } + } +} diff --git a/DynamicIsland/helpers/MediaChecker.swift b/DynamicIsland/helpers/MediaChecker.swift new file mode 100644 index 00000000..af40a1d6 --- /dev/null +++ b/DynamicIsland/helpers/MediaChecker.swift @@ -0,0 +1,109 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +@MainActor +final class MediaChecker: Sendable { + enum MediaCheckerError: Error { + case missingResources + case processExecutionFailed + case timeout + } + + func checkDeprecationStatus() async throws -> Bool { + guard let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"), + let frameworkPath = + Bundle.main.resourceURL? + .appendingPathComponent("MediaRemoteAdapter.framework") + .path + else { + throw MediaCheckerError.missingResources + } + + let nowPlayingTestClientPath = + Bundle.main.bundleURL + .appendingPathComponent("Contents/Helpers/NowPlayingTestClient") + .path + + guard FileManager.default.isExecutableFile(atPath: nowPlayingTestClientPath) else { + throw MediaCheckerError.missingResources + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/perl") + process.arguments = [scriptURL.path, frameworkPath, nowPlayingTestClientPath, "test"] + + // Capture stderr to distinguish script errors from genuine deprecation. + // The perl script's fail() and the framework's test function both exit + // with status 1, but only script-level failures write to stderr. + let stderrPipe = Pipe() + process.standardError = stderrPipe + + do { + try process.run() + } catch { + throw MediaCheckerError.processExecutionFailed + } + + // Timeout after 10 seconds + let didExit: Bool = try await withThrowingTaskGroup(of: Bool.self) { group in + group.addTask { + process.waitUntilExit() + return true + } + group.addTask { + try await Task.sleep(for: .seconds(10)) + if process.isRunning { + process.terminate() + } + return false + } + for try await exited in group { + if exited { + group.cancelAll() + return true + } + } + throw MediaCheckerError.timeout + } + + if !didExit { + throw MediaCheckerError.timeout + } + + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderrOutput = String(data: stderrData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + // If the process exited with a non-zero status AND stderr contains + // output, a script-level error occurred (e.g. framework failed to load, + // symbol not found). Treat this as an execution failure rather than + // reporting deprecated, since perl's fail() also uses exit(1). + if process.terminationStatus != 0, !stderrOutput.isEmpty { + print("MediaChecker: script error (exit \(process.terminationStatus)): \(stderrOutput)") + throw MediaCheckerError.processExecutionFailed + } + + let isDeprecated = process.terminationStatus == 1 + return isDeprecated + } +} diff --git a/DynamicIsland/helpers/ScreenCaptureVisibilityManager.swift b/DynamicIsland/helpers/ScreenCaptureVisibilityManager.swift new file mode 100644 index 00000000..79f088e6 --- /dev/null +++ b/DynamicIsland/helpers/ScreenCaptureVisibilityManager.swift @@ -0,0 +1,69 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Combine +import Defaults + +enum ScreenCaptureScope: Int { + case panelsOnly + case entireInterface +} + +final class ScreenCaptureVisibilityManager { + static let shared = ScreenCaptureVisibilityManager() + + private let scopedWindows = NSMapTable(keyOptions: .weakMemory, valueOptions: .strongMemory) + private var cancellables = Set() + + private init() { + let interfacePublisher = Defaults.publisher(.hideDynamicIslandFromScreenCapture) + .map { _ in () } + .eraseToAnyPublisher() + + interfacePublisher + .receive(on: RunLoop.main) + .sink { [weak self] in + self?.updateAllWindows() + } + .store(in: &cancellables) + } + + func register(_ window: NSWindow, scope: ScreenCaptureScope) { + scopedWindows.setObject(NSNumber(value: scope.rawValue), forKey: window) + applyVisibility(to: window, scope: scope) + } + + func unregister(_ window: NSWindow) { + scopedWindows.removeObject(forKey: window) + } + + private func updateAllWindows() { + guard let windows = scopedWindows.keyEnumerator().allObjects as? [NSWindow] else { return } + for window in windows { + guard let raw = scopedWindows.object(forKey: window)?.intValue, + let scope = ScreenCaptureScope(rawValue: raw) else { continue } + applyVisibility(to: window, scope: scope) + } + } + + private func applyVisibility(to window: NSWindow, scope _: ScreenCaptureScope) { + let shouldHide = Defaults[.hideDynamicIslandFromScreenCapture] + window.sharingType = shouldHide ? .none : .readOnly + } +} diff --git a/DynamicIsland/helpers/SensorError.swift b/DynamicIsland/helpers/SensorError.swift new file mode 100644 index 00000000..0e172de9 --- /dev/null +++ b/DynamicIsland/helpers/SensorError.swift @@ -0,0 +1,32 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +enum SensorError: Error { + enum Display: Error { + case notFound + case notSilicon + case notStandard + } + enum Keyboard: Error { + case notFound + case notSilicon + case notStandard + } +} \ No newline at end of file diff --git a/DynamicIsland/helpers/SensorMethod.swift b/DynamicIsland/helpers/SensorMethod.swift new file mode 100644 index 00000000..9f9bb807 --- /dev/null +++ b/DynamicIsland/helpers/SensorMethod.swift @@ -0,0 +1,25 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +enum SensorMethod { + case standard + case m1 + case allFailed +} \ No newline at end of file diff --git a/DynamicIsland/helpers/SystemHUDDebugger.swift b/DynamicIsland/helpers/SystemHUDDebugger.swift new file mode 100644 index 00000000..deb253e9 --- /dev/null +++ b/DynamicIsland/helpers/SystemHUDDebugger.swift @@ -0,0 +1,93 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +class SystemHUDDebugger { + + /// Test system HUD functionality and print status + public static func testSystemHUD() { + print("\n🔍 === System HUD Debug Report ===") + + // Check current OSDUIHelper status + let isRunning = SystemOSDManager.isOSDUIHelperRunning() + print("📊 OSDUIHelper Status: \(isRunning ? "✅ Running" : "❌ Not running")") + + // Test disable + print("🔇 Testing disable...") + SystemOSDManager.disableSystemHUD() + + // Wait and check + usleep(500000) + let isRunningAfterDisable = SystemOSDManager.isOSDUIHelperRunning() + print("📊 After disable: \(isRunningAfterDisable ? "✅ Running (stopped)" : "❌ Not running")") + + // Test enable + print("🔊 Testing re-enable...") + SystemOSDManager.enableSystemHUD() + + // Wait and check + usleep(1000000) + let isRunningAfterEnable = SystemOSDManager.isOSDUIHelperRunning() + print("📊 After re-enable: \(isRunningAfterEnable ? "✅ Running" : "❌ Not running")") + + print("🔍 === End Debug Report ===\n") + + if !isRunningAfterEnable { + print("⚠️ WARNING: System HUD may not be working properly!") + print("💡 Try pressing volume keys to test system HUD functionality") + } + } + + /// Force restart OSDUIHelper using multiple methods + public static func forceRestartOSDUIHelper() { + print("🔄 Force restarting OSDUIHelper...") + + // Method 1: Kill and kickstart + SystemOSDManager.enableSystemHUD() + + // Method 2: Try launchctl bootstrap (more aggressive) + do { + let bootstrap = Process() + bootstrap.executableURL = URL(fileURLWithPath: "/bin/launchctl") + bootstrap.arguments = ["bootstrap", "gui/\(getuid())", "/System/Library/LaunchAgents/com.apple.OSDUIHelper.plist"] + try bootstrap.run() + bootstrap.waitUntilExit() + print("✅ Bootstrap method completed") + } catch { + print("❌ Bootstrap method failed: \(error)") + } + + // Method 3: Try direct service restart + do { + let restart = Process() + restart.executableURL = URL(fileURLWithPath: "/bin/launchctl") + restart.arguments = ["restart", "gui/\(getuid())/com.apple.OSDUIHelper"] + try restart.run() + restart.waitUntilExit() + print("✅ Restart method completed") + } catch { + print("❌ Restart method failed: \(error)") + } + + // Check final status + usleep(1000000) + let finalStatus = SystemOSDManager.isOSDUIHelperRunning() + print("📊 Final OSDUIHelper status: \(finalStatus ? "✅ Running" : "❌ Not running")") + } +} \ No newline at end of file diff --git a/DynamicIsland/lock.mp3 b/DynamicIsland/lock.mp3 new file mode 100644 index 00000000..5d873fe1 Binary files /dev/null and b/DynamicIsland/lock.mp3 differ diff --git a/DynamicIsland/lowbattery.mp3 b/DynamicIsland/lowbattery.mp3 new file mode 100644 index 00000000..8e5c22a3 Binary files /dev/null and b/DynamicIsland/lowbattery.mp3 differ diff --git a/DynamicIsland/managers/AnimatedArtworkManager.swift b/DynamicIsland/managers/AnimatedArtworkManager.swift new file mode 100644 index 00000000..2993e637 --- /dev/null +++ b/DynamicIsland/managers/AnimatedArtworkManager.swift @@ -0,0 +1,149 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import MusicKit + +actor AnimatedArtworkManager { + static let shared = AnimatedArtworkManager() + + private var cachedSongID: String? + private var cachedVideoURL: URL? + private var cachedKey: String? + + func fetchAnimatedArtworkURL(title: String, artist: String) async -> URL? { + let key = "\(title)|\(artist)" + if key == cachedKey, let url = cachedVideoURL { + return url + } + + guard await requestMusicAuthorization() else { return nil } + + guard let songID = await searchSongID(title: title, artist: artist) else { + return nil + } + + guard let videoURL = await fetchEditorialVideoURL(songID: songID) else { + return nil + } + + cachedKey = key + cachedSongID = songID + cachedVideoURL = videoURL + return videoURL + } + + func clearCache() { + cachedKey = nil + cachedSongID = nil + cachedVideoURL = nil + } + + // MARK: - MusicKit Authorization + + private func requestMusicAuthorization() async -> Bool { + let status = MusicAuthorization.currentStatus + if status == .authorized { return true } + + let newStatus = await MusicAuthorization.request() + return newStatus == .authorized + } + + // MARK: - Song Search + + private func searchSongID(title: String, artist: String) async -> String? { + let term = "\(title) \(artist)" + var request = MusicCatalogSearchRequest(term: term, types: [Song.self]) + request.limit = 5 + + do { + let response = try await request.response() + let normalizedTitle = title.lowercased() + let normalizedArtist = artist.lowercased() + + let match = response.songs.first(where: { + $0.title.lowercased() == normalizedTitle && + $0.artistName.lowercased() == normalizedArtist + }) ?? response.songs.first(where: { + $0.title.lowercased() == normalizedTitle + }) ?? response.songs.first + + guard let song = match else { return nil } + return song.id.rawValue + } catch { + print("[AnimatedArtworkManager] Search failed: \(error)") + return nil + } + } + + // MARK: - Editorial Video Fetch + + private func fetchEditorialVideoURL(songID: String) async -> URL? { + let storefront: String + if let code = try? await MusicDataRequest.currentCountryCode { + storefront = code + } else { + storefront = "us" + } + + return await fetchEditorialVideoURLFallback(songID: songID, storefront: storefront) + } + + private func fetchEditorialVideoURLFallback(songID: String, storefront: String) async -> URL? { + guard let url = URL(string: "https://api.music.apple.com/v1/catalog/\(storefront)/songs/\(songID)?extend=editorialVideo") else { + return nil + } + + do { + let request = MusicDataRequest(urlRequest: URLRequest(url: url)) + let response = try await request.response() + + guard let json = try JSONSerialization.jsonObject(with: response.data) as? [String: Any], + let data = json["data"] as? [[String: Any]], + let first = data.first, + let attributes = first["attributes"] as? [String: Any], + let editorialVideo = attributes["editorialVideo"] as? [String: Any] + else { return nil } + + if let motionTall = editorialVideo["motionTallVideo3x4"] as? [String: Any], + let videoURLString = motionTall["video"] as? String, + let videoURL = URL(string: videoURLString) { + return videoURL + } + + if let motionSquare = editorialVideo["motionSquareVideo1x1"] as? [String: Any], + let videoURLString = motionSquare["video"] as? String, + let videoURL = URL(string: videoURLString) { + return videoURL + } + + let hlsPattern = try? NSRegularExpression(pattern: "https://mvod\\.itunes\\.apple\\.com[^\"]+\\.m3u8") + let jsonString = String(data: response.data, encoding: .utf8) ?? "" + let range = NSRange(jsonString.startIndex..., in: jsonString) + if let match = hlsPattern?.firstMatch(in: jsonString, range: range), + let matchRange = Range(match.range, in: jsonString) { + return URL(string: String(jsonString[matchRange])) + } + + return nil + } catch { + print("[AnimatedArtworkManager] Editorial video fetch failed: \(error)") + return nil + } + } +} diff --git a/DynamicIsland/managers/AppleMusicAirPlayManager.swift b/DynamicIsland/managers/AppleMusicAirPlayManager.swift new file mode 100644 index 00000000..75210bfe --- /dev/null +++ b/DynamicIsland/managers/AppleMusicAirPlayManager.swift @@ -0,0 +1,245 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +struct AirPlayDevice: Identifiable, Equatable { + let id: String // network address or name as fallback + let name: String + let kind: String + var isSelected: Bool + var volume: Int // 0-100 + + var iconName: String { + switch kind { + case "HomePod": + return "homepodmini" + case "AirPort Express": + return "airport.express" + case "computer": + return "laptopcomputer" + case "AirPlay device": + let normalizedName = name.lowercased() + if normalizedName.contains("tv") || normalizedName.contains("theatre") || normalizedName.contains("theater") { + return "appletv" + } + return "airplayaudio" + default: + return "airplayaudio" + } + } +} + +@MainActor +final class AppleMusicAirPlayManager: ObservableObject { + static let shared = AppleMusicAirPlayManager() + + @Published private(set) var devices: [AirPlayDevice] = [] + @Published private(set) var isLoading = false + + private var isBusy = false + + // MARK: - Volume commit pipeline + // Slider values are written to userSetVolume (non-@Published) and drained + // at throttled intervals into a serialised AppleScript commit queue. + // This avoids triggering SwiftUI re-renders that would reset the Slider. + + private var userSetVolume: [String: Int] = [:] + private var throttleActive: [String: Bool] = [:] + private var lastCommittedVolume: [String: Int] = [:] + private var isCommittingVolume = false + private var pendingVolumeCommits: [(volume: Int, deviceName: String, deviceID: String)] = [] + + // MARK: - Device management + + func refreshDevices() async { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + + isLoading = true + defer { isLoading = false } + + let script = """ + tell application "Music" + try + set deviceList to {} + set allDevices to AirPlay devices + repeat with d in allDevices + set deviceName to name of d + set deviceKind to kind of d as string + set deviceActive to selected of d + set deviceAddr to network address of d + set deviceVol to sound volume of d + set end of deviceList to {deviceName, deviceKind, deviceActive, deviceAddr, deviceVol} + end repeat + return deviceList + on error + return {} + end try + end tell + """ + + guard let result = try? await AppleScriptHelper.execute(script) else { return } + + var fetched: [AirPlayDevice] = [] + for i in 1...result.numberOfItems { + guard let item = result.atIndex(i) else { continue } + let name = item.atIndex(1)?.stringValue ?? "Unknown" + let kind = item.atIndex(2)?.stringValue ?? "AirPlay device" + let selected = item.atIndex(3)?.booleanValue ?? false + let addr = item.atIndex(4)?.stringValue ?? "" + let volume = Int(item.atIndex(5)?.int32Value ?? 50) + let id = addr.isEmpty ? name : addr + fetched.append(AirPlayDevice(id: id, name: name, kind: kind, isSelected: selected, volume: volume)) + } + devices = fetched + for device in fetched { + lastCommittedVolume[device.id] = device.volume + } + } + + func toggleDevice(_ device: AirPlayDevice) async { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + + let newState = !device.isSelected + let escapedName = device.name.replacingOccurrences(of: "\"", with: "\\\"") + let script = """ + tell application "Music" + try + set targetDevice to first AirPlay device whose name is "\(escapedName)" + set selected of targetDevice to \(newState) + end try + end tell + """ + try? await AppleScriptHelper.executeVoid(script) + try? await Task.sleep(for: .milliseconds(200)) + + isBusy = false + await refreshDevices() + } + + // MARK: - Volume control + + /// Returns the most recent volume the user set for a device, whether it's + /// still pending, already committed, or (as fallback) the last fetched value. + func currentVolume(for deviceID: String) -> Int { + if let pending = userSetVolume[deviceID] { return pending } + if let committed = lastCommittedVolume[deviceID] { return committed } + return devices.first { $0.id == deviceID }?.volume ?? 0 + } + + /// Called by the slider on every drag value change. + /// Does NOT mutate any @Published property — this is critical to prevent + /// SwiftUI from recreating the slider view and resetting its @State. + func setVolume(_ volume: Int, for deviceID: String) { + let clamped = min(max(volume, 0), 100) + guard let device = devices.first(where: { $0.id == deviceID }) else { return } + + userSetVolume[deviceID] = clamped + guard throttleActive[deviceID] != true else { return } + + throttleActive[deviceID] = true + Task { [weak self] in + guard let self else { return } + + // Commit immediately, then drain at intervals while the user keeps dragging + if let first = self.userSetVolume.removeValue(forKey: deviceID) { + await self.enqueueVolumeCommit(volume: first, deviceName: device.name, deviceID: deviceID) + } + + var alive = true + while alive { + try? await Task.sleep(for: .milliseconds(150)) + guard let pending = self.userSetVolume.removeValue(forKey: deviceID) else { + alive = false + break + } + await self.enqueueVolumeCommit(volume: pending, deviceName: device.name, deviceID: deviceID) + } + self.throttleActive[deviceID] = false + } + } + + // MARK: - Commit queue + + /// Serialises AppleScript calls — only one runs at a time. + /// Queued entries are deduplicated per-device (only the latest value is kept). + private func enqueueVolumeCommit(volume: Int, deviceName: String, deviceID: String) async { + if isCommittingVolume { + pendingVolumeCommits.removeAll { $0.deviceID == deviceID } + pendingVolumeCommits.append((volume: volume, deviceName: deviceName, deviceID: deviceID)) + return + } + + isCommittingVolume = true + await commitVolume(volume: volume, deviceName: deviceName, deviceID: deviceID) + + while !pendingVolumeCommits.isEmpty { + var latestByDevice: [String: (volume: Int, deviceName: String, deviceID: String)] = [:] + for entry in pendingVolumeCommits { + latestByDevice[entry.deviceID] = entry + } + pendingVolumeCommits.removeAll() + + for (_, entry) in latestByDevice { + await commitVolume(volume: entry.volume, deviceName: entry.deviceName, deviceID: entry.deviceID) + } + } + isCommittingVolume = false + } + + /// Commits a volume change via AppleScript. + /// + /// Apple Music internally rebalances other AirPlay devices when one device's + /// volume drops past another device's volume in a single call. To prevent this, + /// when moving DOWN past the highest other selected device's volume, we first + /// set to that device's volume (waypoint), then to the actual target — at most + /// two AppleScript calls. + private func commitVolume(volume: Int, deviceName: String, deviceID: String) async { + let escapedName = deviceName.replacingOccurrences(of: "\"", with: "\\\"") + let current = lastCommittedVolume[deviceID] ?? volume + + let otherMaxVolume = devices + .filter { $0.id != deviceID && $0.isSelected } + .map { lastCommittedVolume[$0.id] ?? $0.volume } + .max() ?? 0 + + // Waypoint needed when crossing below another device's volume + if current > otherMaxVolume && volume < otherMaxVolume && otherMaxVolume > 0 { + await sendVolumeAppleScript(escapedName: escapedName, volume: otherMaxVolume) + } + + await sendVolumeAppleScript(escapedName: escapedName, volume: volume) + lastCommittedVolume[deviceID] = volume + } + + private func sendVolumeAppleScript(escapedName: String, volume: Int) async { + let script = """ + tell application "Music" + try + set targetDevice to first AirPlay device whose name is "\(escapedName)" + set sound volume of targetDevice to \(volume) + end try + end tell + """ + try? await AppleScriptHelper.executeVoid(script) + } +} diff --git a/DynamicIsland/managers/AudioRouteManager.swift b/DynamicIsland/managers/AudioRouteManager.swift new file mode 100644 index 00000000..8de6249a --- /dev/null +++ b/DynamicIsland/managers/AudioRouteManager.swift @@ -0,0 +1,263 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +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/BatteryActivityManager.swift b/DynamicIsland/managers/BatteryActivityManager.swift index 61b1c665..a70d807f 100644 --- a/DynamicIsland/managers/BatteryActivityManager.swift +++ b/DynamicIsland/managers/BatteryActivityManager.swift @@ -1,3 +1,25 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + import Foundation import IOKit.ps @@ -304,6 +326,14 @@ class BatteryActivityManager { } } + /// Determines if the device has a battery + /// - Returns: True if the computer has a battery, false otherwhise + func hasBattery() -> Bool { + guard let snapshot = IOPSCopyPowerSourcesInfo()?.takeRetainedValue() else { return false } + guard let sources = IOPSCopyPowerSourcesList(snapshot)?.takeRetainedValue() as? [CFTypeRef] else { return false } + return !sources.isEmpty + } + deinit { stopMonitoring() NotificationCenter.default.removeObserver(self) diff --git a/DynamicIsland/managers/BetterDisplayManager.swift b/DynamicIsland/managers/BetterDisplayManager.swift new file mode 100644 index 00000000..c4215703 --- /dev/null +++ b/DynamicIsland/managers/BetterDisplayManager.swift @@ -0,0 +1,615 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +@preconcurrency import Foundation +import AppKit +import Defaults +import Combine + +// MARK: - BetterDisplay OSD Notification Model + +/// Matches the JSON structure dispatched by BetterDisplay's OSD notification system. +/// See: https://github.com/waydabber/BetterDisplay/wiki/Integration-features,-CLI#osd-notification-dispatch-integration +struct BetterDisplayOSDNotification: Codable { + var displayID: Int? + var systemIconID: Int? + var customSymbol: String? + var text: String? + var lock: Bool? + var controlTarget: String? + var value: Double? + var maxValue: Double? + var symbolFadeAfter: Int? + var symbolSizeMultiplier: Double? + var textFadeAfter: Int? +} + +// MARK: - BetterDisplay Request / Response Models + +/// JSON structure for sending commands to BetterDisplay via distributed notifications. +struct BetterDisplayRequestData: Codable { + var uuid: String? + var commands: [String] = [] + var parameters: [String: String?] = [:] +} + +/// JSON structure for receiving responses from BetterDisplay. +struct BetterDisplayResponseData: Codable { + var uuid: String? + var result: Bool? + var payload: String? +} + +// MARK: - BetterDisplay Control Target Classification + +enum BetterDisplayControlCategory { + case brightness + case volume + case other +} + +private let brightnessControlTargets: Set = [ + "combinedBrightness", + "hardwareBrightness", + "softwareBrightness", +] + +private let volumeControlTargets: Set = [ + "volume", + "mute", +] + +// MARK: - BetterDisplay Manager + +/// Manages integration with the BetterDisplay app (waydabber.BetterDisplay). +/// +/// Responsibilities: +/// - Detect whether BetterDisplay is installed +/// - Observe OSD notifications from BetterDisplay and route them to Atoll's HUD pipeline +/// - Provide request/response primitives for controlling display properties +@MainActor +final class BetterDisplayManager: ObservableObject { + static let shared = BetterDisplayManager() + + /// The bundle identifier of BetterDisplay. + nonisolated static let bundleID = "pro.betterdisplay.BetterDisplay" + + // Notification names + private static let osdNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.osd") + private static let requestNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.request") + private static let responseNotificationName = NSNotification.Name("com.betterdisplay.BetterDisplay.response") + private static let launchedNotificationName = NSNotification.Name("pro.betterdisplay.BetterDisplay.launched") + private static let terminatedNotificationName = NSNotification.Name("pro.betterdisplay.BetterDisplay.terminated") + + // MARK: Published state + + /// Whether BetterDisplay is currently detected (installed) on this machine. + @Published private(set) var isDetected: Bool = false + + /// Whether BetterDisplay is currently running and ready for communication. + @Published private(set) var isRunning: Bool = false + + // MARK: Private + + private var osdObserver: NSObjectProtocol? + private var responseObserver: NSObjectProtocol? + private var workspaceObserver: NSObjectProtocol? + private var workspaceTermObserver: NSObjectProtocol? + private var launchedObserver: NSObjectProtocol? + private var terminatedObserver: NSObjectProtocol? + private var cancellables = Set() + private weak var coordinator: DynamicIslandViewCoordinator? + + private var isBetterDisplayIntegrationEnabled: Bool { + Defaults[.enableThirdPartyDDCIntegration] && Defaults[.thirdPartyDDCProvider] == .betterDisplay + } + + private var isExternalVolumeListenerEnabled: Bool { + isBetterDisplayIntegrationEnabled && Defaults[.enableExternalVolumeControlListener] + } + + private init() { + isDetected = Self.checkInstallation() + isRunning = Self.checkRunning() + setupWorkspaceObserver() + setupLifecycleObservers() + setupSettingsObserver() + } + + // MARK: - Public API + + /// Configure with the view coordinator for HUD dispatch. + func configure(coordinator: DynamicIslandViewCoordinator) { + self.coordinator = coordinator + refreshListeningState() + } + + /// Refresh detection status (e.g. after app install/uninstall). + func refreshDetectionStatus() { + let wasDetected = isDetected + let wasRunning = isRunning + isDetected = Self.checkInstallation() + isRunning = Self.checkRunning() + if wasDetected != isDetected { + NSLog("📺 BetterDisplay detection changed: detected=\(isDetected)") + } + if wasRunning != isRunning { + NSLog("📺 BetterDisplay running state changed: running=\(isRunning)") + } + refreshListeningState() + } + + // MARK: - Detection + + /// Check if BetterDisplay is installed by looking for its bundle ID. + static func checkInstallation() -> Bool { + // Check running apps first (fast path) + if NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID }) { + return true + } + + // Fallback: check if the app is installed via URL scheme or bundle lookup + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + return FileManager.default.fileExists(atPath: url.path) + } + + return false + } + + /// Check if BetterDisplay is currently running. + static func checkRunning() -> Bool { + NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID }) + } + + // MARK: - OSD Listening + + private func startListening() { + guard osdObserver == nil else { return } + + osdObserver = DistributedNotificationCenter.default().addObserver( + forName: Self.osdNotificationName, + object: nil, + queue: .main + ) { [weak self] notification in + Task { @MainActor in + self?.handleOSDNotification(notification) + } + } + + NSLog("✅ BetterDisplay OSD listener started") + } + + private func stopListening() { + if let observer = osdObserver { + DistributedNotificationCenter.default().removeObserver(observer) + osdObserver = nil + NSLog("⏹ BetterDisplay OSD listener stopped") + } + } + + // MARK: - OSD Handling + + private func handleOSDNotification(_ notification: Notification) { + guard isBetterDisplayIntegrationEnabled, isRunning else { return } + + guard let notificationString = notification.object as? String else { + NSLog("⚠️ BetterDisplay OSD: unexpected notification format") + return + } + + NSLog("📺 BetterDisplay OSD raw payload: \(notificationString)") + + do { + let osd = try JSONDecoder().decode( + BetterDisplayOSDNotification.self, + from: Data(notificationString.utf8) + ) + let displayIDText = osd.displayID.map { String($0) } ?? "nil" + let targetText = osd.controlTarget ?? "nil" + let iconIDText = osd.systemIconID.map { String($0) } ?? "nil" + let valueText = osd.value.map { String($0) } ?? "nil" + let maxValueText = osd.maxValue.map { String($0) } ?? "nil" + let symbolText = osd.customSymbol ?? "nil" + let textValue = osd.text ?? "nil" + NSLog( + "📺 BetterDisplay decoded payload: displayID=\(displayIDText) target=\(targetText) iconID=\(iconIDText) value=\(valueText) maxValue=\(maxValueText) customSymbol=\(symbolText) text=\(textValue)" + ) + routeOSDToHUD(osd) + } catch { + NSLog("⚠️ BetterDisplay OSD decode error: \(error.localizedDescription)") + } + } + + /// Route a decoded BetterDisplay OSD notification to the active Atoll HUD variant. + private func routeOSDToHUD(_ osd: BetterDisplayOSDNotification) { + let category = classifyControlTarget(osd.controlTarget, systemIconID: osd.systemIconID) + let normalizedValue = normalizeValue(osd.value, maxValue: osd.maxValue) + let targetScreen = resolveScreen(for: osd.displayID) + let isExternalDisplay = isExternal(displayID: osd.displayID, resolvedScreen: targetScreen) + let inferredMute = osd.controlTarget == "mute" || osd.systemIconID == 4 + let hasVolumeData = category == .volume && osd.value != nil + let externalVolumeListenerEnabled = isExternalVolumeListenerEnabled + let targetText = osd.controlTarget ?? "nil" + let displayIDText = osd.displayID.map { String($0) } ?? "nil" + let resolvedScreenText = targetScreen?.localizedName ?? "nil" + let valueText = osd.value.map { String($0) } ?? "nil" + let maxValueText = osd.maxValue.map { String($0) } ?? "nil" + let normalizedText = String(format: "%.3f", normalizedValue) + + NSLog( + "📺 BetterDisplay routed payload: category=\(categoryName(category)) target=\(targetText) displayID=\(displayIDText) resolvedScreen=\(resolvedScreenText) isExternal=\(isExternalDisplay) rawValue=\(valueText) maxValue=\(maxValueText) normalized=\(normalizedText) hasVolumeData=\(hasVolumeData) inferredMute=\(inferredMute) externalVolumeListener=\(externalVolumeListenerEnabled)" + ) + + switch category { + case .brightness: + let icon = isExternalDisplay ? "display" : nil + dispatchBrightnessHUD(value: normalizedValue, customSymbol: icon, onScreen: targetScreen) + + case .volume: + guard externalVolumeListenerEnabled else { + NSLog("📺 BetterDisplay volume payload ignored because external volume listener is disabled") + return + } + let isMuted = osd.controlTarget == "mute" || osd.systemIconID == 4 + dispatchVolumeHUD(value: normalizedValue, isMuted: isMuted, onScreen: targetScreen) + + case .other: + // For unsupported control targets (contrast, gamma, temperature, etc.), + // show a generic brightness-style HUD if the user has brightness HUD enabled + let icon = isExternalDisplay ? "display" : osd.customSymbol + dispatchBrightnessHUD(value: normalizedValue, customSymbol: icon, onScreen: targetScreen) + } + } + + // MARK: - HUD Dispatch (mirrors SystemChangesObserver logic) + + private func dispatchVolumeHUD(value: CGFloat, isMuted: Bool, onScreen targetScreen: NSScreen? = nil) { + if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD { return } + + if Defaults[.enableCircularHUD] { + CircularHUDWindowManager.shared.show(type: .volume, value: value, onScreen: targetScreen) + return + } + if Defaults[.enableVerticalHUD] { + VerticalHUDWindowManager.shared.show(type: .volume, value: value, icon: "", onScreen: targetScreen) + return + } + if Defaults[.enableCustomOSD] && Defaults[.enableOSDVolume] { + CustomOSDWindowManager.shared.showVolume(value: value, onScreen: targetScreen) + } + if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] { + coordinator?.toggleSneakPeek( + status: true, + type: .volume, + value: value, + icon: "", + onScreen: targetScreen + ) + } + } + + private func dispatchBrightnessHUD(value: CGFloat, customSymbol: String? = nil, onScreen targetScreen: NSScreen? = nil) { + let icon = customSymbol ?? "" + if Defaults[.enableCircularHUD] { + CircularHUDWindowManager.shared.show(type: .brightness, value: value, icon: icon, onScreen: targetScreen) + return + } + if Defaults[.enableVerticalHUD] { + VerticalHUDWindowManager.shared.show(type: .brightness, value: value, icon: icon, onScreen: targetScreen) + return + } + if Defaults[.enableCustomOSD] && Defaults[.enableOSDBrightness] { + CustomOSDWindowManager.shared.showBrightness(value: value, icon: icon, onScreen: targetScreen) + } + if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] { + coordinator?.toggleSneakPeek( + status: true, + type: .brightness, + value: value, + icon: icon, + onScreen: targetScreen + ) + } + } + + // MARK: - Helpers + + /// Resolve a BetterDisplay `displayID` (CGDirectDisplayID) to the matching NSScreen, if any. + private func resolveScreen(for displayID: Int?) -> NSScreen? { + guard let displayID else { + NSLog("📺 BetterDisplay resolveScreen: displayID is nil, falling back to all screens") + return nil + } + + if let directDisplayID = UInt32(exactly: displayID), + let matchedScreen = NSScreen.screens.first(where: { screenNumber(for: $0) == directDisplayID }) { + return matchedScreen + } + + // Fallback for payloads that use 1-based display indexes (1, 2, ...) + if displayID > 0 { + let index = displayID - 1 + if NSScreen.screens.indices.contains(index) { + let fallback = NSScreen.screens[index] + NSLog("📺 BetterDisplay resolveScreen: displayID=\(displayID) resolved via index fallback to '\(fallback.localizedName)'") + return fallback + } + } + + let target = UInt32(displayID) + let availableScreens = NSScreen.screens.map { screen -> (String, UInt32) in + let num = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0 + return (screen.localizedName, num) + } + NSLog("📺 BetterDisplay resolveScreen: looking for displayID=\(displayID) (UInt32=\(target)) among screens: \(availableScreens)") + NSLog("📺 BetterDisplay resolveScreen: no match found for displayID=\(displayID), HUD will show on all screens") + return nil + } + + /// Whether the given displayID refers to an external (non-built-in) display. + private func isExternal(displayID: Int?, resolvedScreen: NSScreen?) -> Bool { + if let resolvedScreen, + let resolvedDisplayID = screenNumber(for: resolvedScreen) { + return CGDisplayIsBuiltin(resolvedDisplayID) == 0 + } + guard let displayID, + let displayIDValue = UInt32(exactly: displayID) else { + return false + } + return CGDisplayIsBuiltin(displayIDValue) == 0 + } + + private func screenNumber(for screen: NSScreen) -> UInt32? { + guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + return nil + } + return screenNumber.uint32Value + } + + private func classifyControlTarget(_ target: String?, systemIconID: Int?) -> BetterDisplayControlCategory { + if let target { + if brightnessControlTargets.contains(target) { return .brightness } + if volumeControlTargets.contains(target) { return .volume } + } + + // Fallback to systemIconID + switch systemIconID { + case 1: return .brightness // brightness icon + case 3: return .volume // volume icon + case 4: return .volume // mute icon + default: return .other + } + } + + private func categoryName(_ category: BetterDisplayControlCategory) -> String { + switch category { + case .brightness: + return "brightness" + case .volume: + return "volume" + case .other: + return "other" + } + } + + /// Normalize a BetterDisplay value (0...maxValue) to 0...1 range. + private func normalizeValue(_ value: Double?, maxValue: Double?) -> CGFloat { + guard let value else { return 0 } + let maxVal = maxValue ?? 1.0 + guard maxVal > 0 else { return 0 } + return CGFloat(Swift.min(Swift.max(value / maxVal, 0), 1)) + } + + // MARK: - Request API + + /// Send a command to BetterDisplay and optionally receive a response. + /// - Parameters: + /// - commands: e.g. ["set"], ["get"] + /// - parameters: e.g. ["brightness": "0.8"] + /// - completion: Called with the response on the main queue, or nil on timeout. + func sendRequest( + commands: [String], + parameters: [String: String?], + completion: (@MainActor @Sendable (BetterDisplayResponseData?) -> Void)? = nil + ) { + guard isRunning else { + NSLog("⚠️ BetterDisplay sendRequest skipped — app is not running") + completion?(nil) + return + } + + let uuid = UUID().uuidString + let request = BetterDisplayRequestData(uuid: uuid, commands: commands, parameters: parameters) + + // If we need a response, set up a temporary observer + if let completion { + // Use a class wrapper so the observer closure can safely capture & cancel + final class ResponseState: @unchecked Sendable { + var observer: NSObjectProtocol? + var timeoutItem: DispatchWorkItem? + } + let state = ResponseState() + + let timeoutItem = DispatchWorkItem { + if let obs = state.observer { + DistributedNotificationCenter.default().removeObserver(obs) + state.observer = nil + } + Task { @MainActor in + completion(nil) + } + } + state.timeoutItem = timeoutItem + + state.observer = DistributedNotificationCenter.default().addObserver( + forName: Self.responseNotificationName, + object: nil, + queue: .main + ) { notification in + guard let responseString = notification.object as? String, + let response = try? JSONDecoder().decode( + BetterDisplayResponseData.self, + from: Data(responseString.utf8) + ), + response.uuid == uuid + else { return } + + state.timeoutItem?.cancel() + if let obs = state.observer { + DistributedNotificationCenter.default().removeObserver(obs) + state.observer = nil + } + Task { @MainActor in + completion(response) + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeoutItem) + } + + // Send the request + do { + let encoded = try JSONEncoder().encode(request) + if let encodedString = String(data: encoded, encoding: .utf8) { + DistributedNotificationCenter.default().postNotificationName( + Self.requestNotificationName, + object: encodedString, + userInfo: nil, + deliverImmediately: true + ) + } + } catch { + NSLog("⚠️ BetterDisplay request encode error: \(error.localizedDescription)") + completion?(nil) + } + } + + // MARK: - Workspace Observer (detect install/uninstall) + + private func setupWorkspaceObserver() { + let betterDisplayBundleID = Self.bundleID + + workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == betterDisplayBundleID + else { return } + Task { @MainActor in + self?.refreshDetectionStatus() + } + } + + // Also observe app termination — handles crashes and force-quits + // (orderly quits are caught by the lifecycle observer below) + workspaceTermObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didTerminateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == betterDisplayBundleID + else { return } + Task { @MainActor in + NSLog("🔴 BetterDisplay terminated (workspace notification)") + self?.isRunning = false + self?.refreshListeningState() + } + } + } + + // MARK: - Lifecycle Observers (BetterDisplay launched/terminated notifications) + + /// Listen for distributed notifications sent by BetterDisplay itself + /// to know when it becomes ready and when it shuts down cleanly. + private func setupLifecycleObservers() { + launchedObserver = DistributedNotificationCenter.default().addObserver( + forName: Self.launchedNotificationName, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + NSLog("🟢 BetterDisplay launched notification received") + self?.isDetected = true + self?.isRunning = true + self?.refreshListeningState() + } + } + + terminatedObserver = DistributedNotificationCenter.default().addObserver( + forName: Self.terminatedNotificationName, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + NSLog("🔴 BetterDisplay terminated notification received") + self?.isRunning = false + self?.refreshListeningState() + } + } + } + + // MARK: - Settings Observer + + private func refreshListeningState() { + if isBetterDisplayIntegrationEnabled && isRunning { + startListening() + } else { + stopListening() + } + } + + private func setupSettingsObserver() { + Defaults.publisher(.enableThirdPartyDDCIntegration, options: []) + .sink { [weak self] _ in + Task { @MainActor in + self?.refreshListeningState() + } + } + .store(in: &cancellables) + + Defaults.publisher(.thirdPartyDDCProvider, options: []) + .sink { [weak self] _ in + Task { @MainActor in + self?.refreshListeningState() + } + } + .store(in: &cancellables) + } + + deinit { + if let osdObserver { + DistributedNotificationCenter.default().removeObserver(osdObserver) + } + if let launchedObserver { + DistributedNotificationCenter.default().removeObserver(launchedObserver) + } + if let terminatedObserver { + DistributedNotificationCenter.default().removeObserver(terminatedObserver) + } + if let workspaceObserver { + NSWorkspace.shared.notificationCenter.removeObserver(workspaceObserver) + } + if let workspaceTermObserver { + NSWorkspace.shared.notificationCenter.removeObserver(workspaceTermObserver) + } + cancellables.removeAll() + } +} diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift new file mode 100644 index 00000000..869abf6f --- /dev/null +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -0,0 +1,2082 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Combine +import AppKit +import Defaults +import SwiftUI +import IOBluetooth +import IOKit +import CoreBluetooth + +/// Manages detection and monitoring of Bluetooth audio device connections +class BluetoothAudioManager: ObservableObject { + static let shared = BluetoothAudioManager() + + // MARK: - Published Properties + @Published var lastConnectedDevice: BluetoothAudioDevice? + @Published var connectedDevices: [BluetoothAudioDevice] = [] + @Published var isBluetoothAudioConnected: Bool = false + + // MARK: - Private Properties + private var observers: [NSObjectProtocol] = [] + private var cancellables = Set() + private let coordinator = DynamicIslandViewCoordinator.shared + private var pollingTimer: Timer? + private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth" + private let batteryReader = BluetoothLEBatteryReader() + private var isLiveBatteryRefreshInFlight = false + + private let appleVendorID: UInt16 = 0x05AC + private let devicePIDMap: [UInt16: BluetoothAudioDeviceType] = [ + 0x2002: .airpods, // Gen 1 AirPods + 0x200F: .airpods, // Gen 2 AirPods + 0x2013: .airpodsGen3, // Gen 3 AirPods + 0x2019: .airpodsGen4, // Gen 4 AirPods + 0x201B: .airpodsGen4, // Gen 4 AirPods ANC + 0x200A: .airpodsMax, // AirPods Max Lightning + 0x201F: .airpodsMax, // AirPods Max USB-C + 0x200E: .airpodsPro, // AirPods Pro Gen 1 + 0x2014: .airpodsPro, // AirPods Pro Gen 2 Lightning + 0x2024: .airpodsPro, // AirPods Pro Gen 2 USB-C + 0x2027: .airpodsPro3, // AirPods Pro Gen 3 + 0x2017: .beatsstudio, // Beats Studio Pro + 0x2009: .beatsstudio, // Beats Studio 3 + 0x2006: .beatssolo, // Beats Solo 3 + 0x200C: .beatssolo // Beats Solo Pro + ] + + @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 + private let pmsetFetchQueue = DispatchQueue(label: "com.dynamicisland.bluetooth.pmset", qos: .utility) + private var isPmsetRefreshInFlight = false + private var lastPmsetRefreshDate: Date? + private let pmsetRefreshCooldown: TimeInterval = 5 + private var hudBatteryWaitTasks: [UUID: Task] = [:] + private let hudBatteryWaitInterval: TimeInterval = 0.3 + private let hudBatteryWaitTimeout: TimeInterval = 1.8 + + // MARK: - Initialization + private init() { + print("🎧 [BluetoothAudioManager] Initializing...") + setupBluetoothObservers() + checkInitialDevices() + startPollingForChanges() + } + + deinit { + cleanup() + } + + // MARK: - Setup Methods + + /// Sets up observers for Bluetooth device connection/disconnection events + private func setupBluetoothObservers() { + print("🎧 [BluetoothAudioManager] Setting up Bluetooth observers...") + + // Use DistributedNotificationCenter for IOBluetooth notifications + let dnc = DistributedNotificationCenter.default() + + // Observe device connected notifications + dnc.addObserver( + self, + selector: #selector(handleDeviceConnectedNotification(_:)), + name: NSNotification.Name("IOBluetoothDeviceConnectedNotification"), + object: nil + ) + + // Observe device disconnected notifications + dnc.addObserver( + self, + selector: #selector(handleDeviceDisconnectedNotification(_:)), + name: NSNotification.Name("IOBluetoothDeviceDisconnectedNotification"), + object: nil + ) + + print("🎧 [BluetoothAudioManager] ✅ Observers registered with DistributedNotificationCenter") + } + + /// Starts polling for device connection changes (fallback mechanism) + private func startPollingForChanges() { + print("🎧 [BluetoothAudioManager] Starting polling timer (3s interval)...") + + pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + self?.checkForDeviceChanges() + } + } + + /// Checks for device connection/disconnection changes + private func checkForDeviceChanges() { + // Check if Bluetooth is powered on + guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else { + // Bluetooth is off - clear connected devices if any + if !connectedDevices.isEmpty { + print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth powered off - clearing connected devices") + connectedDevices.removeAll() + isBluetoothAudioConnected = false + } + return + } + + guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else { + return + } + + let currentlyConnectedAddresses = Set( + pairedDevices + .filter { $0.isConnected() && isAudioDevice($0) } + .compactMap { $0.addressString } + ) + + let previousAddresses = Set(connectedDevices.map { $0.address }) + + // Check for new connections + let newAddresses = currentlyConnectedAddresses.subtracting(previousAddresses) + if !newAddresses.isEmpty { + print("🎧 [BluetoothAudioManager] 🔍 Polling detected new connection(s)") + checkForNewlyConnectedDevices() + } + + // Check for disconnections + let removedAddresses = previousAddresses.subtracting(currentlyConnectedAddresses) + if !removedAddresses.isEmpty { + print("🎧 [BluetoothAudioManager] 🔍 Polling detected disconnection(s)") + updateConnectedDevices() + } + } + + /// Checks for already connected Bluetooth audio devices on init + private func checkInitialDevices() { + print("🎧 [BluetoothAudioManager] Checking for initially connected devices...") + + // Check if Bluetooth is powered on + guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else { + print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping initial check") + return + } + + guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else { + print("🎧 [BluetoothAudioManager] No paired devices found") + return + } + + let connectedAudioDevices = pairedDevices.filter { device in + device.isConnected() && isAudioDevice(device) + } + + print("🎧 [BluetoothAudioManager] Found \(connectedAudioDevices.count) connected audio devices") + + connectedDevices = connectedAudioDevices.compactMap { device in + createBluetoothAudioDevice(from: device) + } + + // Update connection state + isBluetoothAudioConnected = !connectedDevices.isEmpty + + refreshBatteryLevelsForConnectedDevices() + + if let lastDevice = connectedDevices.last { + lastConnectedDevice = lastDevice + print("🎧 [BluetoothAudioManager] ✅ Bluetooth audio connected: \(lastDevice.name)") + } + } + + // MARK: - Device Event Handlers + + /// Handles Bluetooth device connection notification from DistributedNotificationCenter + @objc private func handleDeviceConnectedNotification(_ notification: Notification) { + print("🎧 [BluetoothAudioManager] 📡 Device connection notification received") + + // Re-check all devices since distributed notification doesn't contain device object + checkForNewlyConnectedDevices() + } + + /// Handles Bluetooth device disconnection notification from DistributedNotificationCenter + @objc private func handleDeviceDisconnectedNotification(_ notification: Notification) { + print("🎧 [BluetoothAudioManager] 📡 Device disconnection notification received") + + // Re-check all devices to update connection state + updateConnectedDevices() + } + + /// Checks for newly connected devices and displays HUD for new ones + private func checkForNewlyConnectedDevices() { + // Check if Bluetooth is powered on + guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else { + print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping device check") + return + } + + guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else { + return + } + + let currentlyConnectedDevices = pairedDevices.filter { device in + device.isConnected() && isAudioDevice(device) + } + + // Find devices that are newly connected + for device in currentlyConnectedDevices { + let address = device.addressString ?? "Unknown" + + // Check if this device wasn't in our list before + if !connectedDevices.contains(where: { $0.address == address }) { + print("🎧 [BluetoothAudioManager] 🎉 New audio device connected: \(device.name ?? "Unknown")") + + guard let audioDevice = createBluetoothAudioDevice(from: device) else { + continue + } + + // Add to connected devices + connectedDevices.append(audioDevice) + lastConnectedDevice = audioDevice + isBluetoothAudioConnected = true + + refreshBatteryLevelsForConnectedDevices() + + // Show HUD for new connection + if let refreshedDevice = connectedDevices.last { + showDeviceConnectedHUD(refreshedDevice) + } else { + showDeviceConnectedHUD(audioDevice) + } + } + } + } + + /// Updates the list of connected devices (for disconnections) + private func updateConnectedDevices() { + guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else { + return + } + + let currentlyConnectedAddresses = pairedDevices + .filter { $0.isConnected() && isAudioDevice($0) } + .compactMap { $0.addressString } + + // Remove disconnected devices + let removedDevices = connectedDevices.filter { device in + !currentlyConnectedAddresses.contains(device.address) + } + connectedDevices.removeAll { device in + !currentlyConnectedAddresses.contains(device.address) + } + + if !removedDevices.isEmpty { + print("🎧 [BluetoothAudioManager] 👋 Audio device(s) disconnected") + removedDevices.forEach { cancelHUDBatteryWait(for: $0) } + } + + isBluetoothAudioConnected = !connectedDevices.isEmpty + + refreshBatteryLevelsForConnectedDevices() + } + + /// Handles Bluetooth device connection event (legacy - kept for compatibility) + private func handleDeviceConnected(_ notification: Notification) { + guard let device = notification.object as? IOBluetoothDevice else { + print("🎧 [BluetoothAudioManager] ⚠️ Could not extract device from notification") + return + } + + // Only handle audio devices + guard isAudioDevice(device) else { + print("🎧 [BluetoothAudioManager] Device is not an audio device, ignoring") + return + } + + print("🎧 [BluetoothAudioManager] 🎉 Audio device connected: \(device.name ?? "Unknown")") + + guard let audioDevice = createBluetoothAudioDevice(from: device) else { + return + } + + // Add to connected devices list + if !connectedDevices.contains(where: { $0.address == audioDevice.address }) { + connectedDevices.append(audioDevice) + } + + // Update last connected device + lastConnectedDevice = audioDevice + isBluetoothAudioConnected = true + + // Show HUD + showDeviceConnectedHUD(audioDevice) + } + + /// Handles Bluetooth device disconnection event + private func handleDeviceDisconnected(_ notification: Notification) { + guard let device = notification.object as? IOBluetoothDevice else { + return + } + + guard isAudioDevice(device) else { + return + } + + print("🎧 [BluetoothAudioManager] 👋 Audio device disconnected: \(device.name ?? "Unknown")") + + // Remove from connected devices + let address = device.addressString ?? "Unknown" + let removed = connectedDevices.filter { $0.address == address } + connectedDevices.removeAll { $0.address == address } + removed.forEach { cancelHUDBatteryWait(for: $0) } + isBluetoothAudioConnected = !connectedDevices.isEmpty + } + + // MARK: - Device Detection Helpers + + /// Determines if a Bluetooth device is an audio device + private func isAudioDevice(_ device: IOBluetoothDevice) -> Bool { + // Check if device has audio service UUID + let audioServiceUUID = IOBluetoothSDPUUID(uuid16: 0x110B) // Audio Sink + let headsetServiceUUID = IOBluetoothSDPUUID(uuid16: 0x1108) // Headset + let handsfreeServiceUUID = IOBluetoothSDPUUID(uuid16: 0x111E) // Handsfree + + // Check if device has any audio-related services + if device.getServiceRecord(for: audioServiceUUID) != nil { + return true + } + if device.getServiceRecord(for: headsetServiceUUID) != nil { + return true + } + if device.getServiceRecord(for: handsfreeServiceUUID) != nil { + return true + } + + // Check device class (major class: Audio/Video) + let deviceClass = device.classOfDevice + let majorClass = (deviceClass >> 8) & 0x1F + let audioVideoMajorClass: UInt32 = 0x04 + + return majorClass == audioVideoMajorClass + } + + /// Creates a BluetoothAudioDevice model from IOBluetoothDevice + private func createBluetoothAudioDevice(from device: IOBluetoothDevice) -> BluetoothAudioDevice? { + let name = device.name ?? "Bluetooth Device" + let address = device.addressString ?? "Unknown" + let batteryLevel = getBatteryLevel(from: device) + let deviceType = detectDeviceType(from: device, name: name) + + return BluetoothAudioDevice( + name: name, + address: address, + batteryLevel: batteryLevel, + deviceType: deviceType + ) + } + + /// Extracts battery level from Bluetooth device + private func getBatteryLevel(from device: IOBluetoothDevice) -> Int? { + 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 + } + + // MARK: - PID-based device detection + + /// Extract a UInt16 from common payload formats (Int/NSNumber/String including hex like "0x201B"). + private func extractUInt16(from payload: [String: Any], keys: [String]) -> UInt16? { + for key in keys { + guard let raw = payload[key] else { continue } + + if let number = raw as? NSNumber { + return UInt16(truncatingIfNeeded: number.uint16Value) + } + if let intValue = raw as? Int { + return UInt16(truncatingIfNeeded: intValue) + } + if let string = raw as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.hasPrefix("0x") { + let hex = trimmed.dropFirst(2) + if let value = UInt16(hex, radix: 16) { return value } + } else if let value = UInt16(trimmed, radix: 10) { + return value + } + } + } + return nil + } + + /// Recursively search nested dictionaries/arrays for the first UInt16 value whose key matches `predicate`. + private func deepSearchUInt16(in value: Any, predicate: (String) -> Bool) -> UInt16? { + if let dict = value as? [String: Any] { + for (key, entry) in dict { + if predicate(key) { + if let found = extractUInt16(from: dict, keys: [key]) { + return found + } + if let number = entry as? NSNumber { return UInt16(truncatingIfNeeded: number.uint16Value) } + if let intValue = entry as? Int { return UInt16(truncatingIfNeeded: intValue) } + if let string = entry as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.hasPrefix("0x"), let value = UInt16(trimmed.dropFirst(2), radix: 16) { return value } + if let value = UInt16(trimmed, radix: 10) { return value } + } + } + } + + for entry in dict.values { + if let found = deepSearchUInt16(in: entry, predicate: predicate) { return found } + } + return nil + } + + if let array = value as? [Any] { + for entry in array { + if let found = deepSearchUInt16(in: entry, predicate: predicate) { return found } + } + return nil + } + + return nil + } + + /// Fallback: attempt to get VendorID/ProductID from system_profiler SPBluetoothDataType JSON. + private func vendorProductIDsFromSystemProfiler(forNormalizedAddress target: String) -> (vendor: UInt16, product: UInt16)? { + guard !target.isEmpty else { return nil } + guard let root = systemProfilerBluetoothDictionary() else { return nil } + guard let deviceConnected = root["device_connected"] as? [Any] else { return nil } + + func pidFromPayload(_ payload: [String: Any]) -> UInt16? { + if let raw = payload["device_productID"] as? String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.hasPrefix("0x"), let value = UInt16(trimmed.dropFirst(2), radix: 16) { return value } + if let value = UInt16(trimmed, radix: 16) { return value } + } + let productKeys = ["device_productID", "ProductID", "product_id", "productID", "DeviceProductID", "ProductId", "Product ID"] + return extractUInt16(from: payload, keys: productKeys) + ?? deepSearchUInt16(in: payload) { $0.lowercased().contains("productid") } + } + + func vidFromPayload(_ payload: [String: Any]) -> UInt16? { + let vendorKeys = ["device_vendorID", "VendorID", "vendor_id", "vendorID", "DeviceVendorID", "VendorId", "Vendor ID"] + return extractUInt16(from: payload, keys: vendorKeys) + ?? deepSearchUInt16(in: payload) { $0.lowercased().contains("vendorid") } + } + + for item in deviceConnected { + guard let dict = item as? [String: Any], + let nameKey = dict.keys.first, + let infoAny = dict[nameKey], + let payload = infoAny as? [String: Any] else { + continue + } + + if let address = payload["device_address"] as? String { + if normalizeBluetoothIdentifier(address) != target { continue } + } else { + let candidates = profilerAddressCandidates(from: payload).map(normalizeBluetoothIdentifier) + if !candidates.contains(target) { continue } + } + + if let pid = pidFromPayload(payload) { + if let vid = vidFromPayload(payload) { + return (vendor: vid, product: pid) + } + if devicePIDMap[pid] != nil { + return (vendor: appleVendorID, product: pid) + } + } + } + + return nil + } + + /// Attempts to find VendorID/ProductID for a device using Bluetooth caches. + private func vendorProductIDs(for device: IOBluetoothDevice) -> (vendor: UInt16, product: UInt16)? { + guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite), + let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { + return nil + } + + let target = normalizeBluetoothIdentifier(device.addressString ?? "") + guard !target.isEmpty else { return nil } + + let vendorKeys = [ + "VendorID", "vendor_id", "vendorID", + "device_vendorID", "DeviceVendorID", "device_vendor_id", + "device_vendorId", "DeviceVendorId", + "VendorId", "Vendor ID", + "VendorIDSource", "VendorIDSourceLocal", "VendorIDSourceRemote" + ] + let productKeys = [ + "ProductID", "product_id", "productID", + "device_productID", "DeviceProductID", "device_product_id", + "device_productId", "DeviceProductId", + "ProductId", "Product ID", + "ProductIDSource", "ProductIDSourceLocal", "ProductIDSourceRemote" + ] + + for (key, value) in deviceCache { + guard let payload = value as? [String: Any] else { continue } + if matchesBluetoothIdentifier(target, key: key, payload: payload) { + let vendor = extractUInt16(from: payload, keys: vendorKeys) + ?? deepSearchUInt16(in: payload) { $0.lowercased().contains("vendorid") } + + let product = extractUInt16(from: payload, keys: productKeys) + ?? deepSearchUInt16(in: payload) { $0.lowercased().contains("productid") } + + if let product { + if let vendor { + return (vendor: vendor, product: product) + } + if devicePIDMap[product] != nil { + return (vendor: appleVendorID, product: product) + } + } + } + } + + if let coreCache = preferences.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] { + for payload in coreCache.values { + if let addressValue = payload["DeviceAddress"] + ?? payload["Address"] + ?? payload["BD_ADDR"] + ?? payload["device_address"], + let address = normalizeBluetoothIdentifier(from: addressValue), + address == target { + let vendor = extractUInt16(from: payload, keys: vendorKeys) + ?? deepSearchUInt16(in: payload) { $0.lowercased().contains("vendorid") } + + let product = extractUInt16(from: payload, keys: productKeys) + ?? deepSearchUInt16(in: payload) { $0.lowercased().contains("productid") } + + if let product { + if let vendor { + return (vendor: vendor, product: product) + } + if devicePIDMap[product] != nil { + return (vendor: appleVendorID, product: product) + } + } + } + } + } + + if let fromProfiler = vendorProductIDsFromSystemProfiler(forNormalizedAddress: target) { + return fromProfiler + } + + return nil + } + + /// Attempts to detect AirPods/Beats type using vendor/product IDs. + private func airPodsTypeFromPID(_ device: IOBluetoothDevice) -> BluetoothAudioDeviceType? { + if let ids = vendorProductIDs(for: device) { + return devicePIDMap[ids.product] + } + return nil + } + + /// Detects the type of audio device based on name and properties + private func detectDeviceType(from device: IOBluetoothDevice, name: String) -> BluetoothAudioDeviceType { + let lowercaseName = name.lowercased() + + if let pidBasedType = airPodsTypeFromPID(device) { + return pidBasedType + } + + // Check for specific AirPods models + if lowercaseName.contains("airpods") { + if lowercaseName.contains("max") { + return .airpodsMax + } else if lowercaseName.contains("pro") { + if lowercaseName.contains("3") || lowercaseName.contains("gen 3") || lowercaseName.contains("gen3") { + return .airpodsPro3 + } + return .airpodsPro + } else if lowercaseName.contains("gen 4") + || lowercaseName.contains("gen4") + || lowercaseName.contains("4th") + || lowercaseName.contains("airpods 4") + || lowercaseName.contains("airpods4") { + return .airpodsGen4 + } else if lowercaseName.contains("gen 3") + || lowercaseName.contains("gen3") + || lowercaseName.contains("3rd") + || lowercaseName.contains("third") + || lowercaseName.contains("airpods 3") + || lowercaseName.contains("airpods3") { + return .airpodsGen3 + } + return .airpods + } + + // Check for other brands + if lowercaseName.contains("beats") { + if lowercaseName.contains("studio") { + return .beatsstudio + } + if lowercaseName.contains("solo") { + return .beatssolo + } + return .beatssolo + } else if lowercaseName.contains("speaker") || lowercaseName.contains("boombox") { + return .speaker + } else if lowercaseName.contains("headphone") || lowercaseName.contains("headset") || + lowercaseName.contains("buds") || lowercaseName.contains("earbuds") { + return .headphones + } + + // Check device class for more specific detection + let deviceClass = device.classOfDevice + let minorClass = (deviceClass >> 2) & 0x3F + + // Minor classes for audio devices + switch minorClass { + case 0x01: return .headphones // Wearable Headset + case 0x02: return .headphones // Hands-free + case 0x06: return .headphones // Headphones + case 0x08: return .speaker // Portable Audio + case 0x0C: return .speaker // Loudspeaker + default: return .generic + } + } + + private func refreshBatteryLevelsForConnectedDevices(forceCacheRefresh: Bool = true) { + if forceCacheRefresh { + updateBatteryStatuses(force: true) + } + + applyConnectedDeviceBatteryLevels() + triggerLiveBatteryRefreshIfNeeded() + } + + private func applyConnectedDeviceBatteryLevels(triggerPmsetFallback: Bool = true) { + guard !connectedDevices.isEmpty else { + lastConnectedDevice = nil + return + } + + var updatedDevices: [BluetoothAudioDevice] = [] + for device in connectedDevices { + let refreshedLevel = bestBatteryLevel(for: device) + 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 = updatedDevices.last { + lastConnectedDevice = last + } + + if triggerPmsetFallback, + updatedDevices.contains(where: { $0.batteryLevel == nil }) { + requestPmsetFallback(reason: "missing battery after refresh") + } + } + + private func bestBatteryLevel(for device: BluetoothAudioDevice) -> Int? { + batteryLevelFromRegistry(forAddress: device.address) + ?? batteryLevelFromRegistry(forName: device.name) + ?? batteryLevelFromDefaults(forAddress: device.address) + ?? batteryLevelFromDefaults(forName: device.name) + ?? device.batteryLevel + } + + private func requestPmsetFallback(reason: String) { + guard connectedDevices.contains(where: { $0.batteryLevel == nil }) else { return } + guard !isPmsetRefreshInFlight else { return } + + let now = Date() + if let lastPmsetRefreshDate, + now.timeIntervalSince(lastPmsetRefreshDate) < pmsetRefreshCooldown { + return + } + + isPmsetRefreshInFlight = true + print("🎧 [BluetoothAudioManager] 🔄 Triggering pmset fallback (\(reason))") + pmsetFetchQueue.async { [weak self] in + guard let self else { return } + let entries = self.collectPmsetAccessoryBatteryEntries() + DispatchQueue.main.async { + self.handlePmsetFallbackResults(entries) + } + } + } + + private func handlePmsetFallbackResults(_ entries: [PmsetAccessoryBatteryEntry]) { + isPmsetRefreshInFlight = false + lastPmsetRefreshDate = Date() + guard !entries.isEmpty else { return } + + var updatedNames = batteryStatusByName + let newlyFilled = mergePmsetEntries(entries, into: &updatedNames, logNewEntries: true) + guard !newlyFilled.isEmpty else { return } + + batteryStatusByName = updatedNames + applyConnectedDeviceBatteryLevels(triggerPmsetFallback: false) + + if let level = hudBatteryLevelCandidate() { + updateActiveBluetoothHUDBattery(with: level) + } + } + + private func triggerLiveBatteryRefreshIfNeeded() { + guard !connectedDevices.isEmpty else { return } + guard connectedDevices.contains(where: { $0.batteryLevel == nil }) else { return } + guard !isLiveBatteryRefreshInFlight else { return } + + let lookups = coreBluetoothLookups(for: connectedDevices) + guard !lookups.isEmpty else { return } + + isLiveBatteryRefreshInFlight = true + batteryReader.fetchBatteryLevels(for: lookups) { [weak self] results in + guard let self else { return } + DispatchQueue.main.async { + self.isLiveBatteryRefreshInFlight = false + self.handleLiveBatteryResults(results) + } + } + } + + private func coreBluetoothLookups(for devices: [BluetoothAudioDevice]) -> [BluetoothLEBatteryReader.Lookup] { + let snapshot = coreBluetoothCacheSnapshot() + guard snapshot.hasEntries else { return [] } + + var lookups: [BluetoothLEBatteryReader.Lookup] = [] + var seenUUIDs: Set = [] + + for device in devices { + let normalizedAddress = normalizeBluetoothIdentifier(device.address) + let normalizedName = normalizeProductName(device.name) + guard !normalizedAddress.isEmpty || !normalizedName.isEmpty else { continue } + + let uuid = snapshot.byAddress[normalizedAddress] + ?? snapshot.byName[normalizedName] + + guard let uuid, !seenUUIDs.contains(uuid) else { continue } + seenUUIDs.insert(uuid) + + let canonicalName = snapshot.namesByUUID[uuid] ?? normalizedName + lookups.append( + .init( + uuid: uuid, + addressKey: normalizedAddress.isEmpty ? nil : normalizedAddress, + nameKey: canonicalName.isEmpty ? nil : canonicalName + ) + ) + } + + return lookups + } + + private func handleLiveBatteryResults(_ results: [BluetoothLEBatteryReader.Result]) { + guard !results.isEmpty else { return } + + var didUpdate = false + + for result in results { + let level = clampBatteryPercentage(result.level) + + if let addressKey = result.addressKey, !addressKey.isEmpty { + let previous = batteryStatusByAddress[addressKey] ?? -1 + if level > previous { + batteryStatusByAddress[addressKey] = level + batteryStatus[addressKey] = String(level) + didUpdate = true + } + } + + if let nameKey = result.nameKey, !nameKey.isEmpty { + let previous = batteryStatusByName[nameKey] ?? -1 + if level > previous { + batteryStatusByName[nameKey] = level + didUpdate = true + } + } + } + + guard didUpdate else { return } + + applyConnectedDeviceBatteryLevels() + if let level = hudBatteryLevelCandidate() { + updateActiveBluetoothHUDBattery(with: level) + } + } + + private func updateActiveBluetoothHUDBattery(with level: Int?) { + guard let level else { return } + DispatchQueue.main.async { + guard self.coordinator.sneakPeek.show, + self.coordinator.sneakPeek.type == .bluetoothAudio else { return } + self.coordinator.sneakPeek.value = CGFloat(level) / 100.0 + } + } + + private func hudBatteryLevelCandidate() -> Int? { + lastConnectedDevice?.batteryLevel + ?? connectedDevices.last(where: { $0.batteryLevel != nil })?.batteryLevel + } + + private func coreBluetoothCacheSnapshot() -> CoreBluetoothCacheSnapshot { + guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite), + let coreCache = preferences.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] else { + return .empty + } + + var byAddress: [String: UUID] = [:] + var byName: [String: UUID] = [:] + var namesByUUID: [UUID: String] = [:] + + for (uuidString, payload) in coreCache { + guard let uuid = UUID(uuidString: uuidString) else { continue } + + let addressKeys = ["DeviceAddress", "Address", "BD_ADDR", "device_address"] + for key in addressKeys { + if let value = payload[key], let normalized = normalizeBluetoothIdentifier(from: value) { + byAddress[normalized] = uuid + } + } + + if let serialValue = payload["SerialNumber"], let normalizedSerial = normalizeBluetoothIdentifier(from: serialValue) { + byAddress[normalizedSerial] = uuid + } + + let nameKeys = ["Name", "DeviceName", "ProductName", "Product", "device_name"] + for key in nameKeys { + if let value = payload[key], let normalizedName = normalizeProductName(from: value) { + byName[normalizedName] = uuid + namesByUUID[uuid] = normalizedName + } + } + } + + return CoreBluetoothCacheSnapshot(byAddress: byAddress, byName: byName, namesByUUID: namesByUUID) + } + + private func normalizeBluetoothIdentifier(from value: Any) -> String? { + if let string = value as? String { + let normalized = normalizeBluetoothIdentifier(string) + return normalized.isEmpty ? nil : normalized + } + + if let data = value as? Data, + let ascii = String(data: data, encoding: .utf8) { + let normalized = normalizeBluetoothIdentifier(ascii) + return normalized.isEmpty ? nil : normalized + } + + return nil + } + + private func normalizeProductName(from value: Any) -> String? { + if let string = value as? String { + let normalized = normalizeProductName(string) + return normalized.isEmpty ? nil : normalized + } + if let data = value as? Data, + let ascii = String(data: data, encoding: .utf8) { + let normalized = normalizeProductName(ascii) + return normalized.isEmpty ? nil : normalized + } + return nil + } + + private struct CoreBluetoothCacheSnapshot { + let byAddress: [String: UUID] + let byName: [String: UUID] + let namesByUUID: [UUID: String] + + var hasEntries: Bool { + !byAddress.isEmpty || !byName.isEmpty + } + + static let empty = CoreBluetoothCacheSnapshot(byAddress: [:], byName: [:], namesByUUID: [:]) + } + + private struct PmsetAccessoryBatteryEntry { + let displayName: String + let normalizedName: String + let level: Int + } + + @discardableResult + private func mergePmsetEntries( + _ entries: [PmsetAccessoryBatteryEntry], + into names: inout [String: Int], + logNewEntries: Bool + ) -> [PmsetAccessoryBatteryEntry] { + guard !entries.isEmpty else { return [] } + + var newlyFilled: [PmsetAccessoryBatteryEntry] = [] + + for entry in entries { + let clamped = clampBatteryPercentage(entry.level) + let previous = names[entry.normalizedName] + + if previous == nil { + newlyFilled.append(entry) + names[entry.normalizedName] = clamped + continue + } + + if let previous, clamped > previous { + names[entry.normalizedName] = clamped + } + } + + if logNewEntries { + for entry in newlyFilled { + print("🎧 [BluetoothAudioManager] ℹ️ pmset reported \(entry.level)% for \(entry.displayName)") + } + } + + return newlyFilled + } + + 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) + + let pmsetEntries = collectPmsetAccessoryBatteryEntries() + mergePmsetEntries(pmsetEntries, into: &combinedNamePercentages, logNewEntries: true) + + 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 collectPmsetAccessoryBatteryEntries() -> [PmsetAccessoryBatteryEntry] { + let process = Process() + process.launchPath = "/usr/bin/pmset" + process.arguments = ["-g", "accps"] + + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = Pipe() + + do { + try process.run() + } catch { + return [] + } + + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + return [] + } + + guard !data.isEmpty, let output = String(data: data, encoding: .utf8) else { + return [] + } + + guard let regex = try? NSRegularExpression( + pattern: #"^\s*-\s*(.+?)\s*(?:\(.+?\))?\s+(\d+)\s*%"#, + options: [.anchorsMatchLines] + ) else { + return [] + } + + var entries: [PmsetAccessoryBatteryEntry] = [] + let nsOutput = output as NSString + let range = NSRange(location: 0, length: nsOutput.length) + + regex.enumerateMatches(in: output, options: [], range: range) { match, _, _ in + guard let match, match.numberOfRanges >= 3 else { return } + + let rawName = nsOutput + .substring(with: match.range(at: 1)) + .trimmingCharacters(in: .whitespacesAndNewlines) + let percentString = nsOutput.substring(with: match.range(at: 2)) + + guard !rawName.isEmpty, let level = Int(percentString) else { return } + + let normalizedName = normalizeProductName(rawName) + guard !normalizedName.isEmpty else { return } + if normalizedName.hasPrefix("internalbattery") { + return + } + + entries.append( + PmsetAccessoryBatteryEntry( + displayName: rawName, + normalizedName: normalizedName, + level: level + ) + ) + } + + return entries + } + + 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 cancelHUDBatteryWait(for device: BluetoothAudioDevice) { + let cancelBlock = { [weak self] in + guard let self else { return } + self.hudBatteryWaitTasks[device.id]?.cancel() + self.hudBatteryWaitTasks.removeValue(forKey: device.id) + } + + if Thread.isMainThread { + cancelBlock() + } else { + DispatchQueue.main.async(execute: cancelBlock) + } + } + + private func normalizeBluetoothIdentifier(_ value: String) -> String { + return value + .lowercased() + .replacingOccurrences(of: ":", with: "") + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: " ", with: "") + } + + private func normalizeProductName(_ name: String) -> String { + let components = name + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + + return components.joined() + } + + // MARK: - HUD Display + + /// Shows HUD notification for newly connected audio device + private func showDeviceConnectedHUD(_ device: BluetoothAudioDevice) { + guard Defaults[.showBluetoothDeviceConnections] else { return } + + cancelHUDBatteryWait(for: device) + + if let battery = bestBatteryLevel(for: device) { + presentDeviceConnectedHUD(device: device, batteryLevel: battery) + return + } + + requestPmsetFallback(reason: "hud missing battery") + + let task = Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + let deadline = Date().addingTimeInterval(self.hudBatteryWaitTimeout) + while Date() < deadline { + try? await Task.sleep(nanoseconds: UInt64(self.hudBatteryWaitInterval * 1_000_000_000)) + guard !Task.isCancelled else { return } + + let batteryInfo = await MainActor.run { () -> (BluetoothAudioDevice, Int)? in + guard let refreshedDevice = self.connectedDevices.first(where: { $0.id == device.id }), + let battery = self.bestBatteryLevel(for: refreshedDevice) else { + return nil + } + return (refreshedDevice, battery) + } + + if let (refreshedDevice, battery) = batteryInfo { + await MainActor.run { + self.presentDeviceConnectedHUD(device: refreshedDevice, batteryLevel: battery) + } + self.cancelHUDBatteryWait(for: device) + return + } + } + + guard !Task.isCancelled else { return } + await MainActor.run { + self.presentDeviceConnectedHUD(device: device, batteryLevel: nil) + } + self.cancelHUDBatteryWait(for: device) + } + + hudBatteryWaitTasks[device.id] = task + } + + private func presentDeviceConnectedHUD(device: BluetoothAudioDevice, batteryLevel: Int?) { + guard Defaults[.showBluetoothDeviceConnections] else { return } + + print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD") + + let batteryValue: CGFloat = if let batteryLevel { + CGFloat(clampBatteryPercentage(batteryLevel)) / 100.0 + } else { + 0.0 + } + + HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5) + + Task { @MainActor in + coordinator.toggleSneakPeek( + status: true, + type: .bluetoothAudio, + duration: 2.5, + value: batteryValue, + icon: device.deviceType.sfSymbol + ) + } + } + + // MARK: - Cleanup + + private func cleanup() { + print("🎧 [BluetoothAudioManager] Cleaning up observers...") + + pollingTimer?.invalidate() + pollingTimer = nil + + let dnc = DistributedNotificationCenter.default() + dnc.removeObserver(self) + observers.removeAll() + cancellables.removeAll() + hudBatteryWaitTasks.values.forEach { $0.cancel() } + hudBatteryWaitTasks.removeAll() + } + + @MainActor + func refreshConnectedDeviceBatteries() { + refreshBatteryLevelsForConnectedDevices() + } + + @MainActor + func activeDeviceIconSymbol() -> String? { + if let prioritizedDevice = connectedDevices.last ?? lastConnectedDevice { + return prioritizedDevice.deviceType.sfSymbol + } + return nil + } +} + +// MARK: - CoreBluetooth Battery Reader + +private final class BluetoothLEBatteryReader: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { + struct Lookup { + let uuid: UUID + let addressKey: String? + let nameKey: String? + } + + struct Result { + let uuid: UUID + let level: Int + let addressKey: String? + let nameKey: String? + } + + private enum State { + case idle + case requesting + } + + private static let batteryServiceUUID = CBUUID(string: "180F") + private static let batteryCharacteristicUUID = CBUUID(string: "2A19") + + private let timeoutInterval: TimeInterval = 6.0 + + private var central: CBCentralManager! + private var state: State = .idle + private var pendingLookups: [Lookup] = [] + private var lookupByUUID: [UUID: Lookup] = [:] + private var completion: (([Result]) -> Void)? + private var pendingPeripherals: [UUID: CBPeripheral] = [:] + private var results: [UUID: Result] = [:] + private var missingUUIDs: Set = [] + private var timeoutWorkItem: DispatchWorkItem? + + override init() { + super.init() + central = CBCentralManager(delegate: self, queue: nil) + } + + func fetchBatteryLevels(for lookups: [Lookup], completion: @escaping ([Result]) -> Void) { + guard !lookups.isEmpty else { + completion([]) + return + } + + guard state == .idle else { + completion([]) + return + } + + state = .requesting + pendingLookups = lookups + lookupByUUID = Dictionary(uniqueKeysWithValues: lookups.map { ($0.uuid, $0) }) + self.completion = completion + results.removeAll() + pendingPeripherals.removeAll() + missingUUIDs = Set(lookups.map { $0.uuid }) + + switch central.state { + case .poweredOn: + startRequest() + case .unauthorized, .unsupported, .poweredOff: + complete(with: []) + default: + break + } + } + + // MARK: - CBCentralManagerDelegate + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + guard state == .requesting else { return } + + switch central.state { + case .poweredOn: + startRequest() + case .unauthorized, .unsupported, .poweredOff: + complete(with: []) + default: + break + } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + peripheral.discoverServices([Self.batteryServiceUUID]) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + markPeripheralFinished(peripheral.identifier) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + guard state == .requesting else { return } + markPeripheralFinished(peripheral.identifier) + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { + guard state == .requesting else { return } + guard missingUUIDs.contains(peripheral.identifier) else { return } + + missingUUIDs.remove(peripheral.identifier) + configurePeripheral(peripheral) + } + + // MARK: - CBPeripheralDelegate + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard state == .requesting else { return } + + if let error { + print("🎧 [BluetoothLEBatteryReader] Service discovery failed: \(error.localizedDescription)") + markPeripheralFinished(peripheral.identifier) + return + } + + guard let service = peripheral.services?.first(where: { $0.uuid == Self.batteryServiceUUID }) else { + markPeripheralFinished(peripheral.identifier) + return + } + + peripheral.discoverCharacteristics([Self.batteryCharacteristicUUID], for: service) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + guard state == .requesting else { return } + + if let error { + print("🎧 [BluetoothLEBatteryReader] Characteristic discovery failed: \(error.localizedDescription)") + markPeripheralFinished(peripheral.identifier) + return + } + + guard let characteristic = service.characteristics?.first(where: { $0.uuid == Self.batteryCharacteristicUUID }) else { + markPeripheralFinished(peripheral.identifier) + return + } + + peripheral.readValue(for: characteristic) + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard state == .requesting else { return } + + defer { markPeripheralFinished(peripheral.identifier) } + + if let error { + print("🎧 [BluetoothLEBatteryReader] Battery read failed: \(error.localizedDescription)") + return + } + + guard let data = characteristic.value, let byte = data.first, let lookup = lookupByUUID[peripheral.identifier] else { + return + } + + let level = Int(byte) + results[peripheral.identifier] = Result( + uuid: peripheral.identifier, + level: level, + addressKey: lookup.addressKey, + nameKey: lookup.nameKey + ) + } + + // MARK: - Helpers + + private func startRequest() { + central.stopScan() + + let identifiers = Array(missingUUIDs) + if !identifiers.isEmpty { + let peripherals = central.retrievePeripherals(withIdentifiers: identifiers) + for peripheral in peripherals { + missingUUIDs.remove(peripheral.identifier) + configurePeripheral(peripheral) + } + } + + let connectedPeripherals = central.retrieveConnectedPeripherals(withServices: [Self.batteryServiceUUID]) + for peripheral in connectedPeripherals where missingUUIDs.contains(peripheral.identifier) { + missingUUIDs.remove(peripheral.identifier) + configurePeripheral(peripheral) + } + + if !missingUUIDs.isEmpty { + central.scanForPeripherals(withServices: [Self.batteryServiceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + } + + if pendingPeripherals.isEmpty && missingUUIDs.isEmpty { + complete(with: Array(results.values)) + return + } + + scheduleTimeout() + } + + private func configurePeripheral(_ peripheral: CBPeripheral) { + pendingPeripherals[peripheral.identifier] = peripheral + peripheral.delegate = self + + switch peripheral.state { + case .connected: + peripheral.discoverServices([Self.batteryServiceUUID]) + default: + central.connect(peripheral, options: nil) + } + } + + private func markPeripheralFinished(_ identifier: UUID) { + pendingPeripherals.removeValue(forKey: identifier) + missingUUIDs.remove(identifier) + + if missingUUIDs.isEmpty { + central.stopScan() + } + + if pendingPeripherals.isEmpty && missingUUIDs.isEmpty { + complete(with: Array(results.values)) + } + } + + private func scheduleTimeout() { + cancelTimeout() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.complete(with: Array(self.results.values)) + } + timeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutInterval, execute: workItem) + } + + private func cancelTimeout() { + timeoutWorkItem?.cancel() + timeoutWorkItem = nil + } + + private func complete(with results: [Result]) { + guard state == .requesting else { return } + cancelTimeout() + central.stopScan() + state = .idle + + pendingPeripherals.removeAll() + missingUUIDs.removeAll() + pendingLookups.removeAll() + lookupByUUID.removeAll() + + let completion = self.completion + self.completion = nil + self.results.removeAll() + + completion?(results) + } +} + +// MARK: - Models + +struct BluetoothAudioDevice: Identifiable { + 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 { + case airpods + case airpodsGen3 + case airpodsGen4 + case airpodsPro + case airpodsPro3 + case airpodsMax + case beats + case beatsstudio + case beatssolo + case headphones + case speaker + case generic + + var sfSymbol: String { + switch self { + case .airpods: + return "airpods" + case .airpodsGen3: + return "airpods.gen3" + case .airpodsGen4: + return "airpods.gen4" + case .airpodsPro: + return "airpods.pro" + case .airpodsPro3: + return "airpods.pro" + case .airpodsMax: + return "airpodsmax" + case .beats: + return "beats.headphones" + case .beatsstudio: + return "beats.headphones" + case .beatssolo: + return "beats.headphones" + case .headphones: + return "headphones" + case .speaker: + return "hifispeaker.fill" + case .generic: + return "bluetooth.circle.fill" + } + } + + var displayName: String { + switch self { + case .airpods: return "AirPods" + case .airpodsGen3: return "AirPods (Gen 3)" + case .airpodsGen4: return "AirPods (Gen 4)" + case .airpodsPro: return "AirPods Pro" + case .airpodsPro3: return "AirPods Pro 3" + case .airpodsMax: return "AirPods Max" + case .beats: return "Beats" + case .beatsstudio: return "Beats Studio" + case .beatssolo: return "Beats Solo" + case .headphones: return "Headphones" + case .speaker: return "Speaker" + case .generic: return "Bluetooth Device" + } + } + + /// Inline HUD only: base filename (no extension) for a looping .mov animation. + var inlineHUDAnimationBaseName: String { + String(describing: self) + } +} + +// MARK: - Notification Name Constants + +private let IOBluetoothDeviceConnectionNotification = "IOBluetoothDeviceConnectionNotification" +private let IOBluetoothDeviceDisconnectionNotification = "IOBluetoothDeviceDisconnectionNotification" diff --git a/DynamicIsland/managers/CalendarManager.swift b/DynamicIsland/managers/CalendarManager.swift index 7afc23ec..42c66e3b 100644 --- a/DynamicIsland/managers/CalendarManager.swift +++ b/DynamicIsland/managers/CalendarManager.swift @@ -1,65 +1,218 @@ -// -// CalendarManager.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 08/09/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import Defaults import EventKit import SwiftUI -import Defaults // MARK: - CalendarManager @MainActor class CalendarManager: ObservableObject { + static let shared = CalendarManager() + @Published var currentWeekStartDate: Date @Published var events: [EventModel] = [] @Published var allCalendars: [CalendarModel] = [] - @Published var authorizationStatus: EKAuthorizationStatus = .notDetermined + @Published var eventCalendars: [CalendarModel] = [] + @Published var reminderLists: [CalendarModel] = [] + @Published var selectedCalendarIDs: Set = [] + @Published var calendarAuthorizationStatus: EKAuthorizationStatus = .notDetermined + @Published var reminderAuthorizationStatus: EKAuthorizationStatus = .notDetermined + @Published var lockScreenEvents: [EventModel] = [] + + private var lockScreenPreviewEvents: [EventModel]? + private var selectedCalendars: [CalendarModel] = [] private let calendarService = CalendarService() + private var lastEventsFetchDate: Date? + private let reloadRefreshInterval: TimeInterval = 15 + private var eventStoreChangedObserver: NSObjectProtocol? + private var pendingEventStoreRefreshTask: Task? + private var nextAllowedEventStoreRefresh: Date = .distantPast + private var ignoreEventStoreChangesUntil: Date = .distantPast + private let eventStoreChangeThrottle: TimeInterval = 20 + private let selfInducedChangeSuppression: TimeInterval = 6 + private let eventFetchLimiter = EventFetchLimiter() + private var lastLockScreenEventsFetchDate: Date? + private let lockScreenRefreshInterval: TimeInterval = 15 + private var lockScreenRefreshTask: Task? + + var hasCalendarAccess: Bool { isAuthorized(calendarAuthorizationStatus) } + var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) } + + private init() { + currentWeekStartDate = CalendarManager.startOfDay(Date()) + setupEventStoreChangedObserver() + startLockScreenRefreshLoop() + Task { + await reloadCalendarAndReminderLists() + } + } + + deinit { + if let observer = eventStoreChangedObserver { + NotificationCenter.default.removeObserver(observer) + } + pendingEventStoreRefreshTask?.cancel() + lockScreenRefreshTask?.cancel() + } + + private func setupEventStoreChangedObserver() { + eventStoreChangedObserver = NotificationCenter.default.addObserver( + forName: .EKEventStoreChanged, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleEventStoreChanged() + } + } + + private func handleEventStoreChanged() { + Logger.log("CalendarManager: Event store changed notification received", category: .lifecycle) + let now = Date() + guard now >= ignoreEventStoreChangesUntil else { return } + + if now < nextAllowedEventStoreRefresh { + let delay = max(nextAllowedEventStoreRefresh.timeIntervalSince(now), 0.05) + scheduleEventStoreRefresh(after: delay) + return + } + + nextAllowedEventStoreRefresh = now.addingTimeInterval(eventStoreChangeThrottle) + scheduleEventStoreRefresh(after: 0) + } - init() { - self.currentWeekStartDate = CalendarManager.startOfDay(Date()) + private func scheduleEventStoreRefresh(after delay: TimeInterval) { + pendingEventStoreRefreshTask?.cancel() + pendingEventStoreRefreshTask = Task { [weak self] in + guard let self else { return } + if delay > 0 { + let nanoseconds = UInt64(delay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } + await self.performEventStoreRefresh() + } + } + + @MainActor + private func performEventStoreRefresh() async { + pendingEventStoreRefreshTask = nil + await reloadCalendarAndReminderLists() + await maybeRefreshEventsAfterReload() + await updateLockScreenEvents(force: true) + nextAllowedEventStoreRefresh = Date().addingTimeInterval(eventStoreChangeThrottle) + ignoreEventStoreChangesUntil = Date().addingTimeInterval(selfInducedChangeSuppression) + } + + @MainActor + func reloadCalendarAndReminderLists() async { + let allCalendars = await calendarService.calendars() + eventCalendars = allCalendars.filter { !$0.isReminder } + reminderLists = allCalendars.filter { $0.isReminder } + self.allCalendars = allCalendars + updateSelectedCalendars() + } + + @MainActor + private func maybeRefreshEventsAfterReload() async { + guard hasCalendarAccess else { return } + let now = Date() + if let lastFetch = lastEventsFetchDate, now.timeIntervalSince(lastFetch) < reloadRefreshInterval { + return + } + await updateEvents() + } + + private func isAuthorized(_ status: EKAuthorizationStatus) -> Bool { + switch status { + case .authorized, .fullAccess: + return true + default: + return false + } } func checkCalendarAuthorization() async { let status = EKEventStore.authorizationStatus(for: .event) - DispatchQueue.main.async { - print("📅 Current calendar authorization status: \(status)") - self.authorizationStatus = status + calendarAuthorizationStatus = status + + switch status { + case .notDetermined: + let granted = await calendarService.requestAccess(to: .event) + calendarAuthorizationStatus = granted ? .fullAccess : .denied + if granted { + await reloadCalendarAndReminderLists() + await updateEvents(force: true) + await updateLockScreenEvents(force: true) + } + case .restricted, .denied: + NSLog("Calendar access denied or restricted") + case .authorized, .fullAccess: + await reloadCalendarAndReminderLists() + await updateEvents(force: true) + await updateLockScreenEvents(force: true) + case .writeOnly: + NSLog("Calendar write only") + @unknown default: + NSLog("Unknown calendar authorization status") } + } + + func checkReminderAuthorization() async { + let status = EKEventStore.authorizationStatus(for: .reminder) + reminderAuthorizationStatus = status switch status { - case .notDetermined: - self.authorizationStatus = await calendarService.requestAccess() ? .fullAccess : .denied - case .restricted, .denied: - // Handle the case where the user has denied or restricted access - NSLog("Calendar access denied or restricted") - case .fullAccess: - NSLog("Full access") - allCalendars = await calendarService.calendars() - updateSelectedCalendars() - events = await calendarService.events(from: currentWeekStartDate, to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, calendars: selectedCalendars.map{$0.id}) - case .writeOnly: - NSLog("Write only") - @unknown default: - print("Unknown authorization status") - } - } - - func updateSelectedCalendars() { - selectedCalendars = allCalendars.filter { getCalendarSelected($0) } + case .notDetermined: + let granted = await calendarService.requestAccess(to: .reminder) + reminderAuthorizationStatus = granted ? .fullAccess : .denied + if granted { + await reloadCalendarAndReminderLists() + } + case .restricted, .denied: + NSLog("Reminder access denied or restricted") + case .authorized, .fullAccess: + await reloadCalendarAndReminderLists() + case .writeOnly: + NSLog("Reminder write only") + @unknown default: + NSLog("Unknown reminder authorization status") + } } - - func getCalendarSelected(_ calendar: CalendarModel) -> Bool { + + func updateSelectedCalendars() { switch Defaults[.calendarSelectionState] { case .all: - return true + selectedCalendarIDs = Set(allCalendars.map { $0.id }) case .selected(let identifiers): - return identifiers.contains(calendar.id) + selectedCalendarIDs = identifiers } + + selectedCalendars = allCalendars.filter { selectedCalendarIDs.contains($0.id) } + } + + func getCalendarSelected(_ calendar: CalendarModel) -> Bool { + selectedCalendarIDs.contains(calendar.id) } func setCalendarSelected(_ calendar: CalendarModel, isSelected: Bool) async { @@ -71,28 +224,230 @@ class CalendarManager: ObservableObject { let identifiers = Set(allCalendars.map { $0.id }).subtracting([calendar.id]) selectionState = .selected(identifiers) } - case .selected(var identifiers): if isSelected { identifiers.insert(calendar.id) } else { identifiers.remove(calendar.id) } - - selectionState = identifiers.isEmpty ? .all : identifiers.count == allCalendars.count ? .all : .selected(identifiers) // if empty, select all + + if identifiers.isEmpty || identifiers.count == allCalendars.count { + selectionState = .all + } else { + selectionState = .selected(identifiers) + } } Defaults[.calendarSelectionState] = selectionState updateSelectedCalendars() - events = await calendarService.events(from: currentWeekStartDate, to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, calendars: selectedCalendars.map{$0.id}) + await updateEvents(force: true) + await updateLockScreenEvents(force: true) } - + static func startOfDay(_ date: Date) -> Date { - return Calendar.current.startOfDay(for: date) + Calendar.current.startOfDay(for: date) } - + func updateCurrentDate(_ date: Date) async { currentWeekStartDate = Calendar.current.startOfDay(for: date) - events = await calendarService.events(from: currentWeekStartDate, to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, calendars: selectedCalendars.map{$0.id}) + await updateEvents(force: true) + await updateLockScreenEvents(force: true) + } + + func updateLockScreenEvents(force: Bool = false) async { + if let previewEvents = lockScreenPreviewEvents { + if lockScreenEvents != previewEvents { + withAnimation(.smooth(duration: 0.25)) { + lockScreenEvents = previewEvents + } + } + lastLockScreenEventsFetchDate = Date() + return + } + + let now = Date() + + if !force, + let lastFetch = lastLockScreenEventsFetchDate, + now.timeIntervalSince(lastFetch) < lockScreenRefreshInterval { + return + } + + let lookaheadRaw = Defaults[.lockScreenCalendarEventLookaheadWindow] + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + let isAllTimeLookahead = + lookaheadRaw == "all_time" || + lookaheadRaw == "all time" || + lookaheadRaw == "alltime" + + let isRestOfDay = + lookaheadRaw == "rest_of_day" || + lookaheadRaw == "rest of the day" || + lookaheadRaw == "restofday" + + func lookaheadMinutes(from raw: String) -> Int? { + switch raw { + case "15m", "15 min", "15 mins", "15min", "15mins": return 15 + case "30m", "30 min", "30 mins", "30min", "30mins": return 30 + case "1h", "1 hr", "1 hour", "1hour": return 60 + case "3h", "3 hr", "3 hours", "3hours": return 180 + case "6h", "6 hr", "6 hours", "6hours": return 360 + case "12h", "12 hr", "12 hours", "12hours": return 720 + default: return nil + } + } + + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: now) + let startDate = calendar.date(byAdding: .day, value: -1, to: startOfToday) ?? startOfToday + + let endDate: Date + if isAllTimeLookahead { + endDate = calendar.date(byAdding: .day, value: 365, to: now) ?? now.addingTimeInterval(365 * 24 * 3600) + } else if isRestOfDay { + endDate = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? startOfToday.addingTimeInterval(24 * 3600) + } else if let minutes = lookaheadMinutes(from: lookaheadRaw) { + endDate = calendar.date(byAdding: .minute, value: minutes, to: now) ?? now.addingTimeInterval(TimeInterval(minutes * 60)) + } else { + endDate = calendar.date(byAdding: .minute, value: 180, to: now) ?? now.addingTimeInterval(180 * 60) + } + + let calendarIDs = allCalendars.map { $0.id } + let service = calendarService + + let fetched = await eventFetchLimiter.run { + await service.events(from: startDate, to: endDate, calendars: calendarIDs) + } + + if lockScreenEvents == fetched { + lastLockScreenEventsFetchDate = Date() + return + } + + withAnimation(.smooth(duration: 0.25)) { + lockScreenEvents = fetched + } + lastLockScreenEventsFetchDate = Date() + } + + func setLockScreenPreviewEvents(_ events: [EventModel]?) { + lockScreenPreviewEvents = events + guard let events else { + Task { await updateLockScreenEvents(force: true) } + return + } + withAnimation(.smooth(duration: 0.25)) { + lockScreenEvents = events + } + lastLockScreenEventsFetchDate = Date() + } + + private func startLockScreenRefreshLoop() { + lockScreenRefreshTask?.cancel() + lockScreenRefreshTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 60 * 1_000_000_000) + if Task.isCancelled { break } + guard self.hasCalendarAccess else { continue } + await self.updateLockScreenEvents(force: false) + } + } + } + + private func updateEvents(force: Bool = false) async { + let now = Date() + if !force, let lastFetch = lastEventsFetchDate, now.timeIntervalSince(lastFetch) < reloadRefreshInterval { + return + } + + Logger.log("CalendarManager: Updating events (force: \(force))", category: .lifecycle) + + let calendarIDs = selectedCalendars.map { $0.id } + let startDate = currentWeekStartDate + guard let endDate = Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate) else { return } + let service = calendarService + + let events = await eventFetchLimiter.run { + await service.events( + from: startDate, + to: endDate, + calendars: calendarIDs + ) + } + + self.events = events + lastEventsFetchDate = Date() + } + + func setCalendarsSelected(_ calendars: [CalendarModel], isSelected: Bool) async { + var selectionState = Defaults[.calendarSelectionState] + let ids = Set(calendars.map { $0.id }) + + switch selectionState { + case .all: + if !isSelected { + let identifiers = Set(allCalendars.map { $0.id }).subtracting(ids) + selectionState = .selected(identifiers) + } + case .selected(var identifiers): + if isSelected { + identifiers.formUnion(ids) + } else { + identifiers.subtract(ids) + } + + if identifiers.isEmpty || identifiers.count == allCalendars.count { + selectionState = .all + } else { + selectionState = .selected(identifiers) + } + } + + Defaults[.calendarSelectionState] = selectionState + updateSelectedCalendars() + await updateEvents(force: true) + await updateLockScreenEvents(force: true) + } + + func setReminderCompleted(reminderID: String, completed: Bool) async { + await calendarService.setReminderCompleted(reminderID: reminderID, completed: completed) + await updateEvents(force: true) + } +} + +// MARK: - Event Fetch Limiter + +private actor EventFetchLimiter { + private var waiters: [CheckedContinuation] = [] + private var isRunning = false + + func run(_ operation: @escaping @Sendable () async -> T) async -> T { + await waitTurn() + defer { resumeNext() } + return await operation() + } + + private func waitTurn() async { + if !isRunning { + isRunning = true + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + private func resumeNext() { + if waiters.isEmpty { + isRunning = false + return + } + + let continuation = waiters.removeFirst() + continuation.resume() } } diff --git a/DynamicIsland/managers/CameraMonitor.swift b/DynamicIsland/managers/CameraMonitor.swift new file mode 100644 index 00000000..9dc300e0 --- /dev/null +++ b/DynamicIsland/managers/CameraMonitor.swift @@ -0,0 +1,382 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import CoreMediaIO +import AVFoundation +import SwiftUI +import Combine + +// MARK: - CMIOObject Property Listener (Event-driven approach) +private func cameraPropertyListener( + objectID: CMIOObjectID, + numberAddresses: UInt32, + addresses: UnsafePointer?, + clientData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let context = clientData else { return OSStatus(kCMIOHardwareNoError) } + let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue() + + DispatchQueue.main.async { + print("CameraMonitor: 📷 Camera property changed") + monitor.checkCameraStatus() + } + + return OSStatus(kCMIOHardwareNoError) +} + +@MainActor +class CameraMonitor: ObservableObject { + // MARK: - Published Properties + @Published var isCameraActive: Bool = false + @Published var activeApp: String? = nil + @Published var isMonitoring: Bool = false + + // MARK: - Private Properties + private var cancellables = Set() + private var isListenerRegistered: Bool = false + private var cameraDeviceIDs: [CMIOObjectID] = [] + + // MARK: - Configuration + // Pure event-driven - no polling + + // MARK: - Initialization + init() { + // No initial setup needed + } + + deinit { + // Clean up listeners synchronously + if isListenerRegistered, !cameraDeviceIDs.isEmpty { + var propertyAddress = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeWildcard), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementWildcard) + ) + + let context = Unmanaged.passUnretained(self).toOpaque() + + for deviceID in cameraDeviceIDs { + CMIOObjectRemovePropertyListener( + deviceID, + &propertyAddress, + cameraPropertyListener, + context + ) + } + } + } + + // MARK: - Public Methods + + /// Start monitoring camera usage + func startMonitoring() { + guard !isMonitoring else { + print("CameraMonitor: Already monitoring, skipping start") + return + } + + print("CameraMonitor: 🟢 Starting camera monitoring...") + + isMonitoring = true + + // Enumerate camera devices + cameraDeviceIDs = enumerateCameraDevices() + + if cameraDeviceIDs.isEmpty { + print("CameraMonitor: ⚠️ No camera devices found") + } else { + print("CameraMonitor: 📷 Found \(cameraDeviceIDs.count) camera device(s)") + } + + // Setup event listener (CoreMediaIO approach) + setupPropertyListener() + + // Check initial state + checkCameraStatus() + + print("CameraMonitor: ✅ Started monitoring (event-driven CMIO + AVFoundation check)") + } + + /// Stop monitoring camera usage + func stopMonitoring() { + guard isMonitoring else { + print("CameraMonitor: Not monitoring, skipping stop") + return + } + + print("CameraMonitor: 🛑 Stopping monitoring...") + + isMonitoring = false + + // Remove property listener + if isListenerRegistered { + removePropertyListener() + } + + // Reset state + if isCameraActive { + isCameraActive = false + } + activeApp = nil + + print("CameraMonitor: ✅ Stopped monitoring") + } + + /// Toggle monitoring state + func toggleMonitoring() { + if isMonitoring { + stopMonitoring() + } else { + startMonitoring() + } + } + + // MARK: - Private Methods + + /// Enumerate all camera devices using CoreMediaIO + private func enumerateCameraDevices() -> [CMIOObjectID] { + var propertyAddress = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIOHardwarePropertyDevices), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain) + ) + + var dataSize: UInt32 = 0 + var status = CMIOObjectGetPropertyDataSize( + CMIOObjectID(kCMIOObjectSystemObject), + &propertyAddress, + 0, + nil, + &dataSize + ) + + guard status == OSStatus(kCMIOHardwareNoError), dataSize > 0 else { + print("CameraMonitor: ⚠️ Failed to get devices data size (status: \(status))") + return [] + } + + let deviceCount = Int(dataSize) / MemoryLayout.size + var devices = [CMIOObjectID](repeating: 0, count: deviceCount) + + status = CMIOObjectGetPropertyData( + CMIOObjectID(kCMIOObjectSystemObject), + &propertyAddress, + 0, + nil, + dataSize, + &dataSize, + &devices + ) + + guard status == OSStatus(kCMIOHardwareNoError) else { + print("CameraMonitor: ⚠️ Failed to get devices (status: \(status))") + return [] + } + + // Filter for video input devices + let videoDevices = devices.filter { deviceID in + return isVideoInputDevice(deviceID) + } + + return videoDevices + } + + /// Check if device is a video input device + private func isVideoInputDevice(_ deviceID: CMIOObjectID) -> Bool { + // Check if device has video input streams + var propertyAddress = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyStreams), + mScope: CMIOObjectPropertyScope(kCMIODevicePropertyScopeInput), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain) + ) + + var dataSize: UInt32 = 0 + let status = CMIOObjectGetPropertyDataSize( + deviceID, + &propertyAddress, + 0, + nil, + &dataSize + ) + + return status == OSStatus(kCMIOHardwareNoError) && dataSize > 0 + } + + /// Setup CoreMediaIO property listener + private func setupPropertyListener() { + guard !cameraDeviceIDs.isEmpty else { return } + + // Listen for device running state changes + var propertyAddress = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeWildcard), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementWildcard) + ) + + let context = Unmanaged.passUnretained(self).toOpaque() + + // Add listener for each camera device + for deviceID in cameraDeviceIDs { + let status = CMIOObjectAddPropertyListener( + deviceID, + &propertyAddress, + cameraPropertyListener, + context + ) + + if status == OSStatus(kCMIOHardwareNoError) { + print("CameraMonitor: ✅ Property listener registered for device \(deviceID)") + isListenerRegistered = true + } else { + print("CameraMonitor: ⚠️ Failed to register listener for device \(deviceID) (status: \(status))") + } + } + } + + /// Remove CoreMediaIO property listener + private func removePropertyListener() { + guard !cameraDeviceIDs.isEmpty else { return } + + var propertyAddress = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeWildcard), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementWildcard) + ) + + let context = Unmanaged.passUnretained(self).toOpaque() + + for deviceID in cameraDeviceIDs { + let status = CMIOObjectRemovePropertyListener( + deviceID, + &propertyAddress, + cameraPropertyListener, + context + ) + + if status == OSStatus(kCMIOHardwareNoError) { + print("CameraMonitor: ✅ Property listener removed for device \(deviceID)") + } else { + print("CameraMonitor: ⚠️ Failed to remove listener for device \(deviceID) (status: \(status))") + } + } + + isListenerRegistered = false + } + + /// Check current camera status (CMIO approach) + private func checkCameraStatusCMIO() -> Bool { + guard !cameraDeviceIDs.isEmpty else { return false } + + var propertyAddress = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeWildcard), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementWildcard) + ) + + // Check if any camera device is running + for deviceID in cameraDeviceIDs { + var isRunning: UInt32 = 0 + var dataSize = UInt32(MemoryLayout.size) + + let status = CMIOObjectGetPropertyData( + deviceID, + &propertyAddress, + 0, + nil, + dataSize, + &dataSize, + &isRunning + ) + + if status == OSStatus(kCMIOHardwareNoError) && isRunning != 0 { + return true + } + } + + return false + } + + /// Check current camera status (AVFoundation fallback) + private func checkCameraStatusAVFoundation() -> Bool { + // Use AVFoundation to check if any camera is in use + let discoverySession = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera, .external], + mediaType: .video, + position: .unspecified + ) + + for device in discoverySession.devices { + if device.isInUseByAnotherApplication { + return true + } + } + + return false + } + + /// Check current camera status (hybrid approach) + func checkCameraStatus() { + // Try CMIO first, fallback to AVFoundation + let isCMIOActive = checkCameraStatusCMIO() + let isAVActive = checkCameraStatusAVFoundation() + + // Use OR logic - either method detects usage + let isActive = isCMIOActive || isAVActive + + // Debug logging + print("CameraMonitor: 🔍 Checking... current=\(isCameraActive), CMIO=\(isCMIOActive), AV=\(isAVActive), final=\(isActive)") + + // Update state if changed + if isActive != isCameraActive { + print("CameraMonitor: 🔄 State change detected (\(isCameraActive) -> \(isActive))") + + withAnimation(.smooth) { + isCameraActive = isActive + } + + if isActive { + print("CameraMonitor: 📷 Camera ACTIVE") + // Could try to identify app here (TODO: investigate) + activeApp = "Unknown App" + } else { + print("CameraMonitor: ⚪ Camera INACTIVE") + activeApp = nil + } + } + } + +} + +// MARK: - Extensions + +extension CameraMonitor { + /// Get current camera status without async + var currentCameraStatus: Bool { + return isCameraActive + } + + /// Check if monitoring is available + var isMonitoringAvailable: Bool { + return !enumerateCameraDevices().isEmpty || !AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera, .external], + mediaType: .video, + position: .unspecified + ).devices.isEmpty + } +} diff --git a/DynamicIsland/managers/CapsLockManager.swift b/DynamicIsland/managers/CapsLockManager.swift new file mode 100644 index 00000000..45d2820a --- /dev/null +++ b/DynamicIsland/managers/CapsLockManager.swift @@ -0,0 +1,99 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Combine +import AppKit +import Defaults +import SwiftUI + +@MainActor +class CapsLockManager: ObservableObject { + static let shared = CapsLockManager() + + @Published var isCapsLockActive: Bool = false + + private var localEventMonitor: Any? + private var globalEventMonitor: Any? + private let coordinator = DynamicIslandViewCoordinator.shared + private let capsLockAnimation = Animation.spring(response: 0.32, dampingFraction: 0.85) + + private init() { + // Get initial state + isCapsLockActive = NSEvent.modifierFlags.contains(.capsLock) + + // Monitor flag changes when app is focused + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + self?.handleFlagsChanged(event) + return event + } + + // Monitor flag changes globally (even when app is not focused) + globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + Task { @MainActor in + self?.handleFlagsChanged(event) + } + } + + print("CapsLockManager: ✅ Initialized with Caps Lock \(isCapsLockActive ? "ON" : "OFF")") + } + + deinit { + if let monitor = localEventMonitor { + NSEvent.removeMonitor(monitor) + } + if let monitor = globalEventMonitor { + NSEvent.removeMonitor(monitor) + } + } + + private func handleFlagsChanged(_ event: NSEvent) { + let newState = event.modifierFlags.contains(.capsLock) + + guard newState != isCapsLockActive else { return } + + withAnimation(capsLockAnimation) { + isCapsLockActive = newState + } + + print("CapsLockManager: Caps Lock \(newState ? "ACTIVATED" : "DEACTIVATED")") + + // Only show/hide if feature is enabled + guard Defaults[.enableCapsLockIndicator] else { return } + + if newState { + // Show inline indicator + coordinator.toggleSneakPeek( + status: true, + type: .capsLock, + duration: .infinity, // Stay visible until deactivated + value: 1.0, + icon: "" + ) + } else { + // Hide indicator + coordinator.toggleSneakPeek( + status: false, + type: .capsLock, + duration: 0, + value: 0, + icon: "" + ) + } + } +} diff --git a/DynamicIsland/managers/CircularHUDWindowManager.swift b/DynamicIsland/managers/CircularHUDWindowManager.swift new file mode 100644 index 00000000..a6847d47 --- /dev/null +++ b/DynamicIsland/managers/CircularHUDWindowManager.swift @@ -0,0 +1,169 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#if os(macOS) +import AppKit +import SwiftUI +import SkyLightWindow +import QuartzCore +import Defaults +import Combine + +@MainActor +final class CircularHUDWindowManager { + static let shared = CircularHUDWindowManager() + + private var windows: [NSScreen: OSDWindow] = [:] + private var hideWorkItem: DispatchWorkItem? + private let displayDuration: TimeInterval = 2.0 + private let animationDuration: TimeInterval = 0.2 + + private var cancellables = Set() + + // Layout constants removed in favor of dynamic calculation + + private init() { + setupSizeObserver() + } + + private func setupSizeObserver() { + Defaults.publisher(.circularHUDSize, options: []).sink { [weak self] _ in + guard let self = self else { return } + Task { @MainActor in + self.updateWindowLayout() + } + }.store(in: &cancellables) + } + + private func updateWindowLayout() { + guard !windows.isEmpty else { return } + + for (screen, window) in windows { + let screenFrame = screen.frame + let hudSize = Defaults[.circularHUDSize] + let windowSize = hudSize + 80 // Padding + + let x = screenFrame.midX - (windowSize / 2) + let y = screenFrame.midY - (windowSize / 2) + let newFrame = NSRect(x: x, y: y, width: windowSize, height: windowSize) + + window.nsWindow.setFrame(newFrame, display: true, animate: true) + } + } + + func show(type: SneakContentType, value: CGFloat, icon: String = "", onScreen targetScreen: NSScreen? = nil) { + guard Defaults[.enableCircularHUD] else { return } + + let screens = targetScreen.map { [$0] } ?? NSScreen.screens + guard !screens.isEmpty else { return } + + // Show on target screen(s) + for screen in screens { + let windowContext = ensureWindow(for: type, screen: screen) + updateContent(window: windowContext, type: type, value: value, icon: icon) + + // Ensure visible + windowContext.nsWindow.orderFrontRegardless() + + NSAnimationContext.runAnimationGroup { context in + context.duration = animationDuration + windowContext.nsWindow.animator().alphaValue = 1 + } + } + + scheduleHide() + } + + private func ensureWindow(for type: SneakContentType, screen: NSScreen) -> OSDWindow { + if let existing = windows[screen] { + return existing + } + + let osdView = CircularHUDView(type: .constant(type), value: .constant(0), icon: .constant("")) + let hostingView = NSHostingView(rootView: osdView) + + let screenFrame = screen.frame + + let hudSize = Defaults[.circularHUDSize] + let windowSize = hudSize + 80 // Padding + + // Position at center of screen + let x = screenFrame.midX - (windowSize / 2) + let y = screenFrame.midY - (windowSize / 2) + let frame = NSRect(x: x, y: y, width: windowSize, height: windowSize) + + let win = NSWindow( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + win.isOpaque = false + win.backgroundColor = .clear + win.level = .screenSaver + win.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] + win.hasShadow = false + win.contentView = hostingView + win.alphaValue = 0 + + SkyLightOperator.shared.delegateWindow(win) + + let windowStruct = OSDWindow(nsWindow: win, hostingView: hostingView, type: type) + windows[screen] = windowStruct + return windowStruct + } + + private func updateContent(window: OSDWindow, type: SneakContentType, value: CGFloat, icon: String) { + let osdView = CircularHUDView(type: .constant(type), value: .constant(value), icon: .constant(icon)) + window.hostingView.rootView = osdView + } + + private func scheduleHide() { + hideWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + Task { @MainActor in + self.hideWindow() + } + } + + hideWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + displayDuration, execute: workItem) + } + + private func hideWindow() { + for window in windows.values { + NSAnimationContext.runAnimationGroup { context in + context.duration = animationDuration + window.nsWindow.animator().alphaValue = 0 + } completionHandler: { + // Keep window around but hidden for faster subsequent shows + } + } + } + + // Helper struct + private struct OSDWindow { + let nsWindow: NSWindow + let hostingView: NSHostingView + let type: SneakContentType + } +} +#endif diff --git a/DynamicIsland/managers/ClipboardManager.swift b/DynamicIsland/managers/ClipboardManager.swift new file mode 100644 index 00000000..d3a11a6a --- /dev/null +++ b/DynamicIsland/managers/ClipboardManager.swift @@ -0,0 +1,542 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI +import Combine +import Foundation +import UniformTypeIdentifiers +import Defaults + +// Clipboard item data structure +struct ClipboardItem: Identifiable, Codable { + let id = UUID() + let type: ClipboardItemType + let timestamp: Date + let preview: String + var isPinned: Bool = false + + // Store different types of data - avoid large binary data in UserDefaults + let stringData: String? + let imageFileName: String? // Store filename instead of data + let fileURLs: [String]? + let rtfData: Data? // RTF is typically small, so we can keep this + + init(stringData: String, type: ClipboardItemType) { + self.stringData = stringData + self.imageFileName = nil + self.fileURLs = nil + self.rtfData = nil + self.type = type + self.timestamp = Date() + self.preview = ClipboardItem.generatePreview(stringData: stringData, type: type) + } + + init(imageData: Data) { + self.stringData = nil + self.fileURLs = nil + self.rtfData = nil + self.type = .image + self.timestamp = Date() + + // Save image data to temporary file instead of storing in UserDefaults + let fileName = "clipboard_image_\(UUID().uuidString).png" + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + + do { + try imageData.write(to: fileURL) + self.imageFileName = fileName + self.preview = "Image (\(ByteCountFormatter.string(fromByteCount: Int64(imageData.count), countStyle: .file)))" + } catch { + print("Failed to save image data: \(error)") + self.imageFileName = nil + self.preview = "Image (failed to save)" + } + } + + init(fileURLs: [String]) { + self.stringData = nil + self.imageFileName = nil + self.fileURLs = fileURLs + self.rtfData = nil + self.type = .file + self.timestamp = Date() + + if fileURLs.count == 1, let url = URL(string: fileURLs.first!) { + self.preview = url.lastPathComponent + } else { + self.preview = "\(fileURLs.count) files" + } + } + + init(rtfData: Data, plainText: String) { + // RTF data is typically small, so we can keep it in UserDefaults + self.stringData = plainText + self.imageFileName = nil + self.fileURLs = nil + self.rtfData = rtfData.count > 100000 ? nil : rtfData // Skip very large RTF files + self.type = .rtf + self.timestamp = Date() + self.preview = String(plainText.prefix(50)) + } + + // Helper to get image data from file + func getImageData() -> Data? { + guard let fileName = imageFileName else { return nil } + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + return try? Data(contentsOf: fileURL) + } + + // Helper to check if this item has the same content as another + func isSameContent(as other: ClipboardItem) -> Bool { + return stringData == other.stringData && + imageFileName == other.imageFileName && + fileURLs == other.fileURLs && + type == other.type + } + + static func generatePreview(stringData: String, type: ClipboardItemType) -> String { + switch type { + case .text: + return String(stringData.prefix(50)) + case .url: + if let url = URL(string: stringData) { + return url.lastPathComponent.isEmpty ? url.host ?? stringData : url.lastPathComponent + } + return String(stringData.prefix(50)) + case .file: + if let url = URL(string: stringData) { + return url.lastPathComponent + } + return "File" + case .image: + return "Image" + case .rtf: + return String(stringData.prefix(50)) + case .unknown: + return String(stringData.prefix(50)) + } + } +} + +enum ClipboardItemType: String, CaseIterable, Codable { + case text = "text" + case url = "url" + case file = "file" + case image = "image" + case rtf = "rtf" + case unknown = "unknown" + + var icon: String { + switch self { + case .text: return "doc.text" + case .url: return "link" + case .file: return "doc" + case .image: return "photo" + case .rtf: return "doc.richtext" + case .unknown: return "questionmark.circle" + } + } + + var displayName: String { + switch self { + case .text: return String(localized: "Text") + case .url: return String(localized: "URL") + case .file: return String(localized: "File") + case .image: return String(localized: "Image") + case .rtf: return String(localized: "Rich Text") + case .unknown: return String(localized: "Unknown") + } + } +} + +class ClipboardManager: ObservableObject { + static let shared = ClipboardManager() + + @Published var clipboardHistory: [ClipboardItem] = [] + @Published var pinnedItems: [ClipboardItem] = [] + @Published var isMonitoring: Bool = false + @Published private(set) var lastCopiedItemDate: Date? + + private var timer: Timer? + private var lastChangeCount: Int = 0 + + // Use configurable history size from settings + private var maxHistoryItems: Int { + return Defaults[.clipboardHistorySize] + } + + // Computed properties for filtered lists + var regularHistory: [ClipboardItem] { + clipboardHistory.filter { !$0.isPinned } + } + + var pinnedHistory: [ClipboardItem] { + pinnedItems + } + + // Directory for storing clipboard data files + static let clipboardDataDirectory: URL = { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let clipboardDir = documentsPath.appendingPathComponent("ClipboardData") + + // Create directory if it doesn't exist + try? FileManager.default.createDirectory(at: clipboardDir, withIntermediateDirectories: true) + + return clipboardDir + }() + + private init() { + lastChangeCount = NSPasteboard.general.changeCount + loadHistoryFromDefaults() + cleanupOldFiles() + } + + deinit { + stopMonitoring() + } + + // MARK: - Public Methods + + func startMonitoring() { + guard !isMonitoring else { return } + + isMonitoring = true + timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.checkClipboard() + } + } + + func stopMonitoring() { + isMonitoring = false + timer?.invalidate() + timer = nil + } + + func copyToClipboard(_ item: ClipboardItem) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + + switch item.type { + case .text, .url: + if let stringData = item.stringData { + pasteboard.setString(stringData, forType: .string) + } + case .image: + if let imageData = item.getImageData() { + pasteboard.setData(imageData, forType: .png) + } + case .file: + if let fileURLs = item.fileURLs { + let urls = fileURLs.compactMap { URL(string: $0) } + pasteboard.writeObjects(urls as [NSPasteboardWriting]) + } + case .rtf: + if let rtfData = item.rtfData { + pasteboard.setData(rtfData, forType: .rtf) + } + // Also set plain text as fallback + if let stringData = item.stringData { + pasteboard.setString(stringData, forType: .string) + } + case .unknown: + if let stringData = item.stringData { + pasteboard.setString(stringData, forType: .string) + } + } + } + + func deleteItem(_ item: ClipboardItem) { + // Clean up associated files + if let fileName = item.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + + clipboardHistory.removeAll { $0.id == item.id } + saveHistoryToDefaults() + } + + func clearHistory() { + // Clean up all associated files + for item in clipboardHistory { + if let fileName = item.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + + clipboardHistory.removeAll() + saveHistoryToDefaults() + } + + func pinItem(_ item: ClipboardItem) { + // Update the item to be pinned + var pinnedItem = item + pinnedItem.isPinned = true + + // Remove from regular history if it exists there + clipboardHistory.removeAll { $0.id == item.id } + + // Add to pinned items if not already there + if !pinnedItems.contains(where: { $0.id == item.id }) { + pinnedItems.append(pinnedItem) + } + + saveHistoryToDefaults() + savePinnedItemsToDefaults() + } + + func unpinItem(_ item: ClipboardItem) { + // Remove from pinned items + pinnedItems.removeAll { $0.id == item.id } + + // Update the item to be unpinned and add back to regular history + var unpinnedItem = item + unpinnedItem.isPinned = false + + // Add back to regular history at the top + clipboardHistory.insert(unpinnedItem, at: 0) + + // Maintain history size limit + if clipboardHistory.count > maxHistoryItems { + let itemsToDelete = Array(clipboardHistory.dropFirst(maxHistoryItems)) + for oldItem in itemsToDelete { + if let fileName = oldItem.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + clipboardHistory = Array(clipboardHistory.prefix(maxHistoryItems)) + } + + saveHistoryToDefaults() + savePinnedItemsToDefaults() + } + + func togglePin(for item: ClipboardItem) { + if item.isPinned || pinnedItems.contains(where: { $0.id == item.id }) { + unpinItem(item) + } else { + pinItem(item) + } + } + + // MARK: - Private Methods + + private func checkClipboard() { + let currentChangeCount = NSPasteboard.general.changeCount + + guard currentChangeCount != lastChangeCount else { return } + lastChangeCount = currentChangeCount + + guard let clipboardItem = getCurrentClipboardItem() else { return } + + // Don't add duplicate items + if !clipboardHistory.contains(where: { isSameContent($0, clipboardItem) }) { + addToHistory(clipboardItem) + } + } + + private func getCurrentClipboardItem() -> ClipboardItem? { + let pasteboard = NSPasteboard.general + + // Step 1: Check what types are available + let hasFileURLs = pasteboard.canReadObject(forClasses: [NSURL.self], options: nil) + let hasImageData = pasteboard.data(forType: .png) != nil || + pasteboard.data(forType: .tiff) != nil || + pasteboard.data(forType: NSPasteboard.PasteboardType("public.jpeg")) != nil + let hasString = pasteboard.string(forType: .string) != nil + + // Step 2: Smart detection based on context + + // Priority 1: If there are file URLs AND the files are actual image files, treat as image files (from Finder) + if hasFileURLs { + if let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] { + let realFileURLs = fileURLs.filter { url in + return url.isFileURL && + !url.path.contains("/.file/id=") && + !url.path.contains("/tmp/") && + !url.path.hasPrefix("/private/var/") && + !url.path.contains("/ClipboardViewer") && + FileManager.default.fileExists(atPath: url.path) + } + + if !realFileURLs.isEmpty { + // Check if these are image files - if so, try to load the actual image data + let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif"] + let imageFiles = realFileURLs.filter { url in + imageExtensions.contains(url.pathExtension.lowercased()) + } + + // If we have image files from Finder, load the actual image data + if !imageFiles.isEmpty, let firstImageURL = imageFiles.first { + if let imageData = try? Data(contentsOf: firstImageURL) { + return ClipboardItem(imageData: imageData) + } + } + + // Otherwise, treat as file(s) + let urlStrings = realFileURLs.map { $0.absoluteString } + return ClipboardItem(fileURLs: urlStrings) + } + } + } + + // Priority 2: If there's ONLY image data without file URLs (screenshots, direct image paste) + if hasImageData && !hasFileURLs { + if let imageData = pasteboard.data(forType: .png) { + return ClipboardItem(imageData: imageData) + } else if let imageData = pasteboard.data(forType: .tiff) { + return ClipboardItem(imageData: imageData) + } else if let imageData = pasteboard.data(forType: NSPasteboard.PasteboardType("public.jpeg")) { + return ClipboardItem(imageData: imageData) + } + } + + // Priority 3: Plain text (including copied text) + if let string = pasteboard.string(forType: .string), !string.isEmpty { + // Determine if it's a URL + if string.hasPrefix("http://") || string.hasPrefix("https://") { + return ClipboardItem(stringData: string, type: .url) + } + return ClipboardItem(stringData: string, type: .text) + } + + // Priority 4: RTF + if let rtfData = pasteboard.data(forType: .rtf), + let rtfString = NSAttributedString(rtf: rtfData, documentAttributes: nil)?.string, !rtfString.isEmpty { + return ClipboardItem(rtfData: rtfData, plainText: rtfString) + } + + // Priority 5: If we have image data WITH file URLs (document thumbnails), + // this is likely a document with a preview - ignore the thumbnail and treat as unknown + if hasImageData && hasFileURLs { + // This is likely a document with a thumbnail preview - we don't want the thumbnail + return nil + } + + // Priority 6: URL strings + if let url = pasteboard.string(forType: .URL) { + return ClipboardItem(stringData: url, type: .url) + } + + return nil + } + + private func addToHistory(_ item: ClipboardItem) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // Remove any existing items with the same data + let itemsToRemove = self.clipboardHistory.filter { existingItem in + return self.isSameContent(existingItem, item) + } + + // Clean up files for items being removed + for oldItem in itemsToRemove { + if let fileName = oldItem.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + + self.clipboardHistory.removeAll { existingItem in + return self.isSameContent(existingItem, item) + } + + // Add to beginning of array + self.clipboardHistory.insert(item, at: 0) + self.lastCopiedItemDate = item.timestamp + + // Keep only the most recent items and clean up old files + let itemsToDelete = Array(self.clipboardHistory.dropFirst(self.maxHistoryItems)) + for oldItem in itemsToDelete { + if let fileName = oldItem.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + + if self.clipboardHistory.count > self.maxHistoryItems { + self.clipboardHistory = Array(self.clipboardHistory.prefix(self.maxHistoryItems)) + } + + self.saveHistoryToDefaults() + } + } + + // Helper to compare clipboard items for duplicates + private func isSameContent(_ item1: ClipboardItem, _ item2: ClipboardItem) -> Bool { + if item1.type != item2.type { return false } + + switch item1.type { + case .text, .url, .unknown: + return item1.stringData == item2.stringData + case .image: + // For images, compare the actual data if both are available + let data1 = item1.getImageData() + let data2 = item2.getImageData() + return data1 == data2 + case .file: + return item1.fileURLs == item2.fileURLs + case .rtf: + return item1.stringData == item2.stringData && item1.rtfData == item2.rtfData + } + } + + // Clean up old image files that are no longer referenced + private func cleanupOldFiles() { + guard let files = try? FileManager.default.contentsOfDirectory(at: ClipboardManager.clipboardDataDirectory, includingPropertiesForKeys: nil) else { return } + + let referencedFiles = Set(clipboardHistory.compactMap { $0.imageFileName }) + + for file in files { + let fileName = file.lastPathComponent + if !referencedFiles.contains(fileName) { + try? FileManager.default.removeItem(at: file) + } + } + } + + // MARK: - Persistence + + private func saveHistoryToDefaults() { + if let encoded = try? JSONEncoder().encode(clipboardHistory) { + UserDefaults.standard.set(encoded, forKey: "ClipboardHistory") + } + } + + func savePinnedItemsToDefaults() { + if let encoded = try? JSONEncoder().encode(pinnedItems) { + UserDefaults.standard.set(encoded, forKey: "ClipboardPinnedItems") + } + } + + private func loadHistoryFromDefaults() { + if let data = UserDefaults.standard.data(forKey: "ClipboardHistory"), + let history = try? JSONDecoder().decode([ClipboardItem].self, from: data) { + clipboardHistory = history + } + + if let data = UserDefaults.standard.data(forKey: "ClipboardPinnedItems"), + let pinned = try? JSONDecoder().decode([ClipboardItem].self, from: data) { + pinnedItems = pinned + } + } +} diff --git a/DynamicIsland/managers/ClipboardPanelManager.swift b/DynamicIsland/managers/ClipboardPanelManager.swift new file mode 100644 index 00000000..434d961f --- /dev/null +++ b/DynamicIsland/managers/ClipboardPanelManager.swift @@ -0,0 +1,66 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI + +class ClipboardPanelManager: ObservableObject { + static let shared = ClipboardPanelManager() + + private var clipboardPanel: ClipboardPanel? + + private init() {} + + func showClipboardPanel() { + hideClipboardPanel() // Close any existing panel + + let panel = ClipboardPanel() + panel.positionNearNotch() + + self.clipboardPanel = panel + + // Make the panel key and order front to ensure it can receive focus + panel.makeKeyAndOrderFront(nil) + panel.orderFrontRegardless() + + // Activate the app to ensure proper focus handling + NSApp.activate(ignoringOtherApps: true) + + // Ensure the panel becomes the key window for text input + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + panel.makeKey() + } + } + + func hideClipboardPanel() { + clipboardPanel?.close() + clipboardPanel = nil + } + + func toggleClipboardPanel() { + if let panel = clipboardPanel, panel.isVisible { + hideClipboardPanel() + } else { + showClipboardPanel() + } + } + + var isPanelVisible: Bool { + return clipboardPanel?.isVisible ?? false + } +} diff --git a/DynamicIsland/managers/ClipboardWindowManager.swift b/DynamicIsland/managers/ClipboardWindowManager.swift new file mode 100644 index 00000000..492ff74c --- /dev/null +++ b/DynamicIsland/managers/ClipboardWindowManager.swift @@ -0,0 +1,118 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import AppKit + +class ClipboardWindowManager: ObservableObject { + static let shared = ClipboardWindowManager() + + private var clipboardWindow: NSWindow? + + private init() {} + + func showClipboardWindow() { + if let existingWindow = clipboardWindow { + // Ensure window appears above fullscreen apps + existingWindow.level = .screenSaver + existingWindow.makeKeyAndOrderFront(nil) + existingWindow.orderFrontRegardless() // Force window to front even above fullscreen + NSApp.activate(ignoringOtherApps: true) + return + } + + // Create the window + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + + window.title = "Clipboard Manager" + window.isMovableByWindowBackground = true + window.level = .screenSaver // Use screenSaver level to appear above fullscreen apps + window.isReleasedWhenClosed = false + window.hasShadow = true + window.titlebarAppearsTransparent = true + window.styleMask.insert(.fullSizeContentView) + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] // Allow on all spaces and above fullscreen + + // Set minimum and maximum sizes + window.minSize = NSSize(width: 350, height: 250) + window.maxSize = NSSize(width: 600, height: 500) + + // Center the window on the current screen (important for fullscreen apps) + let currentScreen = NSScreen.main ?? NSScreen.screens.first! + let screenFrame = currentScreen.frame // Use full frame instead of visibleFrame for fullscreen compatibility + let windowFrame = window.frame + let x = (screenFrame.width - windowFrame.width) / 2 + screenFrame.minX + let y = (screenFrame.height - windowFrame.height) / 2 + screenFrame.minY + window.setFrameOrigin(NSPoint(x: x, y: y)) + + // Set the content view + let contentView = ClipboardWindow() + let hostingView = NSHostingView(rootView: contentView) + window.contentView = hostingView + + // Handle window closing + window.delegate = WindowDelegate { [weak self] window in + ScreenCaptureVisibilityManager.shared.unregister(window) + self?.clipboardWindow = nil + } + + ScreenCaptureVisibilityManager.shared.register(window, scope: .panelsOnly) + + self.clipboardWindow = window + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() // Force window to front even above fullscreen apps + NSApp.activate(ignoringOtherApps: true) + } + + func hideClipboardWindow() { + clipboardWindow?.close() + } + + func toggleClipboardWindow() { + if let window = clipboardWindow, window.isVisible { + hideClipboardWindow() + } else { + showClipboardWindow() + } + } + + var isWindowVisible: Bool { + return clipboardWindow?.isVisible ?? false + } +} + +private class WindowDelegate: NSObject, NSWindowDelegate { + private let onClose: (NSWindow) -> Void + + init(onClose: @escaping (NSWindow) -> Void) { + self.onClose = onClose + super.init() + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + // Hide instead of close when user clicks close button + sender.orderOut(nil) + onClose(sender) + return false + } +} diff --git a/DynamicIsland/managers/ColorPickerManager.swift b/DynamicIsland/managers/ColorPickerManager.swift new file mode 100644 index 00000000..6e3711e1 --- /dev/null +++ b/DynamicIsland/managers/ColorPickerManager.swift @@ -0,0 +1,210 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Cocoa +import CoreGraphics +import Defaults + +// MARK: - NSColor Extensions from Solid +extension NSColor { + var hexString: String { + let red = Int((redComponent * 255).rounded()) + let green = Int((greenComponent * 255).rounded()) + let blue = Int((blueComponent * 255).rounded()) + let alpha = Int((alphaComponent * 255).rounded()) + + if alpha == 255 { + return String(format: "%02x%02x%02x", red, green, blue) + } else { + return String(format: "%02x%02x%02x%02x", red, green, blue, alpha) + } + } +} + +// MARK: - Color Formatter from Solid +struct ColorFormatter { + static let shared = ColorFormatter() + + func hex( + color: NSColor, + includeHashPrefix: Bool = true, + lowerCaseHex: Bool = false + ) -> String { + let prefix = includeHashPrefix ? "#" : "" + let hex = (prefix + color.hexString) + + if lowerCaseHex { + return hex + } else { + return hex.uppercased() + } + } +} + +class ColorPickerManager: ObservableObject { + static let shared = ColorPickerManager() + + @Published var colorHistory: [PickedColor] = [] + @Published var isPickingColor: Bool = false + @Published var isShowingPanel: Bool = false + @Published var showColorPickedFeedback: Bool = false + @Published var lastPickedColor: PickedColor? + + // Use NSColorSampler from Solid project - much more reliable + private let colorSampler = NSColorSampler() + + private init() { + loadColorHistory() + } + + // MARK: - Public Interface + + func toggleColorPicker() { + if isPickingColor { + stopColorPicking() + } else { + startColorPicking() + } + } + + func startColorPicking() { + guard !isPickingColor else { return } + + print("ColorPicker: Starting color picking with NSColorSampler...") + + isPickingColor = true + isShowingPanel = false + showColorPickedFeedback = false + + // Use NSColorSampler like Solid project - much cleaner and more reliable + colorSampler.show { [weak self] pickedColor in + guard let self = self else { return } + + DispatchQueue.main.async { + self.isPickingColor = false + + if let color = pickedColor { + print("ColorPicker: Successfully picked color - R:\(color.redComponent) G:\(color.greenComponent) B:\(color.blueComponent)") + + // Convert to sRGB for consistent color handling + if let sRGBColor = color.usingColorSpace(.sRGB) { + let pickedColor = PickedColor(nsColor: sRGBColor, point: NSEvent.mouseLocation) + self.addColor(pickedColor) + self.showColorPickedFeedback(for: pickedColor) + + // Provide haptic feedback if enabled + if Defaults[.enableHaptics] { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } + } + } else { + print("ColorPicker: Color picking cancelled by user") + } + } + } + } + + func stopColorPicking() { + print("ColorPicker: Stopping color picking...") + isPickingColor = false + } + + func togglePanel() { + isShowingPanel.toggle() + } + + // MARK: - Color Feedback + + private func showColorPickedFeedback(for color: PickedColor) { + print("ColorPicker: Showing color picked feedback for \(color.hexString)") + lastPickedColor = color + showColorPickedFeedback = true + + // Reopen the ColorPicker panel after color is picked + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + ColorPickerPanelManager.shared.showColorPickerPanel() + } + + // Hide feedback after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + print("ColorPicker: Hiding color picked feedback") + self.showColorPickedFeedback = false + } + } + + // MARK: - Color History Management + + func addColor(_ color: PickedColor) { + // Remove if already exists to avoid duplicates + colorHistory.removeAll { $0.id == color.id } + + // Add to beginning of list + colorHistory.insert(color, at: 0) + + // Limit history size + if colorHistory.count > 50 { + colorHistory = Array(colorHistory.prefix(50)) + } + + saveColorHistory() + + // Always copy to clipboard for now (can be made configurable later if needed) + copyToClipboard(color.hexString) + } + + func removeColor(_ color: PickedColor) { + colorHistory.removeAll { $0.id == color.id } + saveColorHistory() + } + + func clearHistory() { + colorHistory.removeAll() + saveColorHistory() + } + + func copyToClipboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + + // Always show confirmation in console for now + print("Copied to clipboard: \(text)") + } + + func copyColorToClipboard(_ color: PickedColor, format: ColorFormat) { + copyToClipboard(format.copyValue) + } + + // MARK: - Persistence + + private func saveColorHistory() { + if let encoded = try? JSONEncoder().encode(colorHistory) { + UserDefaults.standard.set(encoded, forKey: "ColorPickerHistory") + } + } + + private func loadColorHistory() { + guard let data = UserDefaults.standard.data(forKey: "ColorPickerHistory"), + let decoded = try? JSONDecoder().decode([PickedColor].self, from: data) else { + return + } + + colorHistory = decoded + } +} diff --git a/DynamicIsland/managers/ColorPickerManager_Fixed.swift b/DynamicIsland/managers/ColorPickerManager_Fixed.swift new file mode 100644 index 00000000..9553cde4 --- /dev/null +++ b/DynamicIsland/managers/ColorPickerManager_Fixed.swift @@ -0,0 +1,18 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + diff --git a/DynamicIsland/managers/CustomOSDWindowManager.swift b/DynamicIsland/managers/CustomOSDWindowManager.swift new file mode 100644 index 00000000..ee808c86 --- /dev/null +++ b/DynamicIsland/managers/CustomOSDWindowManager.swift @@ -0,0 +1,282 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#if os(macOS) +import AppKit +import SwiftUI +import SkyLightWindow +import QuartzCore +import Defaults + +/// Manages custom OSD windows for volume, brightness, and keyboard backlight controls +/// Mimics macOS native OSD behavior with custom styling +@MainActor +final class CustomOSDWindowManager { + static let shared = CustomOSDWindowManager() + + private var volumeWindows: [NSScreen: OSDWindow] = [:] + private var brightnessWindows: [NSScreen: OSDWindow] = [:] + private var backlightWindows: [NSScreen: OSDWindow] = [:] + + private var hideWorkItem: DispatchWorkItem? + private let displayDuration: TimeInterval = 2.0 + private let animationDuration: TimeInterval = 0.3 + private var isInitialized = false + + // Standard macOS OSD dimensions (approximate) + private let osdWidth: CGFloat = 200 + private let osdHeight: CGFloat = 200 + + private init() {} + + // MARK: - Public API + + func showVolume(value: CGFloat, isMuted: Bool = false, icon: String = "", onScreen targetScreen: NSScreen? = nil) { + guard Defaults[.enableCustomOSD], isInitialized else { return } + show(type: .volume, value: value, icon: icon, onScreen: targetScreen) + } + + func showBrightness(value: CGFloat, icon: String = "", onScreen targetScreen: NSScreen? = nil) { + guard Defaults[.enableCustomOSD], isInitialized else { return } + show(type: .brightness, value: value, icon: icon, onScreen: targetScreen) + } + + func showBacklight(value: CGFloat) { + guard Defaults[.enableCustomOSD], isInitialized else { return } + show(type: .backlight, value: value, icon: "") + } + + func initialize() { + isInitialized = true + } + + // MARK: - Private Implementation + + private func show(type: SneakContentType, value: CGFloat, icon: String, onScreen targetScreen: NSScreen? = nil) { + let screens = targetScreen.map { [$0] } ?? NSScreen.screens + guard !screens.isEmpty else { return } + + // Close other windows first + hideAllWindowsExcept(type: type) + + // Show on target screen(s) + for screen in screens { + let window = ensureWindow(for: type, screen: screen) + updateContent(window: window, type: type, value: value, icon: icon) + + let targetFrame = calculateFrame(for: screen) + + if window.nsWindow.alphaValue <= 0.01 { + // Initial presentation + presentWindow(window, targetFrame: targetFrame) + } else { + // Update existing window + window.nsWindow.setFrame(targetFrame, display: true) + window.nsWindow.orderFrontRegardless() + } + } + + scheduleHide(for: type) + } + + private func ensureWindow(for type: SneakContentType, screen: NSScreen) -> OSDWindow { + switch type { + case .volume: + if let existing = volumeWindows[screen] { + return existing + } + let window = createWindow(for: type, screen: screen) + volumeWindows[screen] = window + return window + case .brightness: + if let existing = brightnessWindows[screen] { + return existing + } + let window = createWindow(for: type, screen: screen) + brightnessWindows[screen] = window + return window + case .backlight: + if let existing = backlightWindows[screen] { + return existing + } + let window = createWindow(for: type, screen: screen) + backlightWindows[screen] = window + return window + default: + fatalError("Unsupported OSD type: \(type)") + } + } + + private func createWindow(for type: SneakContentType, screen: NSScreen) -> OSDWindow { + let osdView = CustomOSDView(type: .constant(type), value: .constant(0), icon: .constant("")) + let hostingView = NSHostingView(rootView: osdView) + + let frame = calculateFrame(for: screen) + let window = NSWindow( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + window.isOpaque = false + window.backgroundColor = .clear + window.level = .statusBar + 1 + window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] + window.hasShadow = false + window.contentView = hostingView + window.alphaValue = 0 + + // Delegate to SkyLight for proper rendering + SkyLightOperator.shared.delegateWindow(window) + + return OSDWindow(nsWindow: window, hostingView: hostingView, type: type) + } + + private func updateContent(window: OSDWindow, type: SneakContentType, value: CGFloat, icon: String) { + let osdView = CustomOSDView(type: .constant(type), value: .constant(value), icon: .constant(icon)) + window.hostingView.rootView = osdView + } + + private func calculateFrame(for screen: NSScreen) -> NSRect { + let screenFrame = screen.frame + let centerX = screenFrame.midX - (osdWidth / 2) + // Position at bottom center, similar to native macOS OSD + let bottomOffset: CGFloat = 120 // Distance from bottom of screen + let centerY = screenFrame.minY + bottomOffset + + return NSRect(x: centerX, y: centerY, width: osdWidth, height: osdHeight) + } + + private func presentWindow(_ window: OSDWindow, targetFrame: NSRect) { + let startFrame = targetFrame.offsetBy(dx: 0, dy: -20) + window.nsWindow.setFrame(startFrame, display: true) + window.nsWindow.alphaValue = 0 + window.nsWindow.orderFrontRegardless() + + NSAnimationContext.runAnimationGroup { context in + context.duration = animationDuration + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.nsWindow.animator().setFrame(targetFrame, display: true) + window.nsWindow.animator().alphaValue = 1 + } + } + + private func hideAllWindowsExcept(type: SneakContentType) { + if type != .volume { + for window in volumeWindows.values where window.nsWindow.alphaValue > 0.01 { + hideWindowImmediately(window) + } + } + if type != .brightness { + for window in brightnessWindows.values where window.nsWindow.alphaValue > 0.01 { + hideWindowImmediately(window) + } + } + if type != .backlight { + for window in backlightWindows.values where window.nsWindow.alphaValue > 0.01 { + hideWindowImmediately(window) + } + } + } + + private func hideWindowImmediately(_ window: OSDWindow) { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.15 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + window.nsWindow.animator().alphaValue = 0 + } completionHandler: { + window.nsWindow.orderOut(nil) + } + } + + private func scheduleHide(for type: SneakContentType) { + hideWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + Task { @MainActor in + self.hideWindow(for: type) + } + } + + hideWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + displayDuration, execute: workItem) + } + + private func hideWindow(for type: SneakContentType) { + let windows: [OSDWindow] + + switch type { + case .volume: + windows = Array(volumeWindows.values) + case .brightness: + windows = Array(brightnessWindows.values) + case .backlight: + windows = Array(backlightWindows.values) + default: + return + } + + for window in windows { + + let currentFrame = window.nsWindow.frame + let hideFrame = currentFrame.offsetBy(dx: 0, dy: -20) + + NSAnimationContext.runAnimationGroup { context in + context.duration = animationDuration + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + window.nsWindow.animator().setFrame(hideFrame, display: true) + window.nsWindow.animator().alphaValue = 0 + } completionHandler: { + window.nsWindow.orderOut(nil) + } + } + } + + // MARK: - Cleanup + + func tearDown() { + hideWorkItem?.cancel() + hideWorkItem = nil + + for window in volumeWindows.values { + window.nsWindow.orderOut(nil) + } + for window in brightnessWindows.values { + window.nsWindow.orderOut(nil) + } + for window in backlightWindows.values { + window.nsWindow.orderOut(nil) + } + + volumeWindows.removeAll() + brightnessWindows.removeAll() + backlightWindows.removeAll() + } +} + +// MARK: - OSD Window Container + +private struct OSDWindow { + let nsWindow: NSWindow + let hostingView: NSHostingView + let type: SneakContentType +} + +#endif diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift new file mode 100644 index 00000000..1bb8a1b8 --- /dev/null +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -0,0 +1,1514 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Combine +import Defaults +import Foundation +import SwiftUI + +final class DoNotDisturbManager: ObservableObject { + static let shared = DoNotDisturbManager() + + @Published private(set) var isMonitoring = false + @Published var isDoNotDisturbActive = false + @Published var currentFocusModeName: String = "" + @Published var currentFocusModeIdentifier: String = "" + + // Used by the brief-toast UI to show an ON toast when the active mode switches while Focus stays enabled. + @Published var focusToastTrigger: UUID = UUID() + + /// Briefly `true` after focus turns OFF while toast mode is enabled, + /// keeping the standalone DoNotDisturbLiveActivity mounted long enough + /// for the "Off" dismissal animation to play out. + @Published private(set) var isFocusToastDismissing: Bool = false + + private let notificationCenter = DistributedNotificationCenter.default() + private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated) + private let pollingQueue = DispatchQueue(label: "com.dynamicisland.focus.polling", qos: .utility) + private let focusLogStream = FocusLogStream() + private let assertionsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/DoNotDisturb/DB/Assertions.json") + private var pollingSource: DispatchSourceTimer? + private var lastAssertionsModificationDate: Date? + private var modeCancellable: AnyCancellable? + /// Periodic task that verifies focus is still active when `isDoNotDisturbActive` is true. + /// Catches cases where the disabled notification fails to fire. + private var stateVerificationTask: Task? + + @Published private(set) var monitoringMode: FocusMonitoringMode = Defaults[.focusMonitoringMode] + + /// Timestamp of the last notification-driven state change. + /// Used to suppress assertions polling from overriding a fresh notification for a short window. + private var lastNotificationTimestamp: Date = .distantPast + private static let notificationCooldown: TimeInterval = 4.0 + + /// Delayed task that clears retained metadata after the OFF animation completes. + private var metadataClearTask: Task? + + /// Task that resets `isFocusToastDismissing` after the OFF toast animation. + private var toastDismissTask: Task? + + private init() { + focusLogStream.onMetadataUpdate = { [weak self] identifier, name in + self?.handleLogMetadataUpdate(identifier: identifier, name: name) + } + + modeCancellable = Defaults.publisher(.focusMonitoringMode, options: []) + .sink { [weak self] change in + guard let self else { return } + + DispatchQueue.main.async { + self.monitoringMode = change.newValue + } + + self.applyMonitoringModeChange(change.newValue) + } + } + + deinit { + stopMonitoring() + } + + func startMonitoring() { + guard !isMonitoring else { return } + + notificationCenter.addObserver( + self, + selector: #selector(handleFocusEnabled(_:)), + name: .focusModeEnabled, + object: nil, + suspensionBehavior: .deliverImmediately + ) + + notificationCenter.addObserver( + self, + selector: #selector(handleFocusDisabled(_:)), + name: .focusModeDisabled, + object: nil, + suspensionBehavior: .deliverImmediately + ) + + isMonitoring = true + applyMonitoringModeChange(Defaults[.focusMonitoringMode]) + } + + func stopMonitoring() { + guard isMonitoring else { return } + + notificationCenter.removeObserver(self, name: .focusModeEnabled, object: nil) + notificationCenter.removeObserver(self, name: .focusModeDisabled, object: nil) + + focusLogStream.stop() + stopAssertionsPolling() + stateVerificationTask?.cancel() + stateVerificationTask = nil + metadataClearTask?.cancel() + metadataClearTask = nil + cancelFocusToastDismiss() + isMonitoring = false + + DispatchQueue.main.async { + self.isDoNotDisturbActive = false + self.currentFocusModeIdentifier = "" + self.currentFocusModeName = "" + } + } + + @objc private func handleFocusEnabled(_ notification: Notification) { + lastNotificationTimestamp = Date() + apply(notification: notification, isActive: true) + } + + @objc private func handleFocusDisabled(_ notification: Notification) { + lastNotificationTimestamp = Date() + apply(notification: notification, isActive: false) + } + + private func apply(notification: Notification, isActive: Bool) { + metadataExtractionQueue.async { [weak self] in + guard let self = self else { return } + + let metadata = self.extractMetadata(from: notification) + self.publishMetadata(identifier: metadata.identifier, name: metadata.name, isActive: isActive, source: notification.name.rawValue) + } + } + + private func publishMetadata(identifier: String?, name: String?, isActive: Bool?, source: String) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) + + let isGenericFocusIdentifier = (trimmedIdentifier?.lowercased() == "com.apple.focus") + let usableIdentifier = (trimmedIdentifier?.isEmpty == false && !isGenericFocusIdentifier) ? trimmedIdentifier : nil + let usableName = (trimmedName?.isEmpty == false) ? trimmedName : nil + + let previousIdentifier = self.currentFocusModeIdentifier + let previousName = self.currentFocusModeName + let previousActive = self.isDoNotDisturbActive + + // When neither identifier nor name is available, only update the active + // state without overwriting existing mode metadata. This prevents the + // assertions-poll (which often lacks metadata) from reverting a correct + // mode set by the log-stream or notification handler. + if usableIdentifier == nil && usableName == nil { + if let isActive = isActive, isActive != previousActive { + withAnimation(.smooth(duration: 0.25)) { + self.isDoNotDisturbActive = isActive + } + // First activation with no prior mode: default to DND. + if isActive && previousIdentifier.isEmpty { + self.currentFocusModeIdentifier = FocusModeType.doNotDisturb.rawValue + self.currentFocusModeName = FocusModeType.doNotDisturb.displayName + } + debugPrint("[DoNotDisturbManager] Focus active-only update -> source: \(source) | isActive: \(isActive)") + } + return + } + + let resolvedMode = FocusModeType.resolve(identifier: usableIdentifier, name: usableName) + + let finalIdentifier: String = usableIdentifier ?? resolvedMode.rawValue + + // Compute display name + let finalName: String + if resolvedMode == .custom, !FullDiskAccessAuthorization.hasPermission() { + // Use the provided name if it looks like a real display name (not an + // icon slug like "graduationcap.fill"). Slugs always contain a dot. + if let tn = trimmedName, !tn.isEmpty, !tn.contains(".") { + finalName = tn + } else { + finalName = "Focus" + } + } else if resolvedMode == .custom, FullDiskAccessAuthorization.hasPermission() { + // With FDA, prefer the real name from ModeConfigurations.json (fixes slug names like graduationcap.fill). + let lookedUp = FocusMetadataReader.shared.getDisplayName(for: trimmedName ?? "", identifier: finalIdentifier) + if !lookedUp.isEmpty { + finalName = lookedUp + } else if let tn = trimmedName, !tn.isEmpty, !tn.contains(".") { + finalName = tn + } else { + finalName = "Focus" + } + } else if let name = trimmedName, !name.isEmpty { + let lower = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch lower { + case "work": + finalName = "Work" + case "personal", "personal-time": + finalName = "Personal" + case "reduce-interruptions": + finalName = "Reduce Interruptions" + case "sleep", "sleep-mode": + finalName = "Sleep" + case "driving": + finalName = "Driving" + case "default", "dnd", "do-not-disturb", "do not disturb", "donotdisturb": + finalName = "Do Not Disturb" + default: + finalName = name + } + } else if !resolvedMode.displayName.isEmpty { + finalName = resolvedMode.displayName + } else { + finalName = "Focus" + } + + let identifierChanged = finalIdentifier != previousIdentifier + let nameChanged = finalName != previousName + let shouldToggleActive = isActive.map { $0 != previousActive } ?? false + + if identifierChanged { + self.currentFocusModeIdentifier = finalIdentifier + } + + if nameChanged { + self.currentFocusModeName = finalName.localizedCaseInsensitiveContains("Reduce Interruptions") ? "Reduce Interr." : finalName + } + + // If Focus remains active and the mode switches (e.g., DND -> Sleep), trigger an ON toast for the new mode. + if isActive == nil, previousActive == true, identifierChanged { + self.focusToastTrigger = UUID() + } + + if identifierChanged || nameChanged || shouldToggleActive { + debugPrint("[DoNotDisturbManager] Focus update -> source: \(source) | identifier: \(trimmedIdentifier ?? "") | name: \(trimmedName ?? "") | resolved: \(resolvedMode.rawValue)") + } + + guard let isActive = isActive, shouldToggleActive else { return } + + withAnimation(.smooth(duration: 0.25)) { + self.isDoNotDisturbActive = isActive + } + + // If Focus turned OFF, retain metadata briefly for the OFF toast, + // then clear it so stale state doesn't linger. + if isActive == false { + self.currentFocusModeIdentifier = previousIdentifier + self.currentFocusModeName = previousName + self.scheduleMetadataClear() + self.beginFocusToastDismissIfNeeded() + } else { + // Focus turned ON — cancel any pending metadata clear and toast dismiss. + self.metadataClearTask?.cancel() + self.metadataClearTask = nil + self.cancelFocusToastDismiss() + } + + // Start or stop periodic verification based on new state. + self.updateStateVerification(focusActive: isActive) + } + } + + /// When focus is believed to be active, periodically verify the assertions + /// file. If the file shows no active assertions, reset `isDoNotDisturbActive` + /// to `false`. This catches the case where `_NSDoNotDisturbDisabledNotification` + /// fails to fire. + private func updateStateVerification(focusActive: Bool) { + stateVerificationTask?.cancel() + stateVerificationTask = nil + + guard focusActive else { return } + + stateVerificationTask = Task { @MainActor [weak self] in + // Wait a bit before starting verification to let notifications settle. + try? await Task.sleep(for: .seconds(5)) + + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(3)) + guard let self, self.isDoNotDisturbActive else { break } + + // Check the assertions file directly (if FDA is available). + let focusStillActive = await self.verifyFocusActiveFromAssertions() + if !focusStillActive { + withAnimation(.smooth(duration: 0.25)) { + self.isDoNotDisturbActive = false + } + self.scheduleMetadataClear() + self.beginFocusToastDismissIfNeeded() + debugPrint("[DoNotDisturbManager] State verification: focus no longer active, resetting.") + break + } + } + } + } + + /// Read the assertions file directly to verify whether focus is truly active. + /// Returns `true` if the file confirms active assertions, or if the check + /// cannot be performed (e.g. no FDA) — erring on the side of not falsely resetting. + private nonisolated func verifyFocusActiveFromAssertions() async -> Bool { + guard FullDiskAccessAuthorization.hasPermission() else { + // Can't verify without FDA — conservatively assume still active. + return true + } + + let url = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/DoNotDisturb/DB/Assertions.json") + + guard let data = try? Data(contentsOf: url), + !data.isEmpty, + let root = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any], + let dataArray = root["data"] as? [[String: Any]], + let firstItem = dataArray.first else { + // File unreadable/empty — assume focus is off. + return false + } + + let assertions = (firstItem["storeAssertionRecords"] as? [Any]) ?? [] + return !assertions.isEmpty + } + + private func scheduleMetadataClear() { + metadataClearTask?.cancel() + metadataClearTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(2)) + guard let self, !self.isDoNotDisturbActive else { return } + self.currentFocusModeIdentifier = "" + self.currentFocusModeName = "" + } + } + + /// Sets `isFocusToastDismissing` to `true` for ~2.5 seconds when focus turns + /// OFF while toast mode is enabled. This keeps the standalone DND live activity + /// mounted long enough for the "Off" toast animation, without permanently + /// occupying the live-activity slot. + private func beginFocusToastDismissIfNeeded() { + guard Defaults[.focusIndicatorNonPersistent] else { return } + + toastDismissTask?.cancel() + isFocusToastDismissing = true + + toastDismissTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(2500)) + guard !Task.isCancelled, let self else { return } + withAnimation(.smooth(duration: 0.25)) { + self.isFocusToastDismissing = false + } + } + } + + /// Cancels any in-progress toast dismiss countdown (e.g. when focus turns back ON). + private func cancelFocusToastDismiss() { + toastDismissTask?.cancel() + toastDismissTask = nil + isFocusToastDismissing = false + } + + private func handleLogMetadataUpdate(identifier: String?, name: String?) { + metadataExtractionQueue.async { [weak self] in + guard let self = self else { return } + let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) + + let hasIdentifier = (trimmedIdentifier?.isEmpty == false) + let hasName = (trimmedName?.isEmpty == false) + + guard hasIdentifier || hasName else { return } + + self.publishMetadata(identifier: trimmedIdentifier, name: trimmedName, isActive: nil, source: "log-stream") + } + } + + private func extractMetadata(from notification: Notification) -> (name: String?, identifier: String?) { + var identifier: String? + var name: String? + + let identifierKeys = [ + "FocusModeIdentifier", + "focusModeIdentifier", + "FocusModeUUID", + "focusModeUUID", + "UUID", + "uuid", + "identifier", + "Identifier" + ] + + let nameKeys = [ + "FocusModeName", + "focusModeName", + "FocusMode", + "focusMode", + "displayName", + "display_name", + "name", + "Name" + ] + + var candidates: [Any] = [] + if let userInfo = notification.userInfo { + candidates.append(userInfo) + } + + if let object = notification.object { + candidates.append(object) + } + + debugPrint("[DoNotDisturbManager] raw focus payload -> name: \(notification.name.rawValue), object: \(String(describing: notification.object)), userInfo: \(String(describing: notification.userInfo))") + + for candidate in candidates { + if identifier == nil { + identifier = firstMatch(for: identifierKeys, in: candidate) + } + + if name == nil { + name = firstMatch(for: nameKeys, in: candidate) + } + + if identifier != nil && name != nil { + break + } + } + + if identifier == nil || name == nil { + for candidate in candidates { + if let decoded = decodeFocusPayloadIfNeeded(candidate) { + if identifier == nil { + identifier = firstMatch(for: identifierKeys, in: decoded) + } + + if name == nil { + name = firstMatch(for: nameKeys, in: decoded) + } + + if identifier != nil && name != nil { + break + } + } + } + } + + if identifier == nil || name == nil { + for candidate in candidates { + if let object = candidate as? NSObject { + if identifier == nil, let extractedIdentifier = extractIdentifier(fromFocusObject: object) { + identifier = extractedIdentifier + } + + if name == nil, let extractedName = extractDisplayName(fromFocusObject: object) { + name = extractedName + } + + if identifier != nil && name != nil { + break + } + } + } + } + + if identifier == nil || name == nil { + var descriptionSources: [Any] = candidates + + for candidate in candidates { + if let decoded = decodeFocusPayloadIfNeeded(candidate) { + descriptionSources.append(decoded) + } + } + + for candidate in descriptionSources { + let description = String(describing: candidate) + + if identifier == nil, let inferredIdentifier = FocusMetadataDecoder.extractIdentifier(from: description) { + identifier = inferredIdentifier + } + + if name == nil, let inferredName = FocusMetadataDecoder.extractName(from: description) { + name = inferredName + } + + if identifier != nil && name != nil { + break + } + } + } + + if identifier == nil || name == nil { + if let logMetadata = focusLogStream.latestMetadata() { + if identifier == nil { + identifier = logMetadata.identifier + } + + if name == nil { + name = logMetadata.name + } + } + } + + return (name, identifier) + } + +} + +private extension Notification.Name { + static let focusModeEnabled = Notification.Name("_NSDoNotDisturbEnabledNotification") + static let focusModeDisabled = Notification.Name("_NSDoNotDisturbDisabledNotification") +} + +private extension DoNotDisturbManager { + func applyMonitoringModeChange(_ mode: FocusMonitoringMode) { + guard isMonitoring else { return } + + if mode == .useDevTools { + stopAssertionsPolling() + focusLogStream.start() + checkInitialFocusStateViaLog() + } else { + focusLogStream.stop() + startAssertionsPolling() + } + } + + /// When using the log-stream mode, `log stream` only delivers future events — it misses any + /// focus mode that was already active when Atoll launched. This one-shot `log show` reads + /// recent duetexpertd debug logs to find the most recent `semanticModeIdentifier` event and + /// seeds the initial focus state from it. Tries progressively larger windows so the common + /// case (focus toggled recently) resolves in ~1-2s without scanning a full day of logs. + private func checkInitialFocusStateViaLog() { + metadataExtractionQueue.async { [weak self] in + guard let self else { return } + + for window in ["5m", "1h", "24h"] { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/log") + task.arguments = [ + "show", + "--last", window, + "--debug", + "--style", "compact", + "--predicate", "process == \"duetexpertd\" AND eventMessage CONTAINS \"semanticModeIdentifier\"" + ] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + + guard (try? task.run()) != nil else { return } + task.waitUntilExit() + + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let lines = output.components(separatedBy: "\n").filter { + $0.contains("semanticModeIdentifier") && !$0.hasPrefix("Filtering") + } + + guard let lastLine = lines.last(where: { !$0.isEmpty }) else { continue } + + // starting: 0 means focus ended — nothing to activate. + guard !lastLine.contains("starting: 0") else { return } + + let identifier = FocusMetadataDecoder.extractIdentifier(from: lastLine) + let name = FocusMetadataDecoder.extractName(from: lastLine) + guard identifier != nil || name != nil else { return } + + self.publishMetadata(identifier: identifier, name: name, isActive: true, source: "log-initial") + return + } + } + } + + func startAssertionsPolling() { + stopAssertionsPolling() + lastAssertionsModificationDate = nil + + let timer = DispatchSource.makeTimerSource(queue: pollingQueue) + timer.schedule(deadline: .now() + .seconds(1), repeating: .seconds(2), leeway: .milliseconds(250)) + timer.setEventHandler { [weak self] in + self?.pollAssertionsState() + } + timer.resume() + pollingSource = timer + } + + func stopAssertionsPolling() { + pollingSource?.cancel() + pollingSource = nil + lastAssertionsModificationDate = nil + } + + func pollAssertionsState() { + guard FullDiskAccessAuthorization.hasPermission() else { return } + + // Skip if a notification just arrived — let the notification take precedence + // to avoid a race where the file hasn't been flushed yet. + if Date().timeIntervalSince(lastNotificationTimestamp) < DoNotDisturbManager.notificationCooldown { + return + } + + if let attributes = try? FileManager.default.attributesOfItem(atPath: assertionsURL.path), + let modifiedAt = attributes[.modificationDate] as? Date, + let lastObservedModificationDate = lastAssertionsModificationDate, + modifiedAt <= lastObservedModificationDate { + return + } + + // Update last-observed modification date before reading content. + if let attributes = try? FileManager.default.attributesOfItem(atPath: assertionsURL.path), + let modifiedAt = attributes[.modificationDate] as? Date { + lastAssertionsModificationDate = modifiedAt + } + + // Default to OFF — if the file can't be read or parsed, treat as no active focus. + var isActive = false + var identifier: String? + var name: String? + + if let data = try? Data(contentsOf: assertionsURL), + !data.isEmpty, + let root = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any], + let dataArray = root["data"] as? [[String: Any]], + let firstItem = dataArray.first { + + let assertions = (firstItem["storeAssertionRecords"] as? [Any]) ?? [] + isActive = !assertions.isEmpty + + if isActive { + let identifierKeys = [ + "modeIdentifier", + "FocusModeIdentifier", + "focusModeIdentifier", + "identifier", + "Identifier", + "focusModeUUID", + "UUID", + "uuid" + ] + + let nameKeys = [ + "activityDisplayName", + "displayName", + "FocusModeName", + "focusModeName", + "focusMode", + "name", + "Name" + ] + + identifier = firstMatch(for: identifierKeys, in: assertions) + name = firstMatch(for: nameKeys, in: assertions) + } + } + + publishMetadata(identifier: identifier, name: name, isActive: isActive, source: "assertions-poll") + } +} + +// MARK: - Focus Mode Types + +enum FocusModeType: String, CaseIterable { + case doNotDisturb = "com.apple.donotdisturb.mode" + case work = "com.apple.focus.work" + case personal = "com.apple.focus.personal" + case sleep = "com.apple.focus.sleep" + case driving = "com.apple.focus.driving" + case fitness = "com.apple.focus.fitness" + case gaming = "com.apple.focus.gaming" + case mindfulness = "com.apple.focus.mindfulness" + case reading = "com.apple.focus.reading" + case reduceInterruptions = "com.apple.focus.reduce-interruptions" + case custom = "com.apple.focus.custom" + case unknown = "" + + var displayName: String { + switch self { + case .doNotDisturb: return String(localized: "Do Not Disturb") + case .work: return "Work" + case .personal: return "Personal" + case .sleep: return "Sleep" + case .driving: return "Driving" + case .fitness: return "Fitness" + case .gaming: return "Gaming" + case .mindfulness: return "Mindfulness" + case .reading: return "Reading" + case .reduceInterruptions: return "Reduce Interr." + case .custom: return "Focus" + case .unknown: return "Focus Mode" + } + } + + var sfSymbol: String { + switch self { + case .doNotDisturb: return "moon.fill" + case .work: return "briefcase.fill" + case .personal: return "person.fill" + case .sleep: return "bed.double.fill" + case .driving: return "car.fill" + case .fitness: return "figure.run" + case .gaming: return "gamecontroller.fill" + case .mindfulness: return "circle.hexagongrid" + case .reading: return "book.closed.fill" + case .reduceInterruptions: return "apple.intelligence" + case .custom: return "app.badge" + case .unknown: return "moon.fill" + } + } + + var internalSymbolName: String? { + switch self { + case .work: return "person.lanyardcard.fill" + case .mindfulness: return "apple.mindfulness" + case .gaming: return "rocket.fill" + default: return nil + } + } + + var activeIcon: Image { + resolvedActiveIcon() + } + + func resolvedActiveIcon(usePrivateSymbol: Bool = true) -> Image { + if usePrivateSymbol, + let internalSymbolName, + let image = Image(internalSystemName: internalSymbolName) { + return image + } + + return Image( + systemName: self == .custom + ? self.getCustomIconFromFile() + : sfSymbol + ) + } + + var accentColor: Color { + switch self { + case .doNotDisturb: + return Color(red: 0.370, green: 0.360, blue: 0.902) + case .work: + return Color(red: 0.414, green: 0.769, blue: 0.863, opacity: 1.0) + case .personal: + return Color(red: 0.748, green: 0.354, blue: 0.948, opacity: 1.0) + case .sleep: + return Color(red: 0.341, green: 0.384, blue: 0.980) + case .driving: + return Color(red: 0.988, green: 0.561, blue: 0.153) + case .fitness: + return Color(red: 0.176, green: 0.804, blue: 0.459) + case .gaming: + return Color(red: 0.043, green: 0.518, blue: 1.000, opacity: 1.0) + case .mindfulness: + return Color(red: 0.361, green: 0.898, blue: 0.883, opacity: 1.0) + case .reading: + return Color(red: 1.000, green: 0.622, blue: 0.044, opacity: 1.0) + case .reduceInterruptions: + return Color(red: 0.686, green: 0.322, blue: 0.871, opacity: 1.0) + case .custom: + return self.getCustomAccentColorFromFile() + case .unknown: + return Color(red: 0.370, green: 0.360, blue: 0.902) + } + } + + var inactiveSymbol: String { + switch self { + case .doNotDisturb: + return "moon.circle.fill" + default: + return sfSymbol + } + } +} + +extension FocusModeType { + init(identifier: String) { + let normalized = identifier.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedLowercased = normalized.lowercased() + + guard !normalized.isEmpty else { + self = .doNotDisturb + return + } + + // 1. Exact rawValue match (e.g., "com.apple.focus.work", "com.apple.donotdisturb.mode"). + if let direct = FocusModeType(rawValue: normalized) ?? FocusModeType(rawValue: normalizedLowercased) { + self = direct + return + } + + // 2. Sleep focus is reported as `com.apple.sleep.sleep-mode` by duetexpertd, + // not as the rawValue `com.apple.focus.sleep`, so handle it explicitly here. + if normalizedLowercased == "com.apple.sleep.sleep-mode" { + self = .sleep + return + } + + // 3. macOS custom Focus modes use `com.apple.donotdisturb.mode.`. + // Must be checked BEFORE the generic prefix match, otherwise the prefix + // `com.apple.donotdisturb.mode` would incorrectly resolve to .doNotDisturb. + if normalizedLowercased.hasPrefix("com.apple.donotdisturb.mode.") { + let suffix = String(normalizedLowercased.dropFirst("com.apple.donotdisturb.mode.".count)) + if !suffix.isEmpty && suffix != "default" { + self = .custom + return + } + } + + // 4. Prefix match for known built-in modes (e.g., com.apple.focus.personal-time -> .personal). + if let resolved = FocusModeType.allCases.first(where: { + guard !$0.rawValue.isEmpty else { return false } + return normalized.hasPrefix($0.rawValue) || normalizedLowercased.hasPrefix($0.rawValue) + }) { + self = resolved + return + } + + // 5. Anything else under com.apple.focus is custom. + if normalizedLowercased.hasPrefix("com.apple.focus") { + self = .custom + return + } + + self = .doNotDisturb + } + + static func resolve(identifier: String?, name: String?) -> FocusModeType { + if let name, !name.isEmpty { + if let match = FocusModeType.allCases.first(where: { + guard !$0.displayName.isEmpty else { return false } + return $0.displayName.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + }) { + return match + } + + // Also try matching the name against known raw identifiers (e.g., a log line may emit "work" or "sleep" as the name). + let lower = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch lower { + case "work": return .work + case "personal", "personal-time": return .personal + case "sleep", "sleep-mode": return .sleep + case "driving": return .driving + case "fitness": return .fitness + case "gaming": return .gaming + case "mindfulness": return .mindfulness + case "reading": return .reading + case "reduce-interruptions", "reduce interruptions": return .reduceInterruptions + case "do not disturb", "dnd", "donotdisturb", "do-not-disturb", "default": return .doNotDisturb + default: break + } + } + + if let identifier, !identifier.isEmpty { + return FocusModeType(identifier: identifier) + } + + // If a name was provided but didn't match any known English mode names, the + // system is likely reporting a localized (non-English) name such as "Travail" + // for Work on a French macOS. Treat it as a custom/unidentified focus mode + // instead of incorrectly falling back to Do Not Disturb. + if let name, !name.isEmpty { + return .custom + } + + return .doNotDisturb + } + + func getCustomIconFromFile() -> String { + return FocusMetadataReader.shared.getIcon( + for: DoNotDisturbManager.shared.currentFocusModeName, + identifier: DoNotDisturbManager.shared.currentFocusModeIdentifier + ) + } + + func getCustomAccentColorFromFile() -> Color { + return FocusMetadataReader.shared.getAccentColor( + for: DoNotDisturbManager.shared.currentFocusModeName, + identifier: DoNotDisturbManager.shared.currentFocusModeIdentifier + ) + } +} + +// MARK: - Metadata helpers + +private extension DoNotDisturbManager { + func firstMatch(for keys: [String], in value: Any) -> String? { + if let dictionary = value as? [AnyHashable: Any] { + for key in keys { + if let candidate = dictionary[key], let string = normalizedString(from: candidate) { + return string + } + } + + for nestedValue in dictionary.values { + if let nestedMatch = firstMatch(for: keys, in: nestedValue) { + return nestedMatch + } + } + } else if let array = value as? [Any] { + for element in array { + if let nestedMatch = firstMatch(for: keys, in: element) { + return nestedMatch + } + } + } + + return nil + } + + func normalizedString(from value: Any) -> String? { + switch value { + case let string as String: + let cleaned = FocusMetadataDecoder.cleanedString(string) + return cleaned.isEmpty ? nil : cleaned + case let number as NSNumber: + return FocusMetadataDecoder.cleanedString(number.stringValue) + case let uuid as UUID: + return uuid.uuidString + case let uuid as NSUUID: + return uuid.uuidString + case let data as Data: + if let decoded = decodeFocusPayload(from: data) { + if let nested = firstMatch(for: ["identifier", "Identifier", "uuid", "UUID"], in: decoded) { + return nested + } + if let name = firstMatch(for: ["name", "Name", "displayName", "display_name"], in: decoded) { + return name + } + } + if let string = String(data: data, encoding: .utf8) { + let cleaned = FocusMetadataDecoder.cleanedString(string) + return cleaned.isEmpty ? nil : cleaned + } + return nil + case let dict as [AnyHashable: Any]: + // Attempt to pull common keys from nested dictionaries + if let nested = firstMatch(for: ["identifier", "Identifier", "uuid", "UUID"], in: dict) { + return nested + } + if let name = firstMatch(for: ["name", "Name", "displayName", "display_name"], in: dict) { + return name + } + return nil + default: + return nil + } + } + + func decodeFocusPayloadIfNeeded(_ value: Any) -> Any? { + switch value { + case let data as Data: + return decodeFocusPayload(from: data) + case let data as NSData: + return decodeFocusPayload(from: data as Data) + default: + return nil + } + } + + func decodeFocusPayload(from data: Data) -> Any? { + guard !data.isEmpty else { return nil } + + if let propertyList = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) { + return propertyList + } + + if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) { + return jsonObject + } + + if let string = String(data: data, encoding: .utf8) { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + return nil + } + + func extractIdentifier(fromFocusObject object: NSObject) -> String? { + if let array = object as? [Any] { + for element in array { + if let nested = element as? NSObject, let identifier = extractIdentifier(fromFocusObject: nested) { + return identifier + } + } + return nil + } + + if let identifier = focusString(object, selector: "modeIdentifier") { + return identifier + } + + if let identifier = focusString(object, selector: "identifier") { + return identifier + } + + if let details = focusObject(object, selector: "details"), let identifier = extractIdentifier(fromFocusObject: details) { + return identifier + } + + if let metadata = focusObject(object, selector: "activeModeAssertionMetadata"), let identifier = extractIdentifier(fromFocusObject: metadata) { + return identifier + } + + if let configuration = focusObject(object, selector: "activeModeConfiguration"), let identifier = extractIdentifier(fromFocusObject: configuration) { + return identifier + } + + if let modeConfiguration = focusObject(object, selector: "modeConfiguration"), let identifier = extractIdentifier(fromFocusObject: modeConfiguration) { + return identifier + } + + if let mode = focusObject(object, selector: "mode") { + return extractIdentifier(fromFocusObject: mode) + } + + if let identifiers = focusObject(object, selector: "activeModeIdentifiers") { + if let stringArray = identifiers as? [String] { + if let first = stringArray.compactMap({ FocusMetadataDecoder.cleanedString($0) }).first(where: { !$0.isEmpty }) { + return first + } + } else if let array = identifiers as? NSArray { + for case let string as String in array { + let trimmed = FocusMetadataDecoder.cleanedString(string) + if !trimmed.isEmpty { + return trimmed + } + } + } + } + + return nil + } + + func extractDisplayName(fromFocusObject object: NSObject) -> String? { + if let array = object as? [Any] { + for element in array { + if let nested = element as? NSObject, let name = extractDisplayName(fromFocusObject: nested) { + return name + } + } + return nil + } + + if let name = focusString(object, selector: "name") { + return name + } + + if let name = focusString(object, selector: "displayName") { + return name + } + + if let name = focusString(object, selector: "activityDisplayName") { + return name + } + + if let descriptor = focusObject(object, selector: "symbolDescriptor"), let name = focusString(descriptor, selector: "name") { + return name + } + + if let mode = focusObject(object, selector: "mode"), let name = extractDisplayName(fromFocusObject: mode) { + return name + } + + if let details = focusObject(object, selector: "details"), let name = extractDisplayName(fromFocusObject: details) { + return name + } + + if let configuration = focusObject(object, selector: "modeConfiguration"), let name = extractDisplayName(fromFocusObject: configuration) { + return name + } + + return nil + } + + func focusObject(_ object: NSObject, selector selectorName: String) -> NSObject? { + let selector = NSSelectorFromString(selectorName) + guard object.responds(to: selector) else { return nil } + guard let value = object.perform(selector)?.takeUnretainedValue() else { return nil } + return value as? NSObject + } + + func focusString(_ object: NSObject, selector selectorName: String) -> String? { + let selector = NSSelectorFromString(selectorName) + guard object.responds(to: selector) else { return nil } + guard let value = object.perform(selector)?.takeUnretainedValue() else { return nil } + + switch value { + case let string as String: + return FocusMetadataDecoder.cleanedString(string) + case let string as NSString: + return FocusMetadataDecoder.cleanedString(string as String) + case let number as NSNumber: + return FocusMetadataDecoder.cleanedString(number.stringValue) + default: + return nil + } + } + +} + +private final class FocusLogStream { + private let queue = DispatchQueue(label: "com.dynamicisland.focus.logstream", qos: .utility) + private var process: Process? + private var pipe: Pipe? + private var buffer = Data() + private var isRunning = false + private var didTerminate = false + + private let metadataLock = NSLock() + private var lastIdentifier: String? + private var lastName: String? + + var onMetadataUpdate: ((String?, String?) -> Void)? + + func start() { + queue.async { [weak self] in + guard let self = self else { return } + guard !self.isRunning else { return } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/log") + process.arguments = [ + "stream", + "--no-backtrace", + "--style", + "compact", + "--level", + "info", + "--predicate", + "process == \"duetexpertd\" AND eventMessage CONTAINS \"semanticModeIdentifier\"" + ] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + guard let self else { return } + let data = handle.availableData + + if data.isEmpty { + self.queue.async { [weak self] in + self?.handleTermination() + } + return + } + + self.queue.async { [weak self] in + self?.handleIncomingData(data) + } + } + + process.terminationHandler = { [weak self] _ in + self?.queue.async { + self?.handleTermination() + } + } + + do { + try process.run() + self.process = process + self.pipe = pipe + self.isRunning = true + self.didTerminate = false + debugPrint("[FocusLogStream] Started unified log tail for duetexpertd/donotdisturbd Focus metadata") + } catch { + debugPrint("[FocusLogStream] Failed to start log stream: \(error)") + pipe.fileHandleForReading.readabilityHandler = nil + self.process = nil + self.pipe = nil + } + } + } + + func stop() { + queue.async { [weak self] in + guard let self else { return } + guard self.isRunning else { return } + self.handleTermination(terminateProcess: true) + } + } + + func latestMetadata() -> (identifier: String?, name: String?)? { + metadataLock.lock() + let identifier = lastIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let name = lastName?.trimmingCharacters(in: .whitespacesAndNewlines) + metadataLock.unlock() + + let normalizedIdentifier = (identifier?.isEmpty == false) ? identifier : nil + let normalizedName = (name?.isEmpty == false) ? name : nil + + if normalizedIdentifier == nil && normalizedName == nil { + return nil + } + + return (normalizedIdentifier, normalizedName) + } + + private func handleIncomingData(_ data: Data) { + buffer.append(data) + + let newline: UInt8 = 0x0A + + while let newlineIndex = buffer.firstIndex(of: newline) { + let lineData = buffer.prefix(upTo: newlineIndex) + buffer.removeSubrange(buffer.startIndex...newlineIndex) + + let trimmedLineData: Data + if let lastByte = lineData.last, lastByte == 0x0D { + trimmedLineData = lineData.dropLast() + } else { + trimmedLineData = lineData + } + + guard !trimmedLineData.isEmpty, + let line = String(data: trimmedLineData, encoding: .utf8) else { + continue + } + + processLine(line) + } + } + + private func processLine(_ line: String) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + if trimmed.hasPrefix("Filtering the log data") || trimmed.hasPrefix("Timestamp") { + return + } + + if trimmed.lowercased().contains("error") && trimmed.lowercased().contains("predicate") { + debugPrint("[FocusLogStream] log stream error: \(trimmed)") + } + + // Clear only when logs explicitly indicate no active mode (helps avoid wiping state during transitions). + if trimmed.contains("active mode assertion: (null)") || trimmed.contains("activeModeIdentifier: (null)") { + clearMetadata() + return + } + + var updatedIdentifier: String? + var updatedName: String? + + // Special-case parsing for donotdisturbd logs which include a full DNDMode description. + // Example: + if trimmed.contains(" String? { + guard let r = trimmed.range(of: key) else { return nil } + let suffix = trimmed[r.upperBound...] + guard let end = suffix.range(of: ";") else { return nil } + let value = suffix[..\\n]+)", + "semanticType:\\s*([A-Za-z][A-Za-z0-9 _-]+)", + "modeIdentifier:\\s*'com\\.apple\\.focus\\.([A-Za-z0-9._-]+)'" + ] + return patterns.compactMap { try? NSRegularExpression(pattern: $0, options: []) } + }() +} + +private enum FocusMetadataDecoder { + static func cleanedString(_ string: String) -> String { + var trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + trimmed = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) + return trimmed + } + + static func extractIdentifier(from description: String) -> String? { + let fullRange = NSRange(description.startIndex.. 0, + let identifierRange = Range(match.range(at: 0), in: description) { + let candidate = cleanedString(String(description[identifierRange])) + if !candidate.isEmpty { + return candidate + } + } + + for regex in FocusNotificationParsing.identifierDetailPatterns { + if let match = regex.firstMatch(in: description, options: [], range: fullRange), + match.numberOfRanges > 1, + let identifierRange = Range(match.range(at: 1), in: description) { + let candidate = cleanedString(String(description[identifierRange])) + if !candidate.isEmpty { + return candidate + } + } + } + + return nil + } + + static func extractName(from description: String) -> String? { + let fullRange = NSRange(description.startIndex.. 1, + let nameRange = Range(match.range(at: 1), in: description) { + let candidate = cleanedString(String(description[nameRange])) + if !candidate.isEmpty { + return candidate + } + } + } + + return nil + } +} + +private final class FocusMetadataReader { + private let pathToDatabase:URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/DoNotDisturb/DB/ModeConfigurations.json") + + struct DNDConfigRoot: Codable { + let data: [DNDDataEntry] + } + + struct DNDDataEntry: Codable { + let modeConfigurations: [String: DNDModeWrapper] + } + + struct DNDModeWrapper: Codable { + let mode: DNDMode + } + + struct DNDMode: Codable { + let name: String + let modeIdentifier: String + let symbolImageName: String? + let tintColorName: String? + } + + private init(){} + + static let shared = FocusMetadataReader() + + private func getModeConfig(for focusName: String, identifier: String? = nil) -> DNDMode? { + guard FullDiskAccessAuthorization.hasPermission() else { return nil } + + let trimmedName = focusName.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) + + do { + let data = try Data(contentsOf: pathToDatabase) + let root = try JSONDecoder().decode(DNDConfigRoot.self, from: data) + + for entry in root.data { + for wrapper in entry.modeConfigurations.values { + let mode = wrapper.mode + + if let id = trimmedIdentifier, !id.isEmpty, + mode.modeIdentifier.compare(id, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame { + return mode + } + + if !trimmedName.isEmpty, + mode.name.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame { + return mode + } + } + } + } catch { + print("ModeConfigurations.json decode error: \(error)") + } + + return nil + } + + func getDisplayName(for focus: String, identifier: String? = nil) -> String { + guard let mode = getModeConfig(for: focus, identifier: identifier) else { return "" } + return mode.name + } + + /// Fetch the icon for the current focus from disk. If the focus is not found return the placeholder `app.badge` + /// - Returns A string representing the sfSymbol of the current focus + func getIcon(for focus: String, identifier: String? = nil) -> String { + guard let mode = getModeConfig(for: focus, identifier: identifier) else { return "app.badge" } + return mode.symbolImageName ?? "app.badge" + } + + /// Fetch the accent color for the current focus from disk. If the focus is not found return the placeholder `Color.indigo` + /// - Returns A Color representing the accent color for the current focus + func getAccentColor(for focus: String, identifier: String? = nil) -> Color { + guard let mode = getModeConfig(for: focus, identifier: identifier), + let colorName = mode.tintColorName else { return .indigo } + + return Color.stringToColor(for: colorName) + } +} + +extension Color { + static func stringToColor(for string:String) -> Color { + let cleanName = string.lowercased() + .replacingOccurrences(of: "system", with: "") + .replacingOccurrences(of: "color", with: "") + + switch cleanName { + case "red": return .red + case "orange": return .orange + case "yellow": return .yellow + case "green": return .green + case "mint": return .mint + case "teal": return .teal + case "cyan": return .cyan + case "blue": return .blue + case "indigo": return .indigo + case "purple": return .purple + case "pink": return .pink + case "gray", "grey": return .gray + default: return .indigo + } + } +} diff --git a/DynamicIsland/managers/DownloadManager.swift b/DynamicIsland/managers/DownloadManager.swift new file mode 100644 index 00000000..dd3d4cd0 --- /dev/null +++ b/DynamicIsland/managers/DownloadManager.swift @@ -0,0 +1,260 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import SwiftUI +import Observation +import Defaults + +@Observable +@MainActor +class DownloadManager { + static let shared = DownloadManager() + + private(set) var isDownloading: Bool = false + private(set) var isDownloadCompleted: Bool = false + + private let coordinator = DynamicIslandViewCoordinator.shared + private var source: DispatchSourceFileSystemObject? + private let queue = DispatchQueue(label: "com.dynamicisland.downloads.monitor", qos: .utility) + private var completionTimer: Timer? + private var hasPerformedInitialScan: Bool = false + private var initialCrDownloadFiles: Set = [] + private var previousAllFiles: Set = [] + private var ignoredFiles: Set = [] + + private var downloadsDirectory: URL? { + FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + } + + init() { + requestDownloadsPermissionIfNeeded() + startMonitoringIfNeeded() + + Defaults.publisher(.enableDownloadListener) + .sink { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.startMonitoringIfNeeded() + } + } + } + + private func startMonitoringIfNeeded() { + if Defaults[.enableDownloadListener] { + startMonitoring() + } else { + stopMonitoring() + updateDownloadingState(isActive: false) + } + } + + private func startMonitoring() { + guard source == nil, let downloadsDirectory else { return } + + hasPerformedInitialScan = false + initialCrDownloadFiles.removeAll() + previousAllFiles.removeAll() + ignoredFiles.removeAll() + isDownloading = false + + let path = downloadsDirectory.path + let fd = open(path, O_EVTONLY) + guard fd >= 0 else { return } + + let src = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .rename, .delete, .attrib], + queue: queue + ) + + src.setEventHandler { [weak self] in + self?.scanDownloadsDirectory() + } + + src.setCancelHandler { + close(fd) + } + + source = src + src.resume() + + scanDownloadsDirectory() + } + + private func stopMonitoring() { + source?.cancel() + source = nil + + hasPerformedInitialScan = false + initialCrDownloadFiles.removeAll() + ignoredFiles.removeAll() + isDownloading = false + } + + private func scanDownloadsDirectory() { + guard let downloadsDirectory else { return } + + let crDownloadFiles: Set + let allFiles: Set + + do { + let contents = try FileManager.default.contentsOfDirectory( + at: downloadsDirectory, + includingPropertiesForKeys: [.creationDateKey] + ) + + crDownloadFiles = Set(contents + .filter { + let ext = $0.pathExtension.lowercased() + return ext == "crdownload" || ext == "download" + } + .map { $0.lastPathComponent } + ) + + allFiles = Set(contents.map { $0.lastPathComponent }) + + } catch { + return + } + + Task { @MainActor in + self.processDownloadFiles(crDownloadFiles, allFiles: allFiles) + } + } + + private func processDownloadFiles(_ crDownloadFiles: Set, allFiles: Set) { + + if !hasPerformedInitialScan { + hasPerformedInitialScan = true + initialCrDownloadFiles = crDownloadFiles + previousAllFiles = allFiles + ignoredFiles = crDownloadFiles + isDownloading = false + return + } + + let newFiles = crDownloadFiles.subtracting(initialCrDownloadFiles) + let disappearedFiles = initialCrDownloadFiles.subtracting(crDownloadFiles) + let newRegularFiles = allFiles.subtracting(previousAllFiles).subtracting(crDownloadFiles) + + initialCrDownloadFiles = crDownloadFiles + previousAllFiles = allFiles + + let activeFiles = crDownloadFiles.subtracting(ignoredFiles) + let hasActiveDownloads = !activeFiles.isEmpty + + if !newFiles.isEmpty { + let newActiveFiles = newFiles.subtracting(ignoredFiles) + if !newActiveFiles.isEmpty { + if !isDownloading { + updateDownloadingState(isActive: true) + } + } + } + + // completion logic + if isDownloading { + if !hasActiveDownloads { + if !newRegularFiles.isEmpty || disappearedFiles.isEmpty { + + if !isDownloadCompleted { + updateDownloadingState(isActive: false) + } + + } else { + closeDownloadViewImmediately() + } + + } + + } else if hasActiveDownloads { + updateDownloadingState(isActive: true) + } + } + + private func requestDownloadsPermissionIfNeeded() { + guard let downloadsDirectory else { return } + _ = try? FileManager.default.contentsOfDirectory(at: downloadsDirectory, includingPropertiesForKeys: nil) + } + + private func updateDownloadingState(isActive: Bool) { + completionTimer?.invalidate() + completionTimer = nil + + if isActive { + isDownloadCompleted = false + + if !isDownloading { + withAnimation(.smooth) { + isDownloading = true + } + coordinator.toggleExpandingView( + status: true, + type: .download, + value: 0, + browser: .chromium + ) + } + + } else { + if isDownloading { + withAnimation(.smooth) { + isDownloadCompleted = true + } + + completionTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + Task { @MainActor in + self?.closeDownloadView() + } + } + } + } + } + + private func closeDownloadView() { + withAnimation(.smooth) { + isDownloading = false + isDownloadCompleted = false + } + + coordinator.toggleExpandingView( + status: false, + type: .download, + value: 0, + browser: .chromium + ) + } + + private func closeDownloadViewImmediately() { + completionTimer?.invalidate() + completionTimer = nil + + withAnimation(.smooth) { + isDownloading = false + isDownloadCompleted = false + } + + coordinator.toggleExpandingView( + status: false, + type: .download, + value: 0, + browser: .chromium + ) + } +} diff --git a/DynamicIsland/managers/DynamicIslandExtensionManager.swift b/DynamicIsland/managers/DynamicIslandExtensionManager.swift deleted file mode 100644 index 0f701758..00000000 --- a/DynamicIsland/managers/DynamicIslandExtensionManager.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// DynamicIslandExtensionManager.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 04/08/24. -// DynamicIslandExtensionManager.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 07/09/24. -// - -import Foundation -import SwiftUI - -var clipboardExtension: String = "dynamicisland.TheDynamicClipboard" -var hudExtension: String = "dynamicisland.TheDynamicHUDs" -var downloadManagerExtension: String = "dynamicisland.TheDynamicDownloadManager" - -struct Extension: Identifiable, Hashable { - var id = UUID() - var name: String - var bundleIdentifier: String - var status: StatusModel = .enabled -} - -enum StatusModel { - case disabled - case enabled -} - -class DynamicIslandExtensionManager: ObservableObject { - - @Published var installedExtensions: [Extension] = [] { - didSet { - print("Extensions installed: \(installedExtensions)") - } - } - - var extensions = [ - clipboardExtension, - hudExtension - ] - - init() { - checkIfExtensionsAreInstalled() - - DistributedNotificationCenter.default().addObserver(self, selector: #selector(checkIfExtensionsAreInstalled), name: NSNotification.Name("NSWorkspaceDidLaunchApplicationNotification"), object: nil) - } - - @objc func checkIfExtensionsAreInstalled() { - installedExtensions = [] - for extensionName in extensions { - if NSWorkspace.shared.urlForApplication(withBundleIdentifier: extensionName) != nil { - let ext = Extension(name: extensionName.components(separatedBy: ".").last ?? extensionName, bundleIdentifier: extensionName) - installedExtensions.append(ext) - } - } - } -} diff --git a/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift new file mode 100644 index 00000000..c21e39b5 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift @@ -0,0 +1,85 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Defaults +import AtollExtensionKit +import SwiftUI + +struct ExtensionLiveActivityPayload: Identifiable, Hashable, Codable { + let bundleIdentifier: String + let descriptor: AtollLiveActivityDescriptor + let receivedAt: Date + + var id: String { descriptor.id } + + var priority: AtollLiveActivityPriority { descriptor.priority } +} + +struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable, Codable { + let bundleIdentifier: String + let descriptor: AtollLockScreenWidgetDescriptor + let receivedAt: Date + + var id: String { descriptor.id } + + var priority: AtollLiveActivityPriority { descriptor.priority } +} + +struct ExtensionNotchExperiencePayload: Identifiable, Hashable, Codable { + let bundleIdentifier: String + let descriptor: AtollNotchExperienceDescriptor + let receivedAt: Date + + var id: String { descriptor.id } + + var priority: AtollLiveActivityPriority { descriptor.priority } + + var hasTabConfiguration: Bool { descriptor.tab != nil } + + var hasMinimalisticConfiguration: Bool { descriptor.minimalistic != nil } +} + +enum ExtensionValidationError: LocalizedError, Equatable { + case featureDisabled + case unauthorized + case invalidDescriptor(String) + case rateLimited + case exceedsCapacity + case duplicateIdentifier + case unsupportedContent + + var errorDescription: String? { + switch self { + case .featureDisabled: + return "Extensions feature is currently disabled in Atoll." + case .unauthorized: + return "Your app is not authorized to post this content." + case .invalidDescriptor(let reason): + return "Descriptor validation failed: \(reason)." + case .rateLimited: + return "Too many requests in a short period. Please slow down." + case .exceedsCapacity: + return "Atoll reached its limit for simultaneous extension content." + case .duplicateIdentifier: + return "An item with the same identifier already exists." + case .unsupportedContent: + return "Descriptor includes unsupported content for this surface." + } + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift new file mode 100644 index 00000000..c4ca4b65 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift @@ -0,0 +1,234 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Defaults +import AtollExtensionKit + +@MainActor +final class ExtensionAuthorizationManager: ObservableObject { + static let shared = ExtensionAuthorizationManager() + + @Published private(set) var entries: [ExtensionAuthorizationEntry] + @Published private(set) var rateLimitRecords: [ExtensionRateLimitRecord] + + private let persistenceQueue = DispatchQueue(label: "com.atoll.extensions.authorization", qos: .utility) + + private init() { + self.entries = Defaults[.extensionAuthorizationEntries] + self.rateLimitRecords = Defaults[.extensionRateLimitRecords] + normalizeState() + } + + // MARK: - Public API + + var isExtensionsFeatureEnabled: Bool { Defaults[.enableThirdPartyExtensions] } + var areLiveActivitiesEnabled: Bool { Defaults[.enableExtensionLiveActivities] } + var areLockScreenWidgetsEnabled: Bool { Defaults[.enableExtensionLockScreenWidgets] } + var areNotchExperiencesEnabled: Bool { Defaults[.enableExtensionNotchExperiences] } + + func updateFeatureToggles(extensionsEnabled: Bool? = nil, + liveActivitiesEnabled: Bool? = nil, + lockScreenWidgetsEnabled: Bool? = nil, + notchExperiencesEnabled: Bool? = nil) { + if let extensionsEnabled { Defaults[.enableThirdPartyExtensions] = extensionsEnabled } + if let liveActivitiesEnabled { Defaults[.enableExtensionLiveActivities] = liveActivitiesEnabled } + if let lockScreenWidgetsEnabled { Defaults[.enableExtensionLockScreenWidgets] = lockScreenWidgetsEnabled } + if let notchExperiencesEnabled { Defaults[.enableExtensionNotchExperiences] = notchExperiencesEnabled } + objectWillChange.send() + } + + func authorizationEntry(for bundleIdentifier: String) -> ExtensionAuthorizationEntry? { + entries.first { $0.bundleIdentifier == bundleIdentifier } + } + + func ensureEntryExists(bundleIdentifier: String, appName: String?) -> ExtensionAuthorizationEntry { + if let existing = authorizationEntry(for: bundleIdentifier) { + return existing + } + let resolvedName = appName ?? bundleIdentifier + let entry = ExtensionAuthorizationEntry( + bundleIdentifier: bundleIdentifier, + appName: resolvedName, + status: .pending, + allowedScopes: defaultScopes() + ) + entries.append(entry) + persistEntries() + return entry + } + + func authorize(bundleIdentifier: String, appName: String?, scopes: Set? = nil) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.appName = appName ?? entry.appName + entry.status = .authorized + entry.allowedScopes = scopes ?? entry.allowedScopes + entry.grantedAt = .now + entry.lastDeniedReason = nil + } + } + + func deny(bundleIdentifier: String, reason: String?) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.status = .denied + entry.lastDeniedReason = reason + } + } + + func revoke(bundleIdentifier: String, reason: String?) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.status = .revoked + entry.lastDeniedReason = reason + } + } + + func recordActivity(for bundleIdentifier: String, scope: ExtensionPermissionScope) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.lastActivityAt = .now + } + + updateRateLimitRecord(bundleIdentifier: bundleIdentifier) { record in + let now = Date() + switch scope { + case .liveActivities: + record.activityTimestamps.append(now) + case .lockScreenWidgets: + record.widgetTimestamps.append(now) + case .notchExperiences: + record.notchExperienceTimestamps.append(now) + case .fileSharing: + record.activityTimestamps.append(now) + } + record.activityTimestamps = flushOldTimestamps(record.activityTimestamps) + record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps) + record.notchExperienceTimestamps = flushOldTimestamps(record.notchExperienceTimestamps) + } + } + + func updateAllowedScopes(bundleIdentifier: String, allowedScopes: Set) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.allowedScopes = allowedScopes + } + } + + func resetRateLimits(for bundleIdentifier: String) { + rateLimitRecords.removeAll { $0.bundleIdentifier == bundleIdentifier } + persistRateLimitRecords() + } + + func removeEntry(bundleIdentifier: String) { + entries.removeAll { $0.bundleIdentifier == bundleIdentifier } + rateLimitRecords.removeAll { $0.bundleIdentifier == bundleIdentifier } + persistEntries() + persistRateLimitRecords() + } + + // MARK: - Validation Helpers + + func canProcessLiveActivityRequest(from bundleIdentifier: String) -> Bool { + guard preflight(bundleIdentifier: bundleIdentifier, scope: .liveActivities) else { return false } + guard areLiveActivitiesEnabled else { return false } + return true + } + + func canProcessLockScreenRequest(from bundleIdentifier: String) -> Bool { + guard preflight(bundleIdentifier: bundleIdentifier, scope: .lockScreenWidgets) else { return false } + guard areLockScreenWidgetsEnabled else { return false } + return true + } + + func canProcessNotchExperienceRequest(from bundleIdentifier: String) -> Bool { + guard preflight(bundleIdentifier: bundleIdentifier, scope: .notchExperiences) else { return false } + guard areNotchExperiencesEnabled else { return false } + return true + } + + func recordDeniedRequest(bundleIdentifier: String, reason: String) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.lastDeniedReason = reason + entry.status = entry.status == .authorized ? .revoked : entry.status + } + } + + // MARK: - Internal State Updates + + private func updateEntry(bundleIdentifier: String, mutate: (inout ExtensionAuthorizationEntry) -> Void) { + guard let index = entries.firstIndex(where: { $0.bundleIdentifier == bundleIdentifier }) else { + var entry = ensureEntryExists(bundleIdentifier: bundleIdentifier, appName: bundleIdentifier) + mutate(&entry) + entries.removeAll { $0.bundleIdentifier == bundleIdentifier } + entries.append(entry) + persistEntries() + return + } + var entry = entries[index] + mutate(&entry) + entries[index] = entry + persistEntries() + } + + private func updateRateLimitRecord(bundleIdentifier: String, mutate: (inout ExtensionRateLimitRecord) -> Void) { + if let index = rateLimitRecords.firstIndex(where: { $0.bundleIdentifier == bundleIdentifier }) { + var record = rateLimitRecords[index] + mutate(&record) + rateLimitRecords[index] = record + persistRateLimitRecords() + } else { + var newRecord = ExtensionRateLimitRecord(bundleIdentifier: bundleIdentifier) + mutate(&newRecord) + rateLimitRecords.append(newRecord) + persistRateLimitRecords() + } + } + + private func preflight(bundleIdentifier: String, scope: ExtensionPermissionScope) -> Bool { + guard isExtensionsFeatureEnabled else { return false } + guard let entry = authorizationEntry(for: bundleIdentifier) else { return false } + guard entry.isAuthorized else { return false } + guard entry.allowedScopes.contains(scope) else { return false } + return true + } + + private func flushOldTimestamps(_ timestamps: [Date]) -> [Date] { + let threshold = Date().addingTimeInterval(-60 * 5) + return timestamps.filter { $0 >= threshold } + } + + private func defaultScopes() -> Set { + Set(ExtensionPermissionScope.allCases) + } + + private func persistEntries() { + let entriesSnapshot = entries + persistenceQueue.async { + Defaults[.extensionAuthorizationEntries] = entriesSnapshot + } + } + + private func persistRateLimitRecords() { + let recordsSnapshot = rateLimitRecords + persistenceQueue.async { + Defaults[.extensionRateLimitRecords] = recordsSnapshot + } + } + + private func normalizeState() { + entries = entries.filter { !$0.bundleIdentifier.isEmpty } + rateLimitRecords = rateLimitRecords.filter { !$0.bundleIdentifier.isEmpty } + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift b/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift new file mode 100644 index 00000000..27fd43e7 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift @@ -0,0 +1,62 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import AtollExtensionKit + +struct ExtensionDescriptorValidator { + static func validate(_ descriptor: AtollLiveActivityDescriptor) throws { + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Missing mandatory fields") + } + guard descriptor.leadingIcon.isValid else { + throw ExtensionValidationError.invalidDescriptor("Invalid leading icon data") + } + if let badge = descriptor.badgeIcon { + guard badge.isValid else { + throw ExtensionValidationError.invalidDescriptor("Invalid badge icon data") + } + } + if descriptor.metadata.count > 32 { + throw ExtensionValidationError.invalidDescriptor("Metadata keys must be ≤ 32") + } + } + + static func validate(_ descriptor: AtollLockScreenWidgetDescriptor) throws { + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Missing mandatory fields") + } + guard descriptor.content.count <= 12 else { + throw ExtensionValidationError.invalidDescriptor("Too many content elements") + } + guard descriptor.size.width <= 480, descriptor.size.height <= 280 else { + throw ExtensionValidationError.invalidDescriptor("Widget exceeds size limits") + } + } + + static func validate(_ descriptor: AtollNotchExperienceDescriptor) throws { + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Missing mandatory fields") + } + if let durationHint = descriptor.durationHint { + guard durationHint > 0, durationHint <= 21_600 else { + throw ExtensionValidationError.invalidDescriptor("Duration hint must be between 0 and 6 hours") + } + } + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift b/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift new file mode 100644 index 00000000..a10d19e3 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift @@ -0,0 +1,184 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +/// Bridges extension payload updates between Atoll processes (main app, helpers, XPC services). +/// Stores the latest snapshot on disk and delivers change notifications through DistributedNotificationCenter. +final class ExtensionEventBridge { + static let shared = ExtensionEventBridge() + + private let notificationCenter = DistributedNotificationCenter.default() + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private let processIdentifier = ProcessInfo.processInfo.processIdentifier + private let ioQueue = DispatchQueue(label: "com.ebullioscopic.Atoll.extensions.bridge", qos: .utility) + + private init() {} + + // MARK: - Public API + + func broadcastLiveActivitySnapshot(_ payloads: [ExtensionLiveActivityPayload]) { + broadcast(payloads, channel: .liveActivities) + } + + func broadcastLockScreenWidgetSnapshot(_ payloads: [ExtensionLockScreenWidgetPayload]) { + broadcast(payloads, channel: .lockScreenWidgets) + } + + func broadcastNotchExperienceSnapshot(_ payloads: [ExtensionNotchExperiencePayload]) { + broadcast(payloads, channel: .notchExperiences) + } + + func loadPersistedLiveActivities() -> [ExtensionLiveActivityPayload] { + loadSnapshot(channel: .liveActivities) + } + + func loadPersistedLockScreenWidgets() -> [ExtensionLockScreenWidgetPayload] { + loadSnapshot(channel: .lockScreenWidgets) + } + + func loadPersistedNotchExperiences() -> [ExtensionNotchExperiencePayload] { + loadSnapshot(channel: .notchExperiences) + } + + func observeLiveActivitySnapshots(_ handler: @escaping ([ExtensionLiveActivityPayload], Int32) -> Void) -> NSObjectProtocol { + observe(channel: .liveActivities, handler: handler) + } + + func observeLockScreenWidgetSnapshots(_ handler: @escaping ([ExtensionLockScreenWidgetPayload], Int32) -> Void) -> NSObjectProtocol { + observe(channel: .lockScreenWidgets, handler: handler) + } + + func observeNotchExperienceSnapshots(_ handler: @escaping ([ExtensionNotchExperiencePayload], Int32) -> Void) -> NSObjectProtocol { + observe(channel: .notchExperiences, handler: handler) + } + + func removeObserver(_ observer: NSObjectProtocol) { + notificationCenter.removeObserver(observer) + } + + // MARK: - Snapshot Storage + + private func broadcast(_ payloads: [T], channel: Channel) { + ioQueue.async { [weak self] in + guard let self else { return } + guard let data = try? self.encoder.encode(payloads) else { return } + self.persistSnapshot(data, channel: channel) + let userInfo: [String: Any] = [ + UserInfoKey.payloads: data, + UserInfoKey.sourcePID: NSNumber(value: self.processIdentifier) + ] + self.notificationCenter.postNotificationName( + channel.notificationName, + object: nil, + userInfo: userInfo, + deliverImmediately: true + ) + } + } + + private func loadSnapshot(channel: Channel) -> [T] { + let url = snapshotURL(for: channel) + guard FileManager.default.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url) else { + return [] + } + return (try? decoder.decode([T].self, from: data)) ?? [] + } + + private func persistSnapshot(_ data: Data, channel: Channel) { + let url = snapshotURL(for: channel) + do { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + } catch { + Logger.log("Failed to persist extension snapshot for \(channel): \(error.localizedDescription)", category: .extensions) + } + } + + private func snapshotURL(for channel: Channel) -> URL { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) + let directory = appSupport.appendingPathComponent("AtollExtensions", isDirectory: true) + switch channel { + case .liveActivities: + return directory.appendingPathComponent("live_activities.json", isDirectory: false) + case .lockScreenWidgets: + return directory.appendingPathComponent("lock_screen_widgets.json", isDirectory: false) + case .notchExperiences: + return directory.appendingPathComponent("notch_experiences.json", isDirectory: false) + } + } + + // MARK: - Observation + + private func observe(channel: Channel, + handler: @escaping ([T], Int32) -> Void) -> NSObjectProtocol { + notificationCenter.addObserver(forName: channel.notificationName, object: nil, queue: .main) { [weak self] notification in + guard let self else { return } + let sourcePID = (notification.userInfo?[UserInfoKey.sourcePID] as? NSNumber)?.int32Value ?? 0 + guard sourcePID != self.processIdentifier else { return } + + if let data = notification.userInfo?[UserInfoKey.payloads] as? Data, + let payloads = try? self.decoder.decode([T].self, from: data) { + handler(payloads, sourcePID) + return + } + + let snapshot: [T] = self.loadSnapshot(channel: channel) + handler(snapshot, sourcePID) + } + } + + // MARK: - Helpers + + private enum Channel: CustomStringConvertible { + case liveActivities + case lockScreenWidgets + case notchExperiences + + var notificationName: Notification.Name { + switch self { + case .liveActivities: + return Notification.Name("com.ebullioscopic.Atoll.extensions.liveActivitySnapshot") + case .lockScreenWidgets: + return Notification.Name("com.ebullioscopic.Atoll.extensions.lockScreenWidgetSnapshot") + case .notchExperiences: + return Notification.Name("com.ebullioscopic.Atoll.extensions.notchExperienceSnapshot") + } + } + + var description: String { + switch self { + case .liveActivities: + return "live-activities" + case .lockScreenWidgets: + return "lock-screen-widgets" + case .notchExperiences: + return "notch-experiences" + } + } + } + + private enum UserInfoKey { + static let payloads = "payloads" + static let sourcePID = "sourcePID" + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift new file mode 100644 index 00000000..4c833b83 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift @@ -0,0 +1,227 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Defaults +import AtollExtensionKit +import SwiftUI + +@MainActor +final class ExtensionLiveActivityManager: ObservableObject { + static let shared = ExtensionLiveActivityManager() + + @Published private(set) var activeActivities: [ExtensionLiveActivityPayload] = [] + + private let authorizationManager = ExtensionAuthorizationManager.shared + private let maxCapacityKey = Defaults.Keys.extensionLiveActivityCapacity + private let eventBridge = ExtensionEventBridge.shared + private var liveActivityObserver: NSObjectProtocol? + private var suppressBroadcast = false + private let currentProcessID = ProcessInfo.processInfo.processIdentifier + + private init() { + activeActivities = eventBridge.loadPersistedLiveActivities() + sortActivities() + liveActivityObserver = eventBridge.observeLiveActivitySnapshots { [weak self] payloads, sourcePID in + self?.applySnapshot(payloads, sourcePID: sourcePID) + } + } + + deinit { + if let token = liveActivityObserver { + eventBridge.removeObserver(token) + } + } + + func present(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws { + guard authorizationManager.canProcessLiveActivityRequest(from: bundleIdentifier) else { + logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): scope disabled or bundle unauthorized") + throw ExtensionValidationError.unauthorized + } + guard descriptor.isValid else { + logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): descriptor validation failed") + throw ExtensionValidationError.invalidDescriptor("Structure validation failed") + } + + let isUpdate: Bool + if let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) { + let payload = ExtensionLiveActivityPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeActivities[index].receivedAt + ) + activeActivities[index] = payload + sortActivities() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) + Logger.log("Replaced extension live activity \(descriptor.id) for \(bundleIdentifier)", category: .extensions) + isUpdate = true + } else { + guard activeActivities.count < Defaults[maxCapacityKey] else { + logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached") + throw ExtensionValidationError.exceedsCapacity + } + + let payload = ExtensionLiveActivityPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: .now + ) + activeActivities.append(payload) + sortActivities() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) + logDiagnostics("Queued live activity \(descriptor.id) for \(bundleIdentifier); total activities: \(activeActivities.count)") + isUpdate = false + } + + broadcastSnapshot() + + // Trigger sneak peek (defaulting to enabled for legacy descriptors) + let resolvedConfig = descriptor.sneakPeekConfig ?? .default + if resolvedConfig.enabled { + let shouldShow = !isUpdate || resolvedConfig.showOnUpdate + if shouldShow { + triggerSneakPeek(for: descriptor, bundleIdentifier: bundleIdentifier, config: resolvedConfig) + } + } + } + + func update(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws { + guard descriptor.isValid else { + logDiagnostics("Rejected live activity update \(descriptor.id) from \(bundleIdentifier): descriptor validation failed") + throw ExtensionValidationError.invalidDescriptor("Structure validation failed") + } + guard let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else { + throw ExtensionValidationError.invalidDescriptor("Missing existing activity") + } + let payload = ExtensionLiveActivityPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeActivities[index].receivedAt + ) + activeActivities[index] = payload + sortActivities() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) + logDiagnostics("Updated live activity \(descriptor.id) for \(bundleIdentifier)") + broadcastSnapshot() + } + + func dismiss(activityID: String, bundleIdentifier: String) { + let previousCount = activeActivities.count + withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { + activeActivities.removeAll { $0.descriptor.id == activityID && $0.bundleIdentifier == bundleIdentifier } + } + if previousCount != activeActivities.count { + Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions) + ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID) + ExtensionRPCServer.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID) + logDiagnostics("Removed live activity \(activityID) for \(bundleIdentifier); remaining: \(activeActivities.count)") + broadcastSnapshot() + } + } + + func dismissAll(for bundleIdentifier: String) { + let ids = activeActivities + .filter { $0.bundleIdentifier == bundleIdentifier } + .map { $0.descriptor.id } + withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { + activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier } + } + ids.forEach { + ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) + ExtensionRPCServer.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) + } + if !ids.isEmpty { + logDiagnostics("Removed all live activities for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))") + broadcastSnapshot() + } + } + + func sortedActivities(for coexistence: Bool = false) -> [ExtensionLiveActivityPayload] { + activeActivities + .filter { coexistence ? $0.descriptor.allowsMusicCoexistence : true } + .sorted(by: descriptorComparator) + } + + func payload(bundleIdentifier: String, activityID: String) -> ExtensionLiveActivityPayload? { + activeActivities.first { $0.bundleIdentifier == bundleIdentifier && $0.descriptor.id == activityID } + } + + private func descriptorComparator(lhs: ExtensionLiveActivityPayload, rhs: ExtensionLiveActivityPayload) -> Bool { + if lhs.descriptor.priority == rhs.descriptor.priority { + return lhs.receivedAt < rhs.receivedAt + } + return lhs.descriptor.priority > rhs.descriptor.priority + } + + private func sortActivities() { + activeActivities.sort(by: descriptorComparator) + } + + private func broadcastSnapshot() { + guard !suppressBroadcast else { return } + eventBridge.broadcastLiveActivitySnapshot(activeActivities) + logDiagnostics("Broadcasted live activity snapshot (count: \(activeActivities.count))") + } + + private func applySnapshot(_ payloads: [ExtensionLiveActivityPayload], sourcePID: Int32) { + guard sourcePID != currentProcessID else { return } + suppressBroadcast = true + activeActivities = payloads.sorted(by: descriptorComparator) + suppressBroadcast = false + logDiagnostics("Applied external live activity snapshot from PID \(sourcePID) (count: \(payloads.count))") + } + + private func triggerSneakPeek(for descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String, config: AtollSneakPeekConfig) { + let coordinator = DynamicIslandViewCoordinator.shared + let duration = config.duration ?? 2.5 + let accentColor = descriptor.accentColor.swiftUIColor + let styleOverride: SneakPeekStyle? = { + guard let requestedStyle = config.style else { return nil } + switch requestedStyle { + case .inline: + logDiagnostics("Inline sneak peek requested for \(descriptor.id); host will show standard mode instead") + return .standard + case .standard: + return .standard + } + }() + + let resolvedTitle = descriptor.sneakPeekTitle?.isEmpty == false ? descriptor.sneakPeekTitle! : descriptor.title + let resolvedSubtitle = descriptor.sneakPeekSubtitle ?? descriptor.subtitle ?? "" + + // Pass the activity's id and bundleID to the sneak peek so the specialized view can look up details if needed + coordinator.toggleSneakPeek( + status: true, + type: .extensionLiveActivity(bundleID: bundleIdentifier, activityID: descriptor.id), + duration: duration, + value: 0, + icon: "", + title: resolvedTitle, + subtitle: resolvedSubtitle, + accentColor: accentColor, + styleOverride: styleOverride + ) + + logDiagnostics("Triggered sneak peek for \(descriptor.id) from \(bundleIdentifier) with duration \(duration)s") + } + + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift new file mode 100644 index 00000000..7cbdcab0 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift @@ -0,0 +1,174 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Defaults +import AtollExtensionKit + +@MainActor +final class ExtensionLockScreenWidgetManager: ObservableObject { + static let shared = ExtensionLockScreenWidgetManager() + + @Published private(set) var activeWidgets: [ExtensionLockScreenWidgetPayload] = [] + + private let authorizationManager = ExtensionAuthorizationManager.shared + private let maxCapacityKey = Defaults.Keys.extensionLockScreenWidgetCapacity + private var presentationController: ExtensionLockScreenWidgetPresentationController! + private let eventBridge = ExtensionEventBridge.shared + private var widgetObserver: NSObjectProtocol? + private var suppressBroadcast = false + private let currentProcessID = ProcessInfo.processInfo.processIdentifier + + private init() { + activeWidgets = eventBridge.loadPersistedLockScreenWidgets() + sortWidgets() + presentationController = ExtensionLockScreenWidgetPresentationController(manager: self) + presentationController.activate() + widgetObserver = eventBridge.observeLockScreenWidgetSnapshots { [weak self] payloads, sourcePID in + self?.applySnapshot(payloads, sourcePID: sourcePID) + } + } + + deinit { + if let token = widgetObserver { + eventBridge.removeObserver(token) + } + } + + func present(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws { + guard authorizationManager.canProcessLockScreenRequest(from: bundleIdentifier) else { + logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): scope disabled or bundle unauthorized") + throw ExtensionValidationError.unauthorized + } + guard descriptor.isValid else { + logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): descriptor validation failed") + throw ExtensionValidationError.invalidDescriptor("Structure validation failed") + } + + if let index = activeWidgets.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) { + let payload = ExtensionLockScreenWidgetPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeWidgets[index].receivedAt + ) + activeWidgets[index] = payload + sortWidgets() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets) + Logger.log("Replaced extension widget \(descriptor.id) for \(bundleIdentifier)", category: .extensions) + broadcastSnapshot() + return + } + + guard activeWidgets.count < Defaults[maxCapacityKey] else { + logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached") + throw ExtensionValidationError.exceedsCapacity + } + + let payload = ExtensionLockScreenWidgetPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: .now + ) + activeWidgets.append(payload) + sortWidgets() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets) + logDiagnostics("Queued lock screen widget \(descriptor.id) for \(bundleIdentifier); total widgets: \(activeWidgets.count)") + broadcastSnapshot() + } + + func update(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws { + guard descriptor.isValid else { + logDiagnostics("Rejected lock screen widget update \(descriptor.id) from \(bundleIdentifier): descriptor validation failed") + throw ExtensionValidationError.invalidDescriptor("Structure validation failed") + } + guard let index = activeWidgets.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else { + throw ExtensionValidationError.invalidDescriptor("Missing existing widget") + } + + let payload = ExtensionLockScreenWidgetPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeWidgets[index].receivedAt + ) + activeWidgets[index] = payload + sortWidgets() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets) + logDiagnostics("Updated lock screen widget \(descriptor.id) for \(bundleIdentifier)") + broadcastSnapshot() + } + + func dismiss(widgetID: String, bundleIdentifier: String) { + let previousCount = activeWidgets.count + activeWidgets.removeAll { $0.descriptor.id == widgetID && $0.bundleIdentifier == bundleIdentifier } + if previousCount != activeWidgets.count { + Logger.log("Dismissed extension widget \(widgetID) from \(bundleIdentifier)", category: .extensions) + ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: widgetID) + ExtensionRPCServer.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: widgetID) + logDiagnostics("Removed lock screen widget \(widgetID) for \(bundleIdentifier); remaining: \(activeWidgets.count)") + broadcastSnapshot() + } + } + + func dismissAll(for bundleIdentifier: String) { + let ids = activeWidgets + .filter { $0.bundleIdentifier == bundleIdentifier } + .map { $0.descriptor.id } + activeWidgets.removeAll { $0.bundleIdentifier == bundleIdentifier } + ids.forEach { + ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0) + ExtensionRPCServer.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0) + } + if !ids.isEmpty { + logDiagnostics("Removed all lock screen widgets for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))") + broadcastSnapshot() + } + } + + private func sortWidgets() { + activeWidgets.sort { lhs, rhs in + if lhs.priority == rhs.priority { + return lhs.receivedAt < rhs.receivedAt + } + return lhs.priority > rhs.priority + } + } + + private func broadcastSnapshot() { + guard !suppressBroadcast else { return } + eventBridge.broadcastLockScreenWidgetSnapshot(activeWidgets) + logDiagnostics("Broadcasted lock screen widget snapshot (count: \(activeWidgets.count))") + } + + private func applySnapshot(_ payloads: [ExtensionLockScreenWidgetPayload], sourcePID: Int32) { + guard sourcePID != currentProcessID else { return } + suppressBroadcast = true + activeWidgets = payloads.sorted { lhs, rhs in + if lhs.priority == rhs.priority { + return lhs.receivedAt < rhs.receivedAt + } + return lhs.priority > rhs.priority + } + suppressBroadcast = false + logDiagnostics("Applied external lock screen widget snapshot from PID \(sourcePID) (count: \(payloads.count))") + } + + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift new file mode 100644 index 00000000..5a4b6c13 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift @@ -0,0 +1,316 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Combine +import Defaults +import AppKit +import SkyLightWindow +import AtollExtensionKit + +@MainActor +final class ExtensionLockScreenWidgetPresentationController { + private unowned let manager: ExtensionLockScreenWidgetManager + private var cancellables: Set = [] + private let windowPool = ExtensionLockScreenWidgetWindowPool() + private var cachedPayloads: [ExtensionLockScreenWidgetPayload] = [] + private var isLocked: Bool = false + private var lastVisibilityState: VisibilityState? + + init(manager: ExtensionLockScreenWidgetManager) { + self.manager = manager + } + + func activate() { + observeLockState() + observePayloads() + observeDefaults() + } + + private func observeLockState() { + LockScreenManager.shared.$isLocked + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] locked in + guard let self else { return } + self.isLocked = locked + self.refreshPresentation() + } + .store(in: &cancellables) + } + + private func observePayloads() { + manager.$activeWidgets + .receive(on: RunLoop.main) + .sink { [weak self] payloads in + guard let self else { return } + self.cachedPayloads = payloads + self.refreshPresentation() + } + .store(in: &cancellables) + } + + private func observeDefaults() { + Defaults.publisher(.enableExtensionLockScreenWidgets, options: []) + .receive(on: RunLoop.main) + .sink { [weak self] change in + guard let self else { return } + if change.newValue { + self.refreshPresentation() + } else { + self.windowPool.hideAll() + self.updateVisibilityLog(.hidden(reason: "feature-disabled")) + } + } + .store(in: &cancellables) + } + + private func refreshPresentation() { + guard isLocked, LockScreenManager.shared.currentLockStatus, Defaults[.enableExtensionLockScreenWidgets] else { + windowPool.hideAll() + updateVisibilityLog(.hidden(reason: "lock-state")) + return + } + guard cachedPayloads.isEmpty == false else { + windowPool.hideAll() + updateVisibilityLog(.hidden(reason: "no-payloads")) + return + } + windowPool.sync(with: cachedPayloads) + updateVisibilityLog(.showing(count: cachedPayloads.count)) + } + + private func updateVisibilityLog(_ state: VisibilityState) { + guard state != lastVisibilityState else { return } + lastVisibilityState = state + switch state { + case let .hidden(reason): + logDiagnostics("Lock screen widgets hidden (reason: \(reason))") + case let .showing(count): + logDiagnostics("Presenting \(count) extension lock screen widget(s)") + } + } + + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } + + private enum VisibilityState: Equatable { + case hidden(reason: String) + case showing(count: Int) + } +} + +@MainActor +private final class ExtensionLockScreenWidgetWindowPool { + private struct WindowRecord { + let window: NSWindow + } + + private var windows: [String: WindowRecord] = [:] + private var payloadsByID: [String: ExtensionLockScreenWidgetPayload] = [:] + private var screenChangeObserver: NSObjectProtocol? + private var workspaceObservers: [NSObjectProtocol] = [] + + init() { + registerScreenChangeObservers() + } + + deinit { + if let screenChangeObserver { + NotificationCenter.default.removeObserver(screenChangeObserver) + } + let workspaceCenter = NSWorkspace.shared.notificationCenter + workspaceObservers.forEach { workspaceCenter.removeObserver($0) } + } + + func sync(with payloads: [ExtensionLockScreenWidgetPayload]) { + payloadsByID = Dictionary(uniqueKeysWithValues: payloads.map { ($0.id, $0) }) + let activeIDs = Set(payloadsByID.keys) + let staleIDs = Set(windows.keys).subtracting(activeIDs) + staleIDs.forEach { hideWindow(withID: $0) } + payloads.forEach { renderWindow(for: $0) } + } + + func hideAll() { + payloadsByID.removeAll() + windows.values.forEach { record in + record.window.orderOut(nil) + record.window.contentView = nil + } + windows.removeAll() + logDiagnostics("Cleared all lock screen widget windows") + } + + private func renderWindow(for payload: ExtensionLockScreenWidgetPayload) { + guard let screen = currentScreen() else { return } + let descriptor = payload.descriptor + let isNewWindow = windows[payload.id] == nil + let window = ensureWindow(for: payload.id) + let hosting = NSHostingView(rootView: ExtensionLockScreenWidgetView(payload: payload)) + hosting.frame = NSRect(origin: .zero, size: descriptor.size) + hosting.wantsLayer = true + hosting.layer?.masksToBounds = true + hosting.layer?.cornerRadius = descriptor.cornerRadius + window.contentView = hosting + window.setFrame(frame(for: descriptor, on: screen), display: true) + window.alphaValue = 1 + window.orderFrontRegardless() + if isNewWindow { + logDiagnostics("Created lock screen widget window for \(payload.bundleIdentifier) id=\(payload.id) style=\(descriptor.layoutStyle)") + } else { + logDiagnostics("Updated lock screen widget window for \(payload.bundleIdentifier) id=\(payload.id)") + } + } + + private func ensureWindow(for id: String) -> NSWindow { + if let record = windows[id] { + return record.window + } + + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: CGSize(width: 200, height: 80)), + 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 = true + window.isMovable = false + + ScreenCaptureVisibilityManager.shared.register(window, scope: .entireInterface) + SkyLightOperator.shared.delegateWindow(window) + + let record = WindowRecord(window: window) + windows[id] = record + return window + } + + private func hideWindow(withID id: String) { + guard let record = windows[id] else { return } + record.window.orderOut(nil) + record.window.contentView = nil + windows.removeValue(forKey: id) + logDiagnostics("Removed lock screen widget window id=\(id)") + } + + private func registerScreenChangeObservers() { + screenChangeObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screen-parameters") + } + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let wakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screens-did-wake") + } + + workspaceObservers = [wakeObserver] + } + + private func handleScreenGeometryChange(reason: String) { + guard windows.isEmpty == false else { return } + repositionAll(animated: false) + logDiagnostics("Realigned lock screen widget windows due to \(reason)") + } + + private func repositionAll(animated: Bool) { + guard let screen = currentScreen() else { return } + for (id, record) in windows { + guard let payload = payloadsByID[id] else { continue } + let targetFrame = frame(for: payload.descriptor, on: screen) + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.22 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + record.window.animator().setFrame(targetFrame, display: true) + } + } else { + record.window.setFrame(targetFrame, display: true) + } + } + } + + private func frame(for descriptor: AtollLockScreenWidgetDescriptor, on screen: NSScreen) -> NSRect { + let size = descriptor.size + let safeInsets = safeInsets(for: descriptor.position.clampMode) + let screenFrame = screen.frame + + let minX = screenFrame.minX + safeInsets.left + let maxX = screenFrame.maxX - safeInsets.right - size.width + let minY = screenFrame.minY + safeInsets.bottom + let maxY = screenFrame.maxY - safeInsets.top - size.height + + var originX: CGFloat + switch descriptor.position.alignment { + case .leading: + originX = minX + case .center: + originX = screenFrame.midX - (size.width / 2) + case .trailing: + originX = maxX + } + + originX += descriptor.position.horizontalOffset + originX = clamp(originX, lower: min(minX, maxX), upper: max(minX, maxX)) + + var originY = screenFrame.midY - (size.height / 2) + descriptor.position.verticalOffset + originY = clamp(originY, lower: min(minY, maxY), upper: max(minY, maxY)) + + return NSRect(x: originX, y: originY, width: size.width, height: size.height) + } + + private func safeInsets(for clampMode: AtollWidgetPosition.ClampMode) -> NSEdgeInsets { + switch clampMode { + case .safeRegion: + return NSEdgeInsets(top: 140, left: 48, bottom: 100, right: 48) + case .relaxed: + return NSEdgeInsets(top: 96, left: 24, bottom: 72, right: 24) + case .unconstrained: + return NSEdgeInsets(top: 16, left: 0, bottom: 32, right: 0) + } + } + + private func clamp(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { + guard lower <= upper else { return value } + return min(max(value, lower), upper) + } + + private func currentScreen() -> NSScreen? { + LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main + } +} + +private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) +} diff --git a/DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift b/DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift new file mode 100644 index 00000000..28d1cf7d --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift @@ -0,0 +1,237 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Defaults +import AtollExtensionKit +import SwiftUI + +@MainActor +final class ExtensionNotchExperienceManager: ObservableObject { + static let shared = ExtensionNotchExperienceManager() + + @Published private(set) var activeExperiences: [ExtensionNotchExperiencePayload] = [] + + private let authorizationManager = ExtensionAuthorizationManager.shared + private let maxCapacityKey = Defaults.Keys.extensionNotchExperienceCapacity + private let eventBridge = ExtensionEventBridge.shared + private var observerToken: NSObjectProtocol? + private var suppressBroadcast = false + private let currentProcessID = ProcessInfo.processInfo.processIdentifier + + private init() { + activeExperiences = eventBridge.loadPersistedNotchExperiences() + sortExperiences() + observerToken = eventBridge.observeNotchExperienceSnapshots { [weak self] payloads, sourcePID in + self?.applySnapshot(payloads, sourcePID: sourcePID) + } + } + + deinit { + if let observerToken { + eventBridge.removeObserver(observerToken) + } + } + + // MARK: - Presentation Lifecycle + + func present(descriptor: AtollNotchExperienceDescriptor, bundleIdentifier: String) throws { + guard authorizationManager.canProcessNotchExperienceRequest(from: bundleIdentifier) else { + logDiagnostics("Rejected notch experience \(descriptor.id) from \(bundleIdentifier): scope disabled or bundle unauthorized") + throw ExtensionValidationError.unauthorized + } + guard descriptor.bundleIdentifier == bundleIdentifier else { + logDiagnostics("Rejected notch experience \(descriptor.id) from \(bundleIdentifier): bundle mismatch (descriptor: \(descriptor.bundleIdentifier))") + throw ExtensionValidationError.invalidDescriptor("Bundle identifier mismatch") + } + + try ExtensionDescriptorValidator.validate(descriptor) + try ensureWebContentSupport(for: descriptor) + + let isUpdate: Bool + if let index = activeExperiences.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) { + let payload = ExtensionNotchExperiencePayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeExperiences[index].receivedAt + ) + activeExperiences[index] = payload + sortExperiences() + isUpdate = true + Logger.log("Replaced notch experience \(descriptor.id) for \(bundleIdentifier)", category: .extensions) + } else { + guard activeExperiences.count < Defaults[maxCapacityKey] else { + logDiagnostics("Rejected notch experience \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached") + throw ExtensionValidationError.exceedsCapacity + } + + let payload = ExtensionNotchExperiencePayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: .now + ) + activeExperiences.append(payload) + sortExperiences() + isUpdate = false + logDiagnostics("Queued notch experience \(descriptor.id) for \(bundleIdentifier); total experiences: \(activeExperiences.count)") + } + + authorizationManager.recordActivity(for: bundleIdentifier, scope: .notchExperiences) + broadcastSnapshot() + + if let tabConfig = descriptor.tab, Defaults[.enableExtensionNotchTabs] { + Logger.log("Notch experience tab ready (title: \(tabConfig.title))", category: .extensions) + } + + if descriptor.minimalistic != nil && Defaults[.enableExtensionNotchMinimalisticOverrides] { + Logger.log("Notch experience minimalistic override available", category: .extensions) + } + + if !isUpdate { + logDiagnostics("Stored notch experience \(descriptor.id) for \(bundleIdentifier); priority \(descriptor.priority.rawValue)") + } + } + + func update(descriptor: AtollNotchExperienceDescriptor, bundleIdentifier: String) throws { + try ExtensionDescriptorValidator.validate(descriptor) + guard descriptor.bundleIdentifier == bundleIdentifier else { + logDiagnostics("Rejected notch experience update \(descriptor.id) from \(bundleIdentifier): bundle mismatch (descriptor: \(descriptor.bundleIdentifier))") + throw ExtensionValidationError.invalidDescriptor("Bundle identifier mismatch") + } + guard let index = activeExperiences.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else { + throw ExtensionValidationError.invalidDescriptor("Missing existing notch experience") + } + try ensureWebContentSupport(for: descriptor) + let payload = ExtensionNotchExperiencePayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeExperiences[index].receivedAt + ) + activeExperiences[index] = payload + sortExperiences() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .notchExperiences) + logDiagnostics("Updated notch experience \(descriptor.id) for \(bundleIdentifier)") + broadcastSnapshot() + } + + func dismiss(experienceID: String, bundleIdentifier: String) { + let previousCount = activeExperiences.count + withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { + activeExperiences.removeAll { $0.descriptor.id == experienceID && $0.bundleIdentifier == bundleIdentifier } + } + guard previousCount != activeExperiences.count else { return } + Logger.log("Dismissed notch experience \(experienceID) from \(bundleIdentifier)", category: .extensions) + ExtensionXPCServiceHost.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: experienceID) + ExtensionRPCServer.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: experienceID) + logDiagnostics("Removed notch experience \(experienceID) for \(bundleIdentifier); remaining: \(activeExperiences.count)") + broadcastSnapshot() + } + + func dismissAll(for bundleIdentifier: String) { + let ids = activeExperiences + .filter { $0.bundleIdentifier == bundleIdentifier } + .map { $0.descriptor.id } + withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { + activeExperiences.removeAll { $0.bundleIdentifier == bundleIdentifier } + } + ids.forEach { + ExtensionXPCServiceHost.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: $0) + ExtensionRPCServer.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: $0) + } + if !ids.isEmpty { + logDiagnostics("Removed all notch experiences for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))") + broadcastSnapshot() + } + } + + // MARK: - Presentation Resolution + + func highestPriorityTabPayload() -> ExtensionNotchExperiencePayload? { + guard Defaults[.enableThirdPartyExtensions], + Defaults[.enableExtensionNotchExperiences], + Defaults[.enableExtensionNotchTabs] else { + return nil + } + return activeExperiences.first(where: { $0.descriptor.tab != nil }) + } + + func minimalisticReplacementPayload() -> ExtensionNotchExperiencePayload? { + guard Defaults[.enableThirdPartyExtensions], + Defaults[.enableExtensionNotchExperiences], + Defaults[.enableExtensionNotchMinimalisticOverrides] else { + return nil + } + return activeExperiences.first(where: { $0.descriptor.minimalistic != nil }) + } + + func payload(bundleIdentifier: String, experienceID: String) -> ExtensionNotchExperiencePayload? { + activeExperiences.first { $0.bundleIdentifier == bundleIdentifier && $0.descriptor.id == experienceID } + } + + func payload(experienceID: String) -> ExtensionNotchExperiencePayload? { + activeExperiences.first { $0.descriptor.id == experienceID } + } + + // MARK: - Snapshot Sync + + private func sortExperiences() { + activeExperiences.sort(by: descriptorComparator) + } + + private func descriptorComparator(lhs: ExtensionNotchExperiencePayload, rhs: ExtensionNotchExperiencePayload) -> Bool { + if lhs.priority == rhs.priority { + return lhs.receivedAt < rhs.receivedAt + } + return lhs.priority > rhs.priority + } + + private func broadcastSnapshot() { + guard !suppressBroadcast else { return } + eventBridge.broadcastNotchExperienceSnapshot(activeExperiences) + logDiagnostics("Broadcasted notch experience snapshot (count: \(activeExperiences.count))") + } + + private func applySnapshot(_ payloads: [ExtensionNotchExperiencePayload], sourcePID: Int32) { + guard sourcePID != currentProcessID else { return } + suppressBroadcast = true + activeExperiences = payloads.sorted(by: descriptorComparator) + suppressBroadcast = false + logDiagnostics("Applied external notch experience snapshot from PID \(sourcePID) (count: \(payloads.count))") + } + + private func ensureWebContentSupport(for descriptor: AtollNotchExperienceDescriptor) throws { + guard descriptor.hasWebContent else { return } + guard Defaults[.enableExtensionNotchInteractiveWebViews] else { + logDiagnostics("Rejected notch experience \(descriptor.id) due to web content while interactive web views are disabled") + throw ExtensionValidationError.unsupportedContent + } + } + + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } +} + +private extension AtollNotchExperienceDescriptor { + var hasWebContent: Bool { + if tab?.webContent != nil { return true } + if minimalistic?.webContent != nil { return true } + return false + } +} diff --git a/DynamicIsland/managers/FullScreenArtworkWindowManager.swift b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift new file mode 100644 index 00000000..8784c609 --- /dev/null +++ b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift @@ -0,0 +1,1874 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import AVFoundation +import Combine +import CoreImage +import Defaults +import SkyLightWindow +import SwiftUI + +// MARK: - Click Receiver Window + +private class ClickReceiverWindow: NSWindow { + var onClick: (() -> Void)? + + override func mouseDown(with event: NSEvent) { + // Keep left click inert so the fallback only dismisses on right click. + } + + override func rightMouseDown(with event: NSEvent) { + onClick?() + } +} + +private final class LoopingVideoView: NSView { + private var playerLayer: AVPlayerLayer? + private var queuePlayer: AVQueuePlayer? + private var playerLooper: AVPlayerLooper? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + override func layout() { + super.layout() + playerLayer?.frame = bounds + } + + func play(url: URL) { + stop() + + let playerItem = AVPlayerItem(url: url) + let player = AVQueuePlayer() + player.isMuted = true + player.actionAtItemEnd = .none + + let layer = AVPlayerLayer(player: player) + layer.videoGravity = .resizeAspectFill + layer.frame = bounds + layer.backgroundColor = NSColor.clear.cgColor + + self.layer?.sublayers?.forEach { $0.removeFromSuperlayer() } + self.layer?.addSublayer(layer) + + playerLayer = layer + queuePlayer = player + playerLooper = AVPlayerLooper(player: player, templateItem: playerItem) + player.play() + } + + func stop() { + queuePlayer?.pause() + playerLooper = nil + queuePlayer = nil + playerLayer?.removeFromSuperlayer() + playerLayer = nil + } + + func pause() { + queuePlayer?.pause() + } + + func resume() { + queuePlayer?.play() + } +} + +private final class WallpaperTransitionImageView: NSView { + private let imageLayer = CALayer() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + imageLayer.contentsGravity = .resizeAspectFill + imageLayer.masksToBounds = true + imageLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + layer?.addSublayer(imageLayer) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + imageLayer.contentsGravity = .resizeAspectFill + imageLayer.masksToBounds = true + imageLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + layer?.addSublayer(imageLayer) + } + + override func layout() { + super.layout() + imageLayer.frame = bounds + } + + func updateImage(_ image: NSImage) { + imageLayer.contents = image.cgImage(forProposedRect: nil, context: nil, hints: nil) + } +} + +private final class SpotifyCanvasFallbackArtworkOverlayView: NSView { + var onDismiss: (() -> Void)? + + private let imageLayer = CALayer() + private let borderLayer = CAShapeLayer() + private var artwork: NSImage + + init(artwork: NSImage) { + self.artwork = artwork + super.init(frame: .zero) + + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + layer?.shadowColor = NSColor.black.withAlphaComponent(0.34).cgColor + layer?.shadowOpacity = 1 + layer?.shadowRadius = 26 + layer?.shadowOffset = CGSize(width: 0, height: -22) + + imageLayer.contentsGravity = .resizeAspectFill + imageLayer.masksToBounds = true + imageLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + imageLayer.contents = artwork.cgImage(forProposedRect: nil, context: nil, hints: nil) + + borderLayer.fillColor = NSColor.clear.cgColor + borderLayer.strokeColor = NSColor.white.withAlphaComponent(0.18).cgColor + borderLayer.lineWidth = 1.1 + + layer?.addSublayer(imageLayer) + layer?.addSublayer(borderLayer) + } + + required init?(coder: NSCoder) { + nil + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func layout() { + super.layout() + + let radius = cornerRadius + imageLayer.frame = bounds + imageLayer.cornerRadius = radius + borderLayer.frame = bounds + borderLayer.path = CGPath( + roundedRect: bounds.insetBy(dx: 0.55, dy: 0.55), + cornerWidth: radius, + cornerHeight: radius, + transform: nil + ) + + layer?.shadowPath = CGPath( + roundedRect: bounds, + cornerWidth: radius, + cornerHeight: radius, + transform: nil + ) + } + + override func mouseDown(with event: NSEvent) { + if event.clickCount >= 2 { + onDismiss?() + } + } + + override func rightMouseDown(with event: NSEvent) { + onDismiss?() + } + + func updateArtwork(_ artwork: NSImage) { + self.artwork = artwork + imageLayer.contents = artwork.cgImage(forProposedRect: nil, context: nil, hints: nil) + needsLayout = true + } + + private var aspectRatio: CGFloat { + let height = max(artwork.size.height, 1) + return artwork.size.width / height + } + + private var cornerRadius: CGFloat { + aspectRatio > 1 ? 16 : 34 + } +} + +private struct FullScreenLyricsOverlayContent: View { + let text: String + let fontSize: CGFloat + + private var isPlaceholder: Bool { + text == "Loading lyrics..." || text == "No lyrics found" + } + + var body: some View { + VStack { + Text(text) + .font(.system(size: fontSize, weight: .semibold, design: .rounded)) + .foregroundColor(.white.opacity(isPlaceholder ? 0.72 : 0.94)) + .multilineTextAlignment(.center) + .lineLimit(4) + .minimumScaleFactor(0.62) + .allowsTightening(true) + .shadow(color: .black.opacity(0.42), radius: 18, x: 0, y: 6) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background(Color.clear) + } +} + +private struct SpotifyCanvasFallbackLayoutFrames { + let artworkFrame: NSRect + let panelFrame: NSRect + let groupFrame: NSRect + let lyricsFrame: NSRect? +} + +// MARK: - Window Manager + +@MainActor +final class FullScreenArtworkWindowManager: ObservableObject { + private enum ArtworkOverlayMode { + case none + case spotifyFallback + } + + static let shared = FullScreenArtworkWindowManager() + + @Published private(set) var isShowing = false + @Published private(set) var isShowingSpotifyCanvasFallback = false + var onDismiss: (() -> Void)? + + private let wallpaperPlistURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/Store/Index.plist") + }() + private let backupPlistURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_wallpaper_backup.plist") + }() + private let aerialManifestURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/manifest/entries.json") + }() + private let aerialVideosDirectoryURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/videos", isDirectory: true) + }() + private let aerialThumbnailsDirectoryURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/thumbnails", isDirectory: true) + }() + private let customLiveWallpaperAssetID = "6D6834A4-2F0F-479A-B053-7D4DC5CB8EB7" + private let liveWallpaperManifestBackupURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_aerial_manifest_backup.json") + }() + private let liveWallpaperVideoBackupURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_aerial_video_backup.mov") + }() + private let liveWallpaperThumbnailBackupURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_aerial_thumbnail_backup.png") + }() + private var artworkFileURL: URL? + private var cachedArtworkPNG: URL? + private var cachedArtworkFingerprint: String? + private var cachedBlurredArtworkPNG: URL? + private var cachedBlurredArtworkFingerprint: String? + private var cachedBlurredArtworkPixelSize: CGSize = .zero + private var activeSongTitle: String? + private var activeArtist: String? + private var trackChangeCancellable: AnyCancellable? + private var artworkCacheCancellable: AnyCancellable? + private var videoArtworkCancellable: AnyCancellable? + private var playbackStateCancellable: AnyCancellable? + private var hasSuspendedWallpaperAgent = false + private var appTerminationObserver: NSObjectProtocol? + private var clickWindow: ClickReceiverWindow? + private var clickWindowDelegated = false + private var videoWindow: NSWindow? + private var videoView: LoopingVideoView? + private var activeVideoWindowURL: URL? + private var videoWindowHideTask: Task? + private var artworkOverlayWindow: NSWindow? + private var artworkOverlayWindowDelegated = false + private var lyricsOverlayWindow: NSWindow? + private var lyricsOverlayWindowDelegated = false + private var currentArtworkOverlayMode: ArtworkOverlayMode = .none + private var wallpaperTransitionWindow: NSWindow? + private var wallpaperTransitionView: WallpaperTransitionImageView? + private var wallpaperTransitionHideTask: Task? + private var panelFrameChangeObserver: NSObjectProtocol? + private var liveWallpaperTask: Task? + private var deferredTrackRefreshTask: Task? + private var isLiveWallpaperAllowed = false + private var activeLiveWallpaperFingerprint: String? + private var activeWallpaperKey: String? + private var pendingFallbackStaticURL: URL? + private var pendingFallbackWallpaperKey: String? + private var originalDesktopImageURLs: [String: URL] = [:] + private var didCaptureStaticBackup = false + private var appliedStaticWallpaper = false + private var didApplyAerialWallpaper = false + private var fallbackRightClickMonitor: Any? + private var artworkLayoutOverCanvasPreferenceCancellable: AnyCancellable? + private var lyricsTextCancellable: AnyCancellable? + private var lyricsPreferenceCancellable: AnyCancellable? + private let spotifyCanvasFallbackHorizontalMargin: CGFloat = 48 + + private init() { + observeArtworkChanges() + observeVideoArtworkChanges() + observePanelFrameChanges() + observeArtworkLayoutOverCanvasPreference() + observeLyricsChanges() + observeLyricsPreference() + observePlaybackStateChanges() + observeAppTermination() + } + + func show(artwork: NSImage, videoURL: URL? = nil, allowLiveWallpaper: Bool = false) { + guard !isShowing else { return } + guard let screen = NSScreen.main else { return } + applyPresentation( + artwork: artwork, + videoURL: videoURL, + allowLiveWallpaper: allowLiveWallpaper, + on: screen, + backupWallpaperConfiguration: true + ) + observeTrackChanges() + } + + func hide() { + guard isShowing else { return } + resumeWallpaperAgentIfNeeded() + isShowing = false + isLiveWallpaperAllowed = false + activeLiveWallpaperFingerprint = nil + activeWallpaperKey = nil + pendingFallbackStaticURL = nil + pendingFallbackWallpaperKey = nil + let shouldRestoreStandardPanelLayout = isShowingSpotifyCanvasFallback + isShowingSpotifyCanvasFallback = false + removeFallbackRightClickMonitor() + trackChangeCancellable?.cancel() + trackChangeCancellable = nil + liveWallpaperTask?.cancel() + liveWallpaperTask = nil + deferredTrackRefreshTask?.cancel() + deferredTrackRefreshTask = nil + + hideVideoWindow() + hideArtworkOverlay() + hideLyricsOverlay() + hideWallpaperTransition() + hideClickReceiver() + if shouldRestoreStandardPanelLayout, LockScreenManager.shared.isLocked { + LockScreenPanelManager.shared.applyOffsetAdjustment(animated: true) + } + restoreWallpaper() + + activeSongTitle = nil + activeArtist = nil + + let callback = onDismiss + onDismiss = nil + callback?() + + print("[FullScreenArtworkWindowManager] Original wallpaper restored") + } + + // MARK: - Artwork Pre-Cache + + private func observeArtworkChanges() { + artworkCacheCancellable = MusicManager.shared.$albumArt + .debounce(for: .milliseconds(180), scheduler: DispatchQueue.main) + .sink { [weak self] newArt in + guard let self else { return } + guard let fingerprint = self.imageFingerprint(for: newArt) else { return } + guard fingerprint != self.cachedArtworkFingerprint else { return } + let url = self.artworkCacheFileURL(for: fingerprint) + Task.detached(priority: .utility) { + guard let pngData = self.encodeToPNG(newArt) else { return } + try? pngData.write(to: url, options: .atomic) + await MainActor.run { + self.cachedArtworkPNG = url + self.cachedArtworkFingerprint = fingerprint + if self.isShowing { + self.refreshPresentationForCurrentTrack() + } + } + } + } + } + + private func observeVideoArtworkChanges() { + videoArtworkCancellable = MusicManager.shared.$videoArtworkURL + .debounce(for: .milliseconds(120), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.isShowing, self.isLiveWallpaperAllowed else { return } + self.refreshPresentationForCurrentTrack() + } + } + + private func observePlaybackStateChanges() { + playbackStateCancellable = MusicManager.shared.$isPlaying + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] isPlaying in + self?.applyCanvasPlaybackState(isPlaying: isPlaying) + } + } + + private static let wallpaperRenderProcessNames: [String] = [ + "WallpaperAgent", + "WallpaperAerialsExtension", + "WallpaperVideoExtension", + "wallpaperexportd" + ] + + private func applyCanvasPlaybackState(isPlaying: Bool) { + if let videoView, videoWindow != nil { + if isPlaying { + videoView.resume() + } else { + videoView.pause() + } + } + + let canSuspend = isShowing && isLiveWallpaperAllowed && activeLiveWallpaperFingerprint != nil + print("[FullScreenArtworkWindowManager] playback state -> isPlaying=\(isPlaying) showing=\(isShowing) liveAllowed=\(isLiveWallpaperAllowed) fingerprint=\(activeLiveWallpaperFingerprint ?? "nil")") + + if isPlaying { + resumeWallpaperAgentIfNeeded() + } else if canSuspend { + suspendWallpaperAgent() + } + } + + private func suspendWallpaperAgent() { + guard !hasSuspendedWallpaperAgent else { return } + var anySucceeded = false + for name in Self.wallpaperRenderProcessNames { + if signalProcess(name: name, signalFlag: "-STOP") { + anySucceeded = true + } + } + hasSuspendedWallpaperAgent = anySucceeded + print("[FullScreenArtworkWindowManager] SIGSTOP wallpaper processes -> anySucceeded=\(anySucceeded)") + } + + private func resumeWallpaperAgentIfNeeded() { + guard hasSuspendedWallpaperAgent else { return } + for name in Self.wallpaperRenderProcessNames { + _ = signalProcess(name: name, signalFlag: "-CONT") + } + hasSuspendedWallpaperAgent = false + print("[FullScreenArtworkWindowManager] SIGCONT wallpaper processes") + } + + @discardableResult + private func signalProcess(name: String, signalFlag: String) -> Bool { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + task.arguments = [signalFlag, name] + task.standardError = Pipe() + task.standardOutput = Pipe() + do { + try task.run() + task.waitUntilExit() + return task.terminationStatus == 0 + } catch { + print("[FullScreenArtworkWindowManager] killall \(signalFlag) \(name) threw: \(error)") + return false + } + } + + private func observeAppTermination() { + appTerminationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.resumeWallpaperAgentIfNeeded() + } + } + + private func applyPresentation( + artwork: NSImage, + videoURL: URL?, + allowLiveWallpaper: Bool, + on screen: NSScreen, + backupWallpaperConfiguration: Bool + ) { + let artworkID = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" + guard let artworkFingerprint = imageFingerprint(for: artwork) else { return } + isLiveWallpaperAllowed = allowLiveWallpaper + let shouldUseStaticFallbackWallpaper = shouldUseBlurredStaticWallpaper(videoURL: videoURL) + let shouldUseFallbackLayout = shouldUseSpotifyFallbackLayout(videoURL: videoURL) + let effectiveVideoURL = resolvedVideoURL(videoURL: videoURL) + let overlayMode = artworkOverlayMode(shouldUseFallbackLayout: shouldUseFallbackLayout) + let previousFallbackState = isShowingSpotifyCanvasFallback + + guard let rawArtworkFileURL = persistedArtworkFileURL(for: artwork, fingerprint: artworkFingerprint) else { return } + let expectCanvas = effectiveVideoURL != nil + let blurredFallbackURL = blurredWallpaperFileURL(for: artwork, fingerprint: artworkFingerprint, screen: screen) ?? rawArtworkFileURL + let wallpaperURL = (shouldUseStaticFallbackWallpaper || expectCanvas) + ? blurredFallbackURL + : rawArtworkFileURL + let wallpaperKey = wallpaperIdentityKey( + fingerprint: artworkFingerprint, + usesFallback: shouldUseStaticFallbackWallpaper || expectCanvas, + screen: screen + ) + let desiredLiveWallpaperFingerprint = effectiveVideoURL?.absoluteString + let requiresWallpaperRefresh = backupWallpaperConfiguration + || activeWallpaperKey != wallpaperKey + || activeLiveWallpaperFingerprint != desiredLiveWallpaperFingerprint + + artworkFileURL = wallpaperURL + + if backupWallpaperConfiguration { + backupWallpaperConfig() + } + + if requiresWallpaperRefresh { + if expectCanvas { + pendingFallbackStaticURL = blurredFallbackURL + pendingFallbackWallpaperKey = wallpaperKey + activeWallpaperKey = nil + activeLiveWallpaperFingerprint = nil + } else { + showWallpaperTransition(on: screen, imageURL: wallpaperURL) + + if backupWallpaperConfiguration { + captureStaticWallpaperBackupIfNeeded() + } + + guard applyStaticWallpaper(imageURL: wallpaperURL) else { + print("[FullScreenArtworkWindowManager] Failed to set desktop image") + hideWallpaperTransition() + return + } + + activeWallpaperKey = wallpaperKey + activeLiveWallpaperFingerprint = nil + pendingFallbackStaticURL = nil + pendingFallbackWallpaperKey = nil + } + } + + isShowing = true + isShowingSpotifyCanvasFallback = shouldUseFallbackLayout + activeSongTitle = MusicManager.shared.songTitle + activeArtist = MusicManager.shared.artistName + + if overlayMode == .spotifyFallback { + installFallbackRightClickMonitorIfNeeded() + } else { + removeFallbackRightClickMonitor() + } + + if overlayMode == .none { + hideArtworkOverlay() + } else { + showArtworkOverlay(on: screen, artwork: artwork, mode: overlayMode) + } + + updateLyricsOverlayIfNeeded(on: screen) + + if previousFallbackState != shouldUseFallbackLayout, LockScreenManager.shared.isLocked { + LockScreenPanelManager.shared.applyOffsetAdjustment(animated: true) + } + + showClickReceiver(on: screen) + scheduleLiveWallpaperPreparation(for: effectiveVideoURL, artwork: artwork, identifier: artworkID) + + if effectiveVideoURL == nil { + hideVideoWindow() + scheduleWallpaperTransitionHide(after: .milliseconds(900)) + } + + print("[FullScreenArtworkWindowManager] Artwork applied as wallpaper") + } + + private func refreshPresentationForCurrentTrack() { + guard isShowing else { return } + guard MusicManager.shared.hasActiveSession else { return } + guard let screen = NSScreen.main else { return } + + applyPresentation( + artwork: MusicManager.shared.albumArt, + videoURL: isLiveWallpaperAllowed ? MusicManager.shared.videoArtworkURL : nil, + allowLiveWallpaper: isLiveWallpaperAllowed, + on: screen, + backupWallpaperConfiguration: false + ) + } + + private func observePanelFrameChanges() { + panelFrameChangeObserver = NotificationCenter.default.addObserver( + forName: .atollLockScreenPanelFrameDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateArtworkOverlayFrameIfNeeded() + self?.updateLyricsOverlayIfNeeded() + } + } + } + + private func observeArtworkLayoutOverCanvasPreference() { + artworkLayoutOverCanvasPreferenceCancellable = Defaults.publisher(.lockScreenUseArtworkLayoutOverFullscreenCanvas) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.isShowing else { return } + self.refreshPresentationForCurrentTrack() + } + } + + private func observeLyricsChanges() { + lyricsTextCancellable = MusicManager.shared.$currentLyrics + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.isShowing else { return } + self.updateLyricsOverlayIfNeeded() + } + } + + private func observeLyricsPreference() { + lyricsPreferenceCancellable = Defaults.publisher(.enableLyrics) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.isShowing else { return } + self.updateLyricsOverlayIfNeeded() + } + } + + private func shouldUseBlurredStaticWallpaper(videoURL: URL?) -> Bool { + let bundle = MusicManager.shared.bundleIdentifier + if bundle == AmazonMusicController.bundleIdentifier { + return true + } + if bundle == SpotifyController.bundleIdentifier { + return videoURL == nil + } + return false + } + + private func shouldUseSpotifyFallbackLayout(videoURL: URL?) -> Bool { + let bundle = MusicManager.shared.bundleIdentifier + if bundle == AmazonMusicController.bundleIdentifier { + return true + } + guard bundle == SpotifyController.bundleIdentifier else { + return false + } + + if videoURL == nil { + return true + } + + return Defaults[.lockScreenUseArtworkLayoutOverFullscreenCanvas] + } + + private func resolvedVideoURL(videoURL: URL?) -> URL? { + guard isLiveWallpaperAllowed else { return nil } + return videoURL + } + + private func artworkOverlayMode(shouldUseFallbackLayout: Bool) -> ArtworkOverlayMode { + if shouldUseFallbackLayout { + return .spotifyFallback + } + + return .none + } + + func spotifyCanvasFallbackInterItemSpacing(screenFrame: NSRect) -> CGFloat { + min(max(screenFrame.width * 0.018, 28), 42) + } + + func spotifyCanvasFallbackArtworkSideLength(screenFrame: NSRect, panelSize: CGSize) -> CGFloat { + let preferred = min(max(screenFrame.height * 0.28, 250), 330) + let spacing = spotifyCanvasFallbackInterItemSpacing(screenFrame: screenFrame) + let maxByWidth = max( + min(screenFrame.width - (spotifyCanvasFallbackHorizontalMargin * 2) - spacing - panelSize.width, 360), + 180 + ) + let maxByHeight = max(min(screenFrame.height * 0.42, 360), 180) + return min(preferred, maxByWidth, maxByHeight) + } + + private func persistedArtworkFileURL(for artwork: NSImage, fingerprint: String) -> URL? { + if let cached = cachedArtworkPNG, + cachedArtworkFingerprint == fingerprint, + FileManager.default.fileExists(atPath: cached.path) { + return cached + } + + guard let encoded = encodeToPNG(artwork) else { return nil } + + let tempURL = artworkCacheFileURL(for: fingerprint) + + do { + try encoded.write(to: tempURL, options: .atomic) + cachedArtworkPNG = tempURL + cachedArtworkFingerprint = fingerprint + return tempURL + } catch { + return nil + } + } + + private func blurredWallpaperFileURL(for artwork: NSImage, fingerprint: String, screen: NSScreen) -> URL? { + let pixelSize = wallpaperPixelSize(for: screen) + + if let cached = cachedBlurredArtworkPNG, + cachedBlurredArtworkFingerprint == fingerprint, + cachedBlurredArtworkPixelSize == pixelSize, + FileManager.default.fileExists(atPath: cached.path) { + return cached + } + + guard let encoded = encodeBlurredWallpaperPNG(from: artwork, targetPixelSize: pixelSize) else { + return nil + } + + let tempURL = blurredArtworkCacheFileURL(for: fingerprint, pixelSize: pixelSize) + + do { + try encoded.write(to: tempURL, options: .atomic) + cachedBlurredArtworkPNG = tempURL + cachedBlurredArtworkFingerprint = fingerprint + cachedBlurredArtworkPixelSize = pixelSize + return tempURL + } catch { + return nil + } + } + + private func wallpaperPixelSize(for screen: NSScreen) -> CGSize { + let scale = max(screen.backingScaleFactor, 1) + let frame = screen.frame + return CGSize( + width: max(frame.width * scale, 1), + height: max(frame.height * scale, 1) + ) + } + + private func artworkCacheFileURL(for fingerprint: String) -> URL { + let normalizedFingerprint = normalizedFingerprintComponent(fingerprint) + return FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_artwork_wallpaper_\(normalizedFingerprint).png") + } + + private func blurredArtworkCacheFileURL(for fingerprint: String, pixelSize: CGSize) -> URL { + let normalizedFingerprint = normalizedFingerprintComponent(fingerprint) + let width = Int(pixelSize.width.rounded()) + let height = Int(pixelSize.height.rounded()) + return FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_artwork_wallpaper_blurred_\(normalizedFingerprint)_\(width)x\(height).png") + } + + private func normalizedFingerprintComponent(_ fingerprint: String) -> String { + fingerprint.replacingOccurrences(of: "-", with: "_") + } + + private func wallpaperIdentityKey(fingerprint: String, usesFallback: Bool, screen: NSScreen) -> String { + if usesFallback { + let pixelSize = wallpaperPixelSize(for: screen) + let width = Int(pixelSize.width.rounded()) + let height = Int(pixelSize.height.rounded()) + return "fallback:\(fingerprint):\(width)x\(height)" + } + + return "artwork:\(fingerprint)" + } + + private nonisolated func encodeToPNG(_ image: NSImage) -> Data? { + guard let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData) + else { return nil } + return bitmap.representation(using: .png, properties: [:]) + } + + private nonisolated func imageFingerprint(for image: NSImage) -> String? { + guard let pngData = encodeToPNG(image) else { return nil } + var hasher = Hasher() + hasher.combine(pngData) + hasher.combine(Int(image.size.width.rounded())) + hasher.combine(Int(image.size.height.rounded())) + return String(hasher.finalize()) + } + + private func encodeBlurredWallpaperPNG(from artwork: NSImage, targetPixelSize: CGSize) -> Data? { + guard let tiffData = artwork.tiffRepresentation, + let sourceImage = CIImage(data: tiffData) + else { return nil } + + let targetRect = CGRect(origin: .zero, size: targetPixelSize) + let sourceExtent = sourceImage.extent + guard sourceExtent.width > 0, sourceExtent.height > 0 else { return nil } + + let scale = max( + targetRect.width / sourceExtent.width, + targetRect.height / sourceExtent.height + ) + let scaledWidth = sourceExtent.width * scale + let scaledHeight = sourceExtent.height * scale + let offsetX = (targetRect.width - scaledWidth) / 2 + let offsetY = (targetRect.height - scaledHeight) / 2 + + let scaledImage = sourceImage + .transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + .transformed(by: CGAffineTransform(translationX: offsetX, y: offsetY)) + .cropped(to: targetRect) + + let blurredImage = scaledImage + .clampedToExtent() + .applyingFilter("CIGaussianBlur", parameters: [kCIInputRadiusKey: 28]) + .cropped(to: targetRect) + .applyingFilter( + "CIColorControls", + parameters: [ + kCIInputSaturationKey: 0.92, + kCIInputBrightnessKey: -0.03, + kCIInputContrastKey: 0.98 + ] + ) + + let context = CIContext(options: nil) + guard let cgImage = context.createCGImage(blurredImage, from: targetRect) else { return nil } + + let renderedImage = NSImage(cgImage: cgImage, size: NSSize(width: targetPixelSize.width, height: targetPixelSize.height)) + let composedImage = NSImage(size: renderedImage.size) + + composedImage.lockFocus() + renderedImage.draw(in: NSRect(origin: .zero, size: renderedImage.size)) + + NSColor(calibratedWhite: 0.02, alpha: 0.08).setFill() + NSBezierPath(rect: NSRect(origin: .zero, size: renderedImage.size)).fill() + + let gradient = NSGradient(colors: [ + NSColor.white.withAlphaComponent(0.05), + NSColor.clear, + NSColor.black.withAlphaComponent(0.16) + ]) + gradient?.draw(in: NSRect(origin: .zero, size: renderedImage.size), angle: -90) + composedImage.unlockFocus() + + guard let finalTIFF = composedImage.tiffRepresentation, + let finalBitmap = NSBitmapImageRep(data: finalTIFF) + else { return nil } + + return finalBitmap.representation(using: .png, properties: [:]) + } + + private func applyDeferredStaticFallback() { + guard let url = pendingFallbackStaticURL else { return } + let key = pendingFallbackWallpaperKey + pendingFallbackStaticURL = nil + pendingFallbackWallpaperKey = nil + + if let screen = NSScreen.main { + showWallpaperTransition(on: screen, imageURL: url) + } + captureStaticWallpaperBackupIfNeeded() + guard applyStaticWallpaper(imageURL: url) else { + print("[FullScreenArtworkWindowManager] Failed to apply deferred static fallback") + hideWallpaperTransition() + return + } + activeWallpaperKey = key + activeLiveWallpaperFingerprint = nil + hideVideoWindow() + scheduleWallpaperTransitionHide(after: .milliseconds(900)) + } + + private func scheduleLiveWallpaperPreparation(for videoURL: URL?, artwork: NSImage, identifier: String) { + liveWallpaperTask?.cancel() + liveWallpaperTask = nil + + guard isLiveWallpaperAllowed else { + scheduleWallpaperTransitionHide(after: .milliseconds(260)) + return + } + + guard let videoURL else { + scheduleWallpaperTransitionHide(after: .milliseconds(260)) + return + } + + let nextFingerprint = videoURL.absoluteString + if activeLiveWallpaperFingerprint == nextFingerprint { + scheduleWallpaperTransitionHide(after: .milliseconds(220)) + scheduleHideVideoWindow(after: .milliseconds(220), expectedURL: videoURL) + return + } + + if let screen = NSScreen.main { + showVideoWindow(on: screen, videoURL: videoURL) + } + scheduleWallpaperTransitionHide(after: .milliseconds(420)) + + let assetID = customLiveWallpaperAssetID + let manifestURL = aerialManifestURL + let videosDirectoryURL = aerialVideosDirectoryURL + let thumbnailsDirectoryURL = aerialThumbnailsDirectoryURL + let manifestBackupURL = liveWallpaperManifestBackupURL + let videoBackupURL = liveWallpaperVideoBackupURL + let thumbnailBackupURL = liveWallpaperThumbnailBackupURL + let thumbnailData = encodeToPNG(artwork) + let title = activeSongTitle ?? MusicManager.shared.songTitle + let artist = activeArtist ?? MusicManager.shared.artistName + let displayName = [title, artist] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: " - ") + + liveWallpaperTask = Task(priority: .utility) { [weak self] in + let prepared = await Self.prepareCustomLiveWallpaperAsset( + from: videoURL, + assetID: assetID, + displayName: displayName.isEmpty ? "Atoll Canvas" : displayName, + manifestURL: manifestURL, + videosDirectoryURL: videosDirectoryURL, + thumbnailsDirectoryURL: thumbnailsDirectoryURL, + thumbnailData: thumbnailData, + manifestBackupURL: manifestBackupURL, + videoBackupURL: videoBackupURL, + thumbnailBackupURL: thumbnailBackupURL + ) + + guard !Task.isCancelled else { return } + + await MainActor.run { + guard let self, self.isShowing, self.isLiveWallpaperAllowed else { return } + let currentIdentifier = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" + let currentVideoURL = self.isLiveWallpaperAllowed ? MusicManager.shared.videoArtworkURL : nil + guard currentIdentifier == identifier else { return } + guard currentVideoURL?.absoluteString == nextFingerprint else { return } + guard !self.shouldUseBlurredStaticWallpaper(videoURL: currentVideoURL) else { return } + guard prepared else { + self.applyDeferredStaticFallback() + return + } + guard self.applyAerialToPlist(assetID: assetID) else { + self.applyDeferredStaticFallback() + return + } + + self.activeLiveWallpaperFingerprint = nextFingerprint + self.didApplyAerialWallpaper = true + self.pendingFallbackStaticURL = nil + self.pendingFallbackWallpaperKey = nil + self.restartWallpaperAgent() + self.scheduleHideVideoWindow(after: .milliseconds(950), expectedURL: videoURL) + print("[FullScreenArtworkWindowManager] Live wallpaper applied") + } + } + } + + private nonisolated static func prepareCustomLiveWallpaperAsset( + from sourceURL: URL, + assetID: String, + displayName: String, + manifestURL: URL, + videosDirectoryURL: URL, + thumbnailsDirectoryURL: URL, + thumbnailData: Data?, + manifestBackupURL: URL, + videoBackupURL: URL, + thumbnailBackupURL: URL + ) async -> Bool { + let fm = FileManager.default + let videoDestinationURL = videosDirectoryURL.appendingPathComponent("\(assetID).mov") + let thumbnailDestinationURL = thumbnailsDirectoryURL.appendingPathComponent("\(assetID).png") + + do { + try fm.createDirectory(at: videosDirectoryURL, withIntermediateDirectories: true) + try fm.createDirectory(at: thumbnailsDirectoryURL, withIntermediateDirectories: true) + } catch { + return false + } + + backupItemIfNeeded(at: manifestURL, backupURL: manifestBackupURL) + backupItemIfNeeded(at: videoDestinationURL, backupURL: videoBackupURL) + backupItemIfNeeded(at: thumbnailDestinationURL, backupURL: thumbnailBackupURL) + + guard await materializeVideo(from: sourceURL, to: videoDestinationURL) else { + return false + } + + if let thumbnailData { + try? thumbnailData.write(to: thumbnailDestinationURL, options: .atomic) + } + + return updateAerialManifest( + manifestURL: manifestURL, + assetID: assetID, + videoURL: videoDestinationURL, + thumbnailURL: fm.fileExists(atPath: thumbnailDestinationURL.path) ? thumbnailDestinationURL : nil, + displayName: displayName + ) + } + + private nonisolated static func materializeVideo(from sourceURL: URL, to destinationURL: URL) async -> Bool { + let fm = FileManager.default + try? fm.removeItem(at: destinationURL) + + if !sourceURL.isFileURL { + guard let downloadedURL = await downloadRemoteVideo(from: sourceURL) else { + return false + } + defer { try? fm.removeItem(at: downloadedURL) } + return await materializeVideo(from: downloadedURL, to: destinationURL) + } + + if sourceURL.isFileURL, sourceURL.pathExtension.lowercased() == "mov" { + do { + try fm.copyItem(at: sourceURL, to: destinationURL) + return validateVideo(at: destinationURL) + } catch { + return false + } + } + + let asset = AVURLAsset(url: sourceURL) + let presets = [AVAssetExportPresetPassthrough, AVAssetExportPresetHighestQuality] + for preset in presets { + try? fm.removeItem(at: destinationURL) + guard let exportSession = AVAssetExportSession(asset: asset, presetName: preset) else { + continue + } + + exportSession.outputURL = destinationURL + exportSession.outputFileType = .mov + exportSession.shouldOptimizeForNetworkUse = true + + let exported = await export(exportSession) + if exported, validateVideo(at: destinationURL) { + return true + } + } + + return false + } + + private nonisolated static func downloadRemoteVideo(from sourceURL: URL) async -> URL? { + do { + let (temporaryURL, response) = try await URLSession.shared.download(from: sourceURL) + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) + else { + return nil + } + + let destinationURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(sourceURL.pathExtension.isEmpty ? "mp4" : sourceURL.pathExtension) + try? FileManager.default.removeItem(at: destinationURL) + try FileManager.default.moveItem(at: temporaryURL, to: destinationURL) + return destinationURL + } catch { + return nil + } + } + + private nonisolated static func export(_ exportSession: AVAssetExportSession) async -> Bool { + await withCheckedContinuation { continuation in + exportSession.exportAsynchronously { + continuation.resume(returning: exportSession.status == .completed) + } + } + } + + private nonisolated static func validateVideo(at url: URL) -> Bool { + guard FileManager.default.fileExists(atPath: url.path) else { return false } + let asset = AVURLAsset(url: url) + return !asset.tracks(withMediaType: .video).isEmpty + } + + private nonisolated static func updateAerialManifest( + manifestURL: URL, + assetID: String, + videoURL: URL, + thumbnailURL: URL?, + displayName: String + ) -> Bool { + guard let manifestData = try? Data(contentsOf: manifestURL), + var root = try? JSONSerialization.jsonObject(with: manifestData) as? [String: Any], + var assets = root["assets"] as? [[String: Any]] + else { + return false + } + + guard let existingIndex = assets.firstIndex(where: { ($0["id"] as? String) == assetID }) else { + return false + } + + var customAsset = assets[existingIndex] + customAsset["accessibilityLabel"] = displayName + customAsset["previewImage"] = thumbnailURL?.absoluteString ?? "" + customAsset["url-4K-SDR-240FPS"] = videoURL.absoluteString + customAsset["pointsOfInterest"] = [:] + assets[existingIndex] = customAsset + + root["assets"] = assets + + guard let encoded = try? JSONSerialization.data(withJSONObject: root, options: []) else { + return false + } + + do { + try encoded.write(to: manifestURL, options: .atomic) + return true + } catch { + return false + } + } + + private nonisolated static func backupItemIfNeeded(at sourceURL: URL, backupURL: URL) { + let fm = FileManager.default + guard fm.fileExists(atPath: sourceURL.path) else { return } + guard !fm.fileExists(atPath: backupURL.path) else { return } + try? fm.copyItem(at: sourceURL, to: backupURL) + } + + private func restoreLiveWallpaperResources() { + let fm = FileManager.default + let assetID = customLiveWallpaperAssetID + let videoDestinationURL = aerialVideosDirectoryURL.appendingPathComponent("\(assetID).mov") + let thumbnailDestinationURL = aerialThumbnailsDirectoryURL.appendingPathComponent("\(assetID).png") + + if fm.fileExists(atPath: liveWallpaperManifestBackupURL.path) { + try? fm.removeItem(at: aerialManifestURL) + try? fm.copyItem(at: liveWallpaperManifestBackupURL, to: aerialManifestURL) + try? fm.removeItem(at: liveWallpaperManifestBackupURL) + } + + if fm.fileExists(atPath: liveWallpaperVideoBackupURL.path) { + try? fm.removeItem(at: videoDestinationURL) + try? fm.copyItem(at: liveWallpaperVideoBackupURL, to: videoDestinationURL) + try? fm.removeItem(at: liveWallpaperVideoBackupURL) + } + + if fm.fileExists(atPath: liveWallpaperThumbnailBackupURL.path) { + try? fm.removeItem(at: thumbnailDestinationURL) + try? fm.copyItem(at: liveWallpaperThumbnailBackupURL, to: thumbnailDestinationURL) + try? fm.removeItem(at: liveWallpaperThumbnailBackupURL) + } + } + + // MARK: - Click Receiver + + private func installFallbackRightClickMonitorIfNeeded() { + guard fallbackRightClickMonitor == nil else { return } + + fallbackRightClickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] event in + guard let self, self.isShowing, self.isShowingSpotifyCanvasFallback else { + return event + } + + self.hide() + return nil + } + } + + private func removeFallbackRightClickMonitor() { + guard let fallbackRightClickMonitor else { return } + NSEvent.removeMonitor(fallbackRightClickMonitor) + self.fallbackRightClickMonitor = nil + } + + private func showClickReceiver(on screen: NSScreen) { + let screenFrame = screen.frame + + let window: ClickReceiverWindow + if let existing = clickWindow { + window = existing + } else { + let newWindow = ClickReceiverWindow( + contentRect: screenFrame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + newWindow.isMovable = false + newWindow.hasShadow = false + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + clickWindow = newWindow + window = newWindow + clickWindowDelegated = false + } + + window.setFrame(screenFrame, display: true) + window.onClick = { [weak self] in + self?.hide() + } + + if !clickWindowDelegated { + SkyLightOperator.shared.delegateWindow(window) + clickWindowDelegated = true + } + + window.orderFrontRegardless() + + for other in NSApp.windows where other !== window && other.level.rawValue >= Int(CGShieldingWindowLevel()) && other.isVisible { + window.order(.below, relativeTo: other.windowNumber) + } + } + + private func hideClickReceiver() { + clickWindow?.orderOut(nil) + clickWindow?.onClick = nil + } + + private func showArtworkOverlay(on screen: NSScreen, artwork: NSImage, mode: ArtworkOverlayMode) { + currentArtworkOverlayMode = mode + guard let targetFrame = artworkOverlayFrame(on: screen) else { return } + + let window: NSWindow + if let existing = artworkOverlayWindow { + window = existing + } else { + let newWindow = NSWindow( + contentRect: targetFrame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.ignoresMouseEvents = false + newWindow.hasShadow = false + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + artworkOverlayWindow = newWindow + window = newWindow + artworkOverlayWindowDelegated = false + } + + window.setFrame(targetFrame, display: true) + + let contentView: SpotifyCanvasFallbackArtworkOverlayView + if let existing = window.contentView as? SpotifyCanvasFallbackArtworkOverlayView { + contentView = existing + contentView.frame = NSRect(origin: .zero, size: targetFrame.size) + contentView.updateArtwork(artwork) + } else { + let newContentView = SpotifyCanvasFallbackArtworkOverlayView(artwork: artwork) + newContentView.frame = NSRect(origin: .zero, size: targetFrame.size) + window.contentView = newContentView + contentView = newContentView + } + + contentView.onDismiss = { [weak self] in + self?.hide() + } + + if !artworkOverlayWindowDelegated { + SkyLightOperator.shared.delegateWindow(window) + artworkOverlayWindowDelegated = true + } + + window.orderFrontRegardless() + } + + private func hideArtworkOverlay() { + currentArtworkOverlayMode = .none + if let contentView = artworkOverlayWindow?.contentView as? SpotifyCanvasFallbackArtworkOverlayView { + contentView.onDismiss = nil + } + artworkOverlayWindow?.orderOut(nil) + artworkOverlayWindow?.contentView = nil + } + + private func updateArtworkOverlayFrameIfNeeded() { + guard isShowing, currentArtworkOverlayMode != .none else { return } + guard let window = artworkOverlayWindow else { return } + guard let screen = NSScreen.main, let frame = artworkOverlayFrame(on: screen) else { return } + window.setFrame(frame, display: true) + } + + private func artworkOverlayFrame(on screen: NSScreen) -> NSRect? { + switch currentArtworkOverlayMode { + case .none: + return nil + case .spotifyFallback: + return spotifyFallbackArtworkOverlayFrame(on: screen) + } + } + + private func spotifyFallbackArtworkOverlayFrame(on screen: NSScreen) -> NSRect? { + guard let frames = spotifyCanvasFallbackLayoutFrames(on: screen) else { return nil } + return frames.artworkFrame + } + + private func updateLyricsOverlayIfNeeded(on screen: NSScreen? = NSScreen.main) { + guard isShowing, isShowingSpotifyCanvasFallback, Defaults[.enableLyrics] else { + hideLyricsOverlay() + return + } + + let line = MusicManager.shared.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { + hideLyricsOverlay() + return + } + + guard let screen, let frame = spotifyCanvasFallbackLayoutFrames(on: screen)?.lyricsFrame else { + hideLyricsOverlay() + return + } + + let fontSize = min(max(frame.height * 0.24, 24), 40) + let content = FullScreenLyricsOverlayContent(text: line, fontSize: fontSize) + + let window: NSWindow + let hostingView: NSHostingView + if let existingWindow = lyricsOverlayWindow, + let existingView = existingWindow.contentView as? NSHostingView { + window = existingWindow + hostingView = existingView + } else { + let newWindow = NSWindow( + contentRect: frame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.ignoresMouseEvents = true + newWindow.hasShadow = false + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + let newHostingView = NSHostingView(rootView: content) + newHostingView.frame = NSRect(origin: .zero, size: frame.size) + newHostingView.wantsLayer = true + newHostingView.layer?.backgroundColor = NSColor.clear.cgColor + newWindow.contentView = newHostingView + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + lyricsOverlayWindow = newWindow + window = newWindow + hostingView = newHostingView + lyricsOverlayWindowDelegated = false + } + + window.setFrame(frame, display: true) + hostingView.frame = NSRect(origin: .zero, size: frame.size) + hostingView.rootView = content + + if !lyricsOverlayWindowDelegated { + SkyLightOperator.shared.delegateWindow(window) + lyricsOverlayWindowDelegated = true + } + + window.orderFrontRegardless() + } + + private func hideLyricsOverlay() { + lyricsOverlayWindow?.orderOut(nil) + } + + private func spotifyCanvasFallbackLayoutFrames(on screen: NSScreen) -> SpotifyCanvasFallbackLayoutFrames? { + let screenFrame = screen.frame + guard let panelFrame = LockScreenPanelManager.shared.latestFrame else { return nil } + + let overlaySide = spotifyCanvasFallbackArtworkSideLength( + screenFrame: screenFrame, + panelSize: panelFrame.size + ) + let spacing = spotifyCanvasFallbackInterItemSpacing(screenFrame: screenFrame) + let targetX = panelFrame.minX - spacing - overlaySide + let targetY = panelFrame.midY - (overlaySide / 2) + let artworkFrame = NSRect(x: targetX, y: targetY, width: overlaySide, height: overlaySide) + let groupFrame = artworkFrame.union(panelFrame) + let lyricsGap = min(max(screenFrame.height * 0.022, 18), 28) + let bottomMargin = min(max(screenFrame.height * 0.16, 140), 220) + let availableHeight = groupFrame.minY - lyricsGap - (screenFrame.minY + bottomMargin) + let desiredHeight = min(max(screenFrame.height * 0.13, 108), 156) + let lyricsHeight = min(desiredHeight, max(availableHeight, 0)) + let lyricsFrame: NSRect? + + if lyricsHeight >= 56 { + lyricsFrame = NSRect( + x: groupFrame.minX, + y: groupFrame.minY - lyricsGap - lyricsHeight, + width: groupFrame.width, + height: lyricsHeight + ) + } else { + lyricsFrame = nil + } + + return SpotifyCanvasFallbackLayoutFrames( + artworkFrame: artworkFrame, + panelFrame: panelFrame, + groupFrame: groupFrame, + lyricsFrame: lyricsFrame + ) + } + + private func showWallpaperTransition(on screen: NSScreen, imageURL: URL) { + guard let image = NSImage(contentsOf: imageURL) else { return } + + wallpaperTransitionHideTask?.cancel() + wallpaperTransitionHideTask = nil + + let screenFrame = screen.frame + + let window: NSWindow + let view: WallpaperTransitionImageView + + if let existingWindow = wallpaperTransitionWindow, let existingView = wallpaperTransitionView { + window = existingWindow + view = existingView + } else { + let newWindow = NSWindow( + contentRect: screenFrame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.ignoresMouseEvents = true + newWindow.hasShadow = false + newWindow.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + let newView = WallpaperTransitionImageView(frame: screenFrame) + newWindow.contentView = newView + + wallpaperTransitionWindow = newWindow + wallpaperTransitionView = newView + window = newWindow + view = newView + } + + window.setFrame(screenFrame, display: true) + view.frame = NSRect(origin: .zero, size: screenFrame.size) + view.updateImage(image) + window.orderFrontRegardless() + } + + private func scheduleWallpaperTransitionHide(after delay: Duration) { + wallpaperTransitionHideTask?.cancel() + wallpaperTransitionHideTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + self?.hideWallpaperTransition() + } + } + + private func hideWallpaperTransition() { + wallpaperTransitionHideTask?.cancel() + wallpaperTransitionHideTask = nil + wallpaperTransitionWindow?.orderOut(nil) + } + + private func showVideoWindow(on screen: NSScreen, videoURL: URL) { + videoWindowHideTask?.cancel() + videoWindowHideTask = nil + + let screenFrame = screen.frame + + let window: NSWindow + let view: LoopingVideoView + + if let existingWindow = videoWindow, let existingView = videoView { + window = existingWindow + view = existingView + } else { + let newWindow = NSWindow( + contentRect: screenFrame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.ignoresMouseEvents = true + newWindow.hasShadow = false + newWindow.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + let newView = LoopingVideoView(frame: screenFrame) + newWindow.contentView = newView + + videoWindow = newWindow + videoView = newView + window = newWindow + view = newView + } + + window.setFrame(screenFrame, display: true) + view.frame = NSRect(origin: .zero, size: screenFrame.size) + if activeVideoWindowURL != videoURL { + view.play(url: videoURL) + activeVideoWindowURL = videoURL + } + window.orderFrontRegardless() + applyCanvasPlaybackState(isPlaying: MusicManager.shared.isPlaying) + } + + private func scheduleHideVideoWindow(after delay: Duration, expectedURL: URL?) { + videoWindowHideTask?.cancel() + let expectedURLString = expectedURL?.absoluteString + + videoWindowHideTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + guard let self else { return } + + if let expectedURLString, + self.activeVideoWindowURL?.absoluteString != expectedURLString { + return + } + + self.hideVideoWindow() + } + } + + private func hideVideoWindow() { + videoWindowHideTask?.cancel() + videoWindowHideTask = nil + activeVideoWindowURL = nil + videoView?.stop() + videoWindow?.orderOut(nil) + } + + // MARK: - Backup / Restore + + private var hasValidBackup: Bool { + FileManager.default.fileExists(atPath: backupPlistURL.path) + } + + private func backupWallpaperConfig() { + guard !hasValidBackup else { return } + try? FileManager.default.copyItem(at: wallpaperPlistURL, to: backupPlistURL) + } + + private func screenKey(_ screen: NSScreen) -> String { + if let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber { + return "screen-\(number.uint32Value)" + } + return "screen-\(screen.localizedName)" + } + + private func captureStaticWallpaperBackupIfNeeded() { + guard !didCaptureStaticBackup else { return } + let ws = NSWorkspace.shared + for screen in NSScreen.screens { + if let url = ws.desktopImageURL(for: screen) { + originalDesktopImageURLs[screenKey(screen)] = url + } + } + didCaptureStaticBackup = true + } + + @discardableResult + private func applyStaticWallpaper(imageURL: URL) -> Bool { + let ws = NSWorkspace.shared + var anySucceeded = false + for screen in NSScreen.screens { + let options = ws.desktopImageOptions(for: screen) ?? [:] + do { + try ws.setDesktopImageURL(imageURL, for: screen, options: options) + anySucceeded = true + } catch { + print("[FullScreenArtworkWindowManager] setDesktopImageURL failed: \(error)") + } + } + if anySucceeded { + appliedStaticWallpaper = true + } + return anySucceeded + } + + private func restoreStaticWallpaper() { + guard appliedStaticWallpaper else { return } + let ws = NSWorkspace.shared + for screen in NSScreen.screens { + guard let url = originalDesktopImageURLs[screenKey(screen)] else { continue } + let options = ws.desktopImageOptions(for: screen) ?? [:] + try? ws.setDesktopImageURL(url, for: screen, options: options) + } + appliedStaticWallpaper = false + didCaptureStaticBackup = false + originalDesktopImageURLs.removeAll() + } + + private func applyArtworkToPlist(imageURL: URL) -> Bool { + guard let plistData = try? Data(contentsOf: wallpaperPlistURL), + var plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] + else { return false } + + let desktopBlock = makeImageWallpaperBlock(imageURL: imageURL) + let idleBlock = makeImageWallpaperBlock(imageURL: imageURL) + + patchWallpaperEntries(&plist, desktopBlock: desktopBlock, idleBlock: idleBlock) + + guard let newData = try? PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0) + else { return false } + + return (try? newData.write(to: wallpaperPlistURL, options: .atomic)) != nil + } + + private func applyAerialToPlist(assetID: String) -> Bool { + guard let plistData = try? Data(contentsOf: wallpaperPlistURL), + var plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] + else { return false } + + let desktopTemplate = wallpaperBlock( + named: "Desktop", + in: plist["AllSpacesAndDisplays"] as? [String: Any], + fallback: plist["SystemDefault"] as? [String: Any] + ) + let idleTemplate = wallpaperBlock( + named: "Idle", + in: plist["AllSpacesAndDisplays"] as? [String: Any], + fallback: plist["SystemDefault"] as? [String: Any] + ) + + let desktopBlock = makeAerialWallpaperBlock(assetID: assetID, existingBlock: desktopTemplate) + let idleBlock = makeAerialWallpaperBlock(assetID: assetID, existingBlock: idleTemplate) + + patchWallpaperEntries(&plist, desktopBlock: desktopBlock, idleBlock: idleBlock) + + guard let newData = try? PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0) + else { return false } + + return (try? newData.write(to: wallpaperPlistURL, options: .atomic)) != nil + } + + private func wallpaperBlock(named name: String, in primary: [String: Any]?, fallback: [String: Any]?) -> [String: Any]? { + if let primaryBlock = primary?[name] as? [String: Any] { + return primaryBlock + } + return fallback?[name] as? [String: Any] + } + + private func makeImageWallpaperBlock(imageURL: URL) -> [String: Any] { + let config: [String: Any] = [ + "type": "imageFile", + "url": ["relative": imageURL.absoluteString] + ] + let configData = (try? PropertyListSerialization.data(fromPropertyList: config, format: .binary, options: 0)) ?? Data() + + let imageChoice: [String: Any] = [ + "Provider": "com.apple.wallpaper.choice.image", + "Files": [] as [Any], + "Configuration": configData + ] + + let contentBlock: [String: Any] = [ + "Choices": [imageChoice], + "Shuffle": "$null" + ] + + return [ + "Content": contentBlock, + "LastSet": Date(), + "LastUse": Date() + ] + } + + private func makeAerialWallpaperBlock(assetID: String, existingBlock: [String: Any]?) -> [String: Any] { + let config: [String: Any] = [ + "assetID": assetID + ] + let configData = (try? PropertyListSerialization.data(fromPropertyList: config, format: .binary, options: 0)) ?? Data() + + let aerialChoice: [String: Any] = [ + "Provider": "com.apple.wallpaper.choice.aerials", + "Files": [] as [Any], + "Configuration": configData + ] + + var contentBlock: [String: Any] = [ + "Choices": [aerialChoice], + "Shuffle": "$null" + ] + + if let existingContent = existingBlock?["Content"] as? [String: Any], + let encodedOptionValues = existingContent["EncodedOptionValues"] { + contentBlock["EncodedOptionValues"] = encodedOptionValues + } + + return [ + "Content": contentBlock, + "LastSet": Date(), + "LastUse": Date() + ] + } + + private func patchWallpaperEntries(_ plist: inout [String: Any], desktopBlock: [String: Any], idleBlock: [String: Any]) { + var allSpaces = (plist["AllSpacesAndDisplays"] as? [String: Any]) ?? [:] + allSpaces["Desktop"] = desktopBlock + allSpaces["Idle"] = idleBlock + allSpaces["Type"] = "individual" + plist["AllSpacesAndDisplays"] = allSpaces + + var systemDefault = (plist["SystemDefault"] as? [String: Any]) ?? [:] + systemDefault["Desktop"] = desktopBlock + systemDefault["Idle"] = idleBlock + systemDefault["Type"] = "individual" + plist["SystemDefault"] = systemDefault + + if var displays = plist["Displays"] as? [String: Any] { + for key in displays.keys { + if var display = displays[key] as? [String: Any] { + display["Desktop"] = desktopBlock + display["Idle"] = idleBlock + if display["Type"] != nil { + display["Type"] = "individual" + } + displays[key] = display + } + } + plist["Displays"] = displays + } + + if var spaces = plist["Spaces"] as? [String: Any] { + for spaceKey in spaces.keys { + if var space = spaces[spaceKey] as? [String: Any] { + if var defaultEntry = space["Default"] as? [String: Any] { + defaultEntry["Desktop"] = desktopBlock + defaultEntry["Idle"] = idleBlock + if defaultEntry["Type"] != nil { + defaultEntry["Type"] = "individual" + } + space["Default"] = defaultEntry + } + if var spaceDisplays = space["Displays"] as? [String: Any] { + for displayKey in spaceDisplays.keys { + if var display = spaceDisplays[displayKey] as? [String: Any] { + display["Desktop"] = desktopBlock + display["Idle"] = idleBlock + if display["Type"] != nil { + display["Type"] = "individual" + } + spaceDisplays[displayKey] = display + } + } + space["Displays"] = spaceDisplays + } + spaces[spaceKey] = space + } + } + plist["Spaces"] = spaces + } + } + + private func restartWallpaperAgent() { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + task.arguments = ["WallpaperAgent"] + try? task.run() + } + + private func restoreWallpaper() { + let fm = FileManager.default + + if appliedStaticWallpaper { + restoreStaticWallpaper() + } + + if didApplyAerialWallpaper, hasValidBackup { + do { + try fm.removeItem(at: wallpaperPlistURL) + try fm.copyItem(at: backupPlistURL, to: wallpaperPlistURL) + try fm.removeItem(at: backupPlistURL) + } catch { + print("[FullScreenArtworkWindowManager] Failed to restore plist: \(error)") + } + restoreLiveWallpaperResources() + restartWallpaperAgent() + } else if hasValidBackup { + try? fm.removeItem(at: backupPlistURL) + } + + didApplyAerialWallpaper = false + } + + // MARK: - Track Change Observer + + private func observeTrackChanges() { + trackChangeCancellable?.cancel() + trackChangeCancellable = MusicManager.shared.$songTitle + .combineLatest(MusicManager.shared.$artistName) + .dropFirst() + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { [weak self] title, artist in + guard let self, self.isShowing else { return } + if title != self.activeSongTitle || artist != self.activeArtist { + self.refreshPresentationForCurrentTrack() + self.scheduleDeferredTrackRefresh(expectedTitle: title, expectedArtist: artist) + } + } + } + + private func scheduleDeferredTrackRefresh(expectedTitle: String, expectedArtist: String) { + deferredTrackRefreshTask?.cancel() + deferredTrackRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(700)) + guard let self, self.isShowing else { return } + guard MusicManager.shared.songTitle == expectedTitle, + MusicManager.shared.artistName == expectedArtist + else { return } + + self.refreshPresentationForCurrentTrack() + } + } +} diff --git a/DynamicIsland/managers/IdleAnimationManager.swift b/DynamicIsland/managers/IdleAnimationManager.swift new file mode 100644 index 00000000..ed1d4f62 --- /dev/null +++ b/DynamicIsland/managers/IdleAnimationManager.swift @@ -0,0 +1,318 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Defaults +import AppKit + +class IdleAnimationManager { + static let shared = IdleAnimationManager() + + // Storage directory for user-imported animations + private let storageDirectory: URL + + private init() { + // Create storage directory in Application Support + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + storageDirectory = appSupport.appendingPathComponent("DynamicIsland/IdleAnimations", isDirectory: true) + + // Create directory if it doesn't exist + try? FileManager.default.createDirectory(at: storageDirectory, withIntermediateDirectories: true) + + print("📁 [IdleAnimationManager] Storage directory: \(storageDirectory.path)") + } + + // MARK: - Initialization + + /// Load bundled animations from the LottieAnimations folder + func initializeDefaultAnimations() { + var animations: [CustomIdleAnimation] = [] + + // Load bundled Lottie files + if let bundledAnimations = loadBundledAnimations() { + animations.append(contentsOf: bundledAnimations) + } + + // Get existing animations + var existing = Defaults[.customIdleAnimations] + + // Remove any legacy builtInFace entries that may be stored from older versions + existing.removeAll { animation in + if case .lottieFile(let url) = animation.source, url.absoluteString == "builtin://face" { + return true + } + return false + } + + if existing.isEmpty { + // First launch - set everything + Defaults[.customIdleAnimations] = animations + Defaults[.selectedIdleAnimation] = animations.first + print("✅ [IdleAnimationManager] First launch: Initialized with \(animations.count) animations") + } else { + // Subsequent launch - ensure all bundled animations are present + let existingNames = Set(existing.filter { $0.isBuiltIn }.map { $0.name }) + + // Add any missing bundled animations + for bundledAnim in animations where bundledAnim.isBuiltIn { + if !existingNames.contains(bundledAnim.name) { + existing.insert(bundledAnim, at: existing.firstIndex(where: { !$0.isBuiltIn }) ?? existing.count) + print("➕ [IdleAnimationManager] Added missing bundled animation: \(bundledAnim.name)") + } + } + + Defaults[.customIdleAnimations] = existing + + // If current selection was the old built-in face (no longer valid), select first available + if let selected = Defaults[.selectedIdleAnimation] { + let isValid: Bool + switch selected.source { + case .lottieFile(let url): + isValid = url.absoluteString != "builtin://face" + case .lottieURL: + isValid = true + } + if !isValid { + Defaults[.selectedIdleAnimation] = existing.first + print("🔄 [IdleAnimationManager] Migrated selection from legacy face to: \(existing.first?.name ?? "nil")") + } + } + + print("✅ [IdleAnimationManager] Subsequent launch: \(existing.count) total animations") + } + } + + // MARK: - Bundled Animations + + /// Load animations from the LottieAnimations folder in the bundle + private func loadBundledAnimations() -> [CustomIdleAnimation]? { + print("📦 [IdleAnimationManager] Loading bundled animations...") + + // The JSON files are added as individual resources, not in a folder + let bundledFiles = ["Dog waiting", "Moody Dog", "Orange Cat Peeping", "Reindeer"] + var animations: [CustomIdleAnimation] = [] + + for filename in bundledFiles { + if let url = Bundle.main.url(forResource: filename, withExtension: "json") { + let animation = CustomIdleAnimation( + name: filename, + source: .lottieFile(url), + speed: 1.0, + isBuiltIn: true + ) + animations.append(animation) + print("✅ [IdleAnimationManager] Loaded bundled animation: \(filename)") + } else { + print("⚠️ [IdleAnimationManager] Could not find bundled animation: \(filename).json") + } + } + + guard !animations.isEmpty else { + print("⚠️ [IdleAnimationManager] No bundled animations found") + return nil + } + + print("📦 [IdleAnimationManager] Loaded \(animations.count) bundled animations") + return animations + } + + // MARK: - User Animations + + /// Load user-imported animations from storage directory + private func loadStoredUserAnimations() -> [CustomIdleAnimation]? { + do { + let files = try FileManager.default.contentsOfDirectory(at: storageDirectory, includingPropertiesForKeys: nil) + let jsonFiles = files.filter { $0.pathExtension.lowercased() == "json" } + + let animations = jsonFiles.map { url -> CustomIdleAnimation in + let name = url.deletingPathExtension().lastPathComponent + return CustomIdleAnimation( + name: name, + source: .lottieFile(url), + speed: 1.0, + isBuiltIn: false + ) + } + + if !animations.isEmpty { + print("💾 [IdleAnimationManager] Loaded \(animations.count) stored user animations") + } + return animations.isEmpty ? nil : animations + + } catch { + print("❌ [IdleAnimationManager] Error loading stored animations: \(error)") + return nil + } + } + + // MARK: - Import & Export + + /// Import a Lottie JSON file from URL (either local file or download from remote) + func importLottieFile(from url: URL, name: String? = nil, speed: CGFloat = 1.0) -> Result { + let fileName = name ?? url.deletingPathExtension().lastPathComponent + + // If it's a remote URL, download it first + if url.scheme == "http" || url.scheme == "https" { + return importRemoteAnimation(from: url, name: fileName, speed: speed) + } + + // Local file import + return importLocalFile(from: url, name: fileName, speed: speed) + } + + /// Import a local Lottie JSON file + private func importLocalFile(from sourceURL: URL, name: String, speed: CGFloat) -> Result { + do { + // Validate it's a JSON file + guard sourceURL.pathExtension.lowercased() == "json" else { + return .failure(AnimationImportError.invalidFileType) + } + + // Validate JSON content (basic check) + let data = try Data(contentsOf: sourceURL) + guard let _ = try? JSONSerialization.jsonObject(with: data) else { + return .failure(AnimationImportError.invalidJSON) + } + + // Generate unique filename + let uniqueFileName = "\(UUID().uuidString).json" + let destinationURL = storageDirectory.appendingPathComponent(uniqueFileName) + + // Copy file to storage + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + + // Create animation object (transforms will be stored separately) + let animation = CustomIdleAnimation( + name: name, + source: .lottieFile(destinationURL), + speed: speed, + isBuiltIn: false + ) + + // Add to defaults + var animations = Defaults[.customIdleAnimations] + animations.append(animation) + Defaults[.customIdleAnimations] = animations + + print("✅ [IdleAnimationManager] Imported local file: \(name)") + return .success(animation) + + } catch { + print("❌ [IdleAnimationManager] Import failed: \(error)") + return .failure(error) + } + } + + /// Import animation from remote URL + private func importRemoteAnimation(from url: URL, name: String, speed: CGFloat) -> Result { + // For remote URLs, we store the URL directly (no download) + // The LottieView will handle downloading when needed + + let animation = CustomIdleAnimation( + name: name, + source: .lottieURL(url), + speed: speed, + isBuiltIn: false + ) + + // Add to defaults + var animations = Defaults[.customIdleAnimations] + animations.append(animation) + Defaults[.customIdleAnimations] = animations + + print("✅ [IdleAnimationManager] Added remote animation: \(name)") + return .success(animation) + } + + // MARK: - Management + + /// Delete an animation (only user-added ones, not built-in) + func deleteAnimation(_ animation: CustomIdleAnimation) -> Bool { + guard !animation.isBuiltIn else { + print("⚠️ [IdleAnimationManager] Cannot delete built-in animation") + return false + } + + // Remove from defaults + var animations = Defaults[.customIdleAnimations] + guard let index = animations.firstIndex(of: animation) else { + return false + } + animations.remove(at: index) + Defaults[.customIdleAnimations] = animations + + // If it's a local file, delete it from storage + if case .lottieFile(let url) = animation.source { + // Only delete if it's in our storage directory (not bundled) + if url.path.contains(storageDirectory.path) { + try? FileManager.default.removeItem(at: url) + print("🗑️ [IdleAnimationManager] Deleted file: \(url.lastPathComponent)") + } + } + + // If deleted animation was selected, select the first one + if Defaults[.selectedIdleAnimation] == animation { + Defaults[.selectedIdleAnimation] = animations.first + } + + print("✅ [IdleAnimationManager] Deleted animation: \(animation.name)") + return true + } + + /// Update animation properties + func updateAnimation(_ animation: CustomIdleAnimation, name: String? = nil, speed: CGFloat? = nil) { + var animations = Defaults[.customIdleAnimations] + guard let index = animations.firstIndex(where: { $0.id == animation.id }) else { + return + } + + if let name = name { + animations[index].name = name + } + if let speed = speed { + animations[index].speed = speed + } + + Defaults[.customIdleAnimations] = animations + + // Update selected animation if it's the same one + if Defaults[.selectedIdleAnimation]?.id == animation.id { + Defaults[.selectedIdleAnimation] = animations[index] + } + + print("✅ [IdleAnimationManager] Updated animation: \(animation.name)") + } +} + +// MARK: - Error Types +enum AnimationImportError: LocalizedError { + case invalidFileType + case invalidJSON + case downloadFailed + + var errorDescription: String? { + switch self { + case .invalidFileType: + return "Only .json files are supported" + case .invalidJSON: + return "Invalid Lottie JSON format" + case .downloadFailed: + return "Failed to download animation" + } + } +} diff --git a/DynamicIsland/managers/ImageService.swift b/DynamicIsland/managers/ImageService.swift new file mode 100644 index 00000000..ecc91b7e --- /dev/null +++ b/DynamicIsland/managers/ImageService.swift @@ -0,0 +1,69 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Defaults + +public protocol ImageServiceProtocol { + func fetchImageData(from url: URL) async throws -> Data +} + +public final class ImageService: ImageServiceProtocol { + public static let shared = ImageService() + + private let session: URLSession + + private init() { + let config = URLSessionConfiguration.default + + // Create proper cache directory path + let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + let artworkCachePath = cacheDir.appendingPathComponent("artwork_cache").path + + let cache = URLCache(memoryCapacity: 50 * 1024 * 1024, // 50MB + diskCapacity: 100 * 1024 * 1024, // 100MB + diskPath: artworkCachePath) + config.urlCache = cache + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 30 + config.httpShouldSetCookies = false + self.session = URLSession(configuration: config) + + performLegacyCacheCleanupIfNeeded() + } + + private func performLegacyCacheCleanupIfNeeded() { + + if !Defaults[.didClearLegacyURLCacheV1] { + URLCache.shared.removeAllCachedResponses() + Defaults[.didClearLegacyURLCacheV1] = true + } + } + + public func fetchImageData(from url: URL) async throws -> Data { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { + throw URLError(.unsupportedURL) + } + let (data, _) = try await session.data(from: url) + return data + } +} diff --git a/DynamicIsland/managers/KeyboardBrightnessSensor.swift b/DynamicIsland/managers/KeyboardBrightnessSensor.swift new file mode 100644 index 00000000..f6555212 --- /dev/null +++ b/DynamicIsland/managers/KeyboardBrightnessSensor.swift @@ -0,0 +1,201 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import IOKit +import IOKit.hid +import ObjectiveC.runtime + +final class KeyboardBrightnessSensor { + private init() {} + + private static let maxKeyboardBrightness: Float = 342 + private static let coreBrightnessClient = CoreBrightnessClient() + private static var method = SensorMethod.standard + + static func currentLevel() throws -> Float { + if let client = coreBrightnessClient, let value = client.currentBrightness() { + return clamp(value) + } + switch method { + case .standard: + do { + return normalizedValue(fromRaw: try readStandardRawLevel()) + } catch { + method = .m1 + } + case .m1: + do { + return normalizedValue(fromRaw: try readM1RawLevel()) + } catch { + method = .allFailed + } + case .allFailed: + throw SensorError.Keyboard.notFound + } + return try currentLevel() + } + + static func setLevel(_ level: Float) throws { + let clamped = max(0, min(1, level)) + if let client = coreBrightnessClient, client.setBrightness(clamped) { + return + } + let raw = rawValue(fromNormalized: clamped) + do { + try writeStandard(rawLevel: raw) + } catch { + method = .m1 + throw error + } + } + + private static func clamp(_ value: Float) -> Float { + max(0, min(1, value)) + } + + private static func normalizedValue(fromRaw rawLevel: Float) -> Float { + let clamped = max(0, min(1, rawLevel)) + if clamped <= 0.07 { + return 0 + } + let value = log10(clamped + 0.03) + 1 + return max(0, min(1, value)) + } + + private static func rawValue(fromNormalized normalized: Float) -> Float { + guard normalized > 0 else { return 0 } + let powValue = powf(10, normalized - 1) - 0.03 + return max(0, min(1, powValue)) + } + + private static func readStandardRawLevel() throws -> Float { + let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("AppleHIDKeyboardEventDriverV2")) + guard service != 0 else { + throw SensorError.Keyboard.notStandard + } + defer { IOObjectRelease(service) } + + guard let property: CFTypeRef = IORegistryEntryCreateCFProperty( + service, + "KeyboardBacklightBrightness" as CFString, + kCFAllocatorDefault, + 0 + )?.takeUnretainedValue() else { + throw SensorError.Keyboard.notStandard + } + if CFGetTypeID(property) == CFNumberGetTypeID() { + var rawValue: Float = 0 + if CFNumberGetValue(property as! CFNumber, .floatType, &rawValue) { + return rawValue / maxKeyboardBrightness + } + } + throw SensorError.Keyboard.notStandard + } + + private static func readM1RawLevel() throws -> Float { + let task = Process() + task.launchPath = "/usr/libexec/corebrightnessdiag" + task.arguments = ["status-info"] + let pipe = Pipe() + task.standardOutput = pipe + try task.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + + if let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? NSDictionary, + let keyboards = plist["CBKeyboards"] as? [String: [String: Any]] { + for keyboard in keyboards.values { + if let backlightInfo = keyboard["CBKeyboardBacklightContainer"] as? [String: Any], + backlightInfo["KeyboardBacklightBuiltIn"] as? Bool == true, + let brightness = backlightInfo["KeyboardBacklightBrightness"] as? Float { + return brightness + } + } + } + + throw SensorError.Keyboard.notSilicon + } + + private static func writeStandard(rawLevel: Float) throws { + let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("AppleHIDKeyboardEventDriverV2")) + guard service != 0 else { + throw SensorError.Keyboard.notStandard + } + defer { IOObjectRelease(service) } + + var scaled = rawLevel * maxKeyboardBrightness + guard let number = CFNumberCreate(kCFAllocatorDefault, .floatType, &scaled) else { + throw SensorError.Keyboard.notStandard + } + let status = IORegistryEntrySetCFProperty(service, "KeyboardBacklightBrightness" as CFString, number) + if status != KERN_SUCCESS { + throw SensorError.Keyboard.notStandard + } + } +} + +// MARK: - CoreBrightness bridge +private final class CoreBrightnessClient { + private static let keyboardID: UInt64 = 1 + private var clientInstance: NSObject? + private let getSelector = NSSelectorFromString("brightnessForKeyboard:") + private let setSelector = NSSelectorFromString("setBrightness:forKeyboard:") + + init?() { + var loaded = false + let bundlePaths = [ + "/System/Library/PrivateFrameworks/CoreBrightness.framework", + "/System/Library/PrivateFrameworks/CoreBrightness.framework/CoreBrightness" + ] + for path in bundlePaths where !loaded { + if let bundle = Bundle(path: path) { + loaded = bundle.load() + } + } + guard loaded, let cls = NSClassFromString("KeyboardBrightnessClient") as? NSObject.Type else { + return nil + } + clientInstance = cls.init() + } + + func currentBrightness() -> Float? { + guard let clientInstance, + let getter: BrightnessGetter = methodIMP(on: clientInstance, selector: getSelector, as: BrightnessGetter.self) + else { return nil } + return getter(clientInstance, getSelector, Self.keyboardID) + } + + func setBrightness(_ value: Float) -> Bool { + guard let clientInstance, + let setter: BrightnessSetter = methodIMP(on: clientInstance, selector: setSelector, as: BrightnessSetter.self) + else { return false } + return setter(clientInstance, setSelector, value, Self.keyboardID).boolValue + } + + private typealias BrightnessGetter = @convention(c) (NSObject, Selector, UInt64) -> Float + private typealias BrightnessSetter = @convention(c) (NSObject, Selector, Float, UInt64) -> ObjCBool + + private func methodIMP(on object: NSObject, selector: Selector, as type: T.Type) -> T? { + guard let cls = object_getClass(object), + let method = class_getInstanceMethod(cls, selector) + else { return nil } + let imp = method_getImplementation(method) + return unsafeBitCast(imp, to: T.self) + } +} diff --git a/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift new file mode 100644 index 00000000..387bdf1c --- /dev/null +++ b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift @@ -0,0 +1,561 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI +import Defaults + +private func applyLocalSendPanelCornerMask(_ view: NSView, radius: CGFloat) { + view.wantsLayer = true + view.layer?.masksToBounds = true + view.layer?.cornerRadius = radius + view.layer?.backgroundColor = NSColor.clear.cgColor + if #available(macOS 13.0, *) { + view.layer?.cornerCurve = .continuous + } +} + +@MainActor +final class LocalSendDevicePickerWindowManager { + static let shared = LocalSendDevicePickerWindowManager() + + private var window: NSWindow? + private var onDeviceSelected: ((LocalSendDeviceInfo) -> Void)? + private var onDismiss: (() -> Void)? + + private init() {} + + func show( + onDeviceSelected: @escaping (LocalSendDeviceInfo) -> Void, + onDismiss: @escaping () -> Void + ) { + let cornerRadius: CGFloat = 24 + self.onDeviceSelected = onDeviceSelected + self.onDismiss = onDismiss + + let pickerView = LocalSendDevicePickerView( + onDeviceSelected: { [weak self] device in + self?.onDeviceSelected?(device) + self?.hide() + }, + onDismiss: { [weak self] in + self?.onDismiss?() + self?.hide() + } + ) + + if let existingWindow = window { + let hostingView = NSHostingView(rootView: pickerView) + applyLocalSendPanelCornerMask(hostingView, radius: cornerRadius) + existingWindow.contentView = hostingView + existingWindow.makeKeyAndOrderFront(nil) + return + } + + guard let screen = NSScreen.main else { return } + + let windowSize = CGSize(width: 480, height: 400) + let windowOrigin = CGPoint( + x: screen.frame.midX - windowSize.width / 2, + y: screen.frame.midY - windowSize.height / 2 + ) + + let newWindow = NSWindow( + contentRect: NSRect(origin: windowOrigin, size: windowSize), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.hasShadow = true + newWindow.level = .floating + newWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + let hostingView = NSHostingView(rootView: + pickerView + .background(Color.clear) + ) + applyLocalSendPanelCornerMask(hostingView, radius: cornerRadius) + newWindow.contentView = hostingView + newWindow.isMovableByWindowBackground = true + + window = newWindow + newWindow.makeKeyAndOrderFront(nil) + + // Add click-outside-to-dismiss + NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in + guard let self, let window = self.window else { return event } + let locationInWindow = event.locationInWindow + let windowFrame = window.frame + let clickLocation = NSPoint( + x: windowFrame.origin.x + locationInWindow.x, + y: windowFrame.origin.y + locationInWindow.y + ) + + if !windowFrame.contains(clickLocation) && event.window != window { + self.onDismiss?() + self.hide() + } + return event + } + } + + func hide() { + window?.orderOut(nil) + window = nil + onDeviceSelected = nil + onDismiss = nil + } +} + +// MARK: - LocalSend Device Picker View + +struct LocalSendDevicePickerView: View { + @StateObject private var localSend = LocalSendService.shared + @Default(.localSendDevicePickerGlassMode) private var glassMode + @Default(.localSendDevicePickerLiquidGlassVariant) private var liquidGlassVariant + + let onDeviceSelected: (LocalSendDeviceInfo) -> Void + let onDismiss: () -> Void + + @State private var hoveredDeviceID: String? + + private let cornerRadius: CGFloat = 24 + + private var usesCustomLiquidGlass: Bool { + glassMode == .customLiquid + } + + private var usesStandardLiquidGlass: Bool { + guard glassMode == .standard else { return false } + if #available(macOS 26.0, *) { + return true + } + return false + } + + var body: some View { + ZStack { + // Explicitly clear background for proper transparency + Color.clear + + // Background - clips to rounded rect + panelBackground + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + + // Content + VStack(spacing: 16) { + // Header + HStack { + Image("LocalSend") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + + Text("Select Device") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + + Spacer() + + // Refresh button + Button { + localSend.refreshDeviceScan() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white.opacity(0.8)) + .rotationEffect(.degrees(localSend.isRefreshing ? 360 : 0)) + .animation( + localSend.isRefreshing + ? .linear(duration: 0.85).repeatForever(autoreverses: false) + : .easeOut(duration: 0.2), + value: localSend.isRefreshing + ) + .frame(width: 32, height: 32) + .background(Circle().fill(Color.white.opacity(0.1))) + } + .buttonStyle(.plain) + .disabled(localSend.isRefreshing) + .help("Refresh devices") + + // Close button + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white.opacity(0.6)) + .frame(width: 28, height: 28) + .background(Circle().fill(Color.white.opacity(0.1))) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 20) + + // Devices grid + if localSend.devices.isEmpty { + emptyStateView + } else { + devicesGridView + } + } + } + .frame(width: 480, height: 400) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + } + .shadow(color: Color.black.opacity(0.4), radius: 30, x: 0, y: 15) + .compositingGroup() + .onAppear { + localSend.startDiscovery() + localSend.refreshDeviceScan() + } + } + + @ViewBuilder + private var panelBackground: some View { + if usesCustomLiquidGlass { + LiquidGlassBackground(variant: liquidGlassVariant, cornerRadius: cornerRadius) { + Color.black.opacity(0.3) + } + } else if usesStandardLiquidGlass { + if #available(macOS 26.0, *) { + LocalSendGlassBackdrop(cornerRadius: cornerRadius) + } + } else { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.ultraThinMaterial) + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color.black.opacity(0.4)) + ) + } + } + + private var emptyStateView: some View { + VStack(spacing: 12) { + Spacer() + + if localSend.isRefreshing { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.2) + + Text("Scanning for devices...") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + } else { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.system(size: 48, weight: .light)) + .foregroundColor(.white.opacity(0.4)) + + Text("No devices found") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + + Text("Make sure LocalSend is running on other devices") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.5)) + .multilineTextAlignment(.center) + + Button { + localSend.refreshDeviceScan() + } label: { + Text("Scan Again") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + Capsule() + .fill(Color.accentColor.opacity(0.8)) + ) + } + .buttonStyle(.plain) + .padding(.top, 8) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var devicesGridView: some View { + ScrollView { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ], + spacing: 16 + ) { + ForEach(localSend.devices) { device in + DeviceGridItem( + device: device, + isHovered: hoveredDeviceID == device.id, + isRejected: localSend.rejectedDeviceIDs.contains(device.id), + onSelect: { + localSend.clearRejectedStatus(for: device.id) + onDeviceSelected(device) + } + ) + .onHover { hovering in + hoveredDeviceID = hovering ? device.id : nil + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } +} + +// MARK: - Device Grid Item + +private struct DeviceGridItem: View { + let device: LocalSendDeviceInfo + let isHovered: Bool + let isRejected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + VStack(spacing: 10) { + // Device icon + ZStack { + Circle() + .fill(isRejected ? Color.red.opacity(0.15) : Color.white.opacity(isHovered ? 0.15 : 0.08)) + .frame(width: 56, height: 56) + + Image(systemName: deviceIcon) + .font(.system(size: 24, weight: .medium)) + .foregroundColor(isRejected ? .red : (isHovered ? .accentColor : .white.opacity(0.8))) + } + + // Device name + VStack(spacing: 2) { + Text(device.alias) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(isRejected ? .red : .white) + .lineLimit(1) + .truncationMode(.tail) + + if isRejected { + Text("Rejected") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.red.opacity(0.8)) + } else if let model = device.model, !model.isEmpty { + Text(model) + .font(.system(size: 10)) + .foregroundColor(.white.opacity(0.5)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(isRejected ? Color.red.opacity(0.08) : Color.white.opacity(isHovered ? 0.12 : 0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isHovered ? Color.accentColor.opacity(0.6) : Color.white.opacity(0.1), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .scaleEffect(isHovered ? 1.02 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovered) + } + + private var deviceIcon: String { + let aliasLower = device.alias.lowercased() + let modelLower = (device.model ?? "").lowercased() + let combined = aliasLower + " " + modelLower + + // iPhone / iOS devices + if combined.contains("iphone") { + return "iphone" + } + if combined.contains("ipad") { + return "ipad" + } + if combined.contains("ipod") { + return "ipodtouch" + } + + // Android phones by brand + if combined.contains("pixel") || combined.contains("nexus") { + return "smartphone" + } + if combined.contains("samsung") || combined.contains("galaxy") { + return "smartphone" + } + if combined.contains("oneplus") || combined.contains("one plus") { + return "smartphone" + } + if combined.contains("xiaomi") || combined.contains("redmi") || combined.contains("poco") || combined.contains("mi ") { + return "smartphone" + } + if combined.contains("huawei") || combined.contains("honor") { + return "smartphone" + } + if combined.contains("oppo") || combined.contains("realme") || combined.contains("vivo") { + return "smartphone" + } + if combined.contains("motorola") || combined.contains("moto ") { + return "smartphone" + } + if combined.contains("nokia") { + return "smartphone" + } + if combined.contains("sony") || combined.contains("xperia") { + return "smartphone" + } + if combined.contains("lg") { + return "smartphone" + } + if combined.contains("asus") && (combined.contains("phone") || combined.contains("rog")) { + return "smartphone" + } + if combined.contains("nothing") && combined.contains("phone") { + return "smartphone" + } + + // Android tablets + if combined.contains("tab") || combined.contains("tablet") { + return "tablet.portrait" + } + + // Mac devices + if combined.contains("macbook") { + return "laptopcomputer" + } + if combined.contains("imac") { + return "desktopcomputer" + } + if combined.contains("mac mini") || combined.contains("mini") && combined.contains("mac") { + return "macmini" + } + if combined.contains("mac pro") { + return "macpro.gen3" + } + if combined.contains("mac studio") { + return "macstudio" + } + if combined.contains("mac") || combined.contains("macos") { + return "desktopcomputer" + } + + // Windows/Linux laptops and desktops + if combined.contains("laptop") || combined.contains("notebook") { + return "laptopcomputer" + } + if combined.contains("thinkpad") || combined.contains("lenovo") { + return "laptopcomputer" + } + if combined.contains("dell") || combined.contains("xps") || combined.contains("inspiron") || combined.contains("latitude") { + return "laptopcomputer" + } + if combined.contains("hp") || combined.contains("pavilion") || combined.contains("envy") || combined.contains("spectre") || combined.contains("elitebook") { + return "laptopcomputer" + } + if combined.contains("asus") || combined.contains("zenbook") || combined.contains("vivobook") { + return "laptopcomputer" + } + if combined.contains("acer") || combined.contains("aspire") || combined.contains("swift") { + return "laptopcomputer" + } + if combined.contains("msi") { + return "laptopcomputer" + } + if combined.contains("surface") { + return "laptopcomputer" + } + if combined.contains("razer") { + return "laptopcomputer" + } + if combined.contains("chromebook") { + return "laptopcomputer" + } + + // Desktop keywords + if combined.contains("desktop") || combined.contains("pc") || combined.contains("workstation") { + return "desktopcomputer" + } + if combined.contains("windows") || combined.contains("linux") || combined.contains("ubuntu") || combined.contains("fedora") { + return "desktopcomputer" + } + + // Android generic + if combined.contains("android") { + return "smartphone" + } + + // Phone/Mobile generic + if combined.contains("phone") || combined.contains("mobile") { + return "smartphone" + } + + // TV devices + if combined.contains("tv") || combined.contains("shield") || combined.contains("fire") || combined.contains("roku") || combined.contains("chromecast") { + return "appletv" + } + + // Default: try to guess based on common patterns + // If it looks like a computer name with a person's name, assume laptop + if combined.contains("'s ") || combined.contains("-pc") || combined.contains("-laptop") { + return "laptopcomputer" + } + + // Default fallback + return "smartphone" + } +} + +// MARK: - Glass Backdrop for LocalSend Picker + +@available(macOS 26.0, *) +private struct LocalSendGlassBackdrop: View { + let cornerRadius: CGFloat + + var body: some View { + GeometryReader { proxy in + let dynamicFontSize = max(min(proxy.size.width, proxy.size.height) / 8, 42) + + Text("LocalSend Device Picker") + .font(.system(size: dynamicFontSize, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.clear) + .frame(width: proxy.size.width, height: proxy.size.height) + .glassEffect( + .clear.interactive(), + in: .rect(cornerRadius: cornerRadius) + ) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} diff --git a/DynamicIsland/managers/LockScreenDisplayContextProvider.swift b/DynamicIsland/managers/LockScreenDisplayContextProvider.swift new file mode 100644 index 00000000..2047841d --- /dev/null +++ b/DynamicIsland/managers/LockScreenDisplayContextProvider.swift @@ -0,0 +1,125 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import CoreGraphics + +struct LockScreenDisplayContext { + let screen: NSScreen + let frame: NSRect + let identifier: String +} + +@MainActor +final class LockScreenDisplayContextProvider { + static let shared = LockScreenDisplayContextProvider() + + private(set) var context: LockScreenDisplayContext? + private var screenChangeObserver: NSObjectProtocol? + private var workspaceObservers: [NSObjectProtocol] = [] + + private init() { + refresh(reason: "init") + registerObservers() + } + + deinit { + if let screenChangeObserver { + NotificationCenter.default.removeObserver(screenChangeObserver) + } + + let workspaceCenter = NSWorkspace.shared.notificationCenter + workspaceObservers.forEach { workspaceCenter.removeObserver($0) } + } + + @discardableResult + func refresh(reason: String) -> LockScreenDisplayContext? { + guard let screen = preferredLockScreen() else { + context = nil + return nil + } + + let snapshot = LockScreenDisplayContext( + screen: screen, + frame: screen.frame, + identifier: screen.localizedName + ) + + context = snapshot + return snapshot + } + + func contextSnapshot() -> LockScreenDisplayContext? { + if let context { + return context + } + return refresh(reason: "snapshot-miss") + } + + private func preferredLockScreen() -> NSScreen? { + if let builtin = NSScreen.screens.first(where: { screen in + guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + return false + } + return CGDisplayIsBuiltin(CGDirectDisplayID(number.uint32Value)) != 0 + }) { + return builtin + } + + let mainDisplayID = CGMainDisplayID() + if let mainScreen = NSScreen.screens.first(where: { screen in + guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + return false + } + return CGDirectDisplayID(number.uint32Value) == mainDisplayID + }) { + return mainScreen + } + + return NSScreen.main ?? NSScreen.screens.first + } + + private func registerObservers() { + screenChangeObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refresh(reason: "screen-parameters") + } + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let wakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refresh(reason: "screens-did-wake") + } + + let spaceObserver = workspaceCenter.addObserver( + forName: NSWorkspace.activeSpaceDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refresh(reason: "space-changed") + } + + workspaceObservers = [wakeObserver, spaceObserver] + } +} diff --git a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift new file mode 100644 index 00000000..7770b046 --- /dev/null +++ b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift @@ -0,0 +1,290 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Defaults +import SkyLightWindow +import SwiftUI +import QuartzCore + +@MainActor +class LockScreenLiveActivityWindowManager { + static let shared = LockScreenLiveActivityWindowManager() + + private var window: NSWindow? + private var hasDelegated = false + private var hideTask: Task? + private var hostingView: NSHostingView? + private let overlayModel = LockScreenLiveActivityOverlayModel() + private let overlayAnimator = LockIconAnimator(initiallyLocked: LockScreenManager.shared.isLocked) + private weak var viewModel: DynamicIslandViewModel? + private var screenChangeObserver: NSObjectProtocol? + private var workspaceObservers: [NSObjectProtocol] = [] + private var currentNotchSize: CGSize? + + /// Whether the target screen uses Dynamic Island (pill) mode. + private var isDynamicIslandMode: Bool { + isDynamicIslandModeForScreen(lockContext()?.screen) + } + + private func isDynamicIslandModeForScreen(_ screen: NSScreen?) -> Bool { + guard let screen else { return false } + return shouldUseDynamicIslandMode(for: screen.localizedName) + } + + private init() { + registerScreenChangeObservers() + } + + private func timestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter.string(from: Date()) + } + + private func windowSize(for notchSize: CGSize) -> CGSize { + let indicatorWidth = max(0, notchSize.height - 12) + let horizontalPadding = isDynamicIslandMode ? 8 : cornerRadiusInsets.closed.bottom + let topOffset: CGFloat = isDynamicIslandMode ? dynamicIslandTopOffset : 0 + + let totalWidth = notchSize.width + (indicatorWidth * 2) + (horizontalPadding * 2) + + return CGSize(width: totalWidth, height: notchSize.height + topOffset) + } + + private func frame(for windowSize: CGSize, on screen: NSScreen) -> NSRect { + let screenFrame = screen.frame + let originX = screenFrame.origin.x + (screenFrame.width / 2) - (windowSize.width / 2) + let originY = screenFrame.origin.y + screenFrame.height - windowSize.height + + return NSRect(x: originX, y: originY, width: windowSize.width, height: windowSize.height) + } + + private func ensureWindow(windowSize: CGSize, screen: NSScreen) -> NSWindow { + if let window { + return window + } + + let window = NSWindow( + contentRect: frame(for: windowSize, on: screen), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + window.isOpaque = false + window.backgroundColor = .clear + window.isReleasedWhenClosed = false + window.ignoresMouseEvents = false + window.hasShadow = false + window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + window.alphaValue = 0 + window.animationBehavior = .none + + ScreenCaptureVisibilityManager.shared.register(window, scope: .entireInterface) + + self.window = window + self.hasDelegated = false + return window + } + + private func lockContext() -> (notchSize: CGSize, screen: NSScreen)? { + guard let screen = LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main else { + print("[\(timestamp())] LockScreenLiveActivityWindowManager: no main screen available") + return nil + } + + guard let viewModel else { + print("[\(timestamp())] LockScreenLiveActivityWindowManager: no view model configured") + return nil + } + + var notchSize = viewModel.closedNotchSize + if notchSize.width <= 0 || notchSize.height <= 0 { + notchSize = getClosedNotchSize(screen: screen.localizedName) + } + + return (notchSize, screen) + } + + private func registerScreenChangeObservers() { + screenChangeObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screen-parameters") + } + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let wakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screens-did-wake") + } + workspaceObservers = [wakeObserver] + } + + private func handleScreenGeometryChange(reason: String) { + guard let window else { return } + guard window.isVisible || window.alphaValue > 0.01 else { return } + guard let context = lockContext() else { return } + + let windowSize = windowSize(for: context.notchSize) + let targetFrame = frame(for: windowSize, on: context.screen) + if window.frame != targetFrame { + window.setFrame(targetFrame, display: true) + } + + if let hostingView { + hostingView.frame = CGRect(origin: .zero, size: targetFrame.size) + hostingView.rootView = LockScreenLiveActivityOverlay(model: overlayModel, animator: overlayAnimator, notchSize: context.notchSize, isDynamicIslandMode: isDynamicIslandMode) + } + + currentNotchSize = context.notchSize + + print("[\(timestamp())] LockScreenLiveActivityWindowManager: realigned window due to \(reason)") + } + + private func present(notchSize: CGSize, on screen: NSScreen) { + guard Defaults[.enableLockScreenLiveActivity] else { + hideImmediately() + return + } + + let windowSize = windowSize(for: notchSize) + let window = ensureWindow(windowSize: windowSize, screen: screen) + let targetFrame = frame(for: windowSize, on: screen) + window.setFrame(targetFrame, display: true) + + let overlayView = LockScreenLiveActivityOverlay(model: overlayModel, animator: overlayAnimator, notchSize: notchSize, isDynamicIslandMode: isDynamicIslandMode) + + if let hostingView { + hostingView.rootView = overlayView + hostingView.frame = CGRect(origin: .zero, size: targetFrame.size) + } else { + let view = NSHostingView(rootView: overlayView) + view.frame = CGRect(origin: .zero, size: targetFrame.size) + hostingView = view + window.contentView = view + } + + if window.contentView !== hostingView { + window.contentView = hostingView + } + + window.displayIfNeeded() + + if !hasDelegated { + SkyLightOperator.shared.delegateWindow(window) + hasDelegated = true + } + + window.orderFrontRegardless() + window.alphaValue = 1 + + currentNotchSize = notchSize + } + + func showLocked() { + hideTask?.cancel() + guard let context = lockContext() else { return } + + let collapsedScale = LockScreenLiveActivityOverlay.collapsedScale(for: context.notchSize, isDynamicIslandMode: isDynamicIslandMode) + + overlayAnimator.update(isLocked: true) + overlayModel.scale = collapsedScale + overlayModel.opacity = 0 + + present(notchSize: context.notchSize, on: context.screen) + + DispatchQueue.main.async { + withAnimation(.spring(response: 0.45, dampingFraction: 0.82)) { + self.overlayModel.scale = 1 + } + withAnimation(.easeOut(duration: 0.18)) { + self.overlayModel.opacity = 1 + } + } + + print("[\(timestamp())] LockScreenLiveActivityWindowManager: showing locked state") + } + + func showUnlockAndScheduleHide() { + hideTask?.cancel() + guard let context = lockContext() else { return } + + overlayModel.scale = 1 + overlayModel.opacity = 1 + + present(notchSize: context.notchSize, on: context.screen) + + overlayAnimator.update(isLocked: false) + + hideTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(LockScreenAnimationTimings.unlockCollapse)) + guard let self, !Task.isCancelled else { return } + await MainActor.run { + self.hideWithAnimation() + } + } + } + + func hideImmediately() { + hideTask?.cancel() + hideTask = nil + + hideWithAnimation() + } + + private func hideWithAnimation() { + guard let window else { return } + + let targetScale: CGFloat + if let notchSize = currentNotchSize { + targetScale = LockScreenLiveActivityOverlay.collapsedScale(for: notchSize, isDynamicIslandMode: isDynamicIslandMode) + } else { + targetScale = 0.7 + } + + withAnimation(.smooth(duration: LockScreenAnimationTimings.unlockCollapse)) { + overlayModel.opacity = 0 + overlayModel.scale = targetScale + } + + NSAnimationContext.runAnimationGroup { context in + context.duration = LockScreenAnimationTimings.unlockCollapse + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + window.animator().alphaValue = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + LockScreenAnimationTimings.unlockCollapse + 0.02) { + window.orderOut(nil) + self.currentNotchSize = nil + } + + print("[\(timestamp())] LockScreenLiveActivityWindowManager: HUD hidden") + } + + func configure(viewModel: DynamicIslandViewModel) { + self.viewModel = viewModel + } +} diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift new file mode 100644 index 00000000..95da4fc4 --- /dev/null +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -0,0 +1,371 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Combine +import AppKit +import Defaults +import SwiftUI +import AVFoundation + +enum LockScreenAnimationTimings { + static let lockExpand: TimeInterval = 0.45 + static let unlockCollapse: TimeInterval = 0.82 + static let postUnlockMusicHUDPause: TimeInterval = 1.0 + static let postUnlockMusicHUDReveal: TimeInterval = 0.34 +} + +@MainActor +class LockScreenManager: ObservableObject { + static let shared = LockScreenManager() + + // MARK: - Coordinator + private let coordinator = DynamicIslandViewCoordinator.shared + private weak var viewModel: DynamicIslandViewModel? + + // MARK: - Published Properties + @Published var isLocked: Bool = false + @Published var isLockIdle: Bool = true + @Published var shouldDelayPostUnlockMusicHUD: Bool = false + @Published var lastUpdated: Date = .distantPast + + // MARK: - Private Properties + private var debounceIdleTask: Task? + private var collapseTask: Task? + private var postUnlockMusicHUDTask: Task? + private var lockStatePollTask: Task? + + // MARK: - Helpers + + private func timestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter.string(from: Date()) + } + + // MARK: - Initialization + private init() { + setupObservers() + print("LockScreenManager: 🔒 Initialized") + } + + deinit { + DistributedNotificationCenter.default().removeObserver(self) + NSWorkspace.shared.notificationCenter.removeObserver(self) + debounceIdleTask?.cancel() + collapseTask?.cancel() + postUnlockMusicHUDTask?.cancel() + lockStatePollTask?.cancel() + } + + // MARK: - Setup + + private func setupObservers() { + // Observe screen locked event + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(screenLocked), + name: .init("com.apple.screenIsLocked"), + object: nil + ) + + // Observe screen unlocked event + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(screenUnlocked), + name: .init("com.apple.screenIsUnlocked"), + object: nil + ) + + // Fallback: macOS sometimes delays `com.apple.screenIsUnlocked`, leaving + // lock-screen widgets visible after the user-perceived unlock. + // The workspace session-active notification typically fires earlier; the + // guard at the top of `screenUnlocked` makes the call idempotent. + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(screenUnlocked), + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + + print("LockScreenManager: ✅ Observers registered for lock/unlock events") + } + + // MARK: - Event Handlers + + @objc private func screenLocked() { + guard !isLocked else { + print("[\(timestamp())] LockScreenManager: 🔁 Duplicate LOCK event ignored") + return + } + print("[\(timestamp())] LockScreenManager: 🔒 Screen LOCKED event received") + Logger.log("LockScreenManager: Screen locked", category: .lifecycle) + LockSoundPlayer.shared.playLockChime() + LockScreenDisplayContextProvider.shared.refresh(reason: "screen-locked") + + // Update state SYNCHRONOUSLY without Task/await to avoid any delay + lastUpdated = Date() + updateIdleState(locked: true) + postUnlockMusicHUDTask?.cancel() + shouldDelayPostUnlockMusicHUD = false + + // Set locked state immediately without animation wrapper + isLocked = true + collapseTask?.cancel() + + viewModel?.closeForLockScreen() + + if coordinator.expandingView.show { + let currentType = coordinator.expandingView.type + coordinator.toggleExpandingView(status: false, type: currentType) + } + + if coordinator.sneakPeek.show { + coordinator.toggleSneakPeek(status: false, type: coordinator.sneakPeek.type) + } + + // Show panel FIRST (creates and shows window on lock screen) + print("[\(timestamp())] LockScreenManager: 🎵 Showing lock screen panel") + LockScreenPanelManager.shared.showPanel() + LockScreenLiveActivityWindowManager.shared.showLocked() + LockScreenWeatherManager.shared.showWeatherWidget() + LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: true) + TimerControlWindowManager.shared.hide(animated: false) + + // THEN trigger lock icon in Atoll (only if enabled in settings) + if Defaults[.enableLockScreenLiveActivity] { + print("[\(timestamp())] LockScreenManager: 🔴 Starting lock icon live activity") + coordinator.toggleExpandingView(status: true, type: .lockScreen) + } else { + print("[\(timestamp())] LockScreenManager: ⏭️ Lock icon disabled in settings") + } + + startLockStatePolling() + + print("[\(timestamp())] LockScreenManager: ✅ Lock screen activated") + } + + @objc private func screenUnlocked() { + guard isLocked else { + print("[\(timestamp())] LockScreenManager: 🔁 Unlock event ignored (already unlocked)") + return + } + print("[\(timestamp())] LockScreenManager: 🔓 Screen UNLOCKED event received") + Logger.log("LockScreenManager: Screen unlocked", category: .lifecycle) + LockSoundPlayer.shared.playUnlockChime() + LockScreenDisplayContextProvider.shared.refresh(reason: "screen-unlocked") + lastUpdated = Date() + updateIdleState(locked: false) + isLocked = false + stopLockStatePolling() + postUnlockMusicHUDTask?.cancel() + shouldDelayPostUnlockMusicHUD = Defaults[.enableLockScreenLiveActivity] + + if shouldDelayPostUnlockMusicHUD { + postUnlockMusicHUDTask = Task { [weak self] in + try? await Task.sleep( + for: .seconds( + LockScreenAnimationTimings.unlockCollapse + + LockScreenAnimationTimings.postUnlockMusicHUDPause + ) + ) + guard let self = self, !Task.isCancelled else { return } + await MainActor.run { + if !self.isLocked { + withAnimation( + .spring( + response: LockScreenAnimationTimings.postUnlockMusicHUDReveal, + dampingFraction: 0.88, + blendDuration: 0.08 + ) + ) { + self.shouldDelayPostUnlockMusicHUD = false + } + } + } + } + } + + // Hide panel window immediately and synchronously + print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") + LockScreenPanelManager.shared.hidePanel() + FullScreenArtworkWindowManager.shared.hide() + LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide() + LockScreenWeatherManager.shared.hideWeatherWidget() + LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false) + + // Update state immediately + if Defaults[.enableLockScreenLiveActivity] { + collapseTask?.cancel() + collapseTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(LockScreenAnimationTimings.unlockCollapse)) + guard let self = self, !Task.isCancelled else { return } + await MainActor.run { + self.coordinator.toggleExpandingView(status: false, type: .lockScreen) + } + } + } + + print("[\(self.timestamp())] LockScreenManager: ✅ Lock screen deactivated") + } + + // MARK: - Lock State Polling + + // Defensive fallback against late/missed `com.apple.screenIsUnlocked` and + // `NSWorkspace.sessionDidBecomeActiveNotification` notifications, which + // macOS sometimes delivers well after the user-perceived unlock — leaving + // lock-screen widgets visible for an extra moment. While we believe we are + // locked, poll the canonical session-lock state and fire `screenUnlocked()` + // the moment the OS flips. The handler's duplicate-event guard makes this + // safe to call alongside any later-arriving notification. + private static func isSessionScreenLocked() -> Bool { + guard let session = CGSessionCopyCurrentDictionary() as? [String: Any] else { + return false + } + return session["CGSSessionScreenIsLocked"] as? Bool ?? false + } + + private func startLockStatePolling() { + lockStatePollTask?.cancel() + lockStatePollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(500)) + if Task.isCancelled { return } + await MainActor.run { + guard let self, self.isLocked else { return } + if !Self.isSessionScreenLocked() { + print("[\(self.timestamp())] LockScreenManager: 🔓 Polling detected unlock ahead of notification") + self.screenUnlocked() + } + } + } + } + } + + private func stopLockStatePolling() { + lockStatePollTask?.cancel() + lockStatePollTask = nil + } + + // MARK: - Idle State Management + + /// Copy EXACT logic from ScreenRecordingManager + private func updateIdleState(locked: Bool) { + if locked { + isLockIdle = false + debounceIdleTask?.cancel() + } else { + debounceIdleTask?.cancel() + debounceIdleTask = Task { [weak self] in + // Keep the lock live activity mounted until the collapse animation finishes, + // otherwise the content disappears before the island fully closes. + let idleDelay = LockScreenAnimationTimings.unlockCollapse + try? await Task.sleep(for: .seconds(idleDelay)) + guard let self = self, !Task.isCancelled else { return } + await MainActor.run { + if self.lastUpdated.timeIntervalSinceNow < -idleDelay { + withAnimation(.smooth(duration: 0.3)) { + self.isLockIdle = !self.isLocked + } + } + } + } + } + } +} + +// MARK: - Extensions + +extension LockScreenManager { + func configure(viewModel: DynamicIslandViewModel) { + self.viewModel = viewModel + } + + /// Get current lock status without async + var currentLockStatus: Bool { + return isLocked + } + + /// Check if monitoring is available (for settings UI) + var isMonitoringAvailable: Bool { + return true // Always available on macOS + } +} + +// MARK: - Lock Sound Playback + +@MainActor +final class LockSoundPlayer { + static let shared = LockSoundPlayer() + private let throttleInterval: TimeInterval = 0.25 + private var players: [SoundType: AVAudioPlayer] = [:] + private var lastPlaybackDates: [SoundType: Date] = [:] + + private init() {} + + func playLockChime() { + play(.lock) + } + + func playUnlockChime() { + play(.unlock) + } + + private func play(_ type: SoundType) { + guard Defaults[.enableLockSounds] else { return } + guard shouldPlay(type) else { return } + guard let player = resolvePlayer(for: type) else { return } + + player.currentTime = 0 + player.play() + lastPlaybackDates[type] = Date() + } + + private func shouldPlay(_ type: SoundType) -> Bool { + guard let last = lastPlaybackDates[type] else { return true } + return Date().timeIntervalSince(last) >= throttleInterval + } + + private func resolvePlayer(for type: SoundType) -> AVAudioPlayer? { + if let cached = players[type] { + return cached + } + + guard let url = Bundle.main.url(forResource: type.resourceName, withExtension: "mp3") else { + Logger.log("Missing \(type.resourceName).mp3 in bundle", category: .warning) + return nil + } + + do { + let player = try AVAudioPlayer(contentsOf: url) + player.prepareToPlay() + players[type] = player + return player + } catch { + Logger.log("Failed to initialize lock sound player for \(type.resourceName): \(error.localizedDescription)", category: .error) + return nil + } + } + + private enum SoundType: String { + case lock + case unlock + + var resourceName: String { rawValue } + } +} diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift new file mode 100644 index 00000000..e076fe72 --- /dev/null +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -0,0 +1,382 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import AppKit +import SkyLightWindow +import Defaults +import QuartzCore +import Combine + +@MainActor +final class LockScreenPanelAnimator: ObservableObject { + @Published var isPresented: Bool = false +} + +@MainActor +class LockScreenPanelManager { + static let shared = LockScreenPanelManager() + + private var panelWindow: NSWindow? + private var hasDelegated = false + private var collapsedFrame: NSRect? + private var isPanelExpanded = false + private var currentAdditionalHeight: CGFloat = 0 + private let collapsedPanelCornerRadius: CGFloat = 28 + private let expandedPanelCornerRadius: CGFloat = 52 + private(set) var latestFrame: NSRect? + private let panelAnimator = LockScreenPanelAnimator() + private var hideTask: Task? + private var screenChangeObserver: NSObjectProtocol? + private var workspaceObservers: [NSObjectProtocol] = [] + private var cancellables = Set() + + private init() { + print("[\(timestamp())] LockScreenPanelManager: initialized") + registerScreenChangeObservers() + observeDefaultChanges() + } + + private func timestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter.string(from: Date()) + } + + private func publishPanelFrame(_ frame: NSRect?) { + latestFrame = frame + + var userInfo: [AnyHashable: Any] = [:] + if let frame { + userInfo["frame"] = NSValue(rect: frame) + } + + NotificationCenter.default.post( + name: .atollLockScreenPanelFrameDidChange, + object: self, + userInfo: userInfo + ) + } + + private func registerScreenChangeObservers() { + screenChangeObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleScreenGeometryChange(reason: "screen-parameters") + } + } + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let wakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleScreenGeometryChange(reason: "screens-did-wake") + } + } + + workspaceObservers = [wakeObserver] + } + + func showPanel() { + print("[\(timestamp())] LockScreenPanelManager: showPanel") + + guard Defaults[.enableLockScreenMediaWidget] else { + print("[\(timestamp())] LockScreenPanelManager: widget disabled") + hidePanel() + return + } + + guard let screen = currentScreen() else { + print("[\(timestamp())] LockScreenPanelManager: no main screen available") + return + } + + let screenFrame = screen.frame + let defaultCollapsedFrame = collapsedFrame(for: screenFrame) + let targetFrame = targetFrame(for: screenFrame, panelSize: LockScreenMusicPanel.collapsedSize) + collapsedFrame = defaultCollapsedFrame + isPanelExpanded = false + currentAdditionalHeight = 0 + + let window: NSWindow + + if let existingWindow = panelWindow { + window = existingWindow + } else { + let newWindow = NSWindow( + contentRect: targetFrame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + newWindow.isMovable = false + newWindow.hasShadow = false + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + + panelWindow = newWindow + window = newWindow + hasDelegated = false + } + + window.setFrame(targetFrame, display: true) + publishPanelFrame(targetFrame) + hideTask?.cancel() + panelAnimator.isPresented = false + LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false) + + let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator)) + hosting.frame = NSRect(origin: .zero, size: targetFrame.size) + hosting.autoresizingMask = [.width, .height] + 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 + content.layer?.backgroundColor = NSColor.clear.cgColor + } + + if !hasDelegated { + SkyLightOperator.shared.delegateWindow(window) + hasDelegated = true + } + + // Keep the window alive and simply order it out on unlock to avoid SkyLight crashes. + window.orderFrontRegardless() + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.panelAnimator.isPresented = true + } + + print("[\(timestamp())] LockScreenPanelManager: panel visible") + } + + func updatePanelSize(expanded: Bool, additionalHeight: CGFloat = 0, animated: Bool = true) { + guard let window = panelWindow, let screen = currentScreen() else { + return + } + + let resizeDuration: CFTimeInterval = 0.28 + + let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize + let targetFrame = targetFrame( + for: screen.frame, + panelSize: CGSize(width: baseSize.width, height: baseSize.height + additionalHeight) + ) + + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = resizeDuration + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + window.animator().setFrame(targetFrame, display: true) + } + } else { + window.setFrame(targetFrame, display: true) + } + + publishPanelFrame(targetFrame) + + LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated) + + // Update corner radius to match the SwiftUI panel's style + let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius + if animated { + CATransaction.begin() + CATransaction.setAnimationDuration(resizeDuration) + CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut)) + window.contentView?.layer?.cornerRadius = targetRadius + CATransaction.commit() + } else { + window.contentView?.layer?.cornerRadius = targetRadius + } + + isPanelExpanded = expanded + currentAdditionalHeight = additionalHeight + } + + func notifyTimerWidgetFrameChanged(animated: Bool) { + guard panelWindow?.isVisible == true || panelAnimator.isPresented else { return } + applyOffsetAdjustment(animated: animated) + } + + func applyOffsetAdjustment(animated: Bool = true) { + guard let screen = currentScreen() else { return } + let screenFrame = screen.frame + let newCollapsed = collapsedFrame(for: screenFrame) + collapsedFrame = newCollapsed + + guard panelWindow != nil else { return } + updatePanelSize(expanded: isPanelExpanded, additionalHeight: currentAdditionalHeight, animated: animated) + LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated) + } + + func hidePanel() { + print("[\(timestamp())] LockScreenPanelManager: hidePanel") + + panelAnimator.isPresented = false + hideTask?.cancel() + + guard let window = panelWindow else { + print("LockScreenPanelManager: no panel to hide") + publishPanelFrame(nil) + return + } + + hideTask = Task { [weak self, weak window] in + try? await Task.sleep(for: .milliseconds(360)) + guard let self else { return } + await MainActor.run { + window?.orderOut(nil) + window?.contentView = nil + self.publishPanelFrame(nil) + print("[\(self.timestamp())] LockScreenPanelManager: panel hidden") + } + } + } + + private func handleScreenGeometryChange(reason: String) { + guard let window = panelWindow else { return } + guard window.isVisible || panelAnimator.isPresented else { return } + guard let screen = currentScreen() else { return } + + let screenFrame = screen.frame + collapsedFrame = collapsedFrame(for: screenFrame) + updatePanelSize(expanded: isPanelExpanded, additionalHeight: currentAdditionalHeight, animated: false) + LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false) + + print("[\(timestamp())] LockScreenPanelManager: realigned window due to \(reason)") + } + + private func observeDefaultChanges() { + Defaults.publisher(.lockScreenMusicPanelWidth) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.applyOffsetAdjustment(animated: true) + } + .store(in: &cancellables) + } + + private func targetFrame(for screenFrame: NSRect, panelSize: CGSize) -> NSRect { + let defaultCollapsedFrame = collapsedFrame(for: screenFrame) + + if FullScreenArtworkWindowManager.shared.isShowingSpotifyCanvasFallback { + return spotifyCanvasFallbackPanelFrame( + screenFrame: screenFrame, + defaultCollapsedFrame: defaultCollapsedFrame, + panelSize: panelSize + ) + } + + let originX = defaultCollapsedFrame.midX - (panelSize.width / 2) + return NSRect( + x: originX, + y: defaultCollapsedFrame.origin.y, + width: panelSize.width, + height: panelSize.height + ) + } + + private func spotifyCanvasFallbackPanelFrame( + screenFrame: NSRect, + defaultCollapsedFrame: NSRect, + panelSize: CGSize + ) -> NSRect { + let fullscreenArtworkManager = FullScreenArtworkWindowManager.shared + let spacing = fullscreenArtworkManager.spotifyCanvasFallbackInterItemSpacing(screenFrame: screenFrame) + let artworkSide = fullscreenArtworkManager.spotifyCanvasFallbackArtworkSideLength( + screenFrame: screenFrame, + panelSize: panelSize + ) + let totalWidth = artworkSide + spacing + panelSize.width + let horizontalMargin: CGFloat = 48 + let minGroupOriginX = screenFrame.minX + horizontalMargin + let maxGroupOriginX = max(screenFrame.maxX - horizontalMargin - totalWidth, minGroupOriginX) + let groupOriginX = min( + max(screenFrame.midX - (totalWidth / 2), minGroupOriginX), + maxGroupOriginX + ) + + let baseCenterY = screenFrame.midY + let largestHalfHeight = max(artworkSide, panelSize.height) / 2 + let minCenterY = screenFrame.minY + largestHalfHeight + 56 + var maxCenterY = screenFrame.maxY - largestHalfHeight - 84 + + let widgetClearance: CGFloat = 20 + if let weatherFrame = LockScreenWeatherPanelManager.shared.latestFrame, + !weatherFrame.isEmpty { + let weatherTopConstrainedCenterY = weatherFrame.minY - widgetClearance - largestHalfHeight + maxCenterY = min(maxCenterY, weatherTopConstrainedCenterY) + } + if let timerFrame = LockScreenTimerWidgetPanelManager.shared.latestFrame, + !timerFrame.isEmpty { + let timerTopConstrainedCenterY = timerFrame.minY - widgetClearance - largestHalfHeight + maxCenterY = min(maxCenterY, timerTopConstrainedCenterY) + } + + let resolvedMaxCenterY = max(maxCenterY, minCenterY) + let centerY = min(max(baseCenterY, minCenterY), resolvedMaxCenterY) + + return NSRect( + x: groupOriginX + artworkSide + spacing, + y: centerY - (panelSize.height / 2), + width: panelSize.width, + height: panelSize.height + ) + } + + private func collapsedFrame(for screenFrame: NSRect) -> NSRect { + let collapsedSize = LockScreenMusicPanel.collapsedSize + let originX = screenFrame.midX - (collapsedSize.width / 2) + let baseOriginY = screenFrame.origin.y + (screenFrame.height / 2) - collapsedSize.height - 32 + let defaultLowering: CGFloat = -28 + let userOffset = CGFloat(Defaults[.lockScreenMusicVerticalOffset]) + let clampedOffset = min(max(userOffset, -160), 160) + var originY = baseOriginY + defaultLowering + clampedOffset + + if let timerFrame = LockScreenTimerWidgetPanelManager.shared.latestFrame { + let maxAllowedTop = timerFrame.minY - 12 + let maxOriginY = maxAllowedTop - collapsedSize.height + originY = min(originY, maxOriginY) + } + + return NSRect(x: originX, y: originY, width: collapsedSize.width, height: collapsedSize.height) + } + + private func currentScreen() -> NSScreen? { + LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main + } +} + +extension Notification.Name { + static let atollLockScreenPanelFrameDidChange = Notification.Name("atollLockScreenPanelFrameDidChange") +} diff --git a/DynamicIsland/managers/LockScreenReminderWidgetManager.swift b/DynamicIsland/managers/LockScreenReminderWidgetManager.swift new file mode 100644 index 00000000..04b58cb8 --- /dev/null +++ b/DynamicIsland/managers/LockScreenReminderWidgetManager.swift @@ -0,0 +1,131 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Combine +import Defaults + +@MainActor +final class LockScreenReminderWidgetManager: ObservableObject { + static let shared = LockScreenReminderWidgetManager() + + @Published private(set) var snapshot: LockScreenReminderWidgetSnapshot? + + private let reminderManager = ReminderLiveActivityManager.shared + private var cancellables = Set() + + private init() { + observeDefaults() + observeLockState() + observeReminderSnapshots() + } + + private func observeLockState() { + LockScreenManager.shared.$isLocked + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] locked in + self?.handleLockStateChange(isLocked: locked) + } + .store(in: &cancellables) + } + + private func observeReminderSnapshots() { + reminderManager.$lockScreenSnapshot + .receive(on: RunLoop.main) + .sink { [weak self] snapshot in + self?.handleSnapshotUpdate(snapshot) + } + .store(in: &cancellables) + } + + private func handleLockStateChange(isLocked: Bool) { + Logger.log("LockScreenReminderWidgetManager: handleLockStateChange(isLocked: \(isLocked))", category: .lifecycle) + guard Defaults[.enableLockScreenReminderWidget] else { + LockScreenReminderWidgetPanelManager.shared.hide() + return + } + + if isLocked { + if let latest = reminderManager.lockScreenSnapshot ?? snapshot { + snapshot = latest + LockScreenReminderWidgetPanelManager.shared.show(with: latest) + } else { + LockScreenReminderWidgetPanelManager.shared.hide() + } + } else { + LockScreenReminderWidgetPanelManager.shared.hide() + } + } + + private func observeDefaults() { + Defaults.publisher(.enableLockScreenReminderWidget, options: []) + .sink { [weak self] change in + guard let self else { return } + if change.newValue { + if LockScreenManager.shared.currentLockStatus { + self.handleSnapshotUpdate(self.reminderManager.lockScreenSnapshot) + } + } else { + self.snapshot = nil + LockScreenReminderWidgetPanelManager.shared.hide() + } + } + .store(in: &cancellables) + + Defaults.publisher(.lockScreenReminderChipStyle, options: []) + .sink { [weak self] _ in + guard let self else { return } + self.handleSnapshotUpdate(self.reminderManager.lockScreenSnapshot) + } + .store(in: &cancellables) + + Defaults.publisher(.lockScreenReminderWidgetHorizontalAlignment, options: []) + .sink { _ in + LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true) + } + .store(in: &cancellables) + + Defaults.publisher(.lockScreenReminderWidgetVerticalOffset, options: []) + .sink { _ in + LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true) + } + .store(in: &cancellables) + } + + private func handleSnapshotUpdate(_ newSnapshot: LockScreenReminderWidgetSnapshot?) { + guard Defaults[.enableLockScreenReminderWidget] else { + snapshot = nil + LockScreenReminderWidgetPanelManager.shared.hide() + return + } + + snapshot = newSnapshot + + guard LockScreenManager.shared.currentLockStatus else { return } + + if let newSnapshot { + Logger.log("LockScreenReminderWidgetManager: Updating snapshot on lock screen", category: .ui) + LockScreenReminderWidgetPanelManager.shared.show(with: newSnapshot) + } else { + LockScreenReminderWidgetPanelManager.shared.hide() + } + } + +} + diff --git a/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift b/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift new file mode 100644 index 00000000..32df8f22 --- /dev/null +++ b/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift @@ -0,0 +1,201 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI +import SkyLightWindow +import QuartzCore +import Defaults + +@MainActor +final class LockScreenReminderWidgetPanelManager { + static let shared = LockScreenReminderWidgetPanelManager() + + private var window: NSWindow? + private var hasDelegated = false + private var screenChangeObserver: NSObjectProtocol? + private var workspaceObservers: [NSObjectProtocol] = [] + + var isVisible: Bool { + window?.isVisible == true + } + + private init() { + registerScreenChangeObservers() + } + + func show(with snapshot: LockScreenReminderWidgetSnapshot) { + render(snapshot: snapshot, makeVisible: true) + } + + func update(with snapshot: LockScreenReminderWidgetSnapshot) { + render(snapshot: snapshot, makeVisible: false) + } + + func hide() { + guard let window else { return } + window.orderOut(nil) + window.contentView = nil + } + + func refreshPosition(animated: Bool) { + guard let window, window.isVisible, let screen = currentScreen() else { return } + let target = frame(for: window.frame.size, on: screen) + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.22 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + window.animator().setFrame(target, display: true) + } + } else { + window.setFrame(target, display: true) + } + } + + private func render(snapshot: LockScreenReminderWidgetSnapshot, makeVisible: Bool) { + guard let screen = currentScreen() else { return } + if !makeVisible, window == nil { + return + } + + let view = LockScreenReminderWidget(snapshot: snapshot) + let hostingView = NSHostingView(rootView: view) + let fittingSize = hostingView.fittingSize + hostingView.frame = NSRect(origin: .zero, size: fittingSize) + + let window = ensureWindow() + window.setFrame(frame(for: fittingSize, on: screen), display: true) + window.contentView = hostingView + + if makeVisible { + window.orderFrontRegardless() + } + } + + private func ensureWindow() -> NSWindow { + if let window { + return window + } + + let frame = NSRect(origin: .zero, size: CGSize(width: 220, height: 48)) + let newWindow = NSWindow( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.hasShadow = false + newWindow.ignoresMouseEvents = true + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + + window = newWindow + if !hasDelegated { + SkyLightOperator.shared.delegateWindow(newWindow) + hasDelegated = true + } + return newWindow + } + + private func frame(for size: CGSize, on screen: NSScreen) -> NSRect { + let screenFrame = screen.frame + let horizontalPadding: CGFloat = 24 + let alignment = Defaults[.lockScreenReminderWidgetHorizontalAlignment].lowercased() + let originX: CGFloat + + switch alignment { + case "leading", "left": + originX = screenFrame.minX + horizontalPadding + case "trailing", "right": + originX = screenFrame.maxX - size.width - horizontalPadding + default: + originX = screenFrame.midX - (size.width / 2) + } + let musicTop = LockScreenPanelManager.shared.latestFrame?.maxY ?? (screenFrame.midY - 32) + let timerFrame = LockScreenTimerWidgetPanelManager.shared.latestFrame + + let marginAboveMusic: CGFloat = 16 + let marginAboveTimer: CGFloat = 24 + let marginBelowWeather: CGFloat = 16 + + let baseLowerBound = musicTop + marginAboveMusic + let timerLowerBound = timerFrame.map { $0.maxY + marginAboveTimer } + let minYFloor = screenFrame.minY + 80 + let lowerBound = max(timerLowerBound ?? baseLowerBound, minYFloor) + + let defaultWeatherBottom = screenFrame.midY + (screenFrame.height * 0.14) + let minimumWeatherBottom = lowerBound + size.height + marginBelowWeather + let weatherBottom = LockScreenWeatherPanelManager.shared.latestFrame?.minY ?? min(screenFrame.maxY - 120, max(defaultWeatherBottom, minimumWeatherBottom)) + + let upperBound = weatherBottom - marginBelowWeather - size.height + let clampedUpperBound = max(upperBound, lowerBound) + + let prefersTopAlignment = timerFrame != nil + var proposedY: CGFloat + + if prefersTopAlignment { + proposedY = clampedUpperBound + } else { + proposedY = (lowerBound + clampedUpperBound) / 2 + } + + let rawOffset = CGFloat(Defaults[.lockScreenReminderWidgetVerticalOffset]) + let clampedOffset = min(max(rawOffset, -160), 160) + proposedY = max(lowerBound, min(proposedY + clampedOffset, clampedUpperBound)) + + return NSRect(x: originX, y: proposedY, width: size.width, height: size.height) + } + + private func registerScreenChangeObservers() { + screenChangeObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screen-parameters") + } + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let wakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screens-did-wake") + } + + workspaceObservers = [wakeObserver] + } + + private func handleScreenGeometryChange(reason: String) { + guard window?.isVisible == true else { return } + refreshPosition(animated: false) + print("LockScreenReminderWidgetPanelManager: realigned window due to \(reason)") + } + + private func currentScreen() -> NSScreen? { + LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main + } +} + diff --git a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift new file mode 100644 index 00000000..d907c017 --- /dev/null +++ b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift @@ -0,0 +1,328 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Combine +import Defaults +import SwiftUI +import AppKit +import SkyLightWindow +import QuartzCore + +@MainActor +final class LockScreenTimerWidgetAnimator: ObservableObject { + @Published var isPresented: Bool + + init(isPresented: Bool = false) { + self.isPresented = isPresented + } +} + +@MainActor +final class LockScreenTimerWidgetManager { + static let shared = LockScreenTimerWidgetManager() + + private let timerManager = TimerManager.shared + private var cancellables = Set() + private var isLocked: Bool = false + private var isPreviewing: Bool = false + + private init() { + observeTimerState() + observeDefaults() + } + + func handleLockStateChange(isLocked: Bool) { + self.isLocked = isLocked + if isLocked { + updateVisibility() + } else { + LockScreenTimerWidgetPanelManager.shared.hide() + } + } + + func setPreviewMode(_ enabled: Bool) { + guard isPreviewing != enabled else { return } + isPreviewing = enabled + updateVisibility() + } + + func refreshPositionForOffsets(animated: Bool) { + LockScreenTimerWidgetPanelManager.shared.refreshPosition(animated: animated) + } + + func notifyMusicPanelFrameChanged(animated: Bool) { + LockScreenTimerWidgetPanelManager.shared.refreshRelativeToMusicPanel(animated: animated) + } + + private func observeTimerState() { + timerManager.$isTimerActive + .combineLatest(timerManager.$activeSource) + .receive(on: RunLoop.main) + .sink { [weak self] _, _ in + self?.updateVisibility() + } + .store(in: &cancellables) + } + + private func observeDefaults() { + Defaults.publisher(.enableLockScreenTimerWidget, options: []) + .receive(on: RunLoop.main) + .sink { [weak self] change in + guard let self else { return } + if change.newValue { + self.updateVisibility() + } else { + LockScreenTimerWidgetPanelManager.shared.hide() + } + } + .store(in: &cancellables) + + Defaults.publisher(.lockScreenTimerVerticalOffset, options: []) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + self.refreshPositionForOffsets(animated: true) + } + .store(in: &cancellables) + + Defaults.publisher(.lockScreenTimerWidgetWidth, options: []) + .receive(on: RunLoop.main) + .sink { _ in + LockScreenTimerWidgetPanelManager.shared.refreshPosition(animated: true) + } + .store(in: &cancellables) + } + + private func updateVisibility() { + guard shouldDisplayWidget() else { + LockScreenTimerWidgetPanelManager.shared.hide() + return + } + LockScreenTimerWidgetPanelManager.shared.showWidget() + } + + private func shouldDisplayWidget() -> Bool { + guard Defaults[.enableLockScreenTimerWidget] else { return false } + if isPreviewing { + return timerManager.isTimerActive + } + guard isLocked else { return false } + return timerManager.hasManualTimerRunning + } +} + +@MainActor +final class LockScreenTimerWidgetPanelManager { + static let shared = LockScreenTimerWidgetPanelManager() + static let hideAnimationDurationNanoseconds: UInt64 = 360_000_000 + + private var window: NSWindow? + private var hasDelegated = false + private let animator = LockScreenTimerWidgetAnimator() + private var hideTask: Task? + private(set) var latestFrame: NSRect? + private var screenChangeObserver: NSObjectProtocol? + private var workspaceObservers: [NSObjectProtocol] = [] + + private init() { + registerScreenChangeObservers() + } + + func showWidget() { + guard let screen = currentScreen() else { return } + let window = ensureWindow() + let frame = targetFrame(on: screen) + window.setFrame(frame, display: true) + updateHostingViewSize(for: window, size: frame.size) + latestFrame = frame + window.alphaValue = 1 + window.orderFrontRegardless() + hideTask?.cancel() + hideTask = nil + animator.isPresented = true + LockScreenPanelManager.shared.notifyTimerWidgetFrameChanged(animated: false) + LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true) + } + + func hide(animated: Bool = true) { + guard let window else { return } + hideTask?.cancel() + animator.isPresented = false + + let delay: UInt64 = animated ? Self.hideAnimationDurationNanoseconds : 0 + if delay == 0 { + window.orderOut(nil) + hideTask = nil + latestFrame = nil + LockScreenPanelManager.shared.notifyTimerWidgetFrameChanged(animated: true) + LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true) + return + } + + hideTask = Task { [weak window, weak self] in + try? await Task.sleep(nanoseconds: delay) + await MainActor.run { + window?.orderOut(nil) + self?.hideTask = nil + self?.latestFrame = nil + LockScreenPanelManager.shared.notifyTimerWidgetFrameChanged(animated: true) + LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true) + } + } + } + + + func refreshPosition(animated: Bool) { + guard let window, window.isVisible, let screen = currentScreen() else { return } + let frame = targetFrame(on: screen) + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.22 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + window.animator().setFrame(frame, display: true) + } + } else { + window.setFrame(frame, display: true) + } + updateHostingViewSize(for: window, size: frame.size) + latestFrame = frame + LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: animated) + } + + func refreshRelativeToMusicPanel(animated: Bool) { + guard window?.isVisible == true else { return } + refreshPosition(animated: animated) + } + + private func ensureWindow() -> NSWindow { + if let window { + if window.contentView == nil { + window.contentView = hostingView() + } + return window + } + + let frame = NSRect(origin: .zero, size: LockScreenTimerWidget.preferredSize) + let newWindow = NSWindow( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.hasShadow = false + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + newWindow.ignoresMouseEvents = false + newWindow.isMovable = false + newWindow.contentView = hostingView() + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + + window = newWindow + + if !hasDelegated { + SkyLightOperator.shared.delegateWindow(newWindow) + hasDelegated = true + } + + return newWindow + } + + private func hostingView() -> NSHostingView { + let view = LockScreenTimerWidget(animator: animator) + let hosting = NSHostingView(rootView: view) + hosting.frame = NSRect(origin: .zero, size: LockScreenTimerWidget.preferredSize) + hosting.autoresizingMask = [.width, .height] + hosting.wantsLayer = true + hosting.layer?.masksToBounds = true + hosting.layer?.cornerRadius = LockScreenTimerWidget.cornerRadius + return hosting + } + + private func updateHostingViewSize(for window: NSWindow, size: CGSize) { + window.contentView?.frame = NSRect(origin: .zero, size: NSSize(width: size.width, height: size.height)) + } + + private func targetFrame(on screen: NSScreen) -> NSRect { + let size = LockScreenTimerWidget.preferredSize + let originX = screen.frame.midX - (size.width / 2) + let defaultLowering: CGFloat = -18 + let baseY = screen.frame.midY + 24 + defaultLowering + + let offset = CGFloat(clampedTimerOffset()) + var originY = baseY + offset + + if let weatherFrame = LockScreenWeatherPanelManager.shared.latestFrame { + originY = min(originY, weatherFrame.minY - size.height - 20) + } else { + let topLimit = screen.frame.maxY - size.height - 72 + originY = min(originY, topLimit) + } + + if let musicFrame = LockScreenPanelManager.shared.latestFrame { + let minAllowedY = musicFrame.maxY + 12 + originY = max(originY, minAllowedY) + } + + let minY = screen.frame.minY + 100 + originY = max(originY, minY) + + return NSRect(x: originX, y: originY, width: size.width, height: size.height) + } + + private func clampedTimerOffset() -> Double { + let raw = Defaults[.lockScreenTimerVerticalOffset] + return min(max(raw, -160), 160) + } + + private func registerScreenChangeObservers() { + screenChangeObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screen-parameters") + } + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let wakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screens-did-wake") + } + + workspaceObservers = [wakeObserver] + } + + private func handleScreenGeometryChange(reason: String) { + guard window?.isVisible == true else { return } + refreshPosition(animated: false) + print("LockScreenTimerWidgetPanelManager: realigned window due to \(reason)") + } + + private func currentScreen() -> NSScreen? { + LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main + } +} diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift new file mode 100644 index 00000000..ec8d8d38 --- /dev/null +++ b/DynamicIsland/managers/LockScreenWeatherManager.swift @@ -0,0 +1,1142 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Defaults +import CoreLocation +import Combine + +@MainActor +final class LockScreenWeatherManager: ObservableObject { + static let shared = LockScreenWeatherManager() + + @Published private(set) var snapshot: LockScreenWeatherSnapshot? + + private let provider = LockScreenWeatherProvider() + private let locationProvider = LockScreenWeatherLocationProvider() + private var lastFetchDate: Date? + private var latestWeatherPayload: LockScreenWeatherSnapshot? + private var cancellables = Set() + + 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() + } + + func showWeatherWidget() { + guard Defaults[.enableLockScreenWeatherWidget] else { + LockScreenWeatherPanelManager.shared.hide() + return + } + + locationProvider.prepareAuthorization() + let existingSnapshot = snapshot + let hadSnapshot = existingSnapshot != nil + var cachedSnapshot = existingSnapshot + + if cachedSnapshot == nil, let payload = latestWeatherPayload { + let derivedSnapshot = makeSnapshot(from: payload) + snapshot = derivedSnapshot + cachedSnapshot = derivedSnapshot + } + + if let cachedSnapshot { + deliver(cachedSnapshot, forceShow: true) + } + + let needsForceShowOnRefresh = cachedSnapshot == nil + let shouldForceRefresh = !hadSnapshot + + Task { @MainActor [weak self] in + guard let self else { return } + let refreshedSnapshot = await self.refresh(force: shouldForceRefresh) + + guard LockScreenManager.shared.currentLockStatus else { return } + + if let refreshedSnapshot { + self.deliver(refreshedSnapshot, forceShow: needsForceShowOnRefresh) + } else if needsForceShowOnRefresh { + LockScreenWeatherPanelManager.shared.hide() + } + } + } + + func hideWeatherWidget() { + LockScreenWeatherPanelManager.shared.hide() + } + + @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 { + 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[.lockScreenBatteryShowsBluetooth] { + 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 snapshot + } + + do { + let location = await locationProvider.currentLocation() + 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[.lockScreenBatteryShowsBluetooth] { + BluetoothAudioManager.shared.refreshConnectedDeviceBatteries() + } + let snapshot = makeSnapshot(from: payload) + self.snapshot = snapshot + lastFetchDate = Date() + 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[.lockScreenBatteryShowsCharging] + let chargingInfo = showsCharging ? makeChargingInfo() : nil + let showsBatteryGauge = Defaults[.lockScreenBatteryShowsBatteryGauge] + let widgetStyle = Defaults[.lockScreenWeatherWidgetStyle] + let batteryInfo = showsBatteryGauge ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil, widgetStyle: widgetStyle) : nil + let showsSunriseSetting = Defaults[.lockScreenWeatherShowsSunrise] + let sunCycle = snapshot?.sunCycle + let shouldShowSunrise = showsSunriseSetting && widgetStyle == .inline && sunCycle?.sunrise != nil + let fallback = LockScreenWeatherSnapshot( + temperatureText: snapshot?.temperatureText ?? "--", + symbolName: snapshot?.symbolName ?? "cloud.fill", + description: snapshot?.description ?? "", + locationName: snapshot?.locationName, + charging: chargingInfo, + bluetooth: Defaults[.lockScreenBatteryShowsBluetooth] ? makeBluetoothInfo() : nil, + battery: batteryInfo, + showsLocation: snapshot?.showsLocation ?? false, + airQuality: (providerSource.supportsAirQuality && Defaults[.lockScreenWeatherShowsAQI]) ? snapshot?.airQuality : nil, + widgetStyle: widgetStyle, + showsChargingPercentage: Defaults[.lockScreenBatteryShowsChargingPercentage], + temperatureInfo: snapshot?.temperatureInfo, + usesGaugeTint: Defaults[.lockScreenWeatherUsesGaugeTint], + sunCycle: sunCycle, + showsSunrise: shouldShowSunrise + ) + + 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(.lockScreenBatteryShowsCharging, options: []) + .map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.lockScreenBatteryShowsChargingPercentage, options: []) + .map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.lockScreenBatteryShowsBluetooth, options: []) + .map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.lockScreenBatteryShowsBatteryGauge, options: []) + .map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.lockScreenBatteryUsesLaptopSymbol, options: []) + .map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.lockScreenWeatherShowsSunrise, 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(), + Defaults.publisher(.lockScreenWeatherAQIScale, 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(.lockScreenWeatherAQIScale, options: []) + .sink { [weak self] _ in + guard let self else { return } + self.latestWeatherPayload = nil + Task { @MainActor in + NSLog("LockScreenWeatherManager: AQI scale 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[.lockScreenBatteryShowsCharging] ? makeChargingInfo() : nil + let bluetoothInfo = Defaults[.lockScreenBatteryShowsBluetooth] ? makeBluetoothInfo() : nil + let widgetStyle = Defaults[.lockScreenWeatherWidgetStyle] + let shouldShowLocation = widgetStyle == .inline && Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true) + let showsChargingPercentage = Defaults[.lockScreenBatteryShowsChargingPercentage] + let providerSource = Defaults[.lockScreenWeatherProviderSource] + let airQualityInfo = (Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality) ? payload.airQuality : nil + let batteryInfo = Defaults[.lockScreenBatteryShowsBatteryGauge] ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil, widgetStyle: widgetStyle) : nil + let usesGaugeTint = Defaults[.lockScreenWeatherUsesGaugeTint] + let showsSunriseSetting = Defaults[.lockScreenWeatherShowsSunrise] + let shouldShowSunrise = showsSunriseSetting && widgetStyle == .inline && payload.sunCycle?.sunrise != nil + + 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, + sunCycle: payload.sunCycle, + showsSunrise: shouldShowSunrise + ) + } + + 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, widgetStyle: LockScreenWeatherWidgetStyle) -> 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 } + + let usesLaptopSymbol = Defaults[.lockScreenBatteryUsesLaptopSymbol] + + return LockScreenWeatherSnapshot.BatteryInfo( + batteryLevel: clampedLevel, + usesLaptopSymbol: usesLaptopSymbol + ) + } + + 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 SunCycleInfo: Equatable { + let sunrise: Date? + let sunset: Date? + } + + 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 + let usesLaptopSymbol: Bool + } + + struct AirQualityInfo: Equatable { + enum Category: String, Equatable { + case good + case fair + case moderate + case unhealthyForSensitive + case unhealthy + case poor + case veryPoor + case veryUnhealthy + case extremelyPoor + case hazardous + case unknown + + var displayName: String { + switch self { + case .good: return "Good" + case .fair: return "Fair" + case .moderate: return "Moderate" + case .unhealthyForSensitive: return "Sensitive" + case .unhealthy: return "Unhealthy" + case .poor: return "Poor" + case .veryPoor: return "Very Poor" + case .veryUnhealthy: return "Very Unhealthy" + case .extremelyPoor: return "Extremely Poor" + case .hazardous: return "Hazardous" + case .unknown: return "Unknown" + } + } + } + + let index: Int + let category: Category + let scale: LockScreenWeatherAirQualityScale + } + + 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 + let sunCycle: SunCycleInfo? + let showsSunrise: Bool +} + +extension LockScreenWeatherSnapshot.AirQualityInfo.Category { + init(index: Int, scale: LockScreenWeatherAirQualityScale) { + switch scale { + case .us: + switch index { + 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 + } + case .european: + switch index { + case ..<0: + self = .unknown + case 0...20: + self = .good + case 21...40: + self = .fair + case 41...60: + self = .moderate + case 61...80: + self = .poor + case 81...100: + self = .veryPoor + case 101...: + self = .extremelyPoor + default: + self = .unknown + } + } + } +} + +private actor LockScreenWeatherProvider { + private let session: URLSession + private let decoder: JSONDecoder + + init() { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 10 + configuration.timeoutIntervalForResource = 10 + session = URLSession(configuration: configuration) + decoder = JSONDecoder() + } + + 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) + let lon = String(format: "%.4f", coordinate.longitude) + locationSuffix = "\(lat),\(lon)" + } else { + locationSuffix = "" + } + + 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 + } + + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) else { + throw WeatherProviderError.invalidResponse + } + + let payload = try decoder.decode(WTTRResponse.self, from: data) + guard let condition = payload.currentCondition.first else { + throw WeatherProviderError.noData + } + + 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 isDaytime = condition.isDaytime ?? true + let baseSymbol = WeatherSymbolMapper.symbol(for: code) + let symbol = symbolAdjustedForDaylight(baseSymbol, isDaytime: isDaytime) + let description = condition.localizedDescription + + let nearest = payload.nearestArea.first + let locationName = nearest?.preferredName + + let airQualityInfo: LockScreenWeatherSnapshot.AirQualityInfo? + if let index = condition.airQuality?.usIndexValue { + let scale: LockScreenWeatherAirQualityScale = .us + airQualityInfo = LockScreenWeatherSnapshot.AirQualityInfo( + index: index, + category: LockScreenWeatherSnapshot.AirQualityInfo.Category(index: index, scale: scale), + scale: scale + ) + } else { + airQualityInfo = nil + } + + return LockScreenWeatherSnapshot( + temperatureText: temperatureText, + symbolName: symbol, + description: description, + locationName: locationName, + charging: nil, + bluetooth: nil, + battery: nil, + showsLocation: true, + airQuality: airQualityInfo, + widgetStyle: .inline, + showsChargingPercentage: true, + temperatureInfo: temperatureInfo, + usesGaugeTint: true, + sunCycle: nil, + showsSunrise: false + ) + } + + 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,is_day,pressure_msl"), + URLQueryItem(name: "daily", value: "temperature_2m_max,temperature_2m_min,sunrise,sunset"), + URLQueryItem(name: "forecast_days", value: "2"), + 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 isDaytime = (current.isDay ?? 1) == 1 + let symbolName = symbolAdjustedForDaylight(mapping.symbol, isDaytime: isDaytime) + let unitSymbol = unit.symbol + let minTempValue = weatherPayload.daily?.temperature2MMin?.first + let maxTempValue = weatherPayload.daily?.temperature2MMax?.first + let timezoneIdentifier = weatherPayload.timezone + let utcOffsetSeconds = weatherPayload.utcOffsetSeconds + let sunriseDate = nextSunEvent(from: weatherPayload.daily?.sunrise, timezoneIdentifier: timezoneIdentifier, offsetSeconds: utcOffsetSeconds) + let sunsetDate = nextSunEvent(from: weatherPayload.daily?.sunset, timezoneIdentifier: timezoneIdentifier, offsetSeconds: utcOffsetSeconds) + let sunCycle: LockScreenWeatherSnapshot.SunCycleInfo? + if sunriseDate != nil || sunsetDate != nil { + sunCycle = LockScreenWeatherSnapshot.SunCycleInfo(sunrise: sunriseDate, sunset: sunsetDate) + } else { + sunCycle = nil + } + + let temperatureInfo = LockScreenWeatherSnapshot.TemperatureInfo( + current: temperatureValue, + minimum: minTempValue, + maximum: maxTempValue, + unitSymbol: unitSymbol + ) + + let selectedScale = Defaults[.lockScreenWeatherAQIScale] + + var airQualityInfo: LockScreenWeatherSnapshot.AirQualityInfo? + if Defaults[.lockScreenWeatherShowsAQI] { + airQualityInfo = try? await fetchOpenMeteoAirQuality(latitude: latitude, longitude: longitude, scale: selectedScale) + } + + return LockScreenWeatherSnapshot( + temperatureText: temperatureText, + symbolName: symbolName, + description: mapping.description, + locationName: nil, + charging: nil, + bluetooth: nil, + battery: nil, + showsLocation: true, + airQuality: airQualityInfo, + widgetStyle: .inline, + showsChargingPercentage: true, + temperatureInfo: temperatureInfo, + usesGaugeTint: true, + sunCycle: sunCycle, + showsSunrise: false + ) + } + + private func fetchOpenMeteoAirQuality(latitude: String, longitude: String, scale: LockScreenWeatherAirQualityScale) 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: scale.queryParameter), + 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 else { + return nil + } + + let rawValue: Double? + switch scale { + case .us: + rawValue = airCurrent.usAqi + case .european: + rawValue = airCurrent.europeanAqi + } + + guard let indexValue = rawValue else { + return nil + } + + let index = Int(round(indexValue)) + return LockScreenWeatherSnapshot.AirQualityInfo( + index: index, + category: LockScreenWeatherSnapshot.AirQualityInfo.Category(index: index, scale: scale), + scale: scale + ) + } + + private func nextSunEvent(from values: [String]?, timezoneIdentifier: String?, offsetSeconds: Int?) -> Date? { + guard let values else { return nil } + let now = Date() + for value in values { + if let date = parseLocalSunTime(value, timezoneIdentifier: timezoneIdentifier, offsetSeconds: offsetSeconds), date >= now { + return date + } + } + guard let fallbackValue = values.last else { return nil } + return parseLocalSunTime(fallbackValue, timezoneIdentifier: timezoneIdentifier, offsetSeconds: offsetSeconds) + } + + private func parseLocalSunTime(_ value: String, timezoneIdentifier: String?, offsetSeconds: Int?) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm" + if let identifier = timezoneIdentifier, let timeZone = TimeZone(identifier: identifier) { + formatter.timeZone = timeZone + } else if let offset = offsetSeconds, let timeZone = TimeZone(secondsFromGMT: offset) { + formatter.timeZone = timeZone + } + return formatter.date(from: value) + } +} + +enum WeatherProviderError: Error { + case invalidURL + case invalidResponse + case noData +} + +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 { + private enum CodingKeys: String, CodingKey { + case tempC = "temp_C" + case tempF = "temp_F" + case weatherCode + case weatherDesc + case langEn = "lang_en" + case pressure = "pressure" + case pressureInches = "pressureInches" + case airQuality = "air_quality" + case isday = "isday" + } + + let tempC: String + let tempF: String + let weatherCode: String + let weatherDesc: [WTTRTextValue]? + let langEn: [WTTRTextValue]? + let pressure: String? + let pressureInches: String? + let airQuality: WTTRAirQuality? + let isday: String? + + var localizedDescription: String { + if let english = langEn?.first?.value, !english.isEmpty { + return english + } + if let desc = weatherDesc?.first?.value, !desc.isEmpty { + return desc + } + return "" + } + + var isDaytime: Bool? { + guard let isday else { return nil } + let normalized = isday.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "1" || normalized == "yes" + } +} + +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]? + let country: [WTTRTextValue]? + + private enum CodingKeys: String, CodingKey { + case areaName = "areaName" + case region + case country + } + + var preferredName: String? { + if let name = areaName?.first?.value, !name.isEmpty { + return name + } + if let regionName = region?.first?.value, !regionName.isEmpty { + return regionName + } + if let countryName = country?.first?.value, !countryName.isEmpty { + return countryName + } + return nil + } +} + +private struct OpenMeteoForecastResponse: Decodable { + struct Current: Decodable { + let time: String? + let temperature2M: Double? + let weatherCode: Int? + let pressureMsl: Double? + let isDay: Int? + } + + let current: Current? + let daily: Daily? + let timezone: String? + let utcOffsetSeconds: Int? + + struct Daily: Decodable { + let temperature2MMax: [Double]? + let temperature2MMin: [Double]? + let sunrise: [String]? + let sunset: [String]? + } +} + +private struct OpenMeteoAirQualityResponse: Decodable { + struct Current: Decodable { + let time: String? + let usAqi: Double? + let europeanAqi: 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 { + case 113: + return "sun.max.fill" + case 116: + return "cloud.sun.fill" + case 119, 122: + return "cloud.fill" + case 143, 248, 260: + return "cloud.fog.fill" + case 176, 263, 266, 293, 296, 299, 302, 353, 356, 359: + return "cloud.rain.fill" + case 179, 182, 185, 311, 314, 317, 320, 362, 365: + return "cloud.sleet.fill" + case 227, 230, 281, 284, 323, 326, 329, 332, 335, 338, 368, 371, 374, 377: + return "cloud.snow.fill" + case 200, 386, 389, 392, 395: + return "cloud.bolt.rain.fill" + default: + return "cloud.sun.fill" + } + } +} + +private func symbolAdjustedForDaylight(_ symbol: String, isDaytime: Bool) -> String { + guard !isDaytime else { return symbol } + switch symbol { + case "sun.max.fill": + return "moon.stars.fill" + case "cloud.sun.fill": + return "cloud.moon.fill" + case "cloud.sun.rain.fill": + return "cloud.moon.rain.fill" + case "cloud.sun.bolt.fill": + return "cloud.moon.bolt.fill" + default: + return symbol + } +} + +@MainActor +private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManagerDelegate { + private let manager: CLLocationManager + private var pendingContinuations: [CheckedContinuation] = [] + private var lastLocation: CLLocation? + + override init() { + manager = CLLocationManager() + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } + + func prepareAuthorization() { + let status = CLLocationManager.authorizationStatus() + if status == .notDetermined { + manager.requestWhenInUseAuthorization() + } + } + + func currentLocation() async -> CLLocation? { + let status = CLLocationManager.authorizationStatus() + switch status { + case .authorizedAlways, .authorizedWhenInUse: + if let lastLocation, abs(lastLocation.timestamp.timeIntervalSinceNow) < 1800 { + return lastLocation + } + manager.requestLocation() + return await withCheckedContinuation { continuation in + self.pendingContinuations.append(continuation) + } + default: + return nil + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + lastLocation = locations.last + flushContinuations(with: lastLocation) + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + flushContinuations(with: nil) + } + + private func flushContinuations(with location: CLLocation?) { + guard !pendingContinuations.isEmpty else { return } + let continuations = pendingContinuations + pendingContinuations.removeAll() + continuations.forEach { $0.resume(returning: location) } + } +} diff --git a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift new file mode 100644 index 00000000..493b5b99 --- /dev/null +++ b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift @@ -0,0 +1,193 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI +import SkyLightWindow +import Defaults +import QuartzCore + +@MainActor +final class LockScreenWeatherPanelManager { + static let shared = LockScreenWeatherPanelManager() + + private var window: NSWindow? + private var hasDelegated = false + private(set) var latestFrame: NSRect? + private var lastSnapshot: LockScreenWeatherSnapshot? + private var lastContentSize: CGSize? + private var lastInlineBaselineHeight: CGFloat = 0 + private var screenChangeObserver: NSObjectProtocol? + private var workspaceObservers: [NSObjectProtocol] = [] + + private init() { + registerScreenChangeObservers() + } + + func show(with snapshot: LockScreenWeatherSnapshot) { + render(snapshot: snapshot, makeVisible: true) + } + + func update(with snapshot: LockScreenWeatherSnapshot) { + render(snapshot: snapshot, makeVisible: false) + } + + func hide() { + guard let window else { return } + window.alphaValue = 0 + window.orderOut(nil) + window.contentView = nil + window.alphaValue = 1 + latestFrame = nil + lastSnapshot = nil + lastContentSize = nil + } + + private func render(snapshot: LockScreenWeatherSnapshot, makeVisible: Bool) { + guard let screen = currentScreen() else { return } + if !makeVisible, window == nil { + return + } + + let view = LockScreenWeatherWidget(snapshot: snapshot) + let hostingView = NSHostingView(rootView: view) + let fittingSize = hostingView.fittingSize + if snapshot.widgetStyle == .inline { + lastInlineBaselineHeight = max(lastInlineBaselineHeight, fittingSize.height) + } + hostingView.frame = NSRect(origin: .zero, size: fittingSize) + + let targetFrame = frame(for: fittingSize, snapshot: snapshot, on: screen) + let window = ensureWindow() + window.setFrame(targetFrame, display: true) + latestFrame = targetFrame + window.contentView = hostingView + lastSnapshot = snapshot + lastContentSize = fittingSize + + if makeVisible { + window.orderFrontRegardless() + } + } + + private func ensureWindow() -> NSWindow { + if let window { + return window + } + + let frame = NSRect(origin: .zero, size: CGSize(width: 110, height: 40)) + let newWindow = NSWindow( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.hasShadow = false + newWindow.ignoresMouseEvents = true + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + + window = newWindow + if !hasDelegated { + SkyLightOperator.shared.delegateWindow(newWindow) + hasDelegated = true + } + return newWindow + } + + private func frame(for size: CGSize, snapshot: LockScreenWeatherSnapshot, on screen: NSScreen) -> NSRect { + let screenFrame = screen.frame + let originX = screenFrame.midX - (size.width / 2) + let verticalOffset = screenFrame.height * 0.15 + let isCircular = snapshot.widgetStyle == .circular + let topMargin: CGFloat = isCircular ? 120 : 48 + let inlineBaselineHeight: CGFloat = max(lastInlineBaselineHeight, 80) + let positionHeight = snapshot.widgetStyle == .inline + ? max(size.height, inlineBaselineHeight) + : size.height + let maxY = screenFrame.maxY - positionHeight - topMargin + let baseY = min(maxY, screenFrame.midY + verticalOffset) + let loweredY = baseY - 36 + + let inlineLift: CGFloat = snapshot.widgetStyle == .inline ? 44 : 0 + let circularDrop: CGFloat = isCircular ? 28 : 0 + let sizeDropHeight = positionHeight + let sizeDrop = max(0, sizeDropHeight - 80) * 0.35 + let userOffset = CGFloat(Defaults[.lockScreenWeatherVerticalOffset]) + let clampedOffset = min(max(userOffset, -160), 160) + let adjustedY = loweredY + inlineLift + clampedOffset - circularDrop - sizeDrop + let upperClampedY = min(maxY, adjustedY) + let clampedY = max(screenFrame.minY + 80, upperClampedY) + return NSRect(x: originX, y: clampedY, width: size.width, height: size.height) + } + + func refreshPositionForOffsets(animated: Bool = true) { + guard let window, let snapshot = lastSnapshot else { return } + guard let screen = currentScreen() else { return } + let size = lastContentSize ?? window.frame.size + let targetFrame = frame(for: size, snapshot: snapshot, on: screen) + latestFrame = targetFrame + + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.22 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + window.animator().setFrame(targetFrame, display: true) + } + } else { + window.setFrame(targetFrame, display: true) + } + } + + private func registerScreenChangeObservers() { + screenChangeObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screen-parameters") + } + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let wakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screens-did-wake") + } + + workspaceObservers = [wakeObserver] + } + + private func handleScreenGeometryChange(reason: String) { + guard window?.isVisible == true else { return } + refreshPositionForOffsets(animated: false) + print("LockScreenWeatherPanelManager: realigned window due to \(reason)") + } + + private func currentScreen() -> NSScreen? { + LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main + } +} diff --git a/DynamicIsland/managers/LockScreenWidgetPreviewManager.swift b/DynamicIsland/managers/LockScreenWidgetPreviewManager.swift new file mode 100644 index 00000000..7c3b5c05 --- /dev/null +++ b/DynamicIsland/managers/LockScreenWidgetPreviewManager.swift @@ -0,0 +1,578 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Combine +import Defaults +import SwiftUI + +@MainActor +final class LockScreenWidgetPreviewManager: ObservableObject { + static let shared = LockScreenWidgetPreviewManager() + + @Published private(set) var isPreviewVisible = false + + private var cancellables = Set() + private var startedDemoTimer = false + private var previewWindow: NSWindow? + private var previewWindowDelegate: PreviewWindowDelegate? + private var previewHostingView: NSHostingView? + private var cachedFocusState: FocusPreviewState? + + private init() { + observeDefaults() + } + + func togglePreview() { + if isPreviewVisible { + hidePreview() + } else { + showPreview() + } + } + + func showPreview() { + guard !isPreviewVisible else { return } + isPreviewVisible = true + ensurePreviewWindow() + applyPreviewState() + renderPreview() + } + + func hidePreview() { + guard isPreviewVisible else { return } + isPreviewVisible = false + hideAllWidgets() + restorePreviewState() + closePreviewWindow() + } + + private func renderPreview() { + guard isPreviewVisible else { return } + + ensurePreviewWindow() + + if Defaults[.enableLockScreenTimerWidget] { + ensureDemoTimerIfNeeded() + } else { + stopDemoTimerIfNeeded() + } + + updatePreviewContent() + } + + private func hideAllWidgets() { + stopDemoTimerIfNeeded() + } + + private func ensureDemoTimerIfNeeded() { + let timerManager = TimerManager.shared + guard !timerManager.isTimerActive else { return } + timerManager.startDemoTimer(duration: 1783) + startedDemoTimer = true + } + + private func stopDemoTimerIfNeeded() { + guard startedDemoTimer else { return } + TimerManager.shared.forceStopTimer() + startedDemoTimer = false + } + + private func observeDefaults() { + observeKey(.enableLockScreenMediaWidget) + observeKey(.enableLockScreenWeatherWidget) + observeKey(.enableLockScreenTimerWidget) + observeKey(.lockScreenWeatherWidgetStyle) + observeKey(.lockScreenWeatherProviderSource) + observeKey(.lockScreenWeatherTemperatureUnit) + observeKey(.lockScreenWeatherShowsLocation) + observeKey(.lockScreenWeatherShowsSunrise) + observeKey(.lockScreenWeatherShowsAQI) + observeKey(.lockScreenWeatherAQIScale) + observeKey(.lockScreenBatteryShowsCharging) + observeKey(.lockScreenBatteryShowsChargingPercentage) + observeKey(.lockScreenBatteryShowsBatteryGauge) + observeKey(.lockScreenBatteryShowsBluetooth) + observeKey(.lockScreenBatteryUsesLaptopSymbol) + observeKey(.lockScreenWeatherUsesGaugeTint) + observeKey(.lockScreenReminderChipStyle) + } + + private func observeKey(_ key: Defaults.Key) { + Defaults.publisher(key, options: []) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.renderPreview() + } + .store(in: &cancellables) + } + + private func mockWeatherSnapshot() -> LockScreenWeatherSnapshot { + let unit = Defaults[.lockScreenWeatherTemperatureUnit] + let temperatureValue: Double = unit == .celsius ? 21 : 72 + let temperatureText = "\(Int(round(temperatureValue)))°" + let temperatureInfo = LockScreenWeatherSnapshot.TemperatureInfo( + current: temperatureValue, + minimum: temperatureValue - 5, + maximum: temperatureValue + 6, + unitSymbol: unit.symbol + ) + + let chargingInfo: LockScreenWeatherSnapshot.ChargingInfo? = Defaults[.lockScreenBatteryShowsCharging] + ? LockScreenWeatherSnapshot.ChargingInfo( + minutesRemaining: 42, + isCharging: true, + isPluggedIn: true, + batteryLevel: 78 + ) + : nil + + let bluetoothInfo: LockScreenWeatherSnapshot.BluetoothInfo? = Defaults[.lockScreenBatteryShowsBluetooth] + ? LockScreenWeatherSnapshot.BluetoothInfo( + deviceName: "AirPods Pro", + batteryLevel: 74, + iconName: "airpodspro" + ) + : nil + + let batteryInfo: LockScreenWeatherSnapshot.BatteryInfo? = Defaults[.lockScreenBatteryShowsBatteryGauge] + ? LockScreenWeatherSnapshot.BatteryInfo( + batteryLevel: 63, + usesLaptopSymbol: Defaults[.lockScreenBatteryUsesLaptopSymbol] + ) + : nil + + let widgetStyle = Defaults[.lockScreenWeatherWidgetStyle] + let showLocation = widgetStyle == .inline && Defaults[.lockScreenWeatherShowsLocation] + let providerSource = Defaults[.lockScreenWeatherProviderSource] + let showsAQI = Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality + let scale = Defaults[.lockScreenWeatherAQIScale] + let airQualityIndex = scale == .us ? 42 : 28 + let airQuality = showsAQI + ? LockScreenWeatherSnapshot.AirQualityInfo( + index: airQualityIndex, + category: .init(index: airQualityIndex, scale: scale), + scale: scale + ) + : nil + + let showsSunrise = Defaults[.lockScreenWeatherShowsSunrise] && widgetStyle == .inline + let sunCycle = LockScreenWeatherSnapshot.SunCycleInfo( + sunrise: Date().addingTimeInterval(2 * 3600), + sunset: Date().addingTimeInterval(9 * 3600) + ) + + return LockScreenWeatherSnapshot( + temperatureText: temperatureText, + symbolName: "cloud.sun.fill", + description: "Partly Cloudy", + locationName: "Cupertino", + charging: chargingInfo, + bluetooth: bluetoothInfo, + battery: batteryInfo, + showsLocation: showLocation, + airQuality: airQuality, + widgetStyle: widgetStyle, + showsChargingPercentage: Defaults[.lockScreenBatteryShowsChargingPercentage], + temperatureInfo: temperatureInfo, + usesGaugeTint: Defaults[.lockScreenWeatherUsesGaugeTint], + sunCycle: sunCycle, + showsSunrise: showsSunrise + ) + } + + private func ensurePreviewWindow() { + guard let screen = NSScreen.main else { return } + + if let window = previewWindow { + window.makeKeyAndOrderFront(nil) + updatePreviewContent() + return + } + + let frame = screen.frame + let window = NSWindow( + contentRect: frame, + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + + window.title = "Lock Screen Preview" + window.isReleasedWhenClosed = false + window.backgroundColor = .clear + window.center() + + let scene = LockScreenPreviewScene( + screenSize: screen.frame.size, + weatherSnapshot: mockWeatherSnapshot(), + showsWeather: Defaults[.enableLockScreenWeatherWidget], + showsTimer: Defaults[.enableLockScreenTimerWidget], + showsMediaPanel: Defaults[.enableLockScreenMediaWidget] + ) + let hosting = NSHostingView(rootView: scene) + hosting.frame = NSRect(origin: .zero, size: frame.size) + hosting.autoresizingMask = [.width, .height] + previewHostingView = hosting + window.contentView = hosting + + let delegate = PreviewWindowDelegate { [weak self] in + self?.hidePreview() + } + previewWindowDelegate = delegate + window.delegate = delegate + + previewWindow = window + window.makeKeyAndOrderFront(nil) + } + + private func updatePreviewContent() { + guard let screen = NSScreen.main else { return } + let scene = LockScreenPreviewScene( + screenSize: screen.frame.size, + weatherSnapshot: mockWeatherSnapshot(), + showsWeather: Defaults[.enableLockScreenWeatherWidget], + showsTimer: Defaults[.enableLockScreenTimerWidget], + showsMediaPanel: Defaults[.enableLockScreenMediaWidget] + ) + + if let hosting = previewHostingView { + hosting.rootView = scene + } else if let window = previewWindow { + let hosting = NSHostingView(rootView: scene) + hosting.frame = NSRect(origin: .zero, size: window.frame.size) + hosting.autoresizingMask = [.width, .height] + previewHostingView = hosting + window.contentView = hosting + } + } + + private func closePreviewWindow() { + previewWindow?.orderOut(nil) + } + + private func applyPreviewState() { + applyFocusPreviewState() + applyCalendarPreviewEvents() + } + + private func restorePreviewState() { + restoreFocusPreviewState() + CalendarManager.shared.setLockScreenPreviewEvents(nil) + } + + private func applyFocusPreviewState() { + let manager = DoNotDisturbManager.shared + if cachedFocusState == nil { + cachedFocusState = FocusPreviewState( + isActive: manager.isDoNotDisturbActive, + name: manager.currentFocusModeName, + identifier: manager.currentFocusModeIdentifier + ) + } + manager.isDoNotDisturbActive = true + manager.currentFocusModeName = "Do Not Disturb" + manager.currentFocusModeIdentifier = "com.apple.donotdisturb" + } + + private func restoreFocusPreviewState() { + guard let cachedFocusState else { return } + let manager = DoNotDisturbManager.shared + manager.isDoNotDisturbActive = cachedFocusState.isActive + manager.currentFocusModeName = cachedFocusState.name + manager.currentFocusModeIdentifier = cachedFocusState.identifier + self.cachedFocusState = nil + } + + private func applyCalendarPreviewEvents() { + let now = Date() + let reminderCalendar = CalendarModel( + accountName: "Preview", + id: "preview.reminders", + title: "Reminders", + color: .systemBlue, + isSubscribed: false, + isReminder: true + ) + let reminderEvent = EventModel( + id: "preview.reminder.pay-rent", + start: now.addingTimeInterval(12 * 60), + end: now.addingTimeInterval(42 * 60), + title: "Pay rent", + location: nil, + notes: nil, + url: nil, + isAllDay: false, + type: .reminder(completed: false), + calendar: reminderCalendar, + participants: [], + timeZone: TimeZone.current, + hasRecurrenceRules: false, + priority: .high, + conferenceURL: nil + ) + + let eventCalendar = CalendarModel( + accountName: "Preview", + id: "preview.calendar", + title: "Calendar", + color: .systemOrange, + isSubscribed: false, + isReminder: false + ) + let event = EventModel( + id: "preview.event.standup", + start: now.addingTimeInterval(60 * 60), + end: now.addingTimeInterval(90 * 60), + title: "Design Review", + location: "Studio", + notes: nil, + url: nil, + isAllDay: false, + type: .event(.accepted), + calendar: eventCalendar, + participants: [], + timeZone: TimeZone.current, + hasRecurrenceRules: false, + priority: nil, + conferenceURL: nil + ) + + CalendarManager.shared.setLockScreenPreviewEvents([reminderEvent, event]) + } +} + +private struct FocusPreviewState { + let isActive: Bool + let name: String + let identifier: String +} + +private final class PreviewWindowDelegate: NSObject, NSWindowDelegate { + private let onClose: () -> Void + + init(onClose: @escaping () -> Void) { + self.onClose = onClose + } + + func windowWillClose(_ notification: Notification) { + onClose() + } +} + +private struct LockScreenPreviewScene: View { + let screenSize: CGSize + let weatherSnapshot: LockScreenWeatherSnapshot + let showsWeather: Bool + let showsTimer: Bool + let showsMediaPanel: Bool + + @State private var weatherSize: CGSize = .zero + @State private var musicSize: CGSize = .zero + @State private var inlineBaselineHeight: CGFloat = 0 + @StateObject private var panelAnimator = LockScreenPanelAnimator() + + var body: some View { + GeometryReader { proxy in + let scale = min(proxy.size.width / screenSize.width, proxy.size.height / screenSize.height) + let contentSize = CGSize(width: screenSize.width * scale, height: screenSize.height * scale) + let origin = CGPoint( + x: (proxy.size.width - contentSize.width) / 2, + y: (proxy.size.height - contentSize.height) / 2 + ) + + ZStack(alignment: .topLeading) { + WallpaperView() + .frame(width: proxy.size.width, height: proxy.size.height) + .clipped() + + ZStack(alignment: .topLeading) { + if showsMediaPanel { + LockScreenMusicPanel(animator: panelAnimator) + .fixedSize() + .background(PreviewSizeReader(size: $musicSize)) + .position(centerPoint(for: swiftUIFrame(for: mediaFrame))) + .onAppear { + panelAnimator.isPresented = true + } + } + + if showsTimer { + LockScreenTimerWidget() + .frame(width: timerFrame.width, height: timerFrame.height) + .position(centerPoint(for: swiftUIFrame(for: timerFrame))) + } + + if showsWeather { + LockScreenWeatherWidget(snapshot: weatherSnapshot) + .fixedSize() + .background(PreviewSizeReader(size: $weatherSize)) + .position(centerPoint(for: swiftUIFrame(for: weatherFrame))) + .onChange(of: weatherSize) { _, newSize in + if weatherSnapshot.widgetStyle == .inline { + inlineBaselineHeight = max(inlineBaselineHeight, newSize.height) + } + } + } + } + .frame(width: screenSize.width, height: screenSize.height, alignment: .topLeading) + .scaleEffect(scale, anchor: .topLeading) + .offset(x: origin.x, y: origin.y) + } + } + .ignoresSafeArea() + } + + private var mediaFrame: CGRect { + let size = musicSize == .zero ? LockScreenMusicPanel.collapsedSize : musicSize + let screenFrame = CGRect(origin: .zero, size: screenSize) + let originX = screenFrame.midX - (size.width / 2) + let baseOriginY = screenFrame.origin.y + (screenFrame.height / 2) - size.height - 32 + let defaultLowering: CGFloat = -28 + let userOffset = CGFloat(Defaults[.lockScreenMusicVerticalOffset]) + let clampedOffset = min(max(userOffset, -160), 160) + var originY = baseOriginY + defaultLowering + clampedOffset + + if showsTimer { + let maxAllowedTop = timerFrame.minY - 12 + let maxOriginY = maxAllowedTop - size.height + originY = min(originY, maxOriginY) + } + + return CGRect(x: originX, y: originY, width: size.width, height: size.height) + } + + private var timerFrame: CGRect { + let size = LockScreenTimerWidget.preferredSize + let screenFrame = CGRect(origin: .zero, size: screenSize) + let originX = screenFrame.midX - (size.width / 2) + let defaultLowering: CGFloat = -18 + let baseY = screenFrame.midY + 24 + defaultLowering + + let offset = CGFloat(min(max(Defaults[.lockScreenTimerVerticalOffset], -160), 160)) + var originY = baseY + offset + + if showsWeather { + originY = min(originY, weatherFrame.minY - size.height - 20) + } else { + let topLimit = screenFrame.maxY - size.height - 72 + originY = min(originY, topLimit) + } + + let minY = screenFrame.minY + 100 + originY = max(originY, minY) + + return CGRect(x: originX, y: originY, width: size.width, height: size.height) + } + + private var weatherFrame: CGRect { + let size = weatherSize == .zero ? CGSize(width: 220, height: 60) : weatherSize + let screenFrame = CGRect(origin: .zero, size: screenSize) + let originX = screenFrame.midX - (size.width / 2) + let verticalOffset = screenFrame.height * 0.15 + let isCircular = weatherSnapshot.widgetStyle == .circular + let topMargin: CGFloat = isCircular ? 120 : 48 + let inlineBaseline = max(inlineBaselineHeight, 80) + let positionHeight = weatherSnapshot.widgetStyle == .inline + ? max(size.height, inlineBaseline) + : size.height + let maxY = screenFrame.maxY - positionHeight - topMargin + let baseY = min(maxY, screenFrame.midY + verticalOffset) + let loweredY = baseY - 36 + + let inlineLift: CGFloat = weatherSnapshot.widgetStyle == .inline ? 44 : 0 + let circularDrop: CGFloat = isCircular ? 28 : 0 + let sizeDropHeight = positionHeight + let sizeDrop = max(0, sizeDropHeight - 80) * 0.35 + let userOffset = CGFloat(Defaults[.lockScreenWeatherVerticalOffset]) + let clampedOffset = min(max(userOffset, -160), 160) + let adjustedY = loweredY + inlineLift + clampedOffset - circularDrop - sizeDrop + let upperClampedY = min(maxY, adjustedY) + let clampedY = max(screenFrame.minY + 80, upperClampedY) + + return CGRect(x: originX, y: clampedY, width: size.width, height: size.height) + } + + private func swiftUIFrame(for appKitFrame: CGRect) -> CGRect { + let originY = screenSize.height - appKitFrame.maxY + return CGRect(x: appKitFrame.origin.x, y: originY, width: appKitFrame.width, height: appKitFrame.height) + } + + private func centerPoint(for frame: CGRect) -> CGPoint { + CGPoint(x: frame.midX, y: frame.midY) + } +} + +private struct PreviewSizeReader: View { + @Binding var size: CGSize + + var body: some View { + GeometryReader { proxy in + Color.clear + .onAppear { size = proxy.size } + .onChange(of: proxy.size) { _, newSize in + size = newSize + } + } + } +} + +struct WallpaperView: View { + @State private var wallpaperImage: NSImage? + + var body: some View { + ZStack { + if let image = wallpaperImage { + Image(nsImage: image) + .resizable() + .scaledToFill() + } else { + Color.black + Text("Loading...") + .foregroundStyle(.secondary) + } + } + .onAppear(perform: loadWallpaper) + } + + func loadWallpaper() { + if let url = Bundle.main.url(forResource: "desktop", withExtension: "jpeg"), + let image = NSImage(contentsOf: url), + image.size.width > 0, + image.size.height > 0 { + self.wallpaperImage = image + return + } + + if let image = NSImage(named: "desktop"), + image.size.width > 0, + image.size.height > 0 { + self.wallpaperImage = image + return + } + + if let screen = NSScreen.main, + let url = NSWorkspace.shared.desktopImageURL(for: screen), + let image = NSImage(contentsOf: url), + image.size.width > 0, + image.size.height > 0 { + self.wallpaperImage = image + return + } + } +} diff --git a/DynamicIsland/managers/LunarManager.swift b/DynamicIsland/managers/LunarManager.swift new file mode 100644 index 00000000..e2e00fb1 --- /dev/null +++ b/DynamicIsland/managers/LunarManager.swift @@ -0,0 +1,574 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +@preconcurrency import Foundation +import AppKit +import Combine +import Defaults +import Network + +// MARK: - Lunar Display Data Model + +/// Matches the JSON structure streamed by Lunar's socket listener. +struct LunarDisplayData: Codable { + var brightness: Float? + var contrast: Float? + var volume: Float? + var mute: Bool? + var nits: Float? + var display: Int +} + +// MARK: - Lunar Control Category + +enum LunarControlCategory { + case brightness + case contrast + case volume +} + +// MARK: - Lunar Manager + +/// Manages integration with the Lunar app (fyi.lunar.Lunar). +/// +/// Responsibilities: +/// - Detect whether Lunar is installed and running +/// - Observe app launch / termination to connect / disconnect automatically +/// - Connect to Lunar's TCP socket on localhost:23803 and listen for DDC changes +/// - Route brightness / contrast / volume events to Atoll's HUD pipeline +@MainActor +final class LunarManager: ObservableObject { + static let shared = LunarManager() + + /// Bundle identifier of the Lunar app. + nonisolated static let bundleID = "fyi.lunar.Lunar" + + /// UserDefaults suite used by Lunar to store its API key. + private static let lunarDomain = "fyi.lunar.Lunar" + private static let apiKeyKey = "apiKey" + + // MARK: Published state + + /// Whether Lunar is currently detected (installed) on this machine. + @Published private(set) var isDetected: Bool = false + + /// Whether Lunar is currently running. + @Published private(set) var isRunning: Bool = false + + /// Whether the TCP socket connection to Lunar is active. + @Published private(set) var isConnected: Bool = false + + // MARK: Private + + private var connection: NWConnection? + private var workspaceLaunchObserver: NSObjectProtocol? + private var workspaceTermObserver: NSObjectProtocol? + private var cancellables = Set() + private weak var coordinator: DynamicIslandViewCoordinator? + + /// Reconnection back-off state. + private var reconnectTask: Task? + private var reconnectAttempts: Int = 0 + private static let maxReconnectAttempts = 5 + private static let baseReconnectDelay: UInt64 = 2_000_000_000 // 2 seconds + + private var isLunarIntegrationEnabled: Bool { + Defaults[.enableThirdPartyDDCIntegration] && Defaults[.thirdPartyDDCProvider] == .lunar + } + + private var isExternalVolumeListenerEnabled: Bool { + isLunarIntegrationEnabled && Defaults[.enableExternalVolumeControlListener] + } + + private init() { + isDetected = Self.checkInstallation() + isRunning = Self.checkRunning() + setupWorkspaceObservers() + setupSettingsObserver() + } + + // MARK: - Public API + + /// Configure with the view coordinator for HUD dispatch. + func configure(coordinator: DynamicIslandViewCoordinator) { + self.coordinator = coordinator + refreshConnectionState() + } + + /// Called when Atoll is about to quit so Lunar's native OSD is restored. + func appWillTerminate() { + setLunarHideOSD(false) + } + + /// Refresh detection status (e.g. after manual check from Settings). + func refreshDetectionStatus() { + let wasRunning = isRunning + isDetected = Self.checkInstallation() + isRunning = Self.checkRunning() + if wasRunning != isRunning { + NSLog("🌙 Lunar running state changed: running=\(isRunning)") + } + refreshConnectionState() + } + + // MARK: - Detection + + /// Check if Lunar is installed by looking for its bundle ID. + nonisolated static func checkInstallation() -> Bool { + if NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID }) { + return true + } + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + return FileManager.default.fileExists(atPath: url.path) + } + return false + } + + /// Check if Lunar is currently running. + nonisolated static func checkRunning() -> Bool { + NSWorkspace.shared.runningApplications.contains(where: { $0.bundleIdentifier == bundleID }) + } + + // MARK: - Socket Connection + + private func connectToLunar() { + guard connection == nil else { return } + guard let apiKey = Self.retrieveAPIKey() else { + NSLog("⚠️ Lunar: failed to retrieve API key from UserDefaults suite '\(Self.lunarDomain)'") + return + } + + setLunarHideOSD(true) + + let host = NWEndpoint.Host("localhost") + let port = NWEndpoint.Port(23803) + let conn = NWConnection(host: host, port: port, using: .tcp) + + conn.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + self?.handleConnectionState(state, apiKey: apiKey) + } + } + + connection = conn + conn.start(queue: DispatchQueue.global(qos: .utility)) + NSLog("🌙 Lunar: connecting to socket on \(host):\(port)…") + } + + private func disconnect() { + reconnectTask?.cancel() + reconnectTask = nil + reconnectAttempts = 0 + + if let conn = connection { + conn.stateUpdateHandler = nil + conn.cancel() + connection = nil + } + isConnected = false + setLunarHideOSD(false) + NSLog("⏹ Lunar: socket disconnected") + } + + private func handleConnectionState(_ state: NWConnection.State, apiKey: String) { + switch state { + case .ready: + isConnected = true + reconnectAttempts = 0 + NSLog("🌙 Lunar: connected to socket") + sendListenCommand(apiKey: apiKey) + receiveMessages() + + case .failed(let error): + NSLog("🌙 Lunar: connection failed — \(error.localizedDescription)") + isConnected = false + connection?.cancel() + connection = nil + scheduleReconnect() + + case .cancelled: + isConnected = false + + case .waiting(let error): + NSLog("🌙 Lunar: connection waiting — \(error.localizedDescription)") + + default: + break + } + } + + /// Send Lunar's listen command with the API key. + private func sendListenCommand(apiKey: String) { + guard let conn = connection else { return } + + let separator = String(UnicodeScalar(0x01)) + let message = [apiKey, "listen", "--json", "--only-user-adjustments"] + .joined(separator: separator) + + guard let data = message.data(using: .utf8) else { + NSLog("⚠️ Lunar: failed to encode listen command") + return + } + + conn.send(content: data, completion: .contentProcessed { error in + if let error { + NSLog("⚠️ Lunar: failed to send listen command — \(error.localizedDescription)") + } else { + NSLog("🌙 Lunar: listen command sent") + } + }) + } + + /// Receive JSON messages from Lunar's socket in a loop. + private func receiveMessages() { + guard let conn = connection else { return } + + conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in + if let data, !data.isEmpty { + Task { @MainActor in + self?.handleReceivedData(data) + } + } + + if isComplete { + NSLog("🌙 Lunar: stream ended") + Task { @MainActor in + self?.isConnected = false + self?.connection?.cancel() + self?.connection = nil + self?.scheduleReconnect() + } + } else if let error { + NSLog("⚠️ Lunar: receive error — \(error.localizedDescription)") + Task { @MainActor in + self?.isConnected = false + self?.connection?.cancel() + self?.connection = nil + self?.scheduleReconnect() + } + } else { + // Continue reading + Task { @MainActor in + self?.receiveMessages() + } + } + } + } + + private func handleReceivedData(_ data: Data) { + guard isLunarIntegrationEnabled, isRunning else { return } + + do { + let displayData = try JSONDecoder().decode(LunarDisplayData.self, from: data) + logDecodedPayload(displayData, source: "socket") + routeToHUD(displayData) + } catch { + // Lunar may send multi-line JSON or partial data; try splitting by newlines + let rawString = String(data: data, encoding: .utf8) ?? "" + let lines = rawString.components(separatedBy: .newlines).filter { !$0.isEmpty } + for line in lines { + guard let lineData = line.data(using: .utf8) else { continue } + if let displayData = try? JSONDecoder().decode(LunarDisplayData.self, from: lineData) { + logDecodedPayload(displayData, source: "socket-line") + routeToHUD(displayData) + } + } + } + } + + // MARK: - HUD Routing + + private func routeToHUD(_ data: LunarDisplayData) { + let targetScreen = resolveScreen(for: data.display) + let isExternal = isExternalDisplay(data.display, resolvedScreen: targetScreen) + let hasVolumeData = data.volume != nil || data.mute != nil + let externalVolumeListenerEnabled = isExternalVolumeListenerEnabled + let resolvedScreenText = targetScreen?.localizedName ?? "nil" + let brightnessText = data.brightness.map { String($0) } ?? "nil" + let contrastText = data.contrast.map { String($0) } ?? "nil" + let volumeText = data.volume.map { String($0) } ?? "nil" + let muteText = data.mute.map { String($0) } ?? "nil" + + NSLog( + "🌙 Lunar routed payload: display=\(data.display) resolvedScreen=\(resolvedScreenText) isExternal=\(isExternal) brightness=\(brightnessText) contrast=\(contrastText) volume=\(volumeText) mute=\(muteText) hasVolumeData=\(hasVolumeData) externalVolumeListener=\(externalVolumeListenerEnabled)" + ) + + // Nits-only events are informational (no user-facing HUD needed). + let hasActionableChange = data.brightness != nil || data.contrast != nil || data.volume != nil || data.mute != nil + guard hasActionableChange else { return } + + // Brightness — values arrive in 0…1 range from Lunar + if let brightness = data.brightness { + let value = CGFloat(brightness) + let icon = isExternal ? "display" : "" + dispatchBrightnessHUD(value: value, icon: icon, onScreen: targetScreen) + } + + // Contrast — show as brightness-style HUD with a contrast icon + if let contrast = data.contrast { + let value = CGFloat(contrast) + dispatchBrightnessHUD(value: value, icon: "circle.righthalf.filled", onScreen: targetScreen) + } + + // Volume + if externalVolumeListenerEnabled { + if let volume = data.volume { + let value = CGFloat(volume) + let isMuted = data.mute ?? false + dispatchVolumeHUD(value: value, isMuted: isMuted, onScreen: targetScreen) + } else if let mute = data.mute { + // Mute toggle without a volume value + dispatchVolumeHUD(value: mute ? 0 : 1, isMuted: mute, onScreen: targetScreen) + } + } else if hasVolumeData { + NSLog("🌙 Lunar volume payload ignored because external volume listener is disabled") + } + } + + // MARK: - HUD Dispatch (mirrors BetterDisplayManager / SystemChangesObserver logic) + + private func dispatchVolumeHUD(value: CGFloat, isMuted: Bool, onScreen targetScreen: NSScreen? = nil) { + if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD { return } + + if Defaults[.enableCircularHUD] { + CircularHUDWindowManager.shared.show(type: .volume, value: value, onScreen: targetScreen) + return + } + if Defaults[.enableVerticalHUD] { + VerticalHUDWindowManager.shared.show(type: .volume, value: value, icon: "", onScreen: targetScreen) + return + } + if Defaults[.enableCustomOSD] && Defaults[.enableOSDVolume] { + CustomOSDWindowManager.shared.showVolume(value: value, onScreen: targetScreen) + } + if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] { + coordinator?.toggleSneakPeek( + status: true, + type: .volume, + value: value, + icon: "", + onScreen: targetScreen + ) + } + } + + private func dispatchBrightnessHUD(value: CGFloat, icon: String = "", onScreen targetScreen: NSScreen? = nil) { + if Defaults[.enableCircularHUD] { + CircularHUDWindowManager.shared.show(type: .brightness, value: value, icon: icon, onScreen: targetScreen) + return + } + if Defaults[.enableVerticalHUD] { + VerticalHUDWindowManager.shared.show(type: .brightness, value: value, icon: icon, onScreen: targetScreen) + return + } + if Defaults[.enableCustomOSD] && Defaults[.enableOSDBrightness] { + CustomOSDWindowManager.shared.showBrightness(value: value, icon: icon, onScreen: targetScreen) + } + if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] { + coordinator?.toggleSneakPeek( + status: true, + type: .brightness, + value: value, + icon: icon, + onScreen: targetScreen + ) + } + } + + // MARK: - Helpers + + /// Retrieve the Lunar API key from its UserDefaults suite. + nonisolated private static func retrieveAPIKey() -> String? { + UserDefaults(suiteName: lunarDomain)?.string(forKey: apiKeyKey) + } + + /// Resolve a Lunar display ID (CGDirectDisplayID) to the matching NSScreen. + private func resolveScreen(for displayID: Int) -> NSScreen? { + if let directDisplayID = UInt32(exactly: displayID), + let matchedScreen = NSScreen.screens.first(where: { screenNumber(for: $0) == directDisplayID }) { + return matchedScreen + } + + // Fallback for integrations that send 1-based display indexes (e.g. 1, 2) + if displayID > 0 { + let index = displayID - 1 + if NSScreen.screens.indices.contains(index) { + let fallback = NSScreen.screens[index] + NSLog("🌙 Lunar: display=\(displayID) resolved via index fallback to '\(fallback.localizedName)'") + return fallback + } + } + + NSLog("🌙 Lunar: unable to resolve display=\(displayID), HUD will show on all screens") + return nil + } + + /// Whether the given displayID refers to an external (non-built-in) display. + private func isExternalDisplay(_ displayID: Int, resolvedScreen: NSScreen?) -> Bool { + if let resolvedScreen, + let resolvedDisplayID = screenNumber(for: resolvedScreen) { + return CGDisplayIsBuiltin(resolvedDisplayID) == 0 + } + if let displayIDValue = UInt32(exactly: displayID) { + return CGDisplayIsBuiltin(displayIDValue) == 0 + } + return false + } + + private func screenNumber(for screen: NSScreen) -> UInt32? { + guard let num = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + return nil + } + return num.uint32Value + } + + private func logDecodedPayload(_ data: LunarDisplayData, source: String) { + let hasVolumeData = data.volume != nil || data.mute != nil + let brightnessText = data.brightness.map { String($0) } ?? "nil" + let contrastText = data.contrast.map { String($0) } ?? "nil" + let volumeText = data.volume.map { String($0) } ?? "nil" + let muteText = data.mute.map { String($0) } ?? "nil" + NSLog( + "🌙 Lunar decoded payload (\(source)): display=\(data.display) brightness=\(brightnessText) contrast=\(contrastText) volume=\(volumeText) mute=\(muteText) hasVolumeData=\(hasVolumeData)" + ) + } + + // MARK: - Reconnection + + private func scheduleReconnect() { + guard isLunarIntegrationEnabled, isRunning else { return } + guard reconnectAttempts < Self.maxReconnectAttempts else { + NSLog("🌙 Lunar: max reconnect attempts reached, giving up") + return + } + + reconnectTask?.cancel() + let attempt = reconnectAttempts + reconnectAttempts += 1 + + let delay = Self.baseReconnectDelay * UInt64(1 << min(attempt, 3)) // exponential back-off, max 16s + + reconnectTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled, self.isRunning, self.isLunarIntegrationEnabled else { return } + NSLog("🌙 Lunar: reconnect attempt \(attempt + 1)/\(Self.maxReconnectAttempts)") + self.connectToLunar() + } + } + + // MARK: - Workspace Observers (detect Lunar launch / quit) + + private func setupWorkspaceObservers() { + let lunarBundleID = Self.bundleID + + workspaceLaunchObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == lunarBundleID + else { return } + Task { @MainActor in + NSLog("🟢 Lunar launched (pid: \(app.processIdentifier))") + self?.isDetected = true + self?.isRunning = true + self?.reconnectAttempts = 0 + if self?.isLunarIntegrationEnabled == true { + // Give Lunar a moment to start its socket server + try? await Task.sleep(nanoseconds: 1_500_000_000) + self?.connectToLunar() + } + } + } + + workspaceTermObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didTerminateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == lunarBundleID + else { return } + Task { @MainActor in + NSLog("🔴 Lunar terminated (pid: \(app.processIdentifier))") + self?.isRunning = false + self?.disconnect() + } + } + } + + // MARK: - Settings Observer + + private func refreshConnectionState() { + if isLunarIntegrationEnabled && isRunning { + connectToLunar() + } else { + disconnect() + } + } + + private func setupSettingsObserver() { + Defaults.publisher(.enableThirdPartyDDCIntegration, options: []) + .sink { [weak self] _ in + Task { @MainActor in + self?.refreshConnectionState() + } + } + .store(in: &cancellables) + + Defaults.publisher(.thirdPartyDDCProvider, options: []) + .sink { [weak self] _ in + Task { @MainActor in + self?.refreshConnectionState() + } + } + .store(in: &cancellables) + } + + deinit { + // Restore Lunar's native OSD synchronously on teardown + LunarManager.writeLunarDefault(hideOSD: false) + connection?.cancel() + reconnectTask?.cancel() + if let workspaceLaunchObserver { + NSWorkspace.shared.notificationCenter.removeObserver(workspaceLaunchObserver) + } + if let workspaceTermObserver { + NSWorkspace.shared.notificationCenter.removeObserver(workspaceTermObserver) + } + cancellables.removeAll() + } + + // MARK: - Lunar OSD Suppression + + /// Tell Lunar to hide (or show) its own native OSD by writing to its UserDefaults. + private func setLunarHideOSD(_ hide: Bool) { + Self.writeLunarDefault(hideOSD: hide) + } + + /// Synchronous, nonisolated helper so it can also be called from `deinit`. + nonisolated private static func writeLunarDefault(hideOSD hide: Bool) { + guard let defaults = UserDefaults(suiteName: lunarDomain) else { + NSLog("⚠️ Lunar: unable to open UserDefaults suite '\(lunarDomain)' to set hideOSD") + return + } + defaults.set(hide, forKey: "hideOSD") + NSLog("🌙 Lunar: hideOSD set to \(hide)") + } +} diff --git a/DynamicIsland/managers/MacBatteryManager.swift b/DynamicIsland/managers/MacBatteryManager.swift new file mode 100644 index 00000000..513feb1e --- /dev/null +++ b/DynamicIsland/managers/MacBatteryManager.swift @@ -0,0 +1,83 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +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/MediaKeyInterceptor.swift b/DynamicIsland/managers/MediaKeyInterceptor.swift new file mode 100644 index 00000000..791fa746 --- /dev/null +++ b/DynamicIsland/managers/MediaKeyInterceptor.swift @@ -0,0 +1,265 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import AppKit +import CoreGraphics +#if canImport(ApplicationServices) +import ApplicationServices +#endif + +private let NX_SYSDEFINED_EVENT_TYPE: UInt32 = 14 +private let NX_KEYTYPE_SOUND_UP: Int32 = 0 +private let NX_KEYTYPE_SOUND_DOWN: Int32 = 1 +private let NX_KEYTYPE_BRIGHTNESS_UP: Int32 = 2 +private let NX_KEYTYPE_BRIGHTNESS_DOWN: Int32 = 3 +private let NX_KEYTYPE_MUTE: Int32 = 7 + +enum MediaKeyDirection { + case up + case down +} + +enum MediaKeyStep { + case standard + case fine +} + +struct MediaKeyConfiguration { + var interceptVolume: Bool + var interceptBrightness: Bool + var interceptCommandModifiedBrightness: Bool + + static let disabled = MediaKeyConfiguration( + interceptVolume: false, + interceptBrightness: false, + interceptCommandModifiedBrightness: false + ) +} + +protocol MediaKeyInterceptorDelegate: AnyObject { + func mediaKeyInterceptor( + _ interceptor: MediaKeyInterceptor, + didReceiveVolumeCommand direction: MediaKeyDirection, + step: MediaKeyStep, + isRepeat: Bool, + modifiers: NSEvent.ModifierFlags + ) + func mediaKeyInterceptor( + _ interceptor: MediaKeyInterceptor, + didReceiveBrightnessCommand direction: MediaKeyDirection, + step: MediaKeyStep, + isRepeat: Bool, + modifiers: NSEvent.ModifierFlags + ) + func mediaKeyInterceptorDidToggleMute(_ interceptor: MediaKeyInterceptor) +} + +final class MediaKeyInterceptor { + static let shared = MediaKeyInterceptor() + + weak var delegate: MediaKeyInterceptorDelegate? + var configuration: MediaKeyConfiguration = .disabled { + didSet { + updateTapState() + } + } + + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var isTapEnabled = false +#if canImport(ApplicationServices) + private var didRequestAccessibilityPrompt = false +#endif + private let systemDefinedEventType = CGEventType(rawValue: NX_SYSDEFINED_EVENT_TYPE) + private let eventTapLocations: [CGEventTapLocation] = [.cghidEventTap, .cgSessionEventTap] + + private init() {} + + @discardableResult + func start() -> Bool { + guard eventTap == nil else { + updateTapState() + return true + } + +#if canImport(ApplicationServices) + requestAccessibilityPermissionIfNeeded() +#endif + + guard let systemDefinedType = systemDefinedEventType else { + NSLog("❌ Unable to resolve system-defined event type") + return false + } + let mask = CGEventMask(1) << systemDefinedType.rawValue + let callback: CGEventTapCallBack = { _, type, cgEvent, userInfo in + guard let userInfo else { return Unmanaged.passUnretained(cgEvent) } + let interceptor = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + return interceptor.handleEvent(cgEvent: cgEvent, type: type) + } + + var createdTap: CFMachPort? + for location in eventTapLocations { + if let tap = CGEvent.tapCreate( + tap: location, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: mask, + callback: callback, + userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) { + createdTap = tap + break + } + } + + guard let tap = createdTap else { +#if canImport(ApplicationServices) + if !AXIsProcessTrusted() { + NSLog("⚠️ Accessibility permission missing; grant access in System Settings › Privacy & Security › Accessibility") + } +#endif + NSLog("❌ Failed to create media key event tap") + return false + } + + eventTap = tap + runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + if let source = runLoopSource { + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + } + CGEvent.tapEnable(tap: tap, enable: true) + isTapEnabled = true + NSLog("✅ Media key event tap installed (HID)") + return true + } + + func stop() { + if let tap = eventTap { + CGEvent.tapEnable(tap: tap, enable: false) + CFMachPortInvalidate(tap) + } + if let source = runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + } + eventTap = nil + runLoopSource = nil + isTapEnabled = false + } + + private func updateTapState() { + guard let tap = eventTap else { return } + let shouldEnable = configuration.interceptVolume || configuration.interceptBrightness || configuration.interceptCommandModifiedBrightness + if shouldEnable != isTapEnabled { + CGEvent.tapEnable(tap: tap, enable: shouldEnable) + isTapEnabled = shouldEnable + } + } + + private func handleEvent(cgEvent: CGEvent, type: CGEventType) -> Unmanaged? { + guard let systemDefinedType = systemDefinedEventType, + type == systemDefinedType, + let nsEvent = NSEvent(cgEvent: cgEvent), + nsEvent.subtype.rawValue == 8 else { + return Unmanaged.passUnretained(cgEvent) + } + + let data1 = nsEvent.data1 + let keyCode = (data1 & 0xFFFF0000) >> 16 + let keyFlags = data1 & 0x0000FFFF + let keyState = ((keyFlags & 0xFF00) >> 8) == 0xA // 0xA = keyDown, 0xB = keyUp + let isRepeat = (keyFlags & 0x0001) == 1 + let step = step(for: nsEvent) + let modifiers = nsEvent.modifierFlags + + guard keyState else { + // Swallow key-up events only when intercepting, otherwise let them pass through + if shouldHandle(keyCode: Int32(keyCode), modifiers: modifiers) { + return nil + } + return Unmanaged.passUnretained(cgEvent) + } + + switch Int32(keyCode) { + case NX_KEYTYPE_SOUND_UP: + guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) } + delegate?.mediaKeyInterceptor(self, didReceiveVolumeCommand: .up, step: step, isRepeat: isRepeat, modifiers: modifiers) + return nil + case NX_KEYTYPE_SOUND_DOWN: + guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) } + delegate?.mediaKeyInterceptor(self, didReceiveVolumeCommand: .down, step: step, isRepeat: isRepeat, modifiers: modifiers) + return nil + case NX_KEYTYPE_MUTE: + guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) } + delegate?.mediaKeyInterceptorDidToggleMute(self) + return nil + case NX_KEYTYPE_BRIGHTNESS_UP: + guard shouldHandleBrightness(modifiers: modifiers) else { return Unmanaged.passUnretained(cgEvent) } + delegate?.mediaKeyInterceptor(self, didReceiveBrightnessCommand: .up, step: step, isRepeat: isRepeat, modifiers: modifiers) + return nil + case NX_KEYTYPE_BRIGHTNESS_DOWN: + guard shouldHandleBrightness(modifiers: modifiers) else { return Unmanaged.passUnretained(cgEvent) } + delegate?.mediaKeyInterceptor(self, didReceiveBrightnessCommand: .down, step: step, isRepeat: isRepeat, modifiers: modifiers) + return nil + default: + return Unmanaged.passUnretained(cgEvent) + } + } + + private func shouldHandle(keyCode: Int32, modifiers: NSEvent.ModifierFlags) -> Bool { + switch keyCode { + case NX_KEYTYPE_SOUND_UP, NX_KEYTYPE_SOUND_DOWN, NX_KEYTYPE_MUTE: + return configuration.interceptVolume + case NX_KEYTYPE_BRIGHTNESS_UP, NX_KEYTYPE_BRIGHTNESS_DOWN: + return configuration.interceptBrightness || (configuration.interceptCommandModifiedBrightness && modifiers.contains(.command)) + default: + return false + } + } + + private func shouldHandleBrightness(modifiers: NSEvent.ModifierFlags) -> Bool { + if configuration.interceptBrightness { + return true + } + return configuration.interceptCommandModifiedBrightness && modifiers.contains(.command) + } + + private func step(for event: NSEvent) -> MediaKeyStep { + let modifiers = event.modifierFlags + if modifiers.contains(.option) && modifiers.contains(.shift) { + return .fine + } + return .standard + } +} + +#if canImport(ApplicationServices) +extension MediaKeyInterceptor { + private func requestAccessibilityPermissionIfNeeded() { + guard !AXIsProcessTrusted(), !didRequestAccessibilityPrompt else { return } + let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String + let options: CFDictionary = [promptKey: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + didRequestAccessibilityPrompt = true + } +} +#endif diff --git a/DynamicIsland/managers/MemoryUsageMonitor.swift b/DynamicIsland/managers/MemoryUsageMonitor.swift new file mode 100644 index 00000000..d03b7853 --- /dev/null +++ b/DynamicIsland/managers/MemoryUsageMonitor.swift @@ -0,0 +1,132 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Foundation +import Darwin + +@MainActor +final class MemoryUsageMonitor { + static let shared = MemoryUsageMonitor() + +#if DEBUG + private let thresholdBytes: UInt64 = 4 * 1_024 * 1_024 * 1_024 +#else + private let thresholdBytes: UInt64 = 1_024 * 1_024 * 1_024 +#endif + private let pollInterval: TimeInterval = 8 // Clamp within 5-10 seconds to limit battery impact + private let restartCooldown: TimeInterval = 300 + private let logSampleInterval: TimeInterval = 300 + private var monitorTask: Task? + private var lastRestartAttempt: Date = .distantPast + private var lastLogSample: Date = .distantPast + + func startMonitoring() { + guard monitorTask == nil else { return } + monitorTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + await self.evaluateMemoryFootprint() + do { + try await Task.sleep(for: .seconds(self.pollInterval)) + } catch { + break + } + } + } + } + + func stopMonitoring() { + monitorTask?.cancel() + monitorTask = nil + } + + private func evaluateMemoryFootprint() async { + guard let usage = currentResidentSize() else { return } + if usage >= thresholdBytes { + restartIfNeeded(currentUsage: usage) + } else if Date().timeIntervalSince(lastLogSample) >= logSampleInterval { + lastLogSample = Date() + Logger.log("[MemoryMonitor] Resident usage: \(formatMegabytes(usage)) MB", category: .memory) + } + } + + private func restartIfNeeded(currentUsage: UInt64) { + let now = Date() + guard now.timeIntervalSince(lastRestartAttempt) >= restartCooldown else { + Logger.log("[MemoryMonitor] Usage \(formatMegabytes(currentUsage)) MB exceeds threshold but cooldown active", category: .warning) + return + } + lastRestartAttempt = now + Logger.log("[MemoryMonitor] Usage \(formatMegabytes(currentUsage)) MB >= \(formatMegabytes(thresholdBytes)) MB. Prompting for restart.", category: .warning) + presentRestartAlert(currentUsage: currentUsage) + } + + private func presentRestartAlert(currentUsage: UInt64) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "DynamicIsland memory usage is high" + alert.informativeText = "The app is currently using \(formatMegabytes(currentUsage)) MB, which exceeds the safe limit of \(formatMegabytes(thresholdBytes)) MB. Restart now to free memory?" + alert.addButton(withTitle: "Restart Now") + alert.addButton(withTitle: "Later") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + relaunchApplication() + } else { + Logger.log("[MemoryMonitor] Restart postponed by user", category: .warning) + } + } + + private func relaunchApplication() { + let workspace = NSWorkspace.shared + let configuration = NSWorkspace.OpenConfiguration() + configuration.createsNewApplicationInstance = true + + let appURL = workspace.urlForApplication(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "") ?? Bundle.main.bundleURL + + workspace.openApplication(at: appURL, configuration: configuration) { _, error in + if let error { + Logger.log("[MemoryMonitor] Failed to launch replacement app: \(error.localizedDescription)", category: .error) + } + Task { @MainActor in + NSApplication.shared.terminate(nil) + } + } + } + + private func currentResidentSize() -> UInt64? { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + guard result == KERN_SUCCESS else { + Logger.log("[MemoryMonitor] task_info failed with code \(result)", category: .error) + return nil + } + return UInt64(info.resident_size) + } + + private func formatMegabytes(_ bytes: UInt64) -> String { + let mb = Double(bytes) / 1_048_576.0 + return String(format: "%.1f", mb) + } +} diff --git a/DynamicIsland/managers/MicrophoneMonitor.swift b/DynamicIsland/managers/MicrophoneMonitor.swift new file mode 100644 index 00000000..386701be --- /dev/null +++ b/DynamicIsland/managers/MicrophoneMonitor.swift @@ -0,0 +1,325 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import CoreAudio +import SwiftUI +import Combine + +// MARK: - CoreAudio Callback Function +// C function pointer for CoreAudio property listener +private func microphonePropertyListener( + inObjectID: AudioObjectID, + inNumberAddresses: UInt32, + inAddresses: UnsafePointer, + inClientData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let context = inClientData else { return noErr } + let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue() + + DispatchQueue.main.async { + print("MicrophoneMonitor: 📢 Microphone property changed") + monitor.checkMicrophoneStatus() + } + + return noErr +} + +@MainActor +class MicrophoneMonitor: ObservableObject { + // MARK: - Published Properties + @Published var isMicActive: Bool = false + @Published var activeApp: String? = nil + @Published var isMonitoring: Bool = false + + // MARK: - Private Properties + private var defaultInputDevice: AudioDeviceID = 0 + private var isListenerRegistered: Bool = false + private var cancellables = Set() + + // MARK: - Configuration + // Pure event-driven - no polling + + // MARK: - Initialization + init() { + // No initial setup needed + } + + deinit { + // Clean up listener synchronously + // Remove property listener + if isListenerRegistered, defaultInputDevice != 0 { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster + ) + + let context = Unmanaged.passUnretained(self).toOpaque() + + AudioObjectRemovePropertyListener( + defaultInputDevice, + &address, + microphonePropertyListener, + context + ) + } + } + + // MARK: - Public Methods + + /// Start monitoring microphone usage + func startMonitoring() { + guard !isMonitoring else { + print("MicrophoneMonitor: Already monitoring, skipping start") + return + } + + print("MicrophoneMonitor: 🟢 Starting microphone monitoring...") + + isMonitoring = true + + // Get default input device + defaultInputDevice = getDefaultInputDevice() + guard defaultInputDevice != 0 else { + print("MicrophoneMonitor: ⚠️ No input device found") + return + } + + print("MicrophoneMonitor: 🎤 Found input device ID: \(defaultInputDevice)") + + // Check if property exists + let propertyExists = checkPropertyExists() + print("MicrophoneMonitor: Property exists: \(propertyExists)") + + // Setup event listener + setupPropertyListener() + + // Check initial state + checkMicrophoneStatus() + + print("MicrophoneMonitor: ✅ Started monitoring (event-driven only)") + } + + /// Stop monitoring microphone usage + func stopMonitoring() { + guard isMonitoring else { + print("MicrophoneMonitor: Not monitoring, skipping stop") + return + } + + print("MicrophoneMonitor: 🛑 Stopping monitoring...") + + isMonitoring = false + + // Remove property listener + if isListenerRegistered { + removePropertyListener() + } + + // Reset state + if isMicActive { + isMicActive = false + } + activeApp = nil + + print("MicrophoneMonitor: ✅ Stopped monitoring") + } + + /// Toggle monitoring state + func toggleMonitoring() { + if isMonitoring { + stopMonitoring() + } else { + startMonitoring() + } + } + + // MARK: - Private Methods + + /// Get default input device ID + private func getDefaultInputDevice() -> AudioDeviceID { + var deviceID: AudioDeviceID = 0 + var size = UInt32(MemoryLayout.size) + + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster + ) + + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, + 0, + nil, + &size, + &deviceID + ) + + if status != noErr { + print("MicrophoneMonitor: ⚠️ Failed to get default input device (status: \(status))") + return 0 + } + + return deviceID + } + + /// Check if the property exists on the device + private func checkPropertyExists() -> Bool { + guard defaultInputDevice != 0 else { return false } + + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster + ) + + let hasProperty = AudioObjectHasProperty(defaultInputDevice, &address) + print("MicrophoneMonitor: Device \(defaultInputDevice) has property: \(hasProperty)") + + return hasProperty + } + + /// Setup CoreAudio property listener + private func setupPropertyListener() { + guard defaultInputDevice != 0 else { return } + + // Use kAudioDevicePropertyDeviceIsRunningSomewhere (tracks when device is in use anywhere) + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster + ) + + // Pass self as context + let context = Unmanaged.passUnretained(self).toOpaque() + + let status = AudioObjectAddPropertyListener( + defaultInputDevice, + &address, + microphonePropertyListener, + context + ) + + if status == noErr { + isListenerRegistered = true + print("MicrophoneMonitor: ✅ Property listener registered") + } else { + print("MicrophoneMonitor: ⚠️ Failed to register property listener (status: \(status))") + } + } + + /// Remove CoreAudio property listener + private func removePropertyListener() { + guard defaultInputDevice != 0, isListenerRegistered else { return } + + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster + ) + + let context = Unmanaged.passUnretained(self).toOpaque() + + let status = AudioObjectRemovePropertyListener( + defaultInputDevice, + &address, + microphonePropertyListener, + context + ) + + if status == noErr { + isListenerRegistered = false + print("MicrophoneMonitor: ✅ Property listener removed") + } else { + print("MicrophoneMonitor: ⚠️ Failed to remove property listener (status: \(status))") + } + } + + /// Check current microphone status + func checkMicrophoneStatus() { + guard defaultInputDevice != 0 else { return } + + let isRunning = isDeviceRunning(defaultInputDevice) + + // Debug logging + print("MicrophoneMonitor: 🔍 Checking... current=\(isMicActive), detected=\(isRunning)") + + // Update state if changed + if isRunning != isMicActive { + print("MicrophoneMonitor: 🔄 State change detected (\(isMicActive) -> \(isRunning))") + + withAnimation(.smooth) { + isMicActive = isRunning + } + + if isRunning { + print("MicrophoneMonitor: 🎤 Microphone ACTIVE") + // Could try to identify app here (TODO: investigate) + activeApp = "Unknown App" + } else { + print("MicrophoneMonitor: ⚪ Microphone INACTIVE") + activeApp = nil + } + } + } + + /// Check if audio device is running + private func isDeviceRunning(_ deviceID: AudioDeviceID) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster + ) + + var isRunning: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + + let status = AudioObjectGetPropertyData( + deviceID, + &address, + 0, + nil, + &size, + &isRunning + ) + + if status != noErr { + print("MicrophoneMonitor: ⚠️ Failed to check device running status (status: \(status))") + return false + } + + return isRunning != 0 + } + +} + +// MARK: - Extensions + +extension MicrophoneMonitor { + /// Get current microphone status without async + var currentMicStatus: Bool { + return isMicActive + } + + /// Check if monitoring is available + var isMonitoringAvailable: Bool { + return getDefaultInputDevice() != 0 + } +} diff --git a/DynamicIsland/managers/MusicControlWindowManager.swift b/DynamicIsland/managers/MusicControlWindowManager.swift new file mode 100644 index 00000000..9789149b --- /dev/null +++ b/DynamicIsland/managers/MusicControlWindowManager.swift @@ -0,0 +1,240 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#if os(macOS) +import AppKit +import SwiftUI +import SkyLightWindow +import QuartzCore + +struct MusicControlWindowMetrics: Equatable { + let notchHeight: CGFloat + let notchWidth: CGFloat + let rightWingWidth: CGFloat + let cornerRadius: CGFloat + let spacing: CGFloat +} + +@MainActor +final class MusicControlWindowManager { + static let shared = MusicControlWindowManager() + + private var window: NSWindow? + private var hostingView: NSHostingView? + private var hasDelegated = false + private var lastMetrics: MusicControlWindowMetrics? + + private init() {} + + @discardableResult + func present(using viewModel: DynamicIslandViewModel, metrics: MusicControlWindowMetrics) -> Bool { + guard let screen = resolveScreen(from: viewModel) else { return false } + guard viewModel.effectiveClosedNotchHeight > 0, viewModel.closedNotchSize.width > 0 else { + hide() + return false + } + + let overlay = MusicControlOverlay( + 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: MusicControlWindowMetrics) -> Bool { + guard window != nil else { + return present(using: viewModel, metrics: metrics) + } + + if let lastMetrics, lastMetrics == metrics { + return true + } + + return present(using: viewModel, metrics: metrics) + } + + func hide(animated: Bool = true, tearDown: Bool = true) { + guard let window else { return } + + guard animated, window.alphaValue > 0.01 else { + window.orderOut(nil) + window.alphaValue = 0 + if tearDown { + tearDownWindowResources(using: window) + } + 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: { [weak self] in + window.setFrame(originalFrame, display: false) + window.orderOut(nil) + window.alphaValue = 0 + if tearDown { + self?.tearDownWindowResources(using: window) + } + } + } + + private func tearDownWindowResources(using window: NSWindow? = nil) { + let targetWindow = window ?? self.window + targetWindow?.contentView = nil + targetWindow?.orderOut(nil) + hostingView = nil + lastMetrics = nil + self.window = nil + hasDelegated = false + } + + private func ensureHostingView(with overlay: MusicControlOverlay) -> 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: 240, height: 68), + 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 + + ScreenCaptureVisibilityManager.shared.register(window, scope: .entireInterface) + + 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: MusicControlWindowMetrics) -> 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: MusicControlWindowMetrics) -> 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/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 9e7fc273..db6ac606 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -1,46 +1,468 @@ -// -// MusicManager.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 03/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + import AppKit import Combine import Defaults +import Foundation import SwiftUI +// MARK: - Lyric Data Structures +struct LyricLine: Identifiable, Codable { + let id = UUID() + let timestamp: TimeInterval + let text: String + + init(timestamp: TimeInterval, text: String) { + self.timestamp = timestamp + self.text = text + } +} + +private struct LyricsLookupKey: Hashable { + let title: String + let artist: String + let album: String + + var isValid: Bool { + !title.isEmpty && !artist.isEmpty + } +} + let defaultImage: NSImage = .init( systemSymbolName: "heart.fill", accessibilityDescription: "Album Art" )! +private struct ITunesExplicitnessSearchResponse: Decodable { + let results: [ITunesExplicitnessTrack] +} + +private struct ITunesExplicitnessTrack: Decodable { + let trackName: String? + let artistName: String? + let collectionName: String? + let trackExplicitness: String? +} + +private actor MusicExplicitnessResolver { + struct LookupKey: Hashable, Sendable { + let title: String + let artist: String + let album: String + + init(title: String, artist: String, album: String) { + self.title = MusicExplicitnessResolver.normalize(title) + self.artist = MusicExplicitnessResolver.normalize(artist) + self.album = MusicExplicitnessResolver.normalize(album) + } + + var canResolve: Bool { + !title.isEmpty && !artist.isEmpty + } + } + + static let shared = MusicExplicitnessResolver() + + private let session = URLSession(configuration: .ephemeral) + private var cache: [LookupKey: Bool] = [:] + private var inFlightTasks: [LookupKey: Task] = [:] + + func resolve(title: String, artist: String, album: String) async -> Bool { + let key = LookupKey(title: title, artist: artist, album: album) + guard key.canResolve else { return false } + + if let cached = cache[key] { + return cached + } + + if let inFlightTask = inFlightTasks[key] { + return await inFlightTask.value + } + + let task = Task { [session] in + await Self.fetchExplicitness(for: key, using: session) + } + + inFlightTasks[key] = task + let result = await task.value + cache[key] = result + inFlightTasks[key] = nil + return result + } + + private static func fetchExplicitness(for key: LookupKey, using session: URLSession) async -> Bool { + let query = "\(key.title) \(key.artist)" + guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL( + string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=song&limit=15") + else { + return false + } + + do { + let (data, _) = try await session.data(from: url) + let response = try JSONDecoder().decode(ITunesExplicitnessSearchResponse.self, from: data) + + let bestMatch = response.results + .map { track in (track, matchScore(for: track, key: key)) } + .max { lhs, rhs in lhs.1 < rhs.1 } + + guard let bestMatch, + bestMatch.1 >= 8 + else { + return false + } + + return bestMatch.0.trackExplicitness?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + == "explicit" + } catch { + return false + } + } + + private static func matchScore(for track: ITunesExplicitnessTrack, key: LookupKey) -> Int { + let trackTitle = canonicalTitle(track.trackName ?? "") + guard !trackTitle.isEmpty else { return Int.min } + + let keyTitle = canonicalTitle(key.title) + let trackArtist = normalize(track.artistName ?? "") + let trackAlbum = normalize(track.collectionName ?? "") + + var score = 0 + + if trackTitle == keyTitle { + score += 6 + } else if trackTitle.contains(keyTitle) || keyTitle.contains(trackTitle) { + score += 4 + } else { + return Int.min + } + + if !key.artist.isEmpty { + if trackArtist == key.artist { + score += 4 + } else if trackArtist.contains(key.artist) || key.artist.contains(trackArtist) { + score += 2 + } + } + + if !key.album.isEmpty { + if trackAlbum == key.album { + score += 2 + } else if !trackAlbum.isEmpty && (trackAlbum.contains(key.album) || key.album.contains(trackAlbum)) { + score += 1 + } + } + + return score + } + + private static func canonicalTitle(_ value: String) -> String { + var title = value.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + title = title.replacingOccurrences( + of: #"\([^)]*\)|\[[^\]]*\]"#, + with: " ", + options: .regularExpression + ) + title = title.replacingOccurrences( + of: #"\s-\s(?:\d{4}\s)?(?:remaster(?:ed)?|live|edit|mix|version|mono|stereo).*$"#, + with: " ", + options: .regularExpression + ) + return normalize(title) + } + + private static func normalize(_ value: String) -> String { + let folded = value.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + let stripped = folded.replacingOccurrences( + of: #"[^a-z0-9]+"#, + with: " ", + options: .regularExpression + ) + return stripped + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +private actor SpotifyExplicitnessResolver { + struct LookupKey: Hashable, Sendable { + let trackID: String + + init?(contentIdentifier: String?, contentURL: String?) { + guard let trackID = SpotifyExplicitnessResolver.extractTrackID(from: contentIdentifier) + ?? SpotifyExplicitnessResolver.extractTrackID(from: contentURL) + else { + return nil + } + + self.trackID = trackID + } + } + + static let shared = SpotifyExplicitnessResolver() + + private let session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpAdditionalHeaders = [ + "User-Agent": "Mozilla/5.0", + "Accept-Language": "en-US,en;q=0.9" + ] + return URLSession(configuration: configuration) + }() + + private var cache: [LookupKey: Bool] = [:] + private var inFlightTasks: [LookupKey: Task] = [:] + + func resolve(key: LookupKey) async -> Bool? { + if let cached = cache[key] { + return cached + } + + if let inFlightTask = inFlightTasks[key] { + return await inFlightTask.value + } + + let task = Task { [session] in + await Self.fetchExplicitness(for: key, using: session) + } + + inFlightTasks[key] = task + let result = await task.value + if let result { + cache[key] = result + } + inFlightTasks[key] = nil + return result + } + + private static func fetchExplicitness(for key: LookupKey, using session: URLSession) async -> Bool? { + guard let url = URL(string: "https://open.spotify.com/embed/track/\(key.trackID)") else { + return nil + } + + do { + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode), + let html = String(data: data, encoding: .utf8) + else { + return nil + } + + if let isExplicit = parseIsExplicitDirectly(from: html) { + return isExplicit + } + + if let nextDataJSON = extractNextDataJSON(from: html), + let isExplicit = parseIsExplicit(from: nextDataJSON) { + return isExplicit + } + + return nil + } catch { + return nil + } + } + + private static func extractNextDataJSON(from html: String) -> String? { + let pattern = #"]*id="__NEXT_DATA__"[^>]*>(.*?)"# + guard let regex = try? NSRegularExpression( + pattern: pattern, + options: [.dotMatchesLineSeparators, .caseInsensitive] + ) else { + return nil + } + + let range = NSRange(html.startIndex.. 1, + let jsonRange = Range(match.range(at: 1), in: html) + else { + return nil + } + + return String(html[jsonRange]) + } + + private static func parseIsExplicitDirectly(from html: String) -> Bool? { + if let value = captureFirstMatch( + in: html, + pattern: #""isExplicit"\s*:\s*(true|false)"# + ) { + return value == "true" + } + + if let label = captureFirstMatch( + in: html, + pattern: #""label"\s*:\s*"([A-Z_]+)""# + ) { + switch label { + case "EXPLICIT": + return true + case "NON_EXPLICIT": + return false + default: + break + } + } + + return nil + } + + private static func captureFirstMatch(in source: String, pattern: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + + let range = NSRange(source.startIndex.. 1, + let valueRange = Range(match.range(at: 1), in: source) + else { + return nil + } + + return String(source[valueRange]).lowercased() + } + + private static func parseIsExplicit(from nextDataJSON: String) -> Bool? { + guard let data = nextDataJSON.data(using: .utf8), + let rootObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let props = rootObject["props"] as? [String: Any], + let pageProps = props["pageProps"] as? [String: Any], + let state = pageProps["state"] as? [String: Any], + let dataObject = state["data"] as? [String: Any], + let entity = dataObject["entity"] as? [String: Any] + else { + return nil + } + + if let isExplicit = entity["isExplicit"] as? Bool { + return isExplicit + } + + if let contentRating = entity["contentRating"] as? [String: Any], + let label = contentRating["label"] as? String { + return label.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() == "EXPLICIT" + } + + return nil + } + + private static func extractTrackID(from rawValue: String?) -> String? { + guard let rawValue else { return nil } + + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("spotify:track:") { + return validatedTrackID(String(trimmed.split(separator: ":").last ?? "")) + } + + if let url = URL(string: trimmed) { + let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty } + if let trackIndex = pathComponents.firstIndex(of: "track"), + trackIndex + 1 < pathComponents.count { + return validatedTrackID(pathComponents[trackIndex + 1]) + } + } + + return validatedTrackID(trimmed) + } + + private static func validatedTrackID(_ candidate: S) -> String? { + let value = String(candidate).trimmingCharacters(in: .whitespacesAndNewlines) + guard value.range(of: #"^[A-Za-z0-9]{22}$"#, options: .regularExpression) != nil else { + return nil + } + + return value + } +} + class MusicManager: ObservableObject { + enum SkipDirection: Equatable { + case backward + case forward + } + + struct SkipGesturePulse: Equatable { + let token: Int + let direction: SkipDirection + let behavior: MusicSkipBehavior + } + + static let skipGestureSeekInterval: TimeInterval = 10 + // MARK: - Properties static let shared = MusicManager() private var cancellables = Set() private var controllerCancellables = Set() - private var debounceToggle: DispatchWorkItem? - - // Helper to check if macOS is too new for NowPlayingController - public var isNowPlayingDeprecated: Bool { - if #available(macOS 15.4, *) { - return true - } - return false - } + private var debounceIdleTask: Task? + @MainActor private var pendingOptimisticPlayState: Bool? + + // Helper to check if macOS has removed support for NowPlayingController + public private(set) var isNowPlayingDeprecated: Bool = false + private let mediaChecker = MediaChecker() // Active controller private var activeController: (any MediaControllerProtocol)? - + + // Pear Desktop auto-detection + private static let pearDesktopBundleID = YouTubeMusicConfiguration.default.bundleIdentifier + private var isPearDesktopAutoSwitched: Bool = false + // Published properties for UI @Published var songTitle: String = "I'm Handsome" @Published var artistName: String = "Me" @Published var albumArt: NSImage = defaultImage @Published var isPlaying = false @Published var album: String = "Self Love" - @Published var lastUpdated: Date = .distantPast - @Published var ignoreLastUpdated = true @Published var isPlayerIdle: Bool = true + @Published var isCurrentTrackExplicit: Bool = false + + /// Whether there is an active music session with real metadata. + /// Returns `false` only when the metadata is still placeholder/fallback defaults + /// (i.e. nothing has been played since app launch, or the controller returned + /// unknown/not-playing placeholders). Paused music with real metadata is still + /// considered an active session. + private static let placeholderTitles: Set = [ + "i'm handsome", "unknown", "not playing" + ] + private static let placeholderArtists: Set = [ + "me", "unknown" + ] + + var hasActiveSession: Bool { + if isPlaying { return true } + let trimmedTitle = songTitle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let trimmedArtist = artistName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let hasRealTitle = !trimmedTitle.isEmpty && !Self.placeholderTitles.contains(trimmedTitle) + let hasRealArtist = !trimmedArtist.isEmpty && !Self.placeholderArtists.contains(trimmedArtist) + return hasRealTitle || hasRealArtist + } + @Published var animations: DynamicIslandAnimations = .init() @Published var avgColor: NSColor = .white @Published var bundleIdentifier: String? = nil @@ -48,37 +470,146 @@ class MusicManager: ObservableObject { @Published var elapsedTime: TimeInterval = 0 @Published var timestampDate: Date = .init() @Published var playbackRate: Double = 1 + @Published var isShuffled: Bool = false + @Published var repeatMode: RepeatMode = .off + @Published var isLiveStream: Bool = false @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @Published var usingAppIconForArtwork: Bool = false - - private var artworkData: Data? = nil + @Published private(set) var skipGesturePulse: SkipGesturePulse? + + // MARK: - Lyrics Properties + @Published var currentLyrics: String = "" + @Published var syncedLyrics: [LyricLine] = [] + @Published var showLyrics: Bool = false + @Published var currentLyricIndex: Int = -1 + + // Task used to periodically sync displayed lyric with playback position + private var lyricSyncTask: Task? + private var lyricsFetchTask: Task? + private var lyricsFetchKey: LyricsLookupKey? + private var activeLyricsKey: LyricsLookupKey? + private var lyricsCache: [LyricsLookupKey: [LyricLine]] = [:] + private var explicitLookupTask: Task? + private var explicitLookupKey: String? + + private(set) var artworkData: Data? = nil + + @Published var videoArtworkURL: URL? = nil + + private var liveStreamUnknownDurationCount: Int = 0 + private var liveStreamEdgeObservationCount: Int = 0 + private var liveStreamCompletionObservationCount: Int = 0 + private var liveStreamCompletionReleaseCount: Int = 0 - @Published var isFlipping: Bool = false - private var flipWorkItem: DispatchWorkItem? + // Store last values at the time artwork was changed + private var lastArtworkTitle: String = "I'm Handsome" + private var lastArtworkArtist: String = "Me" + private var lastArtworkAlbum: String = "Self Love" + private var lastArtworkBundleIdentifier: String? = nil + private var lastArtworkContentIdentifier: String? = nil + private var lastArtworkContentURL: String? = nil + + @Published var flipAngle: Double = 0 + @Published var lastFlipDirection: SkipDirection = .forward + private let flipAnimationDuration: TimeInterval = 0.45 + private var flipCooldownActive: Bool = false @Published var isTransitioning: Bool = false private var transitionWorkItem: DispatchWorkItem? + private var skipGestureToken: Int = 0 // MARK: - Initialization init() { // Listen for changes to the default controller preference NotificationCenter.default.publisher(for: Notification.Name.mediaControllerChanged) .sink { [weak self] _ in + self?.isPearDesktopAutoSwitched = false self?.setActiveControllerBasedOnPreference() } .store(in: &cancellables) + + Defaults.publisher(.enableLyrics) + .receive(on: RunLoop.main) + .sink { [weak self] change in + self?.handleLyricsPreferenceChange(isEnabled: change.newValue) + } + .store(in: &cancellables) + + // Observe Pear Desktop launch/terminate for auto-detection + setupPearDesktopAutoDetection() + + // Initialize deprecation check asynchronously + Task { @MainActor in + do { + self.isNowPlayingDeprecated = try await self.mediaChecker.checkDeprecationStatus() + print("Deprecation check completed: \(self.isNowPlayingDeprecated)") + } catch { + print("Failed to check deprecation status: \(error). Defaulting to false.") + self.isNowPlayingDeprecated = false + } + + // Check if Pear Desktop is already running at startup + let pearDesktopRunning = NSWorkspace.shared.runningApplications.contains { + $0.bundleIdentifier == Self.pearDesktopBundleID + } - // Initialize the active controller - setActiveControllerBasedOnPreference() + if pearDesktopRunning { + print("[MusicManager] Pear Desktop detected at startup, auto-switching to YouTubeMusicController") + self.isPearDesktopAutoSwitched = true + if let controller = self.createController(for: .youtubeMusic) { + self.setActiveController(controller) + } + } else { + // Initialize the active controller after deprecation check + self.setActiveControllerBasedOnPreference() + } + } + } + + // MARK: - Pear Desktop Auto-Detection + private func setupPearDesktopAutoDetection() { + NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didLaunchApplicationNotification) + .sink { [weak self] notification in + guard let self = self, + let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == Self.pearDesktopBundleID else { return } + + print("[MusicManager] Pear Desktop launched, auto-switching to YouTubeMusicController") + self.isPearDesktopAutoSwitched = true + if let controller = self.createController(for: .youtubeMusic) { + self.setActiveController(controller) + } + } + .store(in: &cancellables) + + NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didTerminateApplicationNotification) + .sink { [weak self] notification in + guard let self = self, + let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == Self.pearDesktopBundleID else { return } + + print("[MusicManager] Pear Desktop terminated, reverting to preferred controller") + if self.isPearDesktopAutoSwitched { + self.isPearDesktopAutoSwitched = false + self.setActiveControllerBasedOnPreference() + } + } + .store(in: &cancellables) } deinit { - debounceToggle?.cancel() + destroy() + } + + public func destroy() { + debounceIdleTask?.cancel() + lyricsFetchTask?.cancel() + lyricSyncTask?.cancel() + explicitLookupTask?.cancel() cancellables.removeAll() controllerCancellables.removeAll() - flipWorkItem?.cancel() transitionWorkItem?.cancel() - + // Release active controller activeController = nil } @@ -86,36 +617,35 @@ class MusicManager: ObservableObject { // MARK: - Setup Methods private func createController(for type: MediaControllerType) -> (any MediaControllerProtocol)? { // Cleanup previous controller - if let _ = activeController { + if activeController != nil { controllerCancellables.removeAll() activeController = nil } - + let newController: (any MediaControllerProtocol)? - + switch type { case .nowPlaying: // Only create NowPlayingController if not deprecated on this macOS version if !self.isNowPlayingDeprecated { - ignoreLastUpdated = false newController = NowPlayingController() } else { return nil } case .appleMusic: - ignoreLastUpdated = true newController = AppleMusicController() case .spotify: - ignoreLastUpdated = true newController = SpotifyController() case .youtubeMusic: - ignoreLastUpdated = true newController = YouTubeMusicController() + case .amazonMusic: + newController = AmazonMusicController() } - + // Set up state observation for the new controller if let controller = newController { controller.playbackStatePublisher + .receive(on: DispatchQueue.main) .sink { [weak self] state in guard let self = self, self.activeController === controller else { return } @@ -123,19 +653,19 @@ class MusicManager: ObservableObject { } .store(in: &controllerCancellables) } - + return newController } - + private func setActiveControllerBasedOnPreference() { let preferredType = Defaults[.mediaController] print("Preferred Media Controller: \(preferredType)") - + // If NowPlaying is deprecated but that's the preference, use Apple Music instead let controllerType = (self.isNowPlayingDeprecated && preferredType == .nowPlaying) ? .appleMusic : preferredType - + if let controller = createController(for: controllerType) { setActiveController(controller) } else if controllerType != .appleMusic, let fallbackController = createController(for: .appleMusic) { @@ -143,161 +673,401 @@ class MusicManager: ObservableObject { setActiveController(fallbackController) } } - + private func setActiveController(_ controller: any MediaControllerProtocol) { - // Transition animation when changing controllers - transitionWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - self?.isTransitioning = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self?.isTransitioning = false - } - } - transitionWorkItem = workItem - DispatchQueue.main.async(execute: workItem) - // Set new active controller activeController = controller - + // Get current state from active controller - if let state = Mirror(reflecting: controller).children.first(where: { $0.label == "playbackState" })?.value as? PlaybackState { - updateFromPlaybackState(state) + forceUpdate() + } + + @MainActor + private func applyPlayState(_ state: Bool, animation: Animation?) { + if let animation { + var transaction = Transaction() + transaction.animation = animation + withTransaction(transaction) { + self.isPlaying = state + } + } else { + self.isPlaying = state } + + self.updateIdleState(state: state) } // MARK: - Update Methods + @MainActor private func updateFromPlaybackState(_ state: PlaybackState) { - // Create a batch of updates to apply together - let updateBatch = DispatchWorkItem { [weak self] in - guard let self = self else { return } - - // Check for playback state changes (playing/paused) - if state.isPlaying != self.isPlaying { - self.lastUpdated = Date() - withAnimation(.smooth) { - self.isPlaying = state.isPlaying - self.updateIdleState(state: state.isPlaying) - } - - if state.isPlaying && !state.title.isEmpty && !state.artist.isEmpty { - self.updateSneakPeek() - } - } - - // Check for changes in track metadata - let titleChanged = state.title != self.songTitle - let artistChanged = state.artist != self.artistName - let albumChanged = state.album != self.album - - // Check for artwork changes - let artworkChanged = state.artwork != nil && state.artwork != self.artworkData - let hasContentChange = titleChanged || artistChanged || albumChanged || artworkChanged - - // Handle artwork and visual transitions for changed content - if hasContentChange { - self.triggerFlipAnimation() - - if artworkChanged, let artwork = state.artwork { - self.updateArtwork(artwork) - } else if hasContentChange && state.artwork == nil { - // Try to use app icon if no artwork but track changed - if let appIconImage = AppIconAsNSImage(for: state.bundleIdentifier) { - self.usingAppIconForArtwork = true - self.updateAlbumArt(newAlbumArt: appIconImage) - } - } - self.artworkData = state.artwork - - // Only update sneak peek if there's actual content and something changed - if !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying { - self.updateSneakPeek() - } - } - - let timeChanged = state.currentTime != self.elapsedTime - let durationChanged = state.duration != self.songDuration - let playbackRateChanged = state.playbackRate != self.playbackRate - - if titleChanged { - self.songTitle = state.title - } - - if artistChanged { - self.artistName = state.artist - } - - if albumChanged { - self.album = state.album - } - - if timeChanged { - self.elapsedTime = state.currentTime - } - - if durationChanged { - self.songDuration = state.duration - } - - if playbackRateChanged { - self.playbackRate = state.playbackRate - } - - if state.bundleIdentifier != self.bundleIdentifier { - self.bundleIdentifier = state.bundleIdentifier + // Check for playback state changes (playing/paused) + let eventIsPlaying = state.isPlaying + let expectedState = pendingOptimisticPlayState + pendingOptimisticPlayState = nil + + if eventIsPlaying != self.isPlaying { + let animation: Animation? = (expectedState == eventIsPlaying) ? .smooth(duration: 0.18) : .smooth + applyPlayState(eventIsPlaying, animation: animation) + + if eventIsPlaying && !state.title.isEmpty && !state.artist.isEmpty { + self.updateSneakPeek() } - - self.timestampDate = state.lastUpdated + } else { + self.updateIdleState(state: eventIsPlaying) } - - // Execute the batch update on the main thread - DispatchQueue.main.async(execute: updateBatch) - } - - private func triggerFlipAnimation() { - // Cancel any existing animation - flipWorkItem?.cancel() - - // Create a new animation - let workItem = DispatchWorkItem { [weak self] in - self?.isFlipping = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - self?.isFlipping = false - } + + // Check for changes in track metadata using last artwork change values + let titleChanged = state.title != self.lastArtworkTitle + let artistChanged = state.artist != self.lastArtworkArtist + let albumChanged = state.album != self.lastArtworkAlbum + let bundleChanged = state.bundleIdentifier != self.lastArtworkBundleIdentifier + let contentIdentifierChanged = state.contentIdentifier != self.lastArtworkContentIdentifier + let contentURLChanged = state.contentURL != self.lastArtworkContentURL + + // Check for artwork changes + let artworkChanged = state.artwork != nil && state.artwork != self.artworkData + + let hasContentChange = + titleChanged + || artistChanged + || albumChanged + || artworkChanged + || bundleChanged + || contentIdentifierChanged + || contentURLChanged + let liveArtworkChanged = state.liveArtworkURL != self.videoArtworkURL + + if liveArtworkChanged { + self.videoArtworkURL = state.liveArtworkURL } - - flipWorkItem = workItem - DispatchQueue.main.async(execute: workItem) - } - private func updateArtwork(_ artworkData: Data) { - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self = self else { return } + // Handle artwork and visual transitions for changed content + let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange] - if let artworkImage = NSImage(data: artworkData) { - DispatchQueue.main.async { - self.usingAppIconForArtwork = false - self.updateAlbumArt(newAlbumArt: artworkImage) + if hasContentChange { + self.triggerFlipAnimation() + + if artworkChanged, let artwork = state.artwork { + self.updateArtwork(artwork) + } else if state.artwork == nil { + // Try to use app icon if no artwork but track changed + if let appIconImage = AppIconAsNSImage(for: state.bundleIdentifier) { + self.usingAppIconForArtwork = true + self.updateAlbumArt(newAlbumArt: appIconImage) } } - } + self.artworkData = state.artwork + + // Update last artwork change values + self.lastArtworkTitle = state.title + self.lastArtworkArtist = state.artist + self.lastArtworkAlbum = state.album + self.lastArtworkBundleIdentifier = state.bundleIdentifier + self.lastArtworkContentIdentifier = state.contentIdentifier + self.lastArtworkContentURL = state.contentURL + + self.prepareLyricsForCurrentTrack() + if let liveArtworkURL = state.liveArtworkURL { + self.videoArtworkURL = liveArtworkURL + } else { + self.fetchVideoArtwork() + } + + self.refreshExplicitFlag(for: state) + + + // Only update sneak peek if there's actual content and something changed + if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying { + self.updateSneakPeek() + } + } else if state.isExplicit != nil { + self.refreshExplicitFlag(for: state) + } + + let timeChanged = state.currentTime != self.elapsedTime + let durationChanged = state.duration != self.songDuration + let playbackRateChanged = state.playbackRate != self.playbackRate + let shuffleChanged = state.isShuffled != self.isShuffled + let repeatModeChanged = state.repeatMode != self.repeatMode + + if state.title != self.songTitle { + self.songTitle = state.title + } + + if state.artist != self.artistName { + self.artistName = state.artist + } + + if state.album != self.album { + self.album = state.album + } + + if timeChanged { + self.elapsedTime = state.currentTime + // Update current lyric based on elapsed time + self.updateCurrentLyric(for: state.currentTime) + } + + if durationChanged { + self.songDuration = state.duration + } + + if playbackRateChanged { + self.playbackRate = state.playbackRate + } + + if shuffleChanged { + self.isShuffled = state.isShuffled + } + + if state.bundleIdentifier != self.bundleIdentifier { + self.bundleIdentifier = state.bundleIdentifier + } + + if repeatModeChanged { + self.repeatMode = state.repeatMode + } + + updateLiveStreamState(with: state) + self.timestampDate = state.lastUpdated + + // Manage lyric sync task based on playback/lyrics availability + if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty { + // Ensure syncing runs while lyrics are enabled + startLyricSync() + } else { + stopLyricSync() + } + } + + @MainActor + private func refreshExplicitFlag(for state: PlaybackState) { + if let explicitValue = state.isExplicit { + explicitLookupTask?.cancel() + explicitLookupTask = nil + explicitLookupKey = nil + + if isCurrentTrackExplicit != explicitValue { + isCurrentTrackExplicit = explicitValue + } + return + } + + if state.bundleIdentifier == SpotifyController.bundleIdentifier, + let spotifyLookupKey = SpotifyExplicitnessResolver.LookupKey( + contentIdentifier: state.contentIdentifier, + contentURL: state.contentURL + ) { + let lookupIdentifier = "spotify|\(spotifyLookupKey.trackID)" + let fallbackTitle = state.title + let fallbackArtist = state.artist + let fallbackAlbum = state.album + guard explicitLookupKey != lookupIdentifier else { return } + + explicitLookupTask?.cancel() + explicitLookupKey = lookupIdentifier + + if isCurrentTrackExplicit { + isCurrentTrackExplicit = false + } + + explicitLookupTask = Task { [weak self] in + let isExplicit = await SpotifyExplicitnessResolver.shared.resolve(key: spotifyLookupKey) + + guard !Task.isCancelled else { return } + + await MainActor.run { + guard let self, + self.explicitLookupKey == lookupIdentifier + else { + return + } + + self.explicitLookupTask = nil + + if let isExplicit { + self.isCurrentTrackExplicit = isExplicit + } else { + self.explicitLookupKey = nil + self.refreshGenericExplicitFlag( + title: fallbackTitle, + artist: fallbackArtist, + album: fallbackAlbum + ) + } + } + } + return + } + + refreshGenericExplicitFlag(title: state.title, artist: state.artist, album: state.album) + } + + @MainActor + private func refreshGenericExplicitFlag(title: String, artist: String, album: String) { + let lookupKey = MusicExplicitnessResolver.LookupKey( + title: title, + artist: artist, + album: album + ) + let lookupIdentifier = "generic|\(lookupKey.title)|\(lookupKey.artist)|\(lookupKey.album)" + + guard lookupKey.canResolve, + !Self.placeholderTitles.contains(lookupKey.title), + !Self.placeholderArtists.contains(lookupKey.artist) + else { + explicitLookupTask?.cancel() + explicitLookupTask = nil + explicitLookupKey = nil + if isCurrentTrackExplicit { + isCurrentTrackExplicit = false + } + return + } + + guard explicitLookupKey != lookupIdentifier else { return } + + explicitLookupTask?.cancel() + explicitLookupKey = lookupIdentifier + + if isCurrentTrackExplicit { + isCurrentTrackExplicit = false + } + + explicitLookupTask = Task { [weak self] in + let isExplicit = await MusicExplicitnessResolver.shared.resolve( + title: title, + artist: artist, + album: album + ) + + guard !Task.isCancelled else { return } + + await MainActor.run { + guard let self, + self.explicitLookupKey == lookupIdentifier + else { + return + } + + self.isCurrentTrackExplicit = isExplicit + self.explicitLookupTask = nil + } + } + } + + private func triggerFlipAnimation() { + // Debounce: rapid metadata updates (title, artwork, bundle arriving + // separately for one track change) should only produce a single flip. + guard !flipCooldownActive else { return } + flipCooldownActive = true + + // Direction: positive rotation = next (page turn forward), + // negative rotation = previous (page turn backward). + let delta: Double = lastFlipDirection == .forward ? 180 : -180 + withAnimation(.easeInOut(duration: flipAnimationDuration)) { + flipAngle += delta + } + + // Reset cooldown after the animation completes so the next + // genuine track change can flip again. + DispatchQueue.main.asyncAfter(deadline: .now() + flipAnimationDuration + 0.15) { [weak self] in + self?.flipCooldownActive = false + } + } + + private func updateLiveStreamState(with state: PlaybackState) { + let duration = state.duration + let current = max(state.currentTime, elapsedTime) + let hasKnownDuration = duration.isFinite && duration > 0 + let isPlaying = state.isPlaying + + if hasKnownDuration { + liveStreamUnknownDurationCount = 0 + + let remaining = duration - current + let clampedDuration = max(duration, 0) + let clampedCurrent = clampedDuration > 0 + ? max(0, min(current, clampedDuration)) + : max(0, current) + let progress = clampedDuration > 0 ? clampedCurrent / clampedDuration : 0 + let sliderAppearsComplete = isPlaying && clampedDuration > 0 && progress >= 0.999 + let nearDurationEdge = isPlaying && remaining.isFinite && remaining <= 1.0 && clampedCurrent >= 10 + + if sliderAppearsComplete { + liveStreamCompletionObservationCount = min(liveStreamCompletionObservationCount + 1, 8) + liveStreamCompletionReleaseCount = 0 + } else { + liveStreamCompletionReleaseCount = min(liveStreamCompletionReleaseCount + 1, 8) + if liveStreamCompletionObservationCount > 0 { + liveStreamCompletionObservationCount = max(liveStreamCompletionObservationCount - 1, 0) + } + } + + if nearDurationEdge || sliderAppearsComplete { + liveStreamEdgeObservationCount = min(liveStreamEdgeObservationCount + 1, 12) + } else if liveStreamEdgeObservationCount > 0 { + liveStreamEdgeObservationCount = max(liveStreamEdgeObservationCount - 1, 0) + } + + if !isLiveStream { + if liveStreamCompletionObservationCount >= 3 || liveStreamEdgeObservationCount >= 5 { + isLiveStream = true + } + } else { + let shouldClearForKnownDuration = + (duration > 10 && remaining > 5) + || (liveStreamCompletionObservationCount == 0 + && liveStreamEdgeObservationCount == 0 + && liveStreamCompletionReleaseCount >= 4) + + if shouldClearForKnownDuration { + isLiveStream = false + } + } + } else if isPlaying { + liveStreamEdgeObservationCount = max(liveStreamEdgeObservationCount - 1, 0) + liveStreamCompletionObservationCount = max(liveStreamCompletionObservationCount - 1, 0) + liveStreamCompletionReleaseCount = 0 + + liveStreamUnknownDurationCount = min(liveStreamUnknownDurationCount + 1, 8) + if liveStreamUnknownDurationCount >= 3 && !isLiveStream { + isLiveStream = true + } + } else { + liveStreamUnknownDurationCount = 0 + liveStreamEdgeObservationCount = 0 + liveStreamCompletionObservationCount = 0 + liveStreamCompletionReleaseCount = 0 + } + } + + private func updateArtwork(_ artworkData: Data) { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + + if let artworkImage = NSImage(data: artworkData) { + DispatchQueue.main.async { [weak self] in + self?.usingAppIconForArtwork = false + self?.updateAlbumArt(newAlbumArt: artworkImage) + } + } + } } private func updateIdleState(state: Bool) { if state { isPlayerIdle = false - debounceToggle?.cancel() + debounceIdleTask?.cancel() } else { - debounceToggle = DispatchWorkItem { [weak self] in + debounceIdleTask?.cancel() + debounceIdleTask = Task { [weak self] in guard let self = self else { return } - if self.lastUpdated.timeIntervalSinceNow < -Defaults[.waitInterval] { - withAnimation { - self.isPlayerIdle = !self.isPlaying - } + try? await Task.sleep(for: .seconds(Defaults[.waitInterval])) + withAnimation { + self.isPlayerIdle = !self.isPlaying } } - - DispatchQueue.main.asyncAfter( - deadline: .now() + Defaults[.waitInterval], execute: debounceToggle!) } } @@ -316,6 +1086,15 @@ class MusicManager: ObservableObject { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem!) } + // MARK: - Playback Position Estimation + public func estimatedPlaybackPosition(at date: Date = Date()) -> TimeInterval { + guard isPlaying else { return min(elapsedTime, songDuration) } + + let timeDifference = date.timeIntervalSince(timestampDate) + let estimated = elapsedTime + (timeDifference * playbackRate) + return min(max(0, estimated), songDuration) + } + func calculateAverageColor() { albumArt.averageColor { [weak self] color in DispatchQueue.main.async { @@ -325,8 +1104,13 @@ class MusicManager: ObservableObject { } } } - + private func updateSneakPeek() { + let standardControlsEnabled = Defaults[.showStandardMediaControls] + let minimalisticEnabled = Defaults[.enableMinimalisticUI] + + guard standardControlsEnabled || minimalisticEnabled else { return } + if isPlaying && Defaults[.enableSneakPeek] { if Defaults[.sneakPeekStyles] == .standard { coordinator.toggleSneakPeek(status: true, type: .music) @@ -338,33 +1122,123 @@ class MusicManager: ObservableObject { // MARK: - Public Methods for controlling playback func playPause() { - activeController?.togglePlay() + Task { + await activeController?.togglePlay() + } } - + func play() { - activeController?.play() + Task { + await activeController?.play() + } } - + func pause() { - activeController?.pause() + Task { + await activeController?.pause() + } + } + + func toggleShuffle() { + Task { + await activeController?.toggleShuffle() + } + } + + func toggleRepeat() { + Task { + await activeController?.toggleRepeat() + } } func togglePlay() { - activeController?.togglePlay() + guard let controller = activeController else { return } + let targetState = !isPlaying + + Task { + await MainActor.run { + pendingOptimisticPlayState = targetState + applyPlayState(targetState, animation: .smooth(duration: 0.18)) + } + + if targetState { + await controller.play() + } else { + await controller.pause() + } + } } - + func nextTrack() { - activeController?.nextTrack() + Task { + await activeController?.nextTrack() + } } - + func previousTrack() { - activeController?.previousTrack() + Task { + await activeController?.previousTrack() + } } - + func seek(to position: TimeInterval) { - activeController?.seek(to: position) + Task { + await activeController?.seek(to: position) + } } - + + func seek(by offset: TimeInterval) { + guard !isLiveStream else { return } + let duration = songDuration + guard duration > 0 else { return } + + let current = estimatedPlaybackPosition() + let magnitude = abs(offset) + + if offset < 0, current <= magnitude { + previousTrack() + return + } + + if offset > 0, (duration - current) <= magnitude { + nextTrack() + return + } + + let target = min(max(0, current + offset), duration) + seek(to: target) + } + + @MainActor + func handleSkipGesture(direction: SkipDirection) { + guard Defaults[.enableHorizontalMusicGestures] else { return } + guard !isPlayerIdle || bundleIdentifier != nil else { return } + + let behavior = Defaults[.musicGestureBehavior] + + switch behavior { + case .track: + if direction == .forward { + lastFlipDirection = .forward + nextTrack() + } else { + lastFlipDirection = .backward + previousTrack() + } + case .tenSecond: + let interval = Self.skipGestureSeekInterval + let offset = direction == .forward ? interval : -interval + seek(by: offset) + } + + skipGestureToken = skipGestureToken &+ 1 + skipGesturePulse = SkipGesturePulse( + token: skipGestureToken, + direction: direction, + behavior: behavior + ) + } + func openMusicApp() { guard let bundleID = bundleIdentifier else { print("Error: appBundleIdentifier is nil") @@ -385,13 +1259,491 @@ class MusicManager: ObservableObject { print("Failed to find app with bundle ID: \(bundleID)") } } - + func forceUpdate() { // Request immediate update from the active controller - DispatchQueue.main.async { [weak self] in + Task { [weak self] in if self?.activeController?.isActive() == true { - self?.activeController?.updatePlaybackInfo() + if let youtubeController = self?.activeController as? YouTubeMusicController { + await youtubeController.pollPlaybackState() + } else { + await self?.activeController?.updatePlaybackInfo() + } + } + } + } + + // MARK: - Lyrics Methods + func fetchLyrics() { + prepareLyricsForCurrentTrack(forceFetch: true, prioritizeVisibleResult: Defaults[.enableLyrics]) + } + + private func handleLyricsPreferenceChange(isEnabled: Bool) { + showLyrics = isEnabled + + if isEnabled { + prepareLyricsForCurrentTrack(prioritizeVisibleResult: true) + } else { + stopLyricSync() + } + } + + private func prepareLyricsForCurrentTrack(forceFetch: Bool = false, prioritizeVisibleResult: Bool = false) { + guard let lookup = currentLyricsLookupContext() else { + activeLyricsKey = nil + lyricsFetchKey = nil + lyricsFetchTask?.cancel() + lyricsFetchTask = nil + syncedLyrics = [] + currentLyrics = "" + currentLyricIndex = -1 + stopLyricSync() + return + } + + let key = lookup.key + let lyricsEnabled = Defaults[.enableLyrics] + let shouldShowLoading = lyricsEnabled && prioritizeVisibleResult + let trackChanged = activeLyricsKey != key + activeLyricsKey = key + + if trackChanged { + syncedLyrics = [] + currentLyricIndex = -1 + currentLyrics = shouldShowLoading ? "Loading lyrics..." : "" + stopLyricSync() + } + + if !forceFetch, let cachedLyrics = lyricsCache[key] { + applyLyricsToDisplay(cachedLyrics) + return + } + + if lyricsFetchKey == key { + if shouldShowLoading && syncedLyrics.isEmpty { + currentLyrics = "Loading lyrics..." + } + return + } + + lyricsFetchTask?.cancel() + lyricsFetchKey = key + + if shouldShowLoading || (lyricsEnabled && syncedLyrics.isEmpty) { + currentLyrics = "Loading lyrics..." + } + + let requestArtist = lookup.requestArtist + let requestTitle = lookup.requestTitle + let requestAlbum = lookup.requestAlbum + + lyricsFetchTask = Task { [weak self] in + guard let self else { return } + + do { + let lyrics = try await self.fetchLyricsFromAPI( + artist: requestArtist, + title: requestTitle, + album: requestAlbum + ) + guard !Task.isCancelled else { return } + + await MainActor.run { + guard self.activeLyricsKey == key else { return } + self.lyricsCache[key] = lyrics + self.lyricsFetchKey = nil + self.lyricsFetchTask = nil + self.applyLyricsToDisplay(lyrics) + } + } catch { + print("Failed to fetch lyrics: \(error)") + await MainActor.run { + guard self.activeLyricsKey == key else { return } + self.lyricsFetchKey = nil + self.lyricsFetchTask = nil + self.syncedLyrics = [] + self.currentLyricIndex = -1 + self.currentLyrics = lyricsEnabled ? "No lyrics found" : "" + self.stopLyricSync() + } + } + } + } + + private func fetchLyricsFromAPI(artist: String, title: String, album: String) async throws -> [LyricLine] { + guard !artist.isEmpty, !title.isEmpty else { return [] } + + // Normalize input and percent-encode + let cleanArtist = artist.folding(options: .diacriticInsensitive, locale: .current) + let cleanTitle = title.folding(options: .diacriticInsensitive, locale: .current) + let cleanAlbum = album.folding(options: .diacriticInsensitive, locale: .current) + guard let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + return [] + } + + // Use LRCLIB search endpoint which returns an array JSON with `plainLyrics` and/or `syncedLyrics`. + let urlString = "https://lrclib.net/api/search?track_name=\(encodedTitle)&artist_name=\(encodedArtist)" + guard let url = URL(string: urlString) else { return [] } + + let (data, response) = try await URLSession.shared.data(from: url) + if let http = response as? HTTPURLResponse, http.statusCode == 200 { + // Try parse as array JSON (preferred) + if let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let bestMatch = bestLyricsMatch(in: jsonArray, artist: cleanArtist, title: cleanTitle, album: cleanAlbum) { + let first = bestMatch + let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if !synced.isEmpty { + return parseLRC(synced) + } else if !plain.isEmpty { + return [LyricLine(timestamp: 0, text: plain)] + } else { + return [] + } + } else { + // Fallback: try to decode as UTF8 and handle as LRC or plain text + if let lrcString = String(data: data, encoding: .utf8) { + let trimmed = lrcString.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.isEmpty { + return [] + } + + // If it contains a syncedLyrics key in an object, try that + if let json = try? JSONSerialization.jsonObject(with: data, options: []) { + if let dict = json as? [String: Any], + let synced = dict["syncedLyrics"] as? String + { + return parseLRC(synced) + } + if let array = json as? [Any], array.isEmpty { + return [] + } + } + + // Otherwise treat as plain lyrics blob + return [LyricLine(timestamp: 0, text: trimmed)] + } + return [] + } + } else { + return [] + } + } + + private func currentLyricsLookupContext() -> (key: LyricsLookupKey, requestArtist: String, requestTitle: String, requestAlbum: String)? { + let requestArtist = normalizedLyricsRequestComponent(artistName) + let requestTitle = normalizedLyricsTitle(songTitle) + let requestAlbum = normalizedLyricsRequestComponent(album) + + let key = LyricsLookupKey( + title: requestTitle.lowercased(), + artist: requestArtist.lowercased(), + album: requestAlbum.lowercased() + ) + + return key.isValid ? (key, requestArtist, requestTitle, requestAlbum) : nil + } + + private func normalizedLyricsRequestComponent(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + } + + private func normalizedLyricsTitle(_ value: String) -> String { + var normalized = normalizedLyricsRequestComponent(value) + let cleanupPatterns = [ + "\\s*\\((feat\\.?|ft\\.?|featuring)[^\\)]*\\)", + "\\s*\\[(feat\\.?|ft\\.?|featuring)[^\\]]*\\]", + "\\s*-\\s*(feat\\.?|ft\\.?|featuring)\\s+.*$", + "\\s*\\((remaster(ed)?|live|mono|stereo)[^\\)]*\\)$", + "\\s*\\[(remaster(ed)?|live|mono|stereo)[^\\]]*\\]$" + ] + + for pattern in cleanupPatterns { + normalized = normalized.replacingOccurrences( + of: pattern, + with: "", + options: [.regularExpression, .caseInsensitive] + ) + } + + return normalizedLyricsRequestComponent(normalized) + } + + private func bestLyricsMatch(in results: [[String: Any]], artist: String, title: String, album: String) -> [String: Any]? { + let normalizedArtist = artist.lowercased() + let normalizedTitle = title.lowercased() + let normalizedAlbum = album.lowercased() + + return results.max { lhs, rhs in + lyricsMatchScore(for: lhs, artist: normalizedArtist, title: normalizedTitle, album: normalizedAlbum) + < lyricsMatchScore(for: rhs, artist: normalizedArtist, title: normalizedTitle, album: normalizedAlbum) + } + } + + private func lyricsMatchScore(for result: [String: Any], artist: String, title: String, album: String) -> Int { + let resultArtist = ((result["artistName"] as? String) ?? "").lowercased() + let resultTitle = ((result["trackName"] as? String) ?? "").lowercased() + let resultAlbum = ((result["albumName"] as? String) ?? "").lowercased() + + var score = 0 + + if resultTitle == title { score += 8 } + else if resultTitle.contains(title) || title.contains(resultTitle) { score += 4 } + + if resultArtist == artist { score += 8 } + else if resultArtist.contains(artist) || artist.contains(resultArtist) { score += 4 } + + if !album.isEmpty { + if resultAlbum == album { score += 4 } + else if resultAlbum.contains(album) || album.contains(resultAlbum) { score += 2 } + } + + if !(result["syncedLyrics"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + score += 3 + } + + return score + } + + private func applyLyricsToDisplay(_ lyrics: [LyricLine]) { + syncedLyrics = lyrics + currentLyricIndex = -1 + + guard !lyrics.isEmpty else { + currentLyrics = Defaults[.enableLyrics] ? "No lyrics found" : "" + stopLyricSync() + return + } + + let playbackPosition = max(estimatedPlaybackPosition(), elapsedTime) + updateCurrentLyric(for: playbackPosition) + + if currentLyricIndex == -1, let firstLine = lyrics.first?.text { + currentLyrics = firstLine + } + + if Defaults[.enableLyrics] { + startLyricSync() + } else { + stopLyricSync() + } + } + + private func parseLRC(_ lrc: String) -> [LyricLine] { + let lines = lrc.components(separatedBy: .newlines) + var lyrics: [LyricLine] = [] + + // Accept patterns like [m:ss], [mm:ss], [mm:ss.xx] where centiseconds are optional + let pattern = "\\[(\\d{1,2}):(\\d{2})(?:\\.(\\d{1,2}))?\\]" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } + + for line in lines { + let nsLine = line as NSString + let fullRange = NSRange(location: 0, length: nsLine.length) + if let match = regex.firstMatch(in: line, options: [], range: fullRange) { + let minRange = match.range(at: 1) + let secRange = match.range(at: 2) + let centiRange = match.range(at: 3) + + let minStr = minRange.location != NSNotFound ? nsLine.substring(with: minRange) : "0" + let secStr = secRange.location != NSNotFound ? nsLine.substring(with: secRange) : "0" + let centiStr = (centiRange.location != NSNotFound) ? nsLine.substring(with: centiRange) : "0" + + let minutes = Double(minStr) ?? 0 + let seconds = Double(secStr) ?? 0 + let centis = Double(centiStr) ?? 0 + let timestamp = minutes * 60 + seconds + centis / 100.0 + + let textStart = match.range.location + match.range.length + if textStart <= nsLine.length { + let text = nsLine.substring(from: textStart).trimmingCharacters(in: .whitespaces) + if !text.isEmpty { + lyrics.append(LyricLine(timestamp: timestamp, text: text)) + } + } + } + } + + return lyrics.sorted(by: { $0.timestamp < $1.timestamp }) + } + + func updateCurrentLyric(for elapsedTime: TimeInterval) { + guard !syncedLyrics.isEmpty else { return } + + // Find the current lyric based on elapsed time + var newIndex = -1 + for (index, lyric) in syncedLyrics.enumerated() { + if elapsedTime >= lyric.timestamp { + newIndex = index + } else { + break + } + } + + if newIndex != currentLyricIndex { + currentLyricIndex = newIndex + if newIndex >= 0 && newIndex < syncedLyrics.count { + currentLyrics = syncedLyrics[newIndex].text } } } + + // Start a background task that periodically updates the displayed lyric + private func startLyricSync() { + // If already running, keep it + if lyricSyncTask != nil { return } + + lyricSyncTask = Task { [weak self] in + guard let self = self else { return } + while !Task.isCancelled { + // Compute estimated playback position and update lyric + let position = self.estimatedPlaybackPosition() + await MainActor.run { + self.updateCurrentLyric(for: position) + } + + // Sleep ~300ms between updates + try? await Task.sleep(nanoseconds: 300_000_000) + } + } + } + + private func stopLyricSync() { + lyricSyncTask?.cancel() + lyricSyncTask = nil + } + + // MARK: - Video Artwork + + func fetchVideoArtwork() { + guard Defaults[.lockScreenMusicFullscreenVideoArtwork] else { + videoArtworkURL = nil + return + } + // Se il player non è Apple Music, non toccare videoArtworkURL: + // SpotifyController gestisce il canvas in modo autonomo tramite liveArtworkURL. + guard bundleIdentifier == "com.apple.Music" else { + return + } + + let title = songTitle + let artist = artistName + + Task { + let url = await AnimatedArtworkManager.shared.fetchAnimatedArtworkURL( + title: title, artist: artist + ) + await MainActor.run { + self.videoArtworkURL = url + } + } + } + + func toggleLyrics() { + // Toggle the UI state first so the views can react immediately. + showLyrics.toggle() + + // If lyrics are requested to be shown but we don't have any yet, + // show a loading placeholder and start fetching asynchronously. + if showLyrics && syncedLyrics.isEmpty { + // Provide immediate feedback so the UI can show a loading state. + currentLyrics = "Loading lyrics..." + + Task { + await fetchLyrics() + + // If fetch completed but no lyrics were found, show a friendly message. + await MainActor.run { + if self.syncedLyrics.isEmpty && self.currentLyrics.isEmpty { + self.currentLyrics = "No lyrics found" + } + } + } + } + } +} + +// MARK: - Media Branding + +extension MusicManager { + var brandAccentColor: Color { + Self.brandAccentColor(for: Defaults[.mediaController], bundleIdentifier: bundleIdentifier) + } + + private static func brandAccentColor(for controller: MediaControllerType, bundleIdentifier: String?) -> Color { + switch controller { + case .appleMusic: + return appleMusicPink + case .spotify: + return spotifyGreen + case .amazonMusic: + return amazonOrange + case .nowPlaying: + if let bundleIdentifier, + let bundleColor = brandAccentColor(forBundleIdentifier: bundleIdentifier) { + return bundleColor + } + fallthrough + case .youtubeMusic: + return .accentColor + } + } + + private static func brandAccentColor(forBundleIdentifier bundleIdentifier: String) -> Color? { + switch bundleIdentifier { + case "com.apple.Music": + return appleMusicPink + case "com.spotify.client": + return spotifyGreen + case AmazonMusicController.bundleIdentifier: + return amazonOrange + default: + return nil + } + } + + private static let appleMusicPink = Color(red: 0.999, green: 0.171, blue: 0.331) + private static let spotifyGreen = Color(red: 0.0, green: 0.857, blue: 0.302) + private static let amazonOrange = Color(red: 1.0, green: 0.6, blue: 0.0) +} + +// MARK: - Album Art Flip Helper + +private struct AlbumArtFlipModifier: ViewModifier { + let angle: Double + + func body(content: Content) -> some View { + content + .rotation3DEffect( + .degrees(angle), + axis: (x: 0, y: 1, z: 0), + anchor: .center, + anchorZ: 0, + perspective: 0.5 + ) + // Counter-rotate the content so the image never appears mirrored. + // At odd multiples of 180° the 3D rotation mirrors along X; + // applying an opposite scaleEffect cancels that out. + .scaleEffect(x: cosineSign(for: angle), y: 1) + } + + /// Returns +1 when the front face is showing, −1 when the back face is showing. + private func cosineSign(for degrees: Double) -> CGFloat { + let cos = Darwin.cos(degrees * .pi / 180) + // Use a small tolerance to avoid flickering exactly at 90°/270°. + if cos > 0.001 { return 1 } + if cos < -0.001 { return -1 } + return degrees.truncatingRemainder(dividingBy: 360) >= 0 ? -1 : 1 + } +} + +extension View { + func albumArtFlip(angle: Double) -> some View { + modifier(AlbumArtFlipModifier(angle: angle)) + } } diff --git a/DynamicIsland/managers/NotchSpaceManager.swift b/DynamicIsland/managers/NotchSpaceManager.swift index c7811799..aa19db68 100644 --- a/DynamicIsland/managers/NotchSpaceManager.swift +++ b/DynamicIsland/managers/NotchSpaceManager.swift @@ -1,9 +1,24 @@ -// -// NotchSpaceManager.swift -// DynamicIsland -// -// Created by Alexander Greco on 2024-10-27. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation diff --git a/DynamicIsland/managers/PrivacyIndicatorManager.swift b/DynamicIsland/managers/PrivacyIndicatorManager.swift new file mode 100644 index 00000000..c5a0dcc9 --- /dev/null +++ b/DynamicIsland/managers/PrivacyIndicatorManager.swift @@ -0,0 +1,277 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import SwiftUI +import Combine +import Defaults + +// MARK: - Indicator Layout Enum +enum IndicatorLayout { + case none + case cameraOnly + case microphoneOnly + case cameraAndMicrophone + case recordingOnly + case recordingWithCamera + case recordingWithMicrophone + case recordingWithBoth + + // Computed properties for UI positioning + var showsRecordingPulsator: Bool { + switch self { + case .recordingOnly, .recordingWithCamera, .recordingWithMicrophone, .recordingWithBoth: + return true + default: + return false + } + } + + var showsCameraIndicator: Bool { + switch self { + case .cameraOnly, .cameraAndMicrophone, .recordingWithCamera, .recordingWithBoth: + return true + default: + return false + } + } + + var showsMicrophoneIndicator: Bool { + switch self { + case .microphoneOnly, .cameraAndMicrophone, .recordingWithMicrophone, .recordingWithBoth: + return true + default: + return false + } + } + + // Description for debugging + var description: String { + switch self { + case .none: return "None" + case .cameraOnly: return "Camera Only" + case .microphoneOnly: return "Microphone Only" + case .cameraAndMicrophone: return "Camera + Microphone" + case .recordingOnly: return "Recording Only" + case .recordingWithCamera: return "Recording + Camera" + case .recordingWithMicrophone: return "Recording + Microphone" + case .recordingWithBoth: return "Recording + Camera + Microphone" + } + } +} + +// MARK: - Privacy Indicator Manager +@MainActor +class PrivacyIndicatorManager: ObservableObject { + // MARK: - Singleton + static let shared = PrivacyIndicatorManager() + + // MARK: - Published Properties + @Published var cameraActive: Bool = false + @Published var microphoneActive: Bool = false + @Published var screenRecordingActive: Bool = false + @Published var isMonitoring: Bool = false + + // MARK: - Child Monitors + private let cameraMonitor = CameraMonitor() + private let microphoneMonitor = MicrophoneMonitor() + private var screenRecordingManager: ScreenRecordingManager? + + // MARK: - Cancellables + private var cancellables = Set() + + // MARK: - Computed Properties + + /// Current indicator layout based on active states + var indicatorLayout: IndicatorLayout { + // Respect user settings + let camera = cameraActive && Defaults[.enableCameraDetection] + let mic = microphoneActive && Defaults[.enableMicrophoneDetection] + let recording = screenRecordingActive + + // 8 possible combinations + switch (recording, camera, mic) { + case (false, false, false): + return .none + case (false, true, false): + return .cameraOnly + case (false, false, true): + return .microphoneOnly + case (false, true, true): + return .cameraAndMicrophone + case (true, false, false): + return .recordingOnly + case (true, true, false): + return .recordingWithCamera + case (true, false, true): + return .recordingWithMicrophone + case (true, true, true): + return .recordingWithBoth + } + } + + /// Check if any indicator is active (respecting user settings) + var hasAnyIndicator: Bool { + let showCamera = cameraActive && Defaults[.enableCameraDetection] + let showMic = microphoneActive && Defaults[.enableMicrophoneDetection] + return showCamera || showMic || screenRecordingActive + } + + // MARK: - Initialization + private init() { + print("PrivacyIndicatorManager: 🚀 Initializing...") + setupBindings() + } + + // MARK: - Setup Methods + + /// Setup bindings to child monitors + private func setupBindings() { + // Bind camera monitor + cameraMonitor.$isCameraActive + .receive(on: DispatchQueue.main) + .sink { [weak self] isActive in + guard let self = self else { return } + if self.cameraActive != isActive { + print("PrivacyIndicatorManager: 📷 Camera state: \(isActive)") + withAnimation(.smooth) { + self.cameraActive = isActive + } + self.logLayoutChange() + } + } + .store(in: &cancellables) + + // Bind microphone monitor + microphoneMonitor.$isMicActive + .receive(on: DispatchQueue.main) + .sink { [weak self] isActive in + guard let self = self else { return } + if self.microphoneActive != isActive { + print("PrivacyIndicatorManager: 🎤 Microphone state: \(isActive)") + withAnimation(.smooth) { + self.microphoneActive = isActive + } + self.logLayoutChange() + } + } + .store(in: &cancellables) + + // Bind screen recording manager + let screenRecManager = ScreenRecordingManager.shared + screenRecordingManager = screenRecManager + + screenRecManager.$isRecording + .receive(on: DispatchQueue.main) + .sink { [weak self] isRecording in + guard let self = self else { return } + if self.screenRecordingActive != isRecording { + print("PrivacyIndicatorManager: 📹 Screen recording state: \(isRecording)") + withAnimation(.smooth) { + self.screenRecordingActive = isRecording + } + self.logLayoutChange() + } + } + .store(in: &cancellables) + } + + /// Log layout changes for debugging + private func logLayoutChange() { + print("PrivacyIndicatorManager: 🔄 Layout changed to: \(indicatorLayout.description)") + print("PrivacyIndicatorManager: 📊 States - Camera: \(cameraActive), Mic: \(microphoneActive), Recording: \(screenRecordingActive)") + } + + // MARK: - Public Methods + + /// Start monitoring all privacy indicators + func startMonitoring() { + print("PrivacyIndicatorManager: 🟢 Starting all monitors...") + + isMonitoring = true + + // Start camera monitoring + if cameraMonitor.isMonitoringAvailable { + cameraMonitor.startMonitoring() + } else { + print("PrivacyIndicatorManager: ⚠️ Camera monitoring not available") + } + + // Start microphone monitoring + if microphoneMonitor.isMonitoringAvailable { + microphoneMonitor.startMonitoring() + } else { + print("PrivacyIndicatorManager: ⚠️ Microphone monitoring not available") + } + + // Screen recording is already monitored by ScreenRecordingManager + print("PrivacyIndicatorManager: ✅ All monitors started") + } + + /// Stop monitoring all privacy indicators + func stopMonitoring() { + print("PrivacyIndicatorManager: 🛑 Stopping all monitors...") + + isMonitoring = false + + cameraMonitor.stopMonitoring() + microphoneMonitor.stopMonitoring() + + print("PrivacyIndicatorManager: ✅ All monitors stopped") + } + + /// Toggle monitoring state + func toggleMonitoring() { + if cameraMonitor.isMonitoring || microphoneMonitor.isMonitoring { + stopMonitoring() + } else { + startMonitoring() + } + } + + /// Get detailed status string for debugging + func getStatusString() -> String { + var status = "Privacy Indicators:\n" + status += " Camera: \(cameraActive ? "🟢 Active" : "⚪ Inactive")\n" + status += " Microphone: \(microphoneActive ? "🟢 Active" : "⚪ Inactive")\n" + status += " Screen Recording: \(screenRecordingActive ? "🟢 Active" : "⚪ Inactive")\n" + status += " Layout: \(indicatorLayout.description)" + return status + } +} + +// MARK: - Extensions + +extension PrivacyIndicatorManager { + /// Get camera monitor instance + var camera: CameraMonitor { + return cameraMonitor + } + + /// Get microphone monitor instance + var microphone: MicrophoneMonitor { + return microphoneMonitor + } + + /// Get screen recording manager instance + var screenRecording: ScreenRecordingManager? { + return screenRecordingManager + } +} + + diff --git a/DynamicIsland/managers/ReminderLiveActivityManager.swift b/DynamicIsland/managers/ReminderLiveActivityManager.swift new file mode 100644 index 00000000..19e2a07d --- /dev/null +++ b/DynamicIsland/managers/ReminderLiveActivityManager.swift @@ -0,0 +1,686 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Combine +import Defaults +import Foundation +import CoreGraphics +import SwiftUI +import AppKit +import os + +@MainActor +final class ReminderLiveActivityManager: ObservableObject { + struct ReminderEntry: Equatable { + let event: EventModel + let triggerDate: Date + let leadTime: TimeInterval + } + + static let shared = ReminderLiveActivityManager() + static let standardIconName = "calendar.badge.clock" + static let criticalIconName = "calendar.badge.exclamationmark" + static let listRowHeight: CGFloat = 30 + static let listRowSpacing: CGFloat = 8 + static let listTopPadding: CGFloat = 14 + static let listBottomPadding: CGFloat = 10 + static let baselineMinimalisticBottomPadding: CGFloat = 3 + + @Published private(set) var activeReminder: ReminderEntry? + @Published private(set) var currentDate: Date = Date() + @Published private(set) var upcomingEntries: [ReminderEntry] = [] + @Published private(set) var activeWindowReminders: [ReminderEntry] = [] + @Published private(set) var lockScreenSnapshot: LockScreenReminderWidgetSnapshot? + + private let logger: os.Logger = os.Logger(subsystem: "com.ebullioscopic.Atoll", category: "ReminderLiveActivity") + + private var nextReminder: ReminderEntry? + private var cancellables = Set() + private var tickerTask: Task? { didSet { oldValue?.cancel() } } + private var evaluationTask: Task? + private var hasShownCriticalSneakPeek = false + private var latestEvents: [EventModel] = [] + private var pendingEventsSnapshot: [EventModel]? = nil + private var pendingEventsSignature: Int? + private var lastEventsSignature: Int? + private var eventsUpdateDebounceTask: Task? { didSet { oldValue?.cancel() } } + private var upcomingComputationTask: Task? { didSet { oldValue?.cancel() } } + private let eventsDebounceInterval: TimeInterval = 0.35 + private var settingsUpdateTask: Task? { didSet { oldValue?.cancel() } } + private var pendingSettingsAction: (() -> Void)? + private var pendingSettingsReason: String? + private let settingsUpdateDebounceInterval: TimeInterval = 0.2 + private var suppressUpdatesForLock = false + private var deferredLockResumeTask: Task? { didSet { oldValue?.cancel() } } + private var nextAllowedLockResumeRefresh: Date = .distantPast + private let lockResumeCooldown: TimeInterval = 5 + private let lockScreenTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + + private var lastAppliedLeadTime = Defaults[.reminderLeadTime] + private var lastAppliedHideAllDay = Defaults[.hideAllDayEvents] + private var lastAppliedHideCompleted = Defaults[.hideCompletedReminders] + + private let calendarManager = CalendarManager.shared + + var isActive: Bool { activeReminder != nil } + + private init() { + latestEvents = calendarManager.events + lastEventsSignature = makeEventsSignature(for: latestEvents) + setupObservers() + if !latestEvents.isEmpty { + recalculateUpcomingEntries(reason: "initialization") + } + } + + private func setupObservers() { + Defaults.publisher(.enableReminderLiveActivity, options: []) + .sink { [weak self] change in + guard let self else { return } + if change.newValue { + self.recalculateUpcomingEntries(reason: "defaults-toggle") + } else { + self.deactivateReminder() + } + } + .store(in: &cancellables) + + Defaults.publisher(.reminderLeadTime, options: []) + .map(\.newValue) + .sink { [weak self] newValue in + guard let self else { return } + guard newValue != self.lastAppliedLeadTime else { return } + self.scheduleSettingsRecalculation(reason: "lead-time") { + self.lastAppliedLeadTime = newValue + } + } + .store(in: &cancellables) + + Defaults.publisher(.hideAllDayEvents, options: []) + .map(\.newValue) + .sink { [weak self] newValue in + guard let self else { return } + guard newValue != self.lastAppliedHideAllDay else { return } + self.scheduleSettingsRecalculation(reason: "hide-all-day") { + self.lastAppliedHideAllDay = newValue + } + } + .store(in: &cancellables) + + Defaults.publisher(.hideCompletedReminders, options: []) + .map(\.newValue) + .sink { [weak self] newValue in + guard let self else { return } + guard newValue != self.lastAppliedHideCompleted else { return } + self.scheduleSettingsRecalculation(reason: "hide-completed") { + self.lastAppliedHideCompleted = newValue + } + } + .store(in: &cancellables) + + Defaults.publisher(.reminderPresentationStyle, options: []) + .sink { [weak self] _ in + guard let self else { return } + // Presentation change does not alter scheduling, but ensure state publishes for UI updates. + if let reminder = self.activeReminder { + self.activeReminder = reminder + } + } + .store(in: &cancellables) + + calendarManager.$events + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] events in + self?.handleCalendarEventsUpdate(events) + } + .store(in: &cancellables) + + LockScreenManager.shared.$isLocked + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] locked in + self?.handleLockStateChange(isLocked: locked) + } + .store(in: &cancellables) + } + + private func scheduleSettingsRecalculation(reason: String, action: @escaping () -> Void) { + pendingSettingsAction = action + pendingSettingsReason = reason + settingsUpdateTask = Task { [weak self] in + guard let self else { return } + let delay = UInt64(settingsUpdateDebounceInterval * 1_000_000_000) + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled else { + Logger.log("settingsUpdateTask cancelled after sleep", category: .debug) + return + } + await self.applyPendingSettingsRecalculation() + } + } + + @MainActor + private func applyPendingSettingsRecalculation() { + guard let action = pendingSettingsAction, let reason = pendingSettingsReason else { return } + pendingSettingsAction = nil + pendingSettingsReason = nil + action() + recalculateUpcomingEntries(reason: reason) + } + + private func cancelAllTimers() { + tickerTask = nil + evaluationTask?.cancel() + evaluationTask = nil + hasShownCriticalSneakPeek = false + } + + private func deactivateReminder() { + nextReminder = nil + activeReminder = nil + upcomingEntries = [] + activeWindowReminders = [] + cancelAllTimers() + lockScreenSnapshot = nil + } + + private func handleCalendarEventsUpdate(_ events: [EventModel]) { + let signature = makeEventsSignature(for: events) + if let pendingEventsSignature, pendingEventsSignature == signature { + pendingEventsSnapshot = events + return + } + if let lastEventsSignature, lastEventsSignature == signature { + return + } + pendingEventsSnapshot = events + pendingEventsSignature = signature + guard !suppressUpdatesForLock else { return } + schedulePendingEventsSnapshotApplication() + } + + private func schedulePendingEventsSnapshotApplication() { + eventsUpdateDebounceTask = Task { [weak self] in + guard let self else { return } + let delay = UInt64(eventsDebounceInterval * 1_000_000_000) + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled else { return } + await self.applyPendingEventsSnapshot() + } + } + + @MainActor + private func applyPendingEventsSnapshot() { + guard !suppressUpdatesForLock else { return } + guard let snapshot = pendingEventsSnapshot else { return } + pendingEventsSnapshot = nil + let previousEvents = latestEvents + latestEvents = snapshot + if let pendingEventsSignature { + lastEventsSignature = pendingEventsSignature + self.pendingEventsSignature = nil + } else { + lastEventsSignature = makeEventsSignature(for: snapshot) + } + + guard snapshot != previousEvents else { return } + guard Defaults[.enableReminderLiveActivity] else { + deactivateReminder() + return + } + logger.debug("[Reminder] Applying calendar snapshot update (events=\(snapshot.count, privacy: .public))") + recalculateUpcomingEntries(reason: "calendar-events") + } + + private func handleLockStateChange(isLocked: Bool) { + Logger.log("Lock state changed: \(isLocked)", category: .lifecycle) + suppressUpdatesForLock = isLocked + if isLocked { + eventsUpdateDebounceTask = nil + upcomingComputationTask = nil + settingsUpdateTask = nil + pendingSettingsAction = nil + pendingSettingsReason = nil + pauseReminderActivityForLock() + deferredLockResumeTask = nil + } else { + resumeAfterLockIfNeeded() + } + } + + private func pauseReminderActivityForLock() { + Logger.log("Pausing reminder activity for lock", category: .lifecycle) + tickerTask = nil + evaluationTask?.cancel() + evaluationTask = nil + } + + private func resumeAfterLockIfNeeded() { + Logger.log("Resuming reminder activity after lock", category: .lifecycle) + let now = Date() + if now < nextAllowedLockResumeRefresh { + let delay = nextAllowedLockResumeRefresh.timeIntervalSince(now) + scheduleDeferredLockResume(after: delay) + return + } + performLockResumeRefresh() + } + + private func scheduleDeferredLockResume(after delay: TimeInterval) { + guard delay > 0 else { + performLockResumeRefresh() + return + } + deferredLockResumeTask = Task { [weak self] in + let nanoseconds = UInt64(delay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + guard !Task.isCancelled else { + Logger.log("deferredLockResumeTask cancelled after sleep", category: .debug) + return + } + await MainActor.run { + self?.performLockResumeRefresh() + } + } + } + + @MainActor + private func performLockResumeRefresh() { + deferredLockResumeTask = nil + nextAllowedLockResumeRefresh = Date().addingTimeInterval(lockResumeCooldown) + if pendingEventsSnapshot != nil { + schedulePendingEventsSnapshotApplication() + } else { + Task { [weak self] in + await self?.evaluateCurrentState(at: Date(), source: "performLockResumeRefresh") + } + } + } + + private func recalculateUpcomingEntries(referenceDate: Date = Date(), reason: String) { + Logger.log("Recalculating upcoming entries. Reason: \(reason)", category: .debug) + guard Defaults[.enableReminderLiveActivity] else { + deactivateReminder() + return + } + let snapshot = latestEvents + let leadMinutes = Defaults[.reminderLeadTime] + let hideAllDay = Defaults[.hideAllDayEvents] + let hideCompleted = Defaults[.hideCompletedReminders] + + upcomingComputationTask = Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + guard !Task.isCancelled else { return } + let upcoming = Self.buildUpcomingEntries( + events: snapshot, + leadMinutes: leadMinutes, + referenceDate: referenceDate, + hideAllDayEvents: hideAllDay, + hideCompletedReminders: hideCompleted + ) + guard !Task.isCancelled else { return } + await self.publishUpcomingEntries(upcoming, referenceDate: referenceDate, reason: reason) + } + } + + @MainActor + private func publishUpcomingEntries(_ upcoming: [ReminderEntry], referenceDate: Date, reason: String) { + guard Defaults[.enableReminderLiveActivity] else { + deactivateReminder() + return + } + + upcomingEntries = upcoming + updateActiveWindowReminders(for: referenceDate) + + guard let first = upcoming.first else { + clearActiveReminderState() + logger.debug("[Reminder] No upcoming reminders found (reason=\(reason, privacy: .public))") + return + } + + logger.debug("[Reminder] Next reminder ‘\(first.event.title, privacy: .public)’ (reason=\(reason, privacy: .public))") + handleEntrySelection(first, referenceDate: referenceDate) + } + + nonisolated private static func buildUpcomingEntries( + events: [EventModel], + leadMinutes: Int, + referenceDate: Date, + hideAllDayEvents: Bool, + hideCompletedReminders: Bool + ) -> [ReminderEntry] { + guard !Task.isCancelled else { return [] } + let leadSeconds = max(1, leadMinutes) * 60 + var entries: [ReminderEntry] = [] + entries.reserveCapacity(events.count) + for event in events { + if Task.isCancelled { return [] } + if hideAllDayEvents && event.isAllDay { + continue + } + if hideCompletedReminders, + case let .reminder(completed) = event.type, + completed { + continue + } + guard event.start > referenceDate else { continue } + let trigger = event.start.addingTimeInterval(TimeInterval(-leadSeconds)) + entries.append(.init(event: event, triggerDate: trigger, leadTime: TimeInterval(leadSeconds))) + } + return entries.sorted { $0.triggerDate < $1.triggerDate } + } + + private func clearActiveReminderState() { + nextReminder = nil + if activeReminder != nil { + activeReminder = nil + } + activeWindowReminders = [] + cancelAllTimers() + } + + private func scheduleEvaluation(at date: Date) { + evaluationTask?.cancel() + let delay = date.timeIntervalSinceNow + guard delay > 0 else { + Task { await self.evaluateCurrentState(at: Date(), source: "scheduleEvaluation-immediate") } + return + } + + evaluationTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard !Task.isCancelled else { + Logger.log("scheduleEvaluation task cancelled after sleep", category: .debug) + return + } + await self.evaluateCurrentState(at: Date(), source: "scheduleEvaluation-delayed") + } + } + + private func startTickerIfNeeded() { + guard tickerTask == nil else { return } + Logger.log("Starting ticker", category: .debug) + tickerTask = Task { [weak self] in + while let self, !Task.isCancelled { + await self.handleTick() + do { + try await Task.sleep(for: .seconds(1)) + } catch { + break + } + } + } + } + + private func stopTicker() { + tickerTask?.cancel() + tickerTask = nil + } + + private func handleEntrySelection(_ entry: ReminderEntry?, referenceDate: Date) { + Logger.log("handleEntrySelection called for \(entry?.event.title ?? "nil")", category: .debug) + guard nextReminder != entry else { + Logger.log("handleEntrySelection: entry matches nextReminder, forcing evaluation", category: .debug) + Task { await self.evaluateCurrentState(at: referenceDate, source: "handleEntrySelection-force") } + return + } + + nextReminder = entry + hasShownCriticalSneakPeek = false + Task { await self.evaluateCurrentState(at: referenceDate, source: "handleEntrySelection-change") } + } + + func evaluateCurrentState(at date: Date, source: String = "unknown") async { + Logger.log("evaluateCurrentState at \(date) from \(source)", category: .debug) + guard Defaults[.enableReminderLiveActivity] else { + deactivateReminder() + return + } + + defer { publishLockScreenSnapshot(referenceDate: date) } + + currentDate = date + updateActiveWindowReminders(for: date) + + guard var entry = nextReminder else { + if activeReminder != nil { + activeReminder = nil + } + stopTicker() + hasShownCriticalSneakPeek = false + return + } + + if entry.event.start <= date { + clearActiveReminderState() + Logger.log("[Reminder] Reminder reached start time; advancing to next reminder", category: .debug) + + // Robustly remove all expired entries. + // Note: We use event.start <= date instead of matching entry.id because + // entry might be a modified copy (with different triggerDate) that doesn't match + // the one in upcomingEntries, which would cause index lookup to fail and lead to an infinite loop. + upcomingEntries.removeAll { $0.event.start <= date } + + if let next = upcomingEntries.first { + handleEntrySelection(next, referenceDate: date) + } else { + Logger.log("[Reminder] No more upcoming reminders after expiration", category: .debug) + } + return + } + + if entry.triggerDate <= date { + if entry.triggerDate > entry.event.start { + entry = ReminderEntry(event: entry.event, triggerDate: entry.event.start, leadTime: entry.leadTime) + nextReminder = entry + } + if activeReminder != entry { + activeReminder = entry + DynamicIslandViewCoordinator.shared.toggleSneakPeek( + status: true, + type: .reminder, + duration: Defaults[.reminderSneakPeekDuration], + value: 0, + icon: ReminderLiveActivityManager.standardIconName + ) + hasShownCriticalSneakPeek = false + } + + let criticalWindow = TimeInterval(Defaults[.reminderSneakPeekDuration]) + let timeRemaining = entry.event.start.timeIntervalSince(date) + if criticalWindow > 0 && timeRemaining > 0 { + if timeRemaining <= criticalWindow { + if !hasShownCriticalSneakPeek { + let displayDuration = min(criticalWindow, max(timeRemaining - 2, 0)) + if displayDuration > 0 { + DynamicIslandViewCoordinator.shared.toggleSneakPeek( + status: true, + type: .reminder, + duration: displayDuration, + value: 0, + icon: ReminderLiveActivityManager.criticalIconName + ) + hasShownCriticalSneakPeek = true + } + } + } else { + hasShownCriticalSneakPeek = false + } + } + startTickerIfNeeded() + } else { + if activeReminder != nil { + activeReminder = nil + } + stopTicker() + hasShownCriticalSneakPeek = false + scheduleEvaluation(at: entry.triggerDate) + } + } + + @MainActor + private func handleTick() async { + // Logger.log("Tick", category: .debug) // Too verbose + let now = Date() + if abs(currentDate.timeIntervalSince(now)) >= 0.5 { + currentDate = now + } + await evaluateCurrentState(at: now, source: "handleTick") + } + + private func updateActiveWindowReminders(for date: Date) { + let filtered = upcomingEntries.filter { entry in + entry.triggerDate <= date && entry.event.start >= date + } + if filtered != activeWindowReminders { + logger.debug("[Reminder] Active window reminder count -> \(filtered.count, privacy: .public)") + activeWindowReminders = filtered + } + } + + private func publishLockScreenSnapshot(referenceDate: Date) { + guard Defaults[.enableLockScreenReminderWidget] else { + if lockScreenSnapshot != nil { + lockScreenSnapshot = nil + } + return + } + guard let entry = activeReminder else { + if lockScreenSnapshot != nil { + lockScreenSnapshot = nil + } + return + } + + let snapshot = buildLockScreenSnapshot(for: entry, now: referenceDate) + if lockScreenSnapshot != snapshot { + Logger.log("Publishing new lock screen snapshot: \(snapshot.title)", category: .debug) + lockScreenSnapshot = snapshot + } + } + + private func buildLockScreenSnapshot(for entry: ReminderEntry, now: Date) -> LockScreenReminderWidgetSnapshot { + let title = entry.event.title.isEmpty ? "Upcoming Reminder" : entry.event.title + let isCritical = lockScreenCriticalWindowContains(entry: entry, now: now) + return LockScreenReminderWidgetSnapshot( + title: title, + eventTimeText: lockScreenTimeFormatter.string(from: entry.event.start), + relativeDescription: lockScreenRelativeDescription(for: entry, now: now), + accent: lockScreenAccentColor(for: entry, isCritical: isCritical), + chipStyle: Defaults[.lockScreenReminderChipStyle], + isCritical: isCritical, + iconName: isCritical ? Self.criticalIconName : Self.standardIconName + ) + } + + private func lockScreenAccentColor(for entry: ReminderEntry, isCritical: Bool) -> LockScreenReminderWidgetSnapshot.RGBAColor { + if isCritical { + return .init(nsColor: .systemRed) + } + + let boosted = Color(nsColor: entry.event.calendar.color).ensureMinimumBrightness(factor: 0.7) + return .init(nsColor: NSColor(boosted)) + } + + private func lockScreenRelativeDescription(for entry: ReminderEntry, now: Date) -> String? { + let remaining = entry.event.start.timeIntervalSince(now) + if remaining <= 0 { + return String(format: String(localized: "now")) + } + + let minutes = Int(ceil(remaining / 60)) + switch minutes { + case ..<1: + return String(format: String(localized: "now")) + case 1: + return String(format: String(localized: "in %@"), String(localized: "1 min")) + default: + return "\(String(format: String(localized: "in \(minutes)"))) \(String(format: String(localized: "min")))" + } + } + + private func lockScreenCriticalWindowContains(entry: ReminderEntry, now: Date) -> Bool { + let window = TimeInterval(Defaults[.reminderSneakPeekDuration]) + guard window > 0 else { return false } + let remaining = entry.event.start.timeIntervalSince(now) + return remaining > 0 && remaining <= window + } + + static func additionalHeight(forRowCount rowCount: Int) -> CGFloat { + guard rowCount > 0 else { return 0 } + let rows = CGFloat(rowCount) + let spacing = CGFloat(max(rowCount - 1, 0)) * listRowSpacing + let bottomDelta = max(listBottomPadding - baselineMinimalisticBottomPadding, 0) + return listTopPadding + rows * listRowHeight + spacing + bottomDelta + } + +} + +extension ReminderLiveActivityManager { + private func makeEventsSignature(for events: [EventModel]) -> Int { + var hasher = Hasher() + hasher.combine(events.count) + for event in events { + hasher.combine(event.id) + hasher.combine(event.title) + hasher.combine(event.start.timeIntervalSinceReferenceDate.bitPattern) + hasher.combine(event.end.timeIntervalSinceReferenceDate.bitPattern) + hasher.combine(event.isAllDay) + hasher.combine(event.calendar.id) + hasher.combine(event.location ?? "") + switch event.type { + case .reminder(let completed): + hasher.combine(0) + hasher.combine(completed) + case .event(let attendance): + hasher.combine(1) + hasher.combine(hashValue(for: attendance)) + case .birthday: + hasher.combine(2) + } + } + return hasher.finalize() + } + + private func hashValue(for status: AttendanceStatus) -> Int { + switch status { + case .accepted: + return 1 + case .maybe: + return 2 + case .pending: + return 3 + case .declined: + return 4 + case .unknown: + return 5 + } + } +} + +extension ReminderLiveActivityManager.ReminderEntry: Identifiable { + var id: String { event.id } +} diff --git a/DynamicIsland/managers/ScreenAssistantManager.swift b/DynamicIsland/managers/ScreenAssistantManager.swift new file mode 100644 index 00000000..17b28ed3 --- /dev/null +++ b/DynamicIsland/managers/ScreenAssistantManager.swift @@ -0,0 +1,1317 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI +import AVFoundation +import Defaults +import Foundation + +// Chat message model +struct ChatMessage: Identifiable, Codable { + let id = UUID() + let content: String + let isFromUser: Bool + let timestamp: Date + let attachedFiles: [ScreenAssistantFile]? + + init(content: String, isFromUser: Bool, attachedFiles: [ScreenAssistantFile]? = nil) { + self.content = content + self.isFromUser = isFromUser + self.timestamp = Date() + self.attachedFiles = attachedFiles + } +} + +// Screen Assistant item data structure +struct ScreenAssistantFile: Identifiable, Codable { + let id = UUID() + let name: String + let type: FileType + let timestamp: Date + let fileURL: String? // For local files + let audioFileName: String? // For audio recordings + + enum FileType: String, CaseIterable, Codable { + case document = "document" + case image = "image" + case audio = "audio" + case video = "video" + case other = "other" + + var iconName: String { + switch self { + case .document: return "doc.text" + case .image: return "photo" + case .audio: return "waveform" + case .video: return "video" + case .other: return "doc" + } + } + + var displayName: String { + switch self { + case .document: return "Document" + case .image: return "Image" + case .audio: return "Audio" + case .video: return "Video" + case .other: return "File" + } + } + } + + init(fileURL: URL) { + // Defensive initialization with nil coalescing + self.name = fileURL.lastPathComponent.isEmpty ? "Unknown File" : fileURL.lastPathComponent + self.fileURL = fileURL.absoluteString + self.audioFileName = nil + self.timestamp = Date() + + // Safe file extension extraction + let fileExtension = fileURL.pathExtension.lowercased() + switch fileExtension { + case "jpg", "jpeg", "png", "gif", "bmp", "tiff", "webp", "heic": + self.type = .image + case "mp3", "wav", "m4a", "aac", "flac": + self.type = .audio + case "mp4", "mov", "avi", "mkv": + self.type = .video + case "txt", "md", "pdf", "doc", "docx", "rtf": + self.type = .document + default: + self.type = .other + } + + print("✅ ScreenAssistantFile: Created file entry - name: \(self.name), type: \(self.type), url: \(self.fileURL ?? "nil")") + } + + init(audioFileName: String, name: String) { + self.name = name + self.type = .audio + self.fileURL = nil + self.audioFileName = audioFileName + self.timestamp = Date() + } +} + +class ScreenAssistantManager: NSObject, ObservableObject { + static let shared = ScreenAssistantManager() + + @Published var attachedFiles: [ScreenAssistantFile] = [] + @Published var isRecording: Bool = false + @Published var recordingDuration: TimeInterval = 0 + @Published var chatMessages: [ChatMessage] = [] + @Published var isLoading: Bool = false + + private var audioRecorder: AVAudioRecorder? + private var recordingTimer: Timer? + private var activeRequest: URLSessionTask? + + // Panel management + private var chatMessagesPanel: ChatMessagesPanel? + private var chatInputPanel: ChatInputPanel? + + // Directory for storing audio recordings + static let audioDataDirectory: URL = { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let audioDir = documentsPath.appendingPathComponent("ScreenAssistantAudio") + + // Create directory if it doesn't exist + try? FileManager.default.createDirectory(at: audioDir, withIntermediateDirectories: true) + + return audioDir + }() + + // Directory for storing screenshots + static let screenshotDataDirectory: URL = { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let screenshotDir = documentsPath.appendingPathComponent("ScreenAssistantScreenshots") + + // Create directory if it doesn't exist + try? FileManager.default.createDirectory(at: screenshotDir, withIntermediateDirectories: true) + + return screenshotDir + }() + + private override init() { + super.init() + loadFilesFromDefaults() + } + + deinit { + stopRecording() + closePanels() + } + + // MARK: - Panel Management + + func showPanels() { + // Close existing panels first + closePanels() + + // Create and show chat messages panel (left side) + chatMessagesPanel = ChatMessagesPanel() + chatMessagesPanel?.positionOnLeftSide() + chatMessagesPanel?.makeKeyAndOrderFront(nil) + + // Create and show input panel (center) + chatInputPanel = ChatInputPanel() + chatInputPanel?.positionInCenter() + chatInputPanel?.makeKeyAndOrderFront(nil) + + // Focus on input panel for immediate typing + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.chatInputPanel?.makeKey() + } + } + + func closePanels() { + chatMessagesPanel?.close() + chatInputPanel?.close() + chatMessagesPanel = nil + chatInputPanel = nil + } + + func arePanelsVisible() -> Bool { + return chatMessagesPanel?.isVisible == true || chatInputPanel?.isVisible == true + } + + // MARK: - File Management + + func addFiles(_ urls: [URL]) { + guard !urls.isEmpty else { + print("⚠️ ScreenAssistant: No URLs provided to addFiles") + return + } + + print("📁 ScreenAssistant: Adding \(urls.count) files") + + let newFiles = urls.compactMap { url -> ScreenAssistantFile? in + // Wrap in autoreleasepool to manage memory + return autoreleasepool { + do { + // Verify file exists + guard FileManager.default.fileExists(atPath: url.path) else { + print("❌ ScreenAssistant: File does not exist at \(url.path)") + return nil + } + + // Verify file is readable + guard FileManager.default.isReadableFile(atPath: url.path) else { + print("❌ ScreenAssistant: File is not readable at \(url.path)") + return nil + } + + // Create file entry with error handling + let file = ScreenAssistantFile(fileURL: url) + print("✅ ScreenAssistant: Created file entry for \(file.name)") + return file + + } catch { + print("❌ ScreenAssistant: Error creating file entry - \(error)") + return nil + } + } + } + + guard !newFiles.isEmpty else { + print("⚠️ ScreenAssistant: No valid files to add") + return + } + + // Ensure we're on the main thread for @Published property updates + DispatchQueue.main.async { [weak self] in + guard let self = self else { + print("❌ ScreenAssistant: Self deallocated during addFiles") + return + } + + self.attachedFiles.append(contentsOf: newFiles) + print("📁 ScreenAssistant: Total attached files: \(self.attachedFiles.count)") + + // Save to defaults with error handling + do { + self.saveFilesToDefaults() + } catch { + print("❌ ScreenAssistant: Failed to save files after adding - \(error)") + } + } + } + + func removeFile(_ file: ScreenAssistantFile) { + attachedFiles.removeAll { $0.id == file.id } + + // Clean up audio file if it exists + if let audioFileName = file.audioFileName { + let audioURL = ScreenAssistantManager.audioDataDirectory.appendingPathComponent(audioFileName) + try? FileManager.default.removeItem(at: audioURL) + } + + saveFilesToDefaults() + } + + func clearAllFiles() { + // Clean up all audio files + for file in attachedFiles { + if let audioFileName = file.audioFileName { + let audioURL = ScreenAssistantManager.audioDataDirectory.appendingPathComponent(audioFileName) + try? FileManager.default.removeItem(at: audioURL) + } + } + + attachedFiles.removeAll() + saveFilesToDefaults() + } + + // MARK: - Audio Recording + + func toggleRecording() { + if isRecording { + stopRecording() + } else { + startRecording() + } + } + + private func startRecording() { + guard !isRecording else { return } + + let fileName = "recording_\(Date().timeIntervalSince1970).m4a" + let audioURL = ScreenAssistantManager.audioDataDirectory.appendingPathComponent(fileName) + + let settings: [String: Any] = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44100, + AVNumberOfChannelsKey: 2, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue + ] + + do { + audioRecorder = try AVAudioRecorder(url: audioURL, settings: settings) + audioRecorder?.delegate = self + audioRecorder?.isMeteringEnabled = true + audioRecorder?.prepareToRecord() + audioRecorder?.record() + + isRecording = true + recordingDuration = 0 + + // Start timer for recording duration + recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + self?.updateRecordingDuration() + } + + print("Started recording: \(fileName)") + } catch { + print("Failed to start recording: \(error)") + } + } + + private func stopRecording() { + guard isRecording else { return } + + audioRecorder?.stop() + recordingTimer?.invalidate() + recordingTimer = nil + isRecording = false + + print("Stopped recording") + } + + private func updateRecordingDuration() { + guard let recorder = audioRecorder, recorder.isRecording else { return } + recordingDuration = recorder.currentTime + } + + // MARK: - Persistence + + private func saveFilesToDefaults() { + do { + let encoded = try JSONEncoder().encode(attachedFiles) + UserDefaults.standard.set(encoded, forKey: "ScreenAssistantFiles") + print("✅ ScreenAssistant: Saved \(attachedFiles.count) files to UserDefaults") + } catch { + print("❌ ScreenAssistant: Failed to save files to UserDefaults - \(error)") + // Don't throw - this is a non-critical operation + } + } + + private func loadFilesFromDefaults() { + guard let data = UserDefaults.standard.data(forKey: "ScreenAssistantFiles"), + let decoded = try? JSONDecoder().decode([ScreenAssistantFile].self, from: data) else { + return + } + + attachedFiles = decoded + } + + // MARK: - Chat Management + + func sendMessage(_ message: String) { + print("📤 ScreenAssistant: Sending message - '\(message)'") + print("📁 ScreenAssistant: Attached files count: \(attachedFiles.count)") + + // Add user message to chat + let userMessage = ChatMessage(content: message, isFromUser: true, attachedFiles: attachedFiles.isEmpty ? nil : attachedFiles) + chatMessages.append(userMessage) + + // Print attached files details + for (index, file) in attachedFiles.enumerated() { + print("📎 ScreenAssistant: File \(index + 1): \(file.name) (\(file.type.displayName))") + } + + // Clear input and files after sending + let currentFiles = attachedFiles + clearAllFiles() + + // Send to appropriate AI API based on selected provider + let provider = Defaults[.selectedAIProvider] + sendToAI(message: message, files: currentFiles, provider: provider) + } + + private func sendToAI(message: String, files: [ScreenAssistantFile], provider: AIModelProvider) { + print("🚀 ScreenAssistant: Making API request to \(provider.displayName)") + isLoading = true + + switch provider { + case .gemini: + sendToGeminiAPI(message: message, files: files) + case .openai: + sendToOpenAIAPI(message: message, files: files) + case .claude: + sendToClaudeAPI(message: message, files: files) + case .local: + sendToLocalAPI(message: message, files: files) + case .groq: + sendToGroqAPI(message: message, files: files) + } + } + + private func sendToGeminiAPI(message: String, files: [ScreenAssistantFile]) { + let apiKey = Defaults[.geminiApiKey] + guard !apiKey.isEmpty else { + print("❌ ScreenAssistant: No Gemini API key configured") + addAssistantMessage("Error: No Gemini API key configured. Please set your API key in model settings.") + isLoading = false + return + } + + // Get selected model or default to gemini-2.5-flash + let selectedModel = Defaults[.selectedAIModel] ?? AIModel(id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", supportsThinking: true) + let modelId = selectedModel.id + + guard let url = URL(string: "https://generativelanguage.googleapis.com/v1beta/models/\(modelId):generateContent?key=\(apiKey)") else { + print("❌ ScreenAssistant: Invalid Gemini API URL") + addAssistantMessage("Error: Invalid API URL") + isLoading = false + return + } + + performAPIRequest(url: url, requestBody: buildGeminiRequestBody(message: message, files: files), provider: .gemini) + } + + private func sendToOpenAIAPI(message: String, files: [ScreenAssistantFile]) { + let apiKey = Defaults[.openaiApiKey] + guard !apiKey.isEmpty else { + print("❌ ScreenAssistant: No OpenAI API key configured") + addAssistantMessage("Error: No OpenAI API key configured. Please set your API key in model settings.") + isLoading = false + return + } + + // Get selected model or default to gpt-4o + let selectedModel = Defaults[.selectedAIModel] ?? AIModel(id: "gpt-4o", name: "GPT-4o", supportsThinking: false) + let modelId = selectedModel.id + + guard let url = URL(string: "https://api.openai.com/v1/chat/completions") else { + print("❌ ScreenAssistant: Invalid OpenAI API URL") + addAssistantMessage("Error: Invalid API URL") + isLoading = false + return + } + + performOpenAIRequest(url: url, requestBody: buildOpenAIRequestBody(message: message, files: files, model: modelId), apiKey: apiKey) + } + + private func sendToGroqAPI(message: String, files: [ScreenAssistantFile]) { + let apiKey = Defaults[.groqApiKey] + guard !apiKey.isEmpty else { + print("❌ ScreenAssistant: No Groq API key configured") + addAssistantMessage("Error: No Groq API key configured. Please set your API key in model settings.") + isLoading = false + return + } + + // Get selected model or default to llama-3.3-70b-versatile + let selectedModel = Defaults[.selectedAIModel] + let modelId: String + if let selectedId = selectedModel?.id, + AIModelProvider.groq.supportedModels.contains(where: { $0.id == selectedId }) { + modelId = selectedId + } else { + modelId = "llama-3.3-70b-versatile" + } + + guard let url = URL(string: "https://api.groq.com/openai/v1/chat/completions") else { + print("❌ ScreenAssistant: Invalid Groq API URL") + addAssistantMessage("Error: Invalid API URL") + isLoading = false + return + } + + performOpenAIRequest( + url: url, + requestBody: buildOpenAIRequestBody(message: message, files: files, model: modelId), + apiKey: apiKey, + provider: .groq + ) + } + + private func sendToClaudeAPI(message: String, files: [ScreenAssistantFile]) { + let apiKey = Defaults[.claudeApiKey] + guard !apiKey.isEmpty else { + print("❌ ScreenAssistant: No Claude API key configured") + addAssistantMessage("Error: No Claude API key configured. Please set your API key in model settings.") + isLoading = false + return + } + + // Get selected model or default to claude-3-5-sonnet + let selectedModel = Defaults[.selectedAIModel] ?? AIModel(id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet", supportsThinking: false) + let modelId = selectedModel.id + + guard let url = URL(string: "https://api.anthropic.com/v1/messages") else { + print("❌ ScreenAssistant: Invalid Claude API URL") + addAssistantMessage("Error: Invalid API URL") + isLoading = false + return + } + + performClaudeRequest(url: url, requestBody: buildClaudeRequestBody(message: message, files: files, model: modelId), apiKey: apiKey) + } + + private func sendToLocalAPI(message: String, files: [ScreenAssistantFile]) { + let endpoint = Defaults[.localModelEndpoint] + guard !endpoint.isEmpty else { + print("❌ ScreenAssistant: No local endpoint configured") + addAssistantMessage("Error: No local endpoint configured. Please set your endpoint in model settings.") + isLoading = false + return + } + + guard let url = URL(string: "\(endpoint)/api/chat") else { + print("❌ ScreenAssistant: Invalid local API URL") + addAssistantMessage("Error: Invalid local API URL") + isLoading = false + return + } + + performAPIRequest(url: url, requestBody: buildOllamaRequestBody(message: message, files: files), provider: .local) + } + + // MARK: - API Request Builders + + private func buildGeminiRequestBody(message: String, files: [ScreenAssistantFile]) -> [String: Any] { + var contents: [[String: Any]] = [] + + // Add previous conversation messages (last 10 for context) + let recentMessages = Array(chatMessages.suffix(10)) + for chatMessage in recentMessages { + if chatMessage.id != chatMessages.last?.id { // Don't include the message we just added + let role = chatMessage.isFromUser ? "user" : "model" + contents.append([ + "role": role, + "parts": [["text": chatMessage.content]] + ]) + } + } + + // Build current message parts + var parts: [[String: Any]] = [] + + // Add text part + let contextualMessage = buildContextualMessage(message: message, files: files) + parts.append(["text": contextualMessage]) + + // Add file content for supported types using proper Gemini 2.5 APIs + for file in files { + if let filePart = createGeminiFilePart(for: file) { + parts.append(filePart) + print("📎 ScreenAssistant: Added file part for \(file.name)") + } + } + + // Add current message + contents.append([ + "role": "user", + "parts": parts + ]) + + var requestBody: [String: Any] = [ + "contents": contents, + "generationConfig": [ + "temperature": 0.7, + "topP": 0.8, + "topK": 40, + "maxOutputTokens": 2048, + "responseMimeType": "text/plain" + ], + "safetySettings": [ + [ + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_MEDIUM_AND_ABOVE" + ], + [ + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_MEDIUM_AND_ABOVE" + ], + [ + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_MEDIUM_AND_ABOVE" + ], + [ + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_MEDIUM_AND_ABOVE" + ] + ] + ] + + // Add thinking configuration if enabled and model supports it + let selectedModel = Defaults[.selectedAIModel] + if selectedModel?.supportsThinking == true && Defaults[.enableThinkingMode] { + requestBody["generationConfig"] = (requestBody["generationConfig"] as! [String: Any]).merging([ + "thinkingConfig": [ + "thinkingBudget": 0 // 0 means unlimited thinking + ] + ]) { (_, new) in new } + } + + return requestBody + } + + private func buildOpenAIRequestBody(message: String, files: [ScreenAssistantFile], model: String) -> [String: Any] { + var messages: [[String: Any]] = [] + + // Add previous conversation messages (last 10 for context) + let recentMessages = Array(chatMessages.suffix(10)) + for chatMessage in recentMessages { + if chatMessage.id != chatMessages.last?.id { + let role = chatMessage.isFromUser ? "user" : "assistant" + messages.append([ + "role": role, + "content": chatMessage.content + ]) + } + } + + // Add current message + let contextualMessage = buildContextualMessage(message: message, files: files) + messages.append([ + "role": "user", + "content": contextualMessage + ]) + + return [ + "model": model, + "messages": messages, + "temperature": 0.7, + "max_tokens": 2048 + ] + } + + private func buildClaudeRequestBody(message: String, files: [ScreenAssistantFile], model: String) -> [String: Any] { + var messages: [[String: Any]] = [] + + // Add previous conversation messages (last 10 for context) + let recentMessages = Array(chatMessages.suffix(10)) + for chatMessage in recentMessages { + if chatMessage.id != chatMessages.last?.id { + let role = chatMessage.isFromUser ? "user" : "assistant" + messages.append([ + "role": role, + "content": chatMessage.content + ]) + } + } + + // Add current message + let contextualMessage = buildContextualMessage(message: message, files: files) + messages.append([ + "role": "user", + "content": contextualMessage + ]) + + return [ + "model": model, + "max_tokens": 2048, + "messages": messages + ] + } + + private func buildOllamaRequestBody(message: String, files: [ScreenAssistantFile]) -> [String: Any] { + let selectedModel = Defaults[.selectedAIModel] ?? AIModel(id: "llama3.2", name: "Llama 3.2", supportsThinking: false) + let contextualMessage = buildContextualMessage(message: message, files: files) + + return [ + "model": selectedModel.id, + "messages": [ + [ + "role": "user", + "content": contextualMessage + ] + ], + "stream": false + ] + } + + // MARK: - API Request Performers + + private func performAPIRequest(url: URL, requestBody: [String: Any], provider: AIModelProvider) { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + do { + let jsonData = try JSONSerialization.data(withJSONObject: requestBody, options: .prettyPrinted) + request.httpBody = jsonData + + print("📋 ScreenAssistant: Request body size: \(jsonData.count) bytes") + } catch { + print("❌ ScreenAssistant: Failed to encode request - \(error)") + addAssistantMessage("Error: Failed to encode request - \(error.localizedDescription)") + isLoading = false + return + } + + var task: URLSessionDataTask? + task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + DispatchQueue.main.async { + guard let self = self else { return } + guard let currentTask = task else { return } + + // Ensure this callback belongs to the current in-flight request + guard self.activeRequest === currentTask else { return } + + self.isLoading = false + self.activeRequest = nil + + self.handleResponse(data: data, response: response, error: error, provider: provider) + } + } + + activeRequest = task + task?.resume() + } + + private func performOpenAIRequest(url: URL, requestBody: [String: Any], apiKey: String, provider: AIModelProvider = .openai) { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + do { + let jsonData = try JSONSerialization.data(withJSONObject: requestBody, options: .prettyPrinted) + request.httpBody = jsonData + } catch { + print("❌ ScreenAssistant: Failed to encode OpenAI request - \(error)") + addAssistantMessage("Error: Failed to encode request - \(error.localizedDescription)") + isLoading = false + return + } + + var task: URLSessionDataTask? + task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + DispatchQueue.main.async { + guard let self = self else { return } + guard let currentTask = task else { return } + + // Ensure this callback belongs to the current in-flight request + guard self.activeRequest === currentTask else { return } + + self.isLoading = false + self.activeRequest = nil + + self.handleResponse(data: data, response: response, error: error, provider: provider) + } + } + + activeRequest = task + task?.resume() + } + + private func performClaudeRequest(url: URL, requestBody: [String: Any], apiKey: String) { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue(apiKey, forHTTPHeaderField: "x-api-key") + request.addValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + + do { + let jsonData = try JSONSerialization.data(withJSONObject: requestBody, options: .prettyPrinted) + request.httpBody = jsonData + } catch { + print("❌ ScreenAssistant: Failed to encode Claude request - \(error)") + addAssistantMessage("Error: Failed to encode request - \(error.localizedDescription)") + isLoading = false + return + } + + var task: URLSessionDataTask? + task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + DispatchQueue.main.async { + guard let self = self else { return } + guard let currentTask = task else { return } + + // Ensure this callback belongs to the current in-flight request + guard self.activeRequest === currentTask else { return } + + self.isLoading = false + self.activeRequest = nil + + self.handleResponse(data: data, response: response, error: error, provider: .claude) + } + } + + activeRequest = task + task?.resume() + } + + // MARK: - Response Handlers + + private func handleResponse(data: Data?, response: URLResponse?, error: Error?, provider: AIModelProvider) { + // Check if the request was cancelled (e.g., by resetConversationContext) + if let error = error as? NSError, error.code == NSURLErrorCancelled { + print("ℹ️ ScreenAssistant: Request was cancelled") + return + } + + if let error = error { + print("❌ ScreenAssistant: Network error - \(error)") + addAssistantMessage("Error: \(error.localizedDescription)") + return + } + + if let httpResponse = response as? HTTPURLResponse { + print("📊 ScreenAssistant: HTTP Status: \(httpResponse.statusCode)") + if httpResponse.statusCode != 200 { + handleAPIError(statusCode: httpResponse.statusCode, provider: provider) + return + } + } + + guard let data = data else { + print("❌ ScreenAssistant: No response data") + addAssistantMessage("Error: No response data") + return + } + + print("📨 ScreenAssistant: Response data size: \(data.count) bytes") + + // Parse response based on provider + switch provider { + case .gemini: + parseGeminiResponse(data: data) + case .openai, .groq: + parseOpenAIResponse(data: data) + case .claude: + parseClaudeResponse(data: data) + case .local: + parseOllamaResponse(data: data) + } + } + + private func parseGeminiResponse(data: Data) { + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + print("✅ ScreenAssistant: Successfully parsed Gemini JSON response") + + if let candidates = json["candidates"] as? [[String: Any]], + let firstCandidate = candidates.first, + let content = firstCandidate["content"] as? [String: Any], + let parts = content["parts"] as? [[String: Any]], + let firstPart = parts.first, + let text = firstPart["text"] as? String { + + print("✅ ScreenAssistant: Got Gemini response text: \(text.prefix(100))...") + addAssistantMessage(text) + } else { + if let error = json["error"] as? [String: Any] { + handleAPIError(error: error) + } else { + print("❌ ScreenAssistant: Unexpected Gemini response format") + addAssistantMessage("Error: Unexpected response format from Gemini") + } + } + } + } catch { + print("❌ ScreenAssistant: Gemini JSON parsing error - \(error)") + addAssistantMessage("Error: Failed to parse response - \(error.localizedDescription)") + } + } + + private func parseOpenAIResponse(data: Data) { + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + print("✅ ScreenAssistant: Successfully parsed OpenAI JSON response") + + if let choices = json["choices"] as? [[String: Any]], + let firstChoice = choices.first, + let message = firstChoice["message"] as? [String: Any], + let content = message["content"] as? String { + + print("✅ ScreenAssistant: Got OpenAI response text: \(content.prefix(100))...") + addAssistantMessage(content) + } else { + if let error = json["error"] as? [String: Any] { + handleOpenAIError(error: error) + } else { + print("❌ ScreenAssistant: Unexpected OpenAI response format") + addAssistantMessage("Error: Unexpected response format from OpenAI") + } + } + } + } catch { + print("❌ ScreenAssistant: OpenAI JSON parsing error - \(error)") + addAssistantMessage("Error: Failed to parse response - \(error.localizedDescription)") + } + } + + private func parseClaudeResponse(data: Data) { + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + print("✅ ScreenAssistant: Successfully parsed Claude JSON response") + + if let content = json["content"] as? [[String: Any]], + let firstContent = content.first, + let text = firstContent["text"] as? String { + + print("✅ ScreenAssistant: Got Claude response text: \(text.prefix(100))...") + addAssistantMessage(text) + } else { + if let error = json["error"] as? [String: Any] { + handleClaudeError(error: error) + } else { + print("❌ ScreenAssistant: Unexpected Claude response format") + addAssistantMessage("Error: Unexpected response format from Claude") + } + } + } + } catch { + print("❌ ScreenAssistant: Claude JSON parsing error - \(error)") + addAssistantMessage("Error: Failed to parse response - \(error.localizedDescription)") + } + } + + private func parseOllamaResponse(data: Data) { + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + print("✅ ScreenAssistant: Successfully parsed Ollama JSON response") + + if let message = json["message"] as? [String: Any], + let content = message["content"] as? String { + + print("✅ ScreenAssistant: Got Ollama response text: \(content.prefix(100))...") + addAssistantMessage(content) + } else { + print("❌ ScreenAssistant: Unexpected Ollama response format") + addAssistantMessage("Error: Unexpected response format from local model") + } + } + } catch { + print("❌ ScreenAssistant: Ollama JSON parsing error - \(error)") + addAssistantMessage("Error: Failed to parse response - \(error.localizedDescription)") + } + } + + private func buildContextualMessage(message: String, files: [ScreenAssistantFile]) -> String { + var contextualMessage = message + + // Add file context with specific instructions for different types + if !files.isEmpty { + contextualMessage += "\n\nI have attached the following files for your analysis:" + + var hasImages = false + var hasDocuments = false + var hasAudio = false + var hasVideo = false + + for file in files { + contextualMessage += "\n- \(file.name) (\(file.type.displayName))" + + switch file.type { + case .image: hasImages = true + case .document: hasDocuments = true + case .audio: hasAudio = true + case .video: hasVideo = true + case .other: break + } + } + + // Add specific instructions based on file types + contextualMessage += "\n\nPlease analyze these files in the context of my question. Specifically:" + + if hasImages { + contextualMessage += "\n- For images: Describe what you see, identify objects, text, or patterns, and relate them to my question." + } + + if hasDocuments { + contextualMessage += "\n- For documents: Read and understand the content, extract key information, and provide insights relevant to my question." + } + + if hasAudio { + contextualMessage += "\n- For audio: Listen to and transcribe the audio content, identify speakers, topics, or sounds as relevant." + } + + if hasVideo { + contextualMessage += "\n- For video: Analyze both visual and audio content, describe actions, scenes, or dialogue as applicable." + } + + contextualMessage += "\n\nProvide comprehensive insights that combine information from all attached files with your response to my question." + } + + return contextualMessage + } + + private func createGeminiFilePart(for file: ScreenAssistantFile) -> [String: Any]? { + print("📎 ScreenAssistant: Processing file for Gemini 2.5: \(file.name) (\(file.type.displayName))") + + guard let fileURL = file.fileURL, let url = URL(string: fileURL) else { + print("❌ ScreenAssistant: No valid URL for file \(file.name)") + return ["text": "File: \(file.name) (no valid URL)"] + } + + switch file.type { + case .image: + return createGeminiImagePart(for: url, fileName: file.name) + case .document: + return createGeminiDocumentPart(for: url, fileName: file.name) + case .audio: + return createGeminiAudioPart(for: url, fileName: file.name) + case .video: + return createGeminiVideoPart(for: url, fileName: file.name) + case .other: + return createGeminiTextPart(for: url, fileName: file.name) + } + } + + private func createGeminiImagePart(for url: URL, fileName: String) -> [String: Any]? { + print("🖼️ ScreenAssistant: Processing image file: \(fileName)") + + do { + let imageData = try Data(contentsOf: url) + let base64String = imageData.base64EncodedString() + + // Determine MIME type + let mimeType: String + let pathExtension = url.pathExtension.lowercased() + switch pathExtension { + case "jpg", "jpeg": + mimeType = "image/jpeg" + case "png": + mimeType = "image/png" + case "gif": + mimeType = "image/gif" + case "webp": + mimeType = "image/webp" + case "heic": + mimeType = "image/heic" + default: + mimeType = "image/jpeg" + } + + print("📎 ScreenAssistant: Image encoded - \(base64String.count) bytes, MIME: \(mimeType)") + + return [ + "inline_data": [ + "mime_type": mimeType, + "data": base64String + ] + ] + } catch { + print("❌ ScreenAssistant: Failed to encode image \(fileName): \(error)") + return ["text": "Image file: \(fileName) (failed to encode: \(error.localizedDescription))"] + } + } + + private func createGeminiDocumentPart(for url: URL, fileName: String) -> [String: Any]? { + print("📄 ScreenAssistant: Processing document file: \(fileName)") + + let pathExtension = url.pathExtension.lowercased() + + if pathExtension == "pdf" { + // Handle PDF files using base64 encoding for Gemini 2.5 + do { + let pdfData = try Data(contentsOf: url) + let base64String = pdfData.base64EncodedString() + + print("📎 ScreenAssistant: PDF encoded - \(base64String.count) bytes") + + return [ + "inline_data": [ + "mime_type": "application/pdf", + "data": base64String + ] + ] + } catch { + print("❌ ScreenAssistant: Failed to encode PDF \(fileName): \(error)") + return ["text": "PDF file: \(fileName) (failed to encode: \(error.localizedDescription))"] + } + } else { + // Handle text-based documents + do { + let content = try String(contentsOf: url) + print("📄 ScreenAssistant: Read document content (\(content.count) characters)") + return ["text": "File content of \(fileName):\n\(content)"] + } catch { + print("❌ ScreenAssistant: Failed to read document \(fileName): \(error)") + return ["text": "Document file: \(fileName) (could not read content: \(error.localizedDescription))"] + } + } + } + + private func createGeminiAudioPart(for url: URL, fileName: String) -> [String: Any]? { + print("🎵 ScreenAssistant: Processing audio file: \(fileName)") + + do { + let audioData = try Data(contentsOf: url) + let base64String = audioData.base64EncodedString() + + // Determine MIME type + let mimeType: String + let pathExtension = url.pathExtension.lowercased() + switch pathExtension { + case "mp3": + mimeType = "audio/mpeg" + case "wav": + mimeType = "audio/wav" + case "m4a": + mimeType = "audio/mp4" + case "aac": + mimeType = "audio/aac" + case "flac": + mimeType = "audio/flac" + default: + mimeType = "audio/mpeg" + } + + print("� ScreenAssistant: Audio encoded - \(base64String.count) bytes, MIME: \(mimeType)") + + return [ + "inline_data": [ + "mime_type": mimeType, + "data": base64String + ] + ] + } catch { + print("❌ ScreenAssistant: Failed to encode audio \(fileName): \(error)") + return ["text": "Audio file: \(fileName) (failed to encode: \(error.localizedDescription))"] + } + } + + private func createGeminiVideoPart(for url: URL, fileName: String) -> [String: Any]? { + print("� ScreenAssistant: Processing video file: \(fileName)") + + do { + let videoData = try Data(contentsOf: url) + let base64String = videoData.base64EncodedString() + + // Determine MIME type + let mimeType: String + let pathExtension = url.pathExtension.lowercased() + switch pathExtension { + case "mp4": + mimeType = "video/mp4" + case "mov": + mimeType = "video/quicktime" + case "avi": + mimeType = "video/x-msvideo" + case "mkv": + mimeType = "video/x-matroska" + default: + mimeType = "video/mp4" + } + + print("📎 ScreenAssistant: Video encoded - \(base64String.count) bytes, MIME: \(mimeType)") + + return [ + "inline_data": [ + "mime_type": mimeType, + "data": base64String + ] + ] + } catch { + print("❌ ScreenAssistant: Failed to encode video \(fileName): \(error)") + return ["text": "Video file: \(fileName) (failed to encode: \(error.localizedDescription))"] + } + } + + private func createGeminiTextPart(for url: URL, fileName: String) -> [String: Any]? { + print("📝 ScreenAssistant: Processing text file: \(fileName)") + + do { + let content = try String(contentsOf: url) + print("📄 ScreenAssistant: Read text content (\(content.count) characters)") + return ["text": "File content of \(fileName):\n\(content)"] + } catch { + print("❌ ScreenAssistant: Failed to read text file \(fileName): \(error)") + return ["text": "File: \(fileName) (could not read content: \(error.localizedDescription))"] + } + } + + func clearChat() { + resetConversationContext() + } + + func resetConversationContext() { + // Cancel any in-flight request + activeRequest?.cancel() + activeRequest = nil + + isLoading = false + chatMessages.removeAll() + clearAllFiles() + } + + private func addAssistantMessage(_ content: String) { + print("💬 ScreenAssistant: Adding assistant message: \(content.prefix(100))...") + let assistantMessage = ChatMessage(content: content, isFromUser: false) + chatMessages.append(assistantMessage) + } + + private func handleAPIError(statusCode: Int, provider: AIModelProvider) { + let userFriendlyMessage: String + + switch statusCode { + case 429: + userFriendlyMessage = "🚫 **Rate Limited**\n\n\(provider.displayName) is currently rate limiting requests. Please wait a moment and try again." + case 400: + userFriendlyMessage = "❌ **Invalid Request**\n\nThere was an issue with your request to \(provider.displayName). Please check your message and attached files." + case 401: + userFriendlyMessage = "🔑 **Authentication Error**\n\nYour \(provider.displayName) API key appears to be invalid. Please check your API key in model settings." + case 403: + userFriendlyMessage = "🚫 **Access Denied**\n\nYour \(provider.displayName) API key doesn't have permission for this request." + case 404: + userFriendlyMessage = "🔍 **Model Not Found**\n\nThe requested model is not available on \(provider.displayName)." + case 500, 502, 503: + userFriendlyMessage = "⚠️ **Server Error**\n\n\(provider.displayName) servers are experiencing issues. Please try again in a few minutes." + default: + userFriendlyMessage = "❌ **API Error (\(statusCode))**\n\n\(provider.displayName) returned an error. Please try again." + } + + addAssistantMessage(userFriendlyMessage) + } + + private func handleAPIError(error: [String: Any]) { + guard let code = error["code"] as? Int, + let message = error["message"] as? String else { + print("❌ ScreenAssistant: Unknown API Error") + addAssistantMessage("An unknown error occurred. Please try again.") + return + } + + print("❌ ScreenAssistant: API Error \(code) - \(message)") + + let userFriendlyMessage: String + + switch code { + case 429: + // Quota exceeded + if message.contains("quota") || message.contains("exceeded") { + userFriendlyMessage = "🚫 **API Quota Exceeded**\n\nYou've reached your API usage limit. This usually happens when:\n\n• Too many requests in a short time\n• Daily/monthly quota exceeded\n• Free tier limits reached\n\n**What you can do:**\n• Wait a few minutes and try again\n• Check your API billing\n• Consider upgrading your plan\n\n*The system will work again once the quota resets.*" + } else { + userFriendlyMessage = "⏰ **Rate Limited**\n\nToo many requests. Please wait a moment and try again." + } + + case 400: + userFriendlyMessage = "❌ **Invalid Request**\n\nThere was an issue with your request. Please check your message and attached files." + + case 401: + userFriendlyMessage = "🔑 **Authentication Error**\n\nYour API key appears to be invalid. Please check your API key in settings." + + case 403: + userFriendlyMessage = "🚫 **Access Denied**\n\nYour API key doesn't have permission for this request. Please check your API key settings." + + case 404: + userFriendlyMessage = "🔍 **Model Not Found**\n\nThe requested AI model is not available. Please try again later." + + case 500, 502, 503: + userFriendlyMessage = "⚠️ **Server Error**\n\nThe AI service is experiencing issues. Please try again in a few minutes." + + default: + userFriendlyMessage = "❌ **API Error (\(code))**\n\n\(message.components(separatedBy: ".").first ?? message)" + } + + addAssistantMessage(userFriendlyMessage) + } + + private func handleOpenAIError(error: [String: Any]) { + if let message = error["message"] as? String { + let userFriendlyMessage = "❌ **OpenAI Error**\n\n\(message)" + addAssistantMessage(userFriendlyMessage) + } else { + addAssistantMessage("❌ **OpenAI Error**\n\nAn unknown error occurred with OpenAI.") + } + } + + private func handleClaudeError(error: [String: Any]) { + if let message = error["message"] as? String { + let userFriendlyMessage = "❌ **Claude Error**\n\n\(message)" + addAssistantMessage(userFriendlyMessage) + } else { + addAssistantMessage("❌ **Claude Error**\n\nAn unknown error occurred with Claude.") + } + } +} + +// MARK: - AVAudioRecorderDelegate + +extension ScreenAssistantManager: AVAudioRecorderDelegate { + func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { + isRecording = false + recordingTimer?.invalidate() + recordingTimer = nil + + if flag { + let fileName = recorder.url.lastPathComponent + let displayName = "Recording \(DateFormatter.shortTime.string(from: Date()))" + let audioFile = ScreenAssistantFile(audioFileName: fileName, name: displayName) + attachedFiles.append(audioFile) + saveFilesToDefaults() + print("Recording saved: \(fileName)") + } else { + print("Recording failed") + } + } + + func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { + print("Recording encode error: \(error?.localizedDescription ?? "Unknown error")") + isRecording = false + recordingTimer?.invalidate() + recordingTimer = nil + } +} + +// MARK: - DateFormatter Extension + +extension DateFormatter { + static let shortTime: DateFormatter = { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter + }() +} diff --git a/DynamicIsland/managers/ScreenAssistantPanelManager.swift b/DynamicIsland/managers/ScreenAssistantPanelManager.swift new file mode 100644 index 00000000..5e18565c --- /dev/null +++ b/DynamicIsland/managers/ScreenAssistantPanelManager.swift @@ -0,0 +1,69 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI +import Defaults + +class ScreenAssistantPanelManager: ObservableObject { + static let shared = ScreenAssistantPanelManager() + + // Backward compatibility wrapper - delegates to the new ScreenAssistantManager + private var screenAssistantPanel: ScreenAssistantPanel? + + private init() {} + + func showScreenAssistantPanel() { + hideScreenAssistantPanel() // Close any existing panel + + // Use the new dual-panel system + ScreenAssistantManager.shared.showPanels() + + // Create a dummy panel for backward compatibility + let panel = ScreenAssistantPanel() + self.screenAssistantPanel = panel + + // The actual panels are managed by ScreenAssistantManager + // This is just for compatibility with existing code + + print("ScreenAssistant: New dual-panel system activated") + } + + func hideScreenAssistantPanel() { + // Close the new panel system + ScreenAssistantManager.shared.closePanels() + + // Clean up compatibility panel + screenAssistantPanel?.close() + screenAssistantPanel = nil + + print("ScreenAssistant: Panels hidden") + } + + func toggleScreenAssistantPanel() { + if ScreenAssistantManager.shared.arePanelsVisible() { + hideScreenAssistantPanel() + } else { + showScreenAssistantPanel() + } + } + + var isPanelVisible: Bool { + return ScreenAssistantManager.shared.arePanelsVisible() + } +} \ No newline at end of file diff --git a/DynamicIsland/managers/ScreenRecordingManager.swift b/DynamicIsland/managers/ScreenRecordingManager.swift new file mode 100644 index 00000000..7411cb52 --- /dev/null +++ b/DynamicIsland/managers/ScreenRecordingManager.swift @@ -0,0 +1,275 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Combine +import AppKit +import Defaults +import SwiftUI + +// MARK: - Private API Declarations +// These private APIs provide direct screen capture detection +// Use at your own risk - may break in future macOS versions + +@_silgen_name("CGSIsScreenWatcherPresent") +func CGSIsScreenWatcherPresent() -> Bool + +@_silgen_name("CGSRegisterNotifyProc") +func CGSRegisterNotifyProc( + _ callback: (@convention(c) (Int32, Int32, Int32, UnsafeMutableRawPointer?) -> Void)?, + _ event: Int32, + _ context: UnsafeMutableRawPointer? +) -> Bool + +// MARK: - Global Callback Function +// C function pointer cannot capture context, so we need a global function +private func screenCaptureEventCallback(eventType: Int32, _: Int32, _: Int32, context: UnsafeMutableRawPointer?) { + guard let context = context else { return } + let manager = Unmanaged.fromOpaque(context).takeUnretainedValue() + + DispatchQueue.main.async { + print("ScreenRecordingManager: 📢 Screen capture event received (type: \(eventType))") + manager.checkRecordingStatus() + } +} + +@MainActor +class ScreenRecordingManager: ObservableObject { + static let shared = ScreenRecordingManager() + + // MARK: - Coordinator + private let coordinator = DynamicIslandViewCoordinator.shared + + // MARK: - Published Properties + @Published var isRecording: Bool = false + @Published var isMonitoring: Bool = false + @Published var recordingDuration: TimeInterval = 0 + @Published var isRecorderIdle: Bool = true + @Published var lastUpdated: Date = .distantPast + + // MARK: - Private Properties + private var cancellables = Set() + private var recordingStartTime: Date? + private var durationTimer: Timer? + private var debounceIdleTask: Task? + + // MARK: - Configuration + private let debounceDelay: TimeInterval = 0.2 // Debounce rapid changes + + // MARK: - Initialization + private init() { + // No initial setup needed + } + + deinit { + // Clean up monitoring state + // Note: We can't call async methods in deinit, so we just clean up local state + debounceIdleTask?.cancel() + durationTimer?.invalidate() + } + + // MARK: - Public Methods + + /// Start monitoring for screen recording activity + func startMonitoring() { + guard !isMonitoring else { + print("ScreenRecordingManager: Already monitoring, skipping start") + return + } + + isMonitoring = true + + print("ScreenRecordingManager: 🟢 Starting screen capture monitoring (Private API)...") + + // Setup event-driven capture detection using private CoreGraphics APIs + setupPrivateAPINotifications() + + // Check initial state + checkRecordingStatus() + + print("ScreenRecordingManager: ✅ Started monitoring (event-driven, no polling)") + } + + /// Stop monitoring for screen recording activity + func stopMonitoring() { + guard isMonitoring else { + print("ScreenRecordingManager: Not monitoring, skipping stop") + return + } + + print("ScreenRecordingManager: 🛑 Stopping monitoring...") + + isMonitoring = false + + // Note: We don't unregister the callback as there's no CGSUnregisterNotifyProc API + // The callback will simply not be processed when isMonitoring is false + + // Stop duration tracking + stopDurationTracking() + + // Reset recording state when stopping + if isRecording { + print("ScreenRecordingManager: Resetting isRecording from true to false") + } + isRecording = false + + print("ScreenRecordingManager: ✅ Stopped monitoring") + } + + /// Toggle monitoring state + func toggleMonitoring() { + if isMonitoring { + stopMonitoring() + } else { + startMonitoring() + } + } + + // MARK: - Private Methods + + /// Setup private API notifications for screen capture events + private func setupPrivateAPINotifications() { + // Pass self as context to the global callback function + let context = Unmanaged.passUnretained(self).toOpaque() + + // Register for remote session events (screen capture start/stop) + // kCGSessionRemoteConnect - fires when screen sharing/recording starts + let registered1 = CGSRegisterNotifyProc(screenCaptureEventCallback, 1502, context) + + // kCGSessionRemoteDisconnect - fires when screen sharing/recording stops + let registered2 = CGSRegisterNotifyProc(screenCaptureEventCallback, 1503, context) + + if registered1 && registered2 { + print("ScreenRecordingManager: ✅ Private API notifications registered") + } else { + print("ScreenRecordingManager: ⚠️ Failed to register private API notifications") + } + } + + /// Check current recording status using private API + func checkRecordingStatus() { + let currentRecordingState = CGSIsScreenWatcherPresent() + + // Debug: Always log current check + print("ScreenRecordingManager: 🔍 Checking... current=\(isRecording), detected=\(currentRecordingState)") + + // Debounce changes to avoid flickering + if currentRecordingState != isRecording { + print("ScreenRecordingManager: 🔄 State change detected (\(isRecording) -> \(currentRecordingState))") + + if currentRecordingState && !isRecording { + // Started recording + lastUpdated = Date() + startDurationTracking() + updateIdleState(recording: true) + // Trigger expanding view like music activity + coordinator.toggleExpandingView(status: true, type: .recording) + withAnimation(.smooth) { + isRecording = currentRecordingState + } + print("ScreenRecordingManager: 🔴 Screen recording STARTED") + } else if !currentRecordingState && isRecording { + // Stopped recording - let expanding view auto-collapse naturally (like music) + lastUpdated = Date() + stopDurationTracking() + updateIdleState(recording: false) + withAnimation(.smooth) { + isRecording = currentRecordingState + } + print("ScreenRecordingManager: ⚪ Screen recording STOPPED") + } + } + } + + /// Start tracking recording duration + private func startDurationTracking() { + recordingStartTime = Date() + recordingDuration = 0 + + durationTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.updateDuration() + } + } + + print("ScreenRecordingManager: ⏱️ Started duration tracking") + } + + /// Stop tracking recording duration + private func stopDurationTracking() { + durationTimer?.invalidate() + durationTimer = nil + recordingStartTime = nil + + // Keep the last duration for a moment before resetting + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.recordingDuration = 0 + } + + print("ScreenRecordingManager: ⏹️ Stopped duration tracking") + } + + /// Update the current recording duration + private func updateDuration() { + guard let startTime = recordingStartTime else { return } + recordingDuration = Date().timeIntervalSince(startTime) + } + + /// Copy EXACT music idle state logic + private func updateIdleState(recording: Bool) { + if recording { + isRecorderIdle = false + debounceIdleTask?.cancel() + } else { + debounceIdleTask?.cancel() + debounceIdleTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(Defaults[.waitInterval])) + guard let self = self, !Task.isCancelled else { return } + await MainActor.run { + if self.lastUpdated.timeIntervalSinceNow < -Defaults[.waitInterval] { + withAnimation { + self.isRecorderIdle = !self.isRecording + } + } + } + } + } + } +} + +// MARK: - Extensions + +extension ScreenRecordingManager { + /// Get current recording status without async + var currentRecordingStatus: Bool { + return isRecording + } + + /// Check if monitoring is available (for settings UI) + var isMonitoringAvailable: Bool { + return true // Window-based monitoring is always available + } + + /// Get formatted recording duration string + var formattedDuration: String { + let totalSeconds = Int(recordingDuration) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return String(format: "%d:%02d", minutes, seconds) + } +} \ No newline at end of file diff --git a/DynamicIsland/managers/SpotifyAuthManager.swift b/DynamicIsland/managers/SpotifyAuthManager.swift new file mode 100644 index 00000000..f7334523 --- /dev/null +++ b/DynamicIsland/managers/SpotifyAuthManager.swift @@ -0,0 +1,343 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import CryptoKit +import Defaults +import Foundation + +extension Notification.Name { + static let spotifyCanvasSessionDidChange = Notification.Name("SpotifyCanvasSessionDidChange") +} + +@MainActor +final class SpotifyAuthManager: ObservableObject { + static let shared = SpotifyAuthManager() + + @Published private(set) var isAuthenticated = false + @Published private(set) var isAuthorizing = false + @Published private(set) var authErrorMessage: String? + @Published private(set) var sessionStatusText = "Spotify cookie not configured." + + private let secretsURL = URL(string: "https://raw.githubusercontent.com/xyloflake/spot-secrets-go/main/secrets/secretDict.json")! + private let tokenURL = URL(string: "https://open.spotify.com/api/token")! + private let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" + + private init() { + restorePersistedState() + } + + var configuredCookie: String { + Self.sanitizeCookie(Defaults[.spotifySPDCCookie]) + } + + var hasConfiguredCookie: Bool { + !configuredCookie.isEmpty + } + + func validateSession() async { + let sanitizedCookie = configuredCookie + Defaults[.spotifySPDCCookie] = sanitizedCookie + clearCachedToken() + Defaults[.spotifyAuthLastValidatedAt] = 0 + authErrorMessage = nil + restorePersistedState() + + guard !sanitizedCookie.isEmpty else { + authErrorMessage = "Paste the Spotify sp_dc cookie first." + return + } + + isAuthorizing = true + defer { + isAuthorizing = false + restorePersistedState() + } + + guard await validAccessToken(forceRefresh: true) != nil else { + if authErrorMessage == nil { + authErrorMessage = "Spotify rejected the sp_dc cookie." + } + return + } + + authErrorMessage = nil + NotificationCenter.default.post(name: .spotifyCanvasSessionDidChange, object: nil) + } + + func clearSession() { + Defaults[.spotifySPDCCookie] = "" + clearCachedToken() + Defaults[.spotifyAuthLastValidatedAt] = 0 + authErrorMessage = nil + restorePersistedState() + NotificationCenter.default.post(name: .spotifyCanvasSessionDidChange, object: nil) + } + + func invalidateAccessToken() { + clearCachedToken() + restorePersistedState() + } + + func validAccessToken(forceRefresh: Bool = false) async -> String? { + let sanitizedCookie = configuredCookie + if sanitizedCookie != Defaults[.spotifySPDCCookie] { + Defaults[.spotifySPDCCookie] = sanitizedCookie + } + + guard !sanitizedCookie.isEmpty else { + restorePersistedState() + return nil + } + + let cachedToken = Defaults[.spotifyAuthAccessToken] + let cachedExpiration = Defaults[.spotifyAuthAccessTokenExpiration] + if !forceRefresh, !cachedToken.isEmpty, cachedExpiration > Date().timeIntervalSince1970 + 60 { + restorePersistedState() + return cachedToken + } + + do { + let response = try await fetchWebPlayerToken(spDC: sanitizedCookie) + Defaults[.spotifyAuthAccessToken] = response.accessToken + Defaults[.spotifyAuthAccessTokenExpiration] = response.expirationDate.timeIntervalSince1970 + Defaults[.spotifyAuthLastValidatedAt] = Date().timeIntervalSince1970 + authErrorMessage = nil + restorePersistedState() + return response.accessToken + } catch { + clearCachedToken() + Defaults[.spotifyAuthLastValidatedAt] = 0 + authErrorMessage = error.localizedDescription + restorePersistedState() + return nil + } + } + + private func fetchWebPlayerToken(spDC: String) async throws -> SpotifyWebPlayerTokenResponse { + let secrets = try await fetchSecrets() + guard let latestSecret = latestSecretEntry(from: secrets) else { + throw SpotifyAuthError.missingSecrets + } + + let totp = try Self.generateTOTPCode(from: latestSecret.cipher) + + var components = URLComponents(url: tokenURL, resolvingAgainstBaseURL: false) + components?.queryItems = [ + URLQueryItem(name: "reason", value: "transport"), + URLQueryItem(name: "productType", value: "web-player"), + URLQueryItem(name: "totp", value: totp), + URLQueryItem(name: "totpServer", value: totp), + URLQueryItem(name: "totpVer", value: latestSecret.version) + ] + + guard let url = components?.url else { + throw SpotifyAuthError.invalidRequest + } + + var request = URLRequest(url: url) + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("https://open.spotify.com/", forHTTPHeaderField: "Referer") + request.setValue("WebPlayer", forHTTPHeaderField: "App-Platform") + request.setValue("sp_dc=\(spDC)", forHTTPHeaderField: "Cookie") + + let (data, response) = try await URLSession.shared.data(for: request) + try Self.validateHTTPResponse(response, data: data) + + let tokenResponse = try JSONDecoder().decode(SpotifyWebPlayerTokenResponse.self, from: data) + if tokenResponse.accessToken.isEmpty { + throw SpotifyAuthError.invalidTokenResponse + } + + return tokenResponse + } + + private func fetchSecrets() async throws -> [String: [Int]] { + var request = URLRequest(url: secretsURL) + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + + let (data, response) = try await URLSession.shared.data(for: request) + try Self.validateHTTPResponse(response, data: data) + return try JSONDecoder().decode([String: [Int]].self, from: data) + } + + private func latestSecretEntry(from secrets: [String: [Int]]) -> (version: String, cipher: [Int])? { + var latestVersion: String? + var latestCipher: [Int]? + var highestNumericVersion = Int.min + + for (version, cipher) in secrets { + guard let numericVersion = Int(version) else { continue } + if numericVersion > highestNumericVersion { + highestNumericVersion = numericVersion + latestVersion = version + latestCipher = cipher + } + } + + guard let latestVersion, let latestCipher else { + return nil + } + + return (latestVersion, latestCipher) + } + + private func clearCachedToken() { + Defaults[.spotifyAuthAccessToken] = "" + Defaults[.spotifyAuthAccessTokenExpiration] = 0 + } + + private func restorePersistedState() { + let cookie = configuredCookie + let hasToken = !Defaults[.spotifyAuthAccessToken].isEmpty + && Defaults[.spotifyAuthAccessTokenExpiration] > Date().timeIntervalSince1970 + 60 + let hasValidatedCookie = Defaults[.spotifyAuthLastValidatedAt] > 0 + + isAuthenticated = !cookie.isEmpty && (hasToken || hasValidatedCookie) + + if cookie.isEmpty { + sessionStatusText = "Spotify cookie not configured." + } else if hasToken { + sessionStatusText = "Spotify Canvas session ready." + } else if hasValidatedCookie { + sessionStatusText = "Cookie saved. The access token will refresh automatically." + } else { + sessionStatusText = "Cookie pasted. Validate it once to enable Canvas lookup." + } + } + + static func sanitizeCookie(_ rawValue: String) -> String { + var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + + if let range = value.range(of: "sp_dc=") { + value = String(value[range.upperBound...]) + } + + if let firstSemicolon = value.firstIndex(of: ";") { + value = String(value[.. String { + let transformed = cipher.enumerated().map { index, value in + value ^ ((index % 33) + 9) + } + + let secretString = transformed.map(String.init).joined() + guard let secretData = secretString.data(using: .utf8) else { + throw SpotifyAuthError.invalidSecretMaterial + } + + let counter = UInt64(Date().timeIntervalSince1970 / 30) + var counterBytes = counter.bigEndian + let counterData = Data(bytes: &counterBytes, count: MemoryLayout.size) + + let mac = HMAC.authenticationCode( + for: counterData, + using: SymmetricKey(data: secretData) + ) + let hashData = Data(mac) + let offset = Int(hashData.last.map { $0 & 0x0F } ?? 0) + let truncatedRange = offset..<(offset + 4) + + guard truncatedRange.upperBound <= hashData.count else { + throw SpotifyAuthError.invalidSecretMaterial + } + + let truncatedBytes = Array(hashData[truncatedRange]) + let truncated = + ((UInt32(truncatedBytes[0]) << 24) + | (UInt32(truncatedBytes[1]) << 16) + | (UInt32(truncatedBytes[2]) << 8) + | UInt32(truncatedBytes[3])) + & 0x7FFF_FFFF + + return String(format: "%06u", truncated % 1_000_000) + } + + private static func validateHTTPResponse(_ response: URLResponse, data: Data) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw SpotifyAuthError.invalidServerResponse + } + + guard (200..<300).contains(httpResponse.statusCode) else { + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = object["error"] as? String { + throw SpotifyAuthError.serverError(message) + } + + if let body = String(data: data, encoding: .utf8), !body.isEmpty { + throw SpotifyAuthError.serverError(body) + } + + throw SpotifyAuthError.httpFailure(httpResponse.statusCode) + } + } +} + +private struct SpotifyWebPlayerTokenResponse: Decodable { + let accessToken: String + let accessTokenExpirationTimestampMs: Double? + + var expirationDate: Date { + if let accessTokenExpirationTimestampMs { + return Date(timeIntervalSince1970: accessTokenExpirationTimestampMs / 1000) + } + return Date().addingTimeInterval(3300) + } + + private enum CodingKeys: String, CodingKey { + case accessToken + case accessTokenExpirationTimestampMs + } +} + +private enum SpotifyAuthError: LocalizedError { + case invalidRequest + case invalidServerResponse + case invalidTokenResponse + case invalidSecretMaterial + case missingSecrets + case httpFailure(Int) + case serverError(String) + + var errorDescription: String? { + switch self { + case .invalidRequest: + return "Unable to build the Spotify token request." + case .invalidServerResponse: + return "Spotify returned an invalid response." + case .invalidTokenResponse: + return "Spotify did not return a usable access token." + case .invalidSecretMaterial: + return "The Spotify TOTP secret could not be decoded." + case .missingSecrets: + return "No valid Spotify TOTP secret was found." + case let .httpFailure(statusCode): + return "Spotify token request failed with HTTP \(statusCode)." + case let .serverError(message): + return message + } + } +} diff --git a/DynamicIsland/managers/StatsManager.swift b/DynamicIsland/managers/StatsManager.swift new file mode 100644 index 00000000..7abe47ec --- /dev/null +++ b/DynamicIsland/managers/StatsManager.swift @@ -0,0 +1,1544 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Combine +import Defaults +import SwiftUI +import IOKit +import IOKit.ps +import IOKit.graphics +import Darwin +import AppKit +//import Network + +struct MemoryBreakdown: Equatable { + let totalBytes: UInt64 + let usedBytes: UInt64 + let freeBytes: UInt64 + let wiredBytes: UInt64 + let activeBytes: UInt64 + let inactiveBytes: UInt64 + let compressedBytes: UInt64 + let appBytes: UInt64 + let cacheBytes: UInt64 + let swap: MemorySwap + let pressure: MemoryPressure + + static let zero = MemoryBreakdown( + totalBytes: 0, + usedBytes: 0, + freeBytes: 0, + wiredBytes: 0, + activeBytes: 0, + inactiveBytes: 0, + compressedBytes: 0, + appBytes: 0, + cacheBytes: 0, + swap: .zero, + pressure: .unknown + ) + + var usedPercentage: Double { + guard totalBytes > 0 else { return 0 } + return Double(usedBytes) / Double(totalBytes) * 100 + } + + var freePercentage: Double { + guard totalBytes > 0 else { return 0 } + return Double(freeBytes) / Double(totalBytes) * 100 + } + + var appPercentage: Double { + guard totalBytes > 0 else { return 0 } + return Double(appBytes) / Double(totalBytes) * 100 + } + + var cachePercentage: Double { + guard totalBytes > 0 else { return 0 } + return Double(cacheBytes) / Double(totalBytes) * 100 + } +} + +struct MemorySwap: Equatable { + let totalBytes: UInt64 + let usedBytes: UInt64 + let freeBytes: UInt64 + + static let zero = MemorySwap(totalBytes: 0, usedBytes: 0, freeBytes: 0) +} + +enum MemoryPressureLevel: String, Equatable { + case normal + case warning + case critical +} + +struct MemoryPressure: Equatable { + let rawValue: Int + let level: MemoryPressureLevel + + static let unknown = MemoryPressure(rawValue: 0, level: .normal) +} + +struct CPUCoreUsage: Identifiable, Equatable { + let id: Int + let usage: Double +} + +// MARK: - GPU Helpers + +private final class GPUInfoCollector { + func collectDevices() -> [GPUDeviceMetrics] { + var devices: [GPUDeviceMetrics] = [] + let matching = IOServiceMatching(kIOAcceleratorClassName) + var iterator: io_iterator_t = 0 + guard IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS else { + return devices + } + defer { IOObjectRelease(iterator) } + var index = 0 + var service = IOIteratorNext(iterator) + while service != 0 { + if let properties = copyProperties(for: service), + let device = makeDevice(from: properties, index: index) { + devices.append(device) + } + IOObjectRelease(service) + service = IOIteratorNext(iterator) + index += 1 + } + return devices + } + + private func copyProperties(for service: io_registry_entry_t) -> [String: Any]? { + var properties: Unmanaged? + guard IORegistryEntryCreateCFProperties(service, &properties, kCFAllocatorDefault, 0) == KERN_SUCCESS, + let dict = properties?.takeRetainedValue() as? [String: Any] else { + return nil + } + return dict + } + + private func makeDevice(from dict: [String: Any], index: Int) -> GPUDeviceMetrics? { + guard let ioClass = dict["IOClass"] as? String else { return nil } + let stats = dict["PerformanceStatistics"] as? [String: Any] ?? [:] + let vendor = vendorName(from: ioClass) ?? (dict["vendor"] as? String) + let model = sanitizedModel(primary: stats["model"] as? String, + secondary: dict["model"] as? String, + vendorFallback: vendor) + let id = "\(model)#\(index + 1)" + let utilization = percentValue(for: ["Device Utilization %", "GPU Activity(%)"], in: stats) + let renderUtilization = percentValue(for: ["Renderer Utilization %"], in: stats) + let tilerUtilization = percentValue(for: ["Tiler Utilization %"], in: stats) + let temperature = numericValue(for: ["Temperature(C)", "temperature"], in: stats) + let fanSpeed = intValue(for: ["Fan Speed(%)"], in: stats) + let coreClock = intValue(for: ["Core Clock(MHz)"], in: stats) + let memoryClock = intValue(for: ["Memory Clock(MHz)"], in: stats) + let cores = (dict["gpu-core-count"] as? NSNumber)?.intValue ?? (dict["Cores"] as? Int) + let isActive = isAcceleratorActive(from: dict) + return GPUDeviceMetrics( + id: id, + vendor: vendor, + model: model, + isActive: isActive, + utilization: utilization, + renderUtilization: renderUtilization, + tilerUtilization: tilerUtilization, + temperature: temperature, + fanSpeed: fanSpeed, + coreClock: coreClock, + memoryClock: memoryClock, + cores: cores + ) + } + + private func vendorName(from ioClass: String) -> String? { + let value = ioClass.lowercased() + if value.contains("nvidia") { + return "NVIDIA" + } else if value.contains("amd") { + return "AMD" + } else if value.contains("intel") { + return "Intel" + } else if value.contains("agx") || value.contains("apple") { + return "Apple" + } + return nil + } + + private func sanitizedModel(primary: String?, secondary: String?, vendorFallback: String?) -> String { + let normalizedPrimary = normalizedString(primary) + if let normalizedPrimary, !normalizedPrimary.isEmpty { + return normalizedPrimary + } + let normalizedSecondary = normalizedString(secondary) + if let normalizedSecondary, !normalizedSecondary.isEmpty { + return normalizedSecondary + } + if let vendorFallback, !vendorFallback.isEmpty { + return "\(vendorFallback) Graphics" + } + return "GPU" + } + + private func normalizedString(_ raw: String?) -> String? { + guard var value = raw else { return nil } + value = value.replacingOccurrences(of: "\0", with: "") + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private func percentValue(for keys: [String], in dict: [String: Any]) -> Double? { + for key in keys { + if let number = dict[key] as? NSNumber { + return clampPercent(number.doubleValue) + } + if let value = dict[key] as? Double { + return clampPercent(value) + } + if let value = dict[key] as? Int { + return clampPercent(Double(value)) + } + } + return nil + } + + private func numericValue(for keys: [String], in dict: [String: Any]) -> Double? { + for key in keys { + if let number = dict[key] as? NSNumber { + return number.doubleValue + } + if let value = dict[key] as? Double { + return value + } + if let value = dict[key] as? Int { + return Double(value) + } + } + return nil + } + + private func intValue(for keys: [String], in dict: [String: Any]) -> Int? { + for key in keys { + if let number = dict[key] as? NSNumber { + return number.intValue + } + if let value = dict[key] as? Int { + return value + } + } + return nil + } + + private func isAcceleratorActive(from dict: [String: Any]) -> Bool { + guard let agcInfo = dict["AGCInfo"] as? [String: Any] else { + return true + } + if let poweredOff = agcInfo["poweredOffByAGC"] as? NSNumber { + return poweredOff.intValue == 0 + } + if let poweredOff = agcInfo["poweredOffByAGC"] as? Int { + return poweredOff == 0 + } + return true + } + + private func clampPercent(_ value: Double) -> Double { + return min(max(value, 0), 100) + } +} + +struct GPUBreakdown: Equatable { + let render: Double + let compute: Double + let video: Double + let other: Double + + static let zero = GPUBreakdown(render: 0, compute: 0, video: 0, other: 0) + + var totalUsage: Double { + render + compute + video + other + } +} + +struct GPUMetricsSnapshot { + let usage: Double + let breakdown: GPUBreakdown + let devices: [GPUDeviceMetrics] + + static let zero = GPUMetricsSnapshot(usage: 0, breakdown: .zero, devices: []) +} + +enum NetworkInterfaceType: String { + case wifi + case ethernet + case loopback + case cellular + case other +} + +struct NetworkInterfaceMetrics: Identifiable, Equatable { + let name: String + let displayName: String + let type: NetworkInterfaceType + let ipv4: String? + let ipv6: String? + let isActive: Bool + let currentDownload: Double + let currentUpload: Double + let totalDownloaded: Double + let totalUploaded: Double + + var id: String { name } +} + +struct DiskDeviceMetrics: Identifiable, Equatable { + let id: String + let name: String + let path: URL + let totalBytes: UInt64 + let freeBytes: UInt64 + let isRoot: Bool + let isRemovable: Bool + + var usedBytes: UInt64 { + totalBytes > freeBytes ? totalBytes - freeBytes : 0 + } + + var usagePercentage: Double { + guard totalBytes > 0 else { return 0 } + return Double(usedBytes) / Double(totalBytes) * 100 + } +} + +struct GPUDeviceMetrics: Identifiable, Equatable { + let id: String + let vendor: String? + let model: String + let isActive: Bool + let utilization: Double? + let renderUtilization: Double? + let tilerUtilization: Double? + let temperature: Double? + let fanSpeed: Int? + let coreClock: Int? + let memoryClock: Int? + let cores: Int? + + var formattedVendorModel: String { + if let vendor { + return vendor == model ? model : "\(vendor) \(model)".trimmingCharacters(in: .whitespaces) + } + return model + } + + var utilizationText: String { + guard let utilization else { return "—" } + return StatsFormatting.percentage(utilization) + } + + var temperatureText: String { + guard let temperature else { return "—" } + return String(format: "%.0f°C", temperature) + } +} + +struct NetworkTotals: Equatable { + var downloadedMB: Double + var uploadedMB: Double + + static let zero = NetworkTotals(downloadedMB: 0, uploadedMB: 0) +} + +struct DiskTotals: Equatable { + var readMB: Double + var writtenMB: Double + + static let zero = DiskTotals(readMB: 0, writtenMB: 0) +} + +class StatsManager: ObservableObject { + // MARK: - Properties + static let shared = StatsManager() + + @Published var isMonitoring: Bool = false + @Published var cpuUsage: Double = 0.0 + @Published var memoryUsage: Double = 0.0 + @Published var gpuUsage: Double = 0.0 + @Published var networkDownload: Double = 0.0 // MB/s + @Published var networkUpload: Double = 0.0 // MB/s + @Published var diskRead: Double = 0.0 // MB/s + @Published var diskWrite: Double = 0.0 // MB/s + @Published var lastUpdated: Date = .distantPast + @Published private(set) var cpuBreakdown: CPULoadBreakdown = .zero + @Published private(set) var cpuLoadAverage: LoadAverage = .zero + @Published private(set) var cpuCoreUsage: [CPUCoreUsage] = [] + @Published private(set) var cpuUptime: TimeInterval = 0 + @Published private(set) var topCPUProcesses: [ProcessStats] = [] + @Published private(set) var cpuTemperature: CPUTemperatureMetrics = CPUTemperatureMetrics(celsius: nil) + @Published private(set) var cpuFrequency: CPUFrequencyMetrics? + @Published private(set) var memoryBreakdown: MemoryBreakdown = .zero + @Published private(set) var gpuBreakdown: GPUBreakdown = .zero + @Published private(set) var gpuDevices: [GPUDeviceMetrics] = [] + @Published private(set) var networkTotals: NetworkTotals = .zero + @Published private(set) var diskTotals: DiskTotals = .zero + @Published private(set) var networkInterfaces: [NetworkInterfaceMetrics] = [] + @Published private(set) var diskDevices: [DiskDeviceMetrics] = [] + + // Historical data for graphs (last 30 data points) + @Published var cpuHistory: [Double] = [] + @Published var memoryHistory: [Double] = [] + @Published var gpuHistory: [Double] = [] + @Published var networkDownloadHistory: [Double] = [] + @Published var networkUploadHistory: [Double] = [] + @Published var diskReadHistory: [Double] = [] + @Published var diskWriteHistory: [Double] = [] + + private var monitoringTimer: Timer? + private var delayedStopTimer: Timer? + private var delayedStartTimer: Timer? + private let maxHistoryPoints = 30 + /// Cached host port to avoid leaking Mach send rights. + /// Every call to `mach_host_self()` acquires a new send right that must be + /// explicitly deallocated; caching it once prevents port exhaustion over time. + private let hostPort: mach_port_t = mach_host_self() + private let totalPhysicalMemory: UInt64 = { + var stats = host_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let initHostPort = mach_host_self() + let result = withUnsafeMutablePointer(to: &stats) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_info(initHostPort, HOST_BASIC_INFO, $0, &count) + } + } + mach_port_deallocate(mach_task_self_, initHostPort) + if result == KERN_SUCCESS { + return UInt64(stats.max_mem) + } + return UInt64(ProcessInfo.processInfo.physicalMemory) + }() + + // Smart monitoring state + private var shouldMonitorForStats: Bool = false + private var lastNotchState: String = "closed" + private var lastCurrentView: String = "other" + + // Network monitoring state + private var previousNetworkStats: (bytesIn: UInt64, bytesOut: UInt64) = (0, 0) + private var previousTimestamp: Date = Date() + + // Disk monitoring state + private var previousDiskStats: (bytesRead: UInt64, bytesWritten: UInt64) = (0, 0) + private var previousCPULoadInfo: host_cpu_load_info? + private var previousCpuInfo: processor_info_array_t? + private var previousCpuInfoCount: mach_msg_type_number_t = 0 + private var processorCount: natural_t = 0 + private var previousInterfaceCounters: [String: (bytesIn: UInt64, bytesOut: UInt64)] = [:] + private var interfaceTotals: [String: NetworkTotals] = [:] + + // Per-process monitoring cache (updated via ps sampling) + private var cachedProcessStats: [ProcessStats] = [] + private var lastProcessStatsUpdate: Date = .distantPast + private let processStatsUpdateInterval: TimeInterval = 2.0 + private let maxProcessEntries: Int = 20 + private var isProcessRefreshInFlight = false + private let gpuCollector = GPUInfoCollector() + private let cpuSensorCollector = CPUSensorCollector() + private var cancellables = Set() + private let minUpdateInterval: TimeInterval = 1.0 + private let maxUpdateInterval: TimeInterval = 60.0 + private let notchCloseStopDelay: TimeInterval = 3.0 + private let tabSwitchStopDelay: TimeInterval = 0.1 + + // MARK: - Initialization + private init() { + // Initialize with empty history + cpuHistory = Array(repeating: 0.0, count: maxHistoryPoints) + memoryHistory = Array(repeating: 0.0, count: maxHistoryPoints) + gpuHistory = Array(repeating: 0.0, count: maxHistoryPoints) + networkDownloadHistory = Array(repeating: 0.0, count: maxHistoryPoints) + networkUploadHistory = Array(repeating: 0.0, count: maxHistoryPoints) + diskReadHistory = Array(repeating: 0.0, count: maxHistoryPoints) + diskWriteHistory = Array(repeating: 0.0, count: maxHistoryPoints) + + // Initialize baseline network stats + let initialStats = getNetworkStats() + previousNetworkStats = initialStats + previousTimestamp = Date() + + // Initialize baseline disk stats + let initialDiskStats = getDiskStats() + previousDiskStats = initialDiskStats + + Defaults.publisher(.statsUpdateInterval, options: []).sink { [weak self] change in + self?.handleUpdateIntervalChange(change.newValue) + }.store(in: &cancellables) + + Defaults.publisher(.statsStopWhenNotchCloses, options: []).sink { [weak self] change in + guard let self else { return } + + if change.newValue { + if self.isMonitoring && self.lastNotchState == "closed" { + self.scheduleDelayedStop(after: self.notchCloseStopDelay) + } + } else { + self.delayedStopTimer?.invalidate() + self.delayedStopTimer = nil + } + }.store(in: &cancellables) + } + + deinit { + stopMonitoring() + delayedStartTimer?.invalidate() + delayedStopTimer?.invalidate() + } + + // MARK: - Smart Monitoring + func updateMonitoringState(notchIsOpen: Bool, currentView: String) { + let notchState = notchIsOpen ? "open" : "closed" + + // Only react to actual state changes + guard notchState != lastNotchState || currentView != lastCurrentView else { return } + + lastNotchState = notchState + lastCurrentView = currentView + + // Cancel any pending timers + delayedStartTimer?.invalidate() + delayedStopTimer?.invalidate() + + // Determine if we should be monitoring + shouldMonitorForStats = notchIsOpen && (currentView == "stats") + + if shouldMonitorForStats { + // Start monitoring after 3.5 seconds (when notch is open and stats tab is active) + delayedStartTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + DispatchQueue.main.async { + self?.startMonitoring() + } + } + } else { + if notchIsOpen && currentView != "stats" { + scheduleDelayedStop(after: tabSwitchStopDelay) + } else if !notchIsOpen { + if Defaults[.statsStopWhenNotchCloses] { + scheduleDelayedStop(after: notchCloseStopDelay) + } + } else { + scheduleDelayedStop(after: tabSwitchStopDelay) + } + } + } + + // MARK: - Public Monitoring Controls + func startMonitoring() { + guard !isMonitoring else { return } + + print("StatsManager: Starting monitoring...") + + // Reset baseline for accurate measurement + let initialStats = getNetworkStats() + previousNetworkStats = initialStats + + let initialDiskStats = getDiskStats() + previousDiskStats = initialDiskStats + + previousTimestamp = Date() + + isMonitoring = true + lastUpdated = Date() + networkTotals = .zero + diskTotals = .zero + + scheduleMonitoringTimer() + + Task { @MainActor in + self.updateSystemStats() + } + + print("StatsManager: Monitoring started") + } + + func stopMonitoring() { + guard isMonitoring else { return } + + // Clean up all timers + monitoringTimer?.invalidate() + monitoringTimer = nil + delayedStartTimer?.invalidate() + delayedStopTimer?.invalidate() + + isMonitoring = false + print("StatsManager: Monitoring stopped") + cachedProcessStats.removeAll() + lastProcessStatsUpdate = .distantPast + isProcessRefreshInFlight = false + previousCPULoadInfo = nil + if let previousCpuInfo { + let size = vm_size_t(previousCpuInfoCount) * vm_size_t(MemoryLayout.size) + vm_deallocate(mach_task_self_, vm_address_t(bitPattern: previousCpuInfo), size) + } + previousCpuInfo = nil + previousCpuInfoCount = 0 + processorCount = 0 + topCPUProcesses = [] + cpuCoreUsage = [] + networkInterfaces = [] + diskDevices = [] + previousInterfaceCounters.removeAll() + interfaceTotals.removeAll() + cpuTemperature = CPUTemperatureMetrics(celsius: nil) + cpuFrequency = nil + } + + private func scheduleMonitoringTimer() { + monitoringTimer?.invalidate() + + let configuredInterval = Defaults[.statsUpdateInterval] + let interval = validatedUpdateInterval(configuredInterval) + + if abs(interval - configuredInterval) > 0.0001 { + Defaults[.statsUpdateInterval] = interval + } + + monitoringTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + guard let self = self else { return } + + Task { @MainActor in + self.updateSystemStats() + } + } + } + + private func handleUpdateIntervalChange(_ newValue: Double) { + let interval = validatedUpdateInterval(newValue) + + if abs(interval - newValue) > 0.0001 { + Defaults[.statsUpdateInterval] = interval + return + } + + guard isMonitoring else { return } + + DispatchQueue.main.async { [weak self] in + self?.scheduleMonitoringTimer() + } + } + + private func validatedUpdateInterval(_ value: Double) -> TimeInterval { + min(max(value, minUpdateInterval), maxUpdateInterval) + } + + private func scheduleDelayedStop(after delay: TimeInterval) { + guard delay > 0 else { + stopMonitoring() + return + } + + delayedStopTimer?.invalidate() + + delayedStopTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in + DispatchQueue.main.async { + self?.stopMonitoring() + } + } + } + + // MARK: - Private Methods + @MainActor + private func updateSystemStats() { + let cpuMetrics = getCPULoadBreakdown() + let newCpuUsage = cpuMetrics.activeUsage + let memorySnapshot = getMemorySnapshot() + let newMemoryUsage = memorySnapshot.usage + let gpuSnapshot = getGPUMetrics() + let newGpuUsage = gpuSnapshot.usage + let coreUsage = collectCPUCoreUsage() + + // Calculate network speeds + let currentNetworkStats = getNetworkStats() + let currentTime = Date() + let timeInterval = currentTime.timeIntervalSince(previousTimestamp) + + var downloadSpeed: Double = 0.0 + var uploadSpeed: Double = 0.0 + var bytesDownloaded: UInt64 = 0 + var bytesUploaded: UInt64 = 0 + + // Only calculate speeds if we have a reasonable time interval and this isn't the first run + if timeInterval > 0.1 && (previousNetworkStats.bytesIn > 0 || previousNetworkStats.bytesOut > 0) { + bytesDownloaded = currentNetworkStats.bytesIn > previousNetworkStats.bytesIn ? + currentNetworkStats.bytesIn - previousNetworkStats.bytesIn : 0 + bytesUploaded = currentNetworkStats.bytesOut > previousNetworkStats.bytesOut ? + currentNetworkStats.bytesOut - previousNetworkStats.bytesOut : 0 + + downloadSpeed = Double(bytesDownloaded) / timeInterval / 1_048_576 // Convert to MB/s + uploadSpeed = Double(bytesUploaded) / timeInterval / 1_048_576 // Convert to MB/s + } + + // Calculate disk speeds + let currentDiskStats = getDiskStats() + var readSpeed: Double = 0.0 + var writeSpeed: Double = 0.0 + var bytesRead: UInt64 = 0 + var bytesWritten: UInt64 = 0 + + // Only calculate speeds if we have a reasonable time interval and this isn't the first run + if timeInterval > 0.1 && (previousDiskStats.bytesRead > 0 || previousDiskStats.bytesWritten > 0) { + bytesRead = currentDiskStats.bytesRead > previousDiskStats.bytesRead ? + currentDiskStats.bytesRead - previousDiskStats.bytesRead : 0 + bytesWritten = currentDiskStats.bytesWritten > previousDiskStats.bytesWritten ? + currentDiskStats.bytesWritten - previousDiskStats.bytesWritten : 0 + + readSpeed = Double(bytesRead) / timeInterval / 1_048_576 // Convert to MB/s + writeSpeed = Double(bytesWritten) / timeInterval / 1_048_576 // Convert to MB/s + } + + // Update cumulative transfer totals + if bytesDownloaded > 0 { + var updatedTotals = networkTotals + updatedTotals.downloadedMB += Double(bytesDownloaded) / 1_048_576 + networkTotals = updatedTotals + } + if bytesUploaded > 0 { + var updatedTotals = networkTotals + updatedTotals.uploadedMB += Double(bytesUploaded) / 1_048_576 + networkTotals = updatedTotals + } + if bytesRead > 0 { + var updatedDiskTotals = diskTotals + updatedDiskTotals.readMB += Double(bytesRead) / 1_048_576 + diskTotals = updatedDiskTotals + } + if bytesWritten > 0 { + var updatedDiskTotals = diskTotals + updatedDiskTotals.writtenMB += Double(bytesWritten) / 1_048_576 + diskTotals = updatedDiskTotals + } + + // Update current values + cpuUsage = newCpuUsage + gpuUsage = newGpuUsage + memoryUsage = newMemoryUsage + networkDownload = max(0.0, downloadSpeed) + networkUpload = max(0.0, uploadSpeed) + diskRead = max(0.0, readSpeed) + diskWrite = max(0.0, writeSpeed) + lastUpdated = Date() + cpuBreakdown = cpuMetrics + memoryBreakdown = memorySnapshot.breakdown + cpuLoadAverage = getLoadAverage() + gpuBreakdown = gpuSnapshot.breakdown + if gpuDevices != gpuSnapshot.devices { + gpuDevices = gpuSnapshot.devices + } + if cpuCoreUsage != coreUsage { + cpuCoreUsage = coreUsage + } + cpuUptime = ProcessInfo.processInfo.systemUptime + cpuTemperature = cpuSensorCollector.readTemperature() + if let frequencyMetrics = cpuSensorCollector.readFrequency() { + cpuFrequency = frequencyMetrics + } + + // Update history arrays (sliding window) + updateHistory(value: newCpuUsage, history: &cpuHistory) + updateHistory(value: newMemoryUsage, history: &memoryHistory) + updateHistory(value: newGpuUsage, history: &gpuHistory) + updateHistory(value: downloadSpeed, history: &networkDownloadHistory) + updateHistory(value: uploadSpeed, history: &networkUploadHistory) + updateHistory(value: readSpeed, history: &diskReadHistory) + updateHistory(value: writeSpeed, history: &diskWriteHistory) + + // Update previous stats for next calculation + previousNetworkStats = currentNetworkStats + previousDiskStats = currentDiskStats + previousTimestamp = currentTime + networkInterfaces = collectNetworkInterfaces(deltaTime: timeInterval) + diskDevices = collectDiskDevices() + refreshProcessStatsIfNeeded(force: true) + } + + private func updateHistory(value: Double, history: inout [Double]) { + // Remove first element and append new value + if history.count >= maxHistoryPoints { + history.removeFirst() + } + history.append(value) + } + + // MARK: - System Monitoring Functions + + private func getCPULoadBreakdown() -> CPULoadBreakdown { + var info = host_cpu_load_info() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics(hostPort, HOST_CPU_LOAD_INFO, $0, &count) + } + } + + guard result == KERN_SUCCESS else { + return cpuBreakdown + } + + let clamped: (Double) -> Double = { value in + return min(max(value, 0), 100) + } + + let breakdown: CPULoadBreakdown + + if let previous = previousCPULoadInfo { + let userDiff = Double(info.cpu_ticks.0 - previous.cpu_ticks.0) + let systemDiff = Double(info.cpu_ticks.1 - previous.cpu_ticks.1) + let idleDiff = Double(info.cpu_ticks.2 - previous.cpu_ticks.2) + let niceDiff = Double(info.cpu_ticks.3 - previous.cpu_ticks.3) + let total = userDiff + systemDiff + idleDiff + niceDiff + + if total > 0 { + let userPercent = ((userDiff + niceDiff) / total) * 100 + let systemPercent = (systemDiff / total) * 100 + let idlePercent = (idleDiff / total) * 100 + breakdown = CPULoadBreakdown( + user: clamped(userPercent), + system: clamped(systemPercent), + idle: clamped(idlePercent) + ) + } else { + breakdown = cpuBreakdown + } + } else { + let totalTicks = Double(info.cpu_ticks.0 + info.cpu_ticks.1 + info.cpu_ticks.2 + info.cpu_ticks.3) + if totalTicks > 0 { + let userPercent = ((Double(info.cpu_ticks.0) + Double(info.cpu_ticks.3)) / totalTicks) * 100 + let systemPercent = (Double(info.cpu_ticks.1) / totalTicks) * 100 + let idlePercent = (Double(info.cpu_ticks.2) / totalTicks) * 100 + breakdown = CPULoadBreakdown( + user: clamped(userPercent), + system: clamped(systemPercent), + idle: clamped(idlePercent) + ) + } else { + breakdown = cpuBreakdown + } + } + + previousCPULoadInfo = info + return breakdown + } + + private func getLoadAverage() -> LoadAverage { + var loads = [Double](repeating: 0, count: 3) + let result = loads.withUnsafeMutableBufferPointer { buffer -> Int32 in + guard let baseAddress = buffer.baseAddress else { return 0 } + return getloadavg(baseAddress, 3) + } + guard result == 3 else { + return cpuLoadAverage + } + return LoadAverage(oneMinute: loads[0], fiveMinutes: loads[1], fifteenMinutes: loads[2]) + } + + private func getMemorySnapshot() -> (usage: Double, breakdown: MemoryBreakdown) { + // Memory usage monitoring using host_statistics64 + var vmStatistics = vm_statistics64() + var size = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + + let vmResult = withUnsafeMutablePointer(to: &vmStatistics) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(size)) { + host_statistics64(hostPort, HOST_VM_INFO64, $0, &size) + } + } + + guard vmResult == KERN_SUCCESS else { + return (memoryUsage, memoryBreakdown) + } + + let pageSize = UInt64(vm_kernel_page_size) + let freeBytes = UInt64(vmStatistics.free_count) * pageSize + let speculativeBytes = UInt64(vmStatistics.speculative_count) * pageSize + let activeBytes = UInt64(vmStatistics.active_count) * pageSize + let inactiveBytes = UInt64(vmStatistics.inactive_count) * pageSize + let wiredBytes = UInt64(vmStatistics.wire_count) * pageSize + let compressedBytes = UInt64(vmStatistics.compressor_page_count) * pageSize + let purgeableBytes = UInt64(vmStatistics.purgeable_count) * pageSize + let externalBytes = UInt64(vmStatistics.external_page_count) * pageSize + + let totalMemoryBytes: UInt64 + if totalPhysicalMemory > 0 { + totalMemoryBytes = totalPhysicalMemory + } else { + totalMemoryBytes = freeBytes + speculativeBytes + activeBytes + inactiveBytes + wiredBytes + compressedBytes + } + guard totalMemoryBytes > 0 else { + return (0.0, .zero) + } + + let usedWithoutCache = activeBytes + inactiveBytes + speculativeBytes + wiredBytes + compressedBytes + let cacheBytes = purgeableBytes + externalBytes + let usedBytes = usedWithoutCache > cacheBytes ? usedWithoutCache - cacheBytes : 0 + let clampedUsedBytes = min(usedBytes, totalMemoryBytes) + let freeComputedBytes = totalMemoryBytes > clampedUsedBytes ? totalMemoryBytes - clampedUsedBytes : 0 + let nonAppBytes = wiredBytes + compressedBytes + let appBytes = clampedUsedBytes > nonAppBytes ? clampedUsedBytes - nonAppBytes : 0 + + var pressureLevelRaw: Int32 = 0 + var pressureSize = MemoryLayout.size + sysctlbyname("kern.memorystatus_vm_pressure_level", &pressureLevelRaw, &pressureSize, nil, 0) + let pressureLevel: MemoryPressureLevel + switch pressureLevelRaw { + case 2: + pressureLevel = .warning + case 4: + pressureLevel = .critical + default: + pressureLevel = .normal + } + let pressure = MemoryPressure(rawValue: Int(pressureLevelRaw), level: pressureLevel) + + var swapUsage = xsw_usage() + var swapSize = MemoryLayout.size + sysctlbyname("vm.swapusage", &swapUsage, &swapSize, nil, 0) + let swapInfo = MemorySwap( + totalBytes: UInt64(swapUsage.xsu_total), + usedBytes: UInt64(swapUsage.xsu_used), + freeBytes: UInt64(swapUsage.xsu_avail) + ) + + let usage = Double(clampedUsedBytes) / Double(totalMemoryBytes) * 100.0 + let breakdown = MemoryBreakdown( + totalBytes: totalMemoryBytes, + usedBytes: clampedUsedBytes, + freeBytes: freeComputedBytes, + wiredBytes: wiredBytes, + activeBytes: activeBytes, + inactiveBytes: inactiveBytes, + compressedBytes: compressedBytes, + appBytes: appBytes, + cacheBytes: cacheBytes, + swap: swapInfo, + pressure: pressure + ) + + return (min(100.0, max(0.0, usage)), breakdown) + } + + private func getGPUMetrics() -> GPUMetricsSnapshot { + let devices = gpuCollector.collectDevices() + guard !devices.isEmpty else { + return .zero + } + let utilizationValues = devices.compactMap { $0.utilization } + let usage: Double + if utilizationValues.isEmpty { + usage = 0 + } else { + usage = utilizationValues.reduce(0, +) / Double(utilizationValues.count) + } + let breakdown = makeGPUBreakdown(from: devices) + return GPUMetricsSnapshot(usage: usage, breakdown: breakdown, devices: devices) + } + + private func makeGPUBreakdown(from devices: [GPUDeviceMetrics]) -> GPUBreakdown { + guard let primary = devices.first(where: { ($0.utilization ?? 0) > 0 || ($0.renderUtilization ?? 0) > 0 || ($0.tilerUtilization ?? 0) > 0 }) else { + return .zero + } + let fallbackTotal = max((primary.renderUtilization ?? 0) + (primary.tilerUtilization ?? 0), 0) + let total = max(primary.utilization ?? fallbackTotal, 0) + if total.isZero { + return GPUBreakdown( + render: primary.renderUtilization ?? 0, + compute: primary.tilerUtilization ?? 0, + video: 0, + other: 0 + ) + } + let render = min(primary.renderUtilization ?? 0, total) + let tiler = min(primary.tilerUtilization ?? 0, max(total - render, 0)) + let remaining = max(total - render - tiler, 0) + let compute = remaining * 0.6 + let video = remaining * 0.25 + let other = max(remaining - compute - video, 0) + return GPUBreakdown(render: render, compute: compute, video: video, other: other) + } + + private func collectCPUCoreUsage() -> [CPUCoreUsage] { + var cpuInfo: processor_info_array_t? + var numCpuInfo: mach_msg_type_number_t = 0 + var numCPUsU: natural_t = 0 + let result = host_processor_info(hostPort, PROCESSOR_CPU_LOAD_INFO, &numCPUsU, &cpuInfo, &numCpuInfo) + guard result == KERN_SUCCESS, let cpuInfo else { + return cpuCoreUsage + } + let cpuCount = Int(numCPUsU) + var usages: [CPUCoreUsage] = [] + usages.reserveCapacity(cpuCount) + let cpuStateMax = Int(CPU_STATE_MAX) + if let previousCpuInfo { + for cpu in 0.. 0 ? Double(inUse) / Double(total) : 0 + usages.append(CPUCoreUsage(id: cpu, usage: max(0, min(percent * 100, 100)))) + } + } else { + for cpu in 0.. 0 ? Double(inUse) / Double(total) : 0 + usages.append(CPUCoreUsage(id: cpu, usage: max(0, min(percent * 100, 100)))) + } + } + if let previousCpuInfo { + let size = vm_size_t(previousCpuInfoCount) * vm_size_t(MemoryLayout.size) + vm_deallocate(mach_task_self_, vm_address_t(bitPattern: previousCpuInfo), size) + } + previousCpuInfo = cpuInfo + previousCpuInfoCount = numCpuInfo + processorCount = numCPUsU + return usages + } + + private func collectNetworkInterfaces(deltaTime: TimeInterval) -> [NetworkInterfaceMetrics] { + var interfacesPointer: UnsafeMutablePointer? = nil + guard getifaddrs(&interfacesPointer) == 0, let startPointer = interfacesPointer else { + return networkInterfaces + } + defer { freeifaddrs(startPointer) } + struct InterfaceAccumulator { + var name: String + var flags: UInt32 + var bytesIn: UInt64 + var bytesOut: UInt64 + var ipv4: String? + var ipv6: String? + } + var accumulators: [String: InterfaceAccumulator] = [:] + var pointer: UnsafeMutablePointer? = startPointer + while let current = pointer { + let interface = current.pointee + let name = String(cString: interface.ifa_name) + var accumulator = accumulators[name] ?? InterfaceAccumulator(name: name, flags: interface.ifa_flags, bytesIn: 0, bytesOut: 0, ipv4: nil, ipv6: nil) + if let addr = interface.ifa_addr { + switch Int32(addr.pointee.sa_family) { + case AF_LINK: + if let data = interface.ifa_data?.assumingMemoryBound(to: if_data.self) { + accumulator.bytesIn = UInt64(data.pointee.ifi_ibytes) + accumulator.bytesOut = UInt64(data.pointee.ifi_obytes) + } + case AF_INET: + accumulator.ipv4 = stringFromSockaddr(addr) + case AF_INET6: + accumulator.ipv6 = stringFromSockaddr(addr) + default: + break + } + } + accumulators[name] = accumulator + pointer = interface.ifa_next + } + var results: [NetworkInterfaceMetrics] = [] + var updatedCounters: [String: (bytesIn: UInt64, bytesOut: UInt64)] = [:] + for accumulator in accumulators.values { + guard shouldIncludeInterface(name: accumulator.name) else { continue } + let previous = previousInterfaceCounters[accumulator.name] ?? (accumulator.bytesIn, accumulator.bytesOut) + let deltaIn = accumulator.bytesIn >= previous.bytesIn ? accumulator.bytesIn - previous.bytesIn : 0 + let deltaOut = accumulator.bytesOut >= previous.bytesOut ? accumulator.bytesOut - previous.bytesOut : 0 + let speedDivider = max(deltaTime, 0.001) + let downloadSpeed = Double(deltaIn) / speedDivider / 1_048_576 + let uploadSpeed = Double(deltaOut) / speedDivider / 1_048_576 + var totals = interfaceTotals[accumulator.name] ?? .zero + totals.downloadedMB += Double(deltaIn) / 1_048_576 + totals.uploadedMB += Double(deltaOut) / 1_048_576 + interfaceTotals[accumulator.name] = totals + let metrics = NetworkInterfaceMetrics( + name: accumulator.name, + displayName: interfaceDisplayName(for: accumulator.name), + type: interfaceType(for: accumulator.name), + ipv4: accumulator.ipv4, + ipv6: accumulator.ipv6, + isActive: interfaceIsActive(flags: accumulator.flags), + currentDownload: max(downloadSpeed, 0), + currentUpload: max(uploadSpeed, 0), + totalDownloaded: totals.downloadedMB, + totalUploaded: totals.uploadedMB + ) + results.append(metrics) + updatedCounters[accumulator.name] = (accumulator.bytesIn, accumulator.bytesOut) + } + previousInterfaceCounters = updatedCounters + return results.sorted { lhs, rhs in + if lhs.type == rhs.type { + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + return networkTypeSortOrder(lhs.type) < networkTypeSortOrder(rhs.type) + } + } + + private func collectDiskDevices() -> [DiskDeviceMetrics] { + let keys: [URLResourceKey] = [ + .volumeNameKey, + .volumeTotalCapacityKey, + .volumeAvailableCapacityKey, + .volumeAvailableCapacityForImportantUsageKey, + .volumeAvailableCapacityForOpportunisticUsageKey, + .volumeIsRootFileSystemKey, + .volumeIsRemovableKey + ] + guard let urls = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: keys, options: [.skipHiddenVolumes]) else { + return diskDevices + } + var devices: [DiskDeviceMetrics] = [] + for url in urls { + guard let values = try? url.resourceValues(forKeys: Set(keys)) else { continue } + guard let totalCapacity = values.volumeTotalCapacity.flatMap({ Int64($0) }), totalCapacity > 0 else { continue } + let name = values.volumeName ?? url.lastPathComponent + let freeCapacityValue: Int64 + if let free = values.volumeAvailableCapacity { + freeCapacityValue = Int64(free) + } else if let important = values.volumeAvailableCapacityForImportantUsage { + freeCapacityValue = important + } else if let opportunistic = values.volumeAvailableCapacityForOpportunisticUsage { + freeCapacityValue = opportunistic + } else { + freeCapacityValue = 0 + } + let clampedFree = max(freeCapacityValue, 0) + let device = DiskDeviceMetrics( + id: url.path, + name: name, + path: url, + totalBytes: UInt64(totalCapacity), + freeBytes: UInt64(clampedFree), + isRoot: values.volumeIsRootFileSystem ?? false, + isRemovable: values.volumeIsRemovable ?? false + ) + devices.append(device) + } + return devices.sorted { lhs, rhs in + if lhs.isRoot != rhs.isRoot { + return lhs.isRoot + } + if lhs.isRemovable != rhs.isRemovable { + return !lhs.isRemovable + } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + + private func stringFromSockaddr(_ addressPointer: UnsafePointer?) -> String? { + guard let addressPointer else { return nil } + let family = Int32(addressPointer.pointee.sa_family) + switch family { + case AF_INET: + var addr = addressPointer.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr } + var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) + guard inet_ntop(AF_INET, &addr, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil } + return String(cString: buffer) + case AF_INET6: + var addr = addressPointer.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee.sin6_addr } + var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + guard inet_ntop(AF_INET6, &addr, &buffer, socklen_t(INET6_ADDRSTRLEN)) != nil else { return nil } + return String(cString: buffer) + default: + return nil + } + } + + private func shouldIncludeInterface(name: String) -> Bool { + guard !name.hasPrefix("lo"), + !name.hasPrefix("gif"), + !name.hasPrefix("stf"), + !name.hasPrefix("awdl"), + !name.hasPrefix("llw"), + !name.hasPrefix("utun"), + !name.hasPrefix("p2p") else { + return false + } + return true + } + + private func interfaceIsActive(flags: UInt32) -> Bool { + let isUp = (flags & UInt32(IFF_UP)) == UInt32(IFF_UP) + let isRunning = (flags & UInt32(IFF_RUNNING)) == UInt32(IFF_RUNNING) + return isUp && isRunning + } + + private func interfaceDisplayName(for name: String) -> String { + if name.hasPrefix("en") { + return name == "en0" ? "Wi-Fi" : "Ethernet" + } + if name.hasPrefix("bridge") { + return "Bridge" + } + if name.hasPrefix("pd") { + return "USB" + } + if name.hasPrefix("vmnet") { + return "Virtual" + } + if name.hasPrefix("ap") { + return "Hotspot" + } + if name.hasPrefix("awdl") { + return "AirDrop" + } + return name.uppercased() + } + + private func interfaceType(for name: String) -> NetworkInterfaceType { + if name.hasPrefix("en") { + if name == "en0" { + return .wifi + } + return .ethernet + } + if name.hasPrefix("bridge") || name.hasPrefix("vmnet") { + return .ethernet + } + if name.hasPrefix("ap") { + return .wifi + } + if name.hasPrefix("pdp_ip") { + return .cellular + } + if name.hasPrefix("lo") { + return .loopback + } + return .other + } + + private func networkTypeSortOrder(_ type: NetworkInterfaceType) -> Int { + switch type { + case .wifi: + return 0 + case .ethernet: + return 1 + case .cellular: + return 2 + case .other: + return 3 + case .loopback: + return 4 + } + } + + private func getNetworkStats() -> (bytesIn: UInt64, bytesOut: UInt64) { + // Use BSD sockets to get network interface statistics + var totalBytesIn: UInt64 = 0 + var totalBytesOut: UInt64 = 0 + + var ifaddrs: UnsafeMutablePointer? + guard getifaddrs(&ifaddrs) == 0 else { + return (totalBytesIn, totalBytesOut) + } + + defer { freeifaddrs(ifaddrs) } + + var ptr = ifaddrs + while ptr != nil { + defer { ptr = ptr?.pointee.ifa_next } + + guard let interface = ptr?.pointee, + interface.ifa_addr.pointee.sa_family == UInt8(AF_LINK) else { + continue + } + + let name = String(cString: interface.ifa_name) + // Skip loopback and virtual interfaces, but include en0, en1, etc. and Wi-Fi interfaces + guard !name.hasPrefix("lo") && + !name.hasPrefix("gif") && + !name.hasPrefix("stf") && + !name.hasPrefix("bridge") && + !name.hasPrefix("utun") && + !name.hasPrefix("awdl") else { + continue + } + + // Only count active interfaces (en0, en1, etc.) + if name.hasPrefix("en") || name.contains("Wi-Fi") { + if let data = interface.ifa_data?.assumingMemoryBound(to: if_data.self) { + totalBytesIn += UInt64(data.pointee.ifi_ibytes) + totalBytesOut += UInt64(data.pointee.ifi_obytes) + } + } + } + + return (totalBytesIn, totalBytesOut) + } + + private func getDiskStats() -> (bytesRead: UInt64, bytesWritten: UInt64) { + // Use IOKit to get disk I/O statistics from IOStorage service + var totalBytesRead: UInt64 = 0 + var totalBytesWritten: UInt64 = 0 + + let matchingDict = IOServiceMatching("IOStorage") + var iterator: io_iterator_t = 0 + let result = IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) + + guard result == KERN_SUCCESS else { + return (totalBytesRead, totalBytesWritten) + } + + defer { IOObjectRelease(iterator) } + + var service: io_registry_entry_t = IOIteratorNext(iterator) + while service != 0 { + defer { + IOObjectRelease(service) + service = IOIteratorNext(iterator) + } + + var properties: Unmanaged? + let propertiesResult = IORegistryEntryCreateCFProperties(service, &properties, kCFAllocatorDefault, 0) + + guard propertiesResult == KERN_SUCCESS, + let props = properties?.takeRetainedValue() as? [String: Any], + let statistics = props["Statistics"] as? [String: Any] else { + continue + } + + // Use the correct property names for APFS/modern filesystems + if let bytesRead = statistics["Bytes read from block device"] as? UInt64 { + totalBytesRead += bytesRead + } + + if let bytesWritten = statistics["Bytes written to block device"] as? UInt64 { + totalBytesWritten += bytesWritten + } + } + + return (totalBytesRead, totalBytesWritten) + } + + // MARK: - Computed Properties for UI + var cpuUsageString: String { + return String(format: "%.1f%%", cpuUsage) + } + + var memoryUsageString: String { + return String(format: "%.1f%%", memoryUsage) + } + + var gpuUsageString: String { + return String(format: "%.1f%%", gpuUsage) + } + + var networkDownloadString: String { + StatsFormatting.throughput(networkDownload) + } + + var networkUploadString: String { + StatsFormatting.throughput(networkUpload) + } + + var diskReadString: String { + return String(format: "%.1f MB/s", diskRead) + } + + var diskWriteString: String { + return String(format: "%.1f MB/s", diskWrite) + } + + var maxCpuUsage: Double { + return cpuHistory.max() ?? 0.0 + } + + var maxMemoryUsage: Double { + return memoryHistory.max() ?? 0.0 + } + + var maxGpuUsage: Double { + return gpuHistory.max() ?? 0.0 + } + + var avgCpuUsage: Double { + let nonZeroValues = cpuHistory.filter { $0 > 0 } + guard !nonZeroValues.isEmpty else { return 0.0 } + return nonZeroValues.reduce(0, +) / Double(nonZeroValues.count) + } + + var avgMemoryUsage: Double { + let nonZeroValues = memoryHistory.filter { $0 > 0 } + guard !nonZeroValues.isEmpty else { return 0.0 } + return nonZeroValues.reduce(0, +) / Double(nonZeroValues.count) + } + + var avgGpuUsage: Double { + let nonZeroValues = gpuHistory.filter { $0 > 0 } + guard !nonZeroValues.isEmpty else { return 0.0 } + return nonZeroValues.reduce(0, +) / Double(nonZeroValues.count) + } + + // MARK: - Clear History Method + func clearHistory() { + cpuHistory = Array(repeating: 0.0, count: maxHistoryPoints) + memoryHistory = Array(repeating: 0.0, count: maxHistoryPoints) + gpuHistory = Array(repeating: 0.0, count: maxHistoryPoints) + networkDownloadHistory = Array(repeating: 0.0, count: maxHistoryPoints) + networkUploadHistory = Array(repeating: 0.0, count: maxHistoryPoints) + diskReadHistory = Array(repeating: 0.0, count: maxHistoryPoints) + diskWriteHistory = Array(repeating: 0.0, count: maxHistoryPoints) + } + + // MARK: - Process Monitoring Methods + @MainActor + func getProcessesRankedByCPU() -> [ProcessStats] { + refreshProcessStatsIfNeeded() + return cachedProcessStats.sorted { $0.cpuUsage > $1.cpuUsage } + } + + @MainActor + func getProcessesRankedByMemory() -> [ProcessStats] { + refreshProcessStatsIfNeeded() + return cachedProcessStats.sorted { $0.memoryUsage > $1.memoryUsage } + } + + @MainActor + func getProcessesRankedByGPU() -> [ProcessStats] { + // For now, rank by CPU usage as GPU per-process stats are complex to obtain + refreshProcessStatsIfNeeded() + return cachedProcessStats.sorted { $0.cpuUsage > $1.cpuUsage } + } + + @MainActor + private func refreshProcessStatsIfNeeded(force: Bool = false) { + let now = Date() + guard force || now.timeIntervalSince(lastProcessStatsUpdate) >= processStatsUpdateInterval else { return } + guard !isProcessRefreshInFlight else { return } + + isProcessRefreshInFlight = true + + Task.detached { [weak self] in + guard let self else { return } + let processes = StatsManager.collectTopProcesses(limit: self.maxProcessEntries) + await MainActor.run { + self.cachedProcessStats = processes + self.lastProcessStatsUpdate = Date() + self.isProcessRefreshInFlight = false + self.topCPUProcesses = processes + } + } + } + + private static func collectTopProcesses(limit: Int) -> [ProcessStats] { + guard limit > 0 else { return [] } + + let task = Process() + task.launchPath = "/bin/ps" + task.arguments = ["-Aceo", "pid,pcpu,comm", "-r"] + + let outputPipe = Pipe() + let errorPipe = Pipe() + task.standardOutput = outputPipe + task.standardError = errorPipe + + defer { + outputPipe.fileHandleForReading.closeFile() + errorPipe.fileHandleForReading.closeFile() + } + + do { + try task.run() + } catch { + NSLog("StatsManager: Failed to run ps command: \(error.localizedDescription)") + return [] + } + + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + _ = errorPipe.fileHandleForReading.readDataToEndOfFile() + + guard !outputData.isEmpty, let output = String(data: outputData, encoding: .utf8) else { + return [] + } + + var results: [ProcessStats] = [] + + let lines = output.split(separator: "\n", omittingEmptySubsequences: true) + guard lines.count > 1 else { return [] } + + for line in lines.dropFirst() { + guard let parsed = parseProcessLine(String(line)) else { continue } + let (pid, cpuUsage, command) = parsed + let (displayName, icon) = runningApplicationInfo(for: pid, fallbackCommand: command) + let memoryUsage = residentMemory(for: pid) + + let process = ProcessStats( + pid: pid, + name: displayName, + cpuUsage: cpuUsage, + memoryUsage: memoryUsage, + icon: icon + ) + + results.append(process) + if results.count >= limit { break } + } + + return results + } + + private static func parseProcessLine(_ rawLine: String) -> (pid_t, Double, String)? { + let trimmed = rawLine.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + + let scanner = Scanner(string: trimmed) + scanner.charactersToBeSkipped = .whitespaces + + guard let pidToken = scanner.scanCharacters(from: .decimalDigits), + let pidValue = Int32(pidToken) else { return nil } + + let cpuCharacterSet = CharacterSet(charactersIn: "0123456789.,") + guard let cpuToken = scanner.scanCharacters(from: cpuCharacterSet) else { return nil } + let normalizedCPU = cpuToken.replacingOccurrences(of: ",", with: ".") + guard let cpuValue = Double(normalizedCPU), cpuValue.isFinite, cpuValue >= 0 else { return nil } + _ = scanner.scanCharacters(from: .whitespaces) + let remainingIndex = scanner.currentIndex + let command = String(trimmed[remainingIndex...]).trimmingCharacters(in: .whitespaces) + + return (pidValue, cpuValue, command.isEmpty ? "Unknown" : command) + } + + private static func runningApplicationInfo(for pid: pid_t, fallbackCommand: String) -> (String, NSImage?) { + if let app = NSRunningApplication(processIdentifier: pid) { + let name = app.localizedName?.trimmingCharacters(in: .whitespacesAndNewlines) + return ((name?.isEmpty ?? true) ? fallbackCommand : name!, app.icon) + } + return (fallbackCommand, nil) + } + + private static func residentMemory(for pid: pid_t) -> UInt64 { + var taskInfo = proc_taskinfo() + let result = proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, Int32(MemoryLayout.size)) + guard result == MemoryLayout.size else { return 0 } + return taskInfo.pti_resident_size + } +} diff --git a/DynamicIsland/managers/SystemChangesObserver.swift b/DynamicIsland/managers/SystemChangesObserver.swift new file mode 100644 index 00000000..35253221 --- /dev/null +++ b/DynamicIsland/managers/SystemChangesObserver.swift @@ -0,0 +1,416 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import CoreGraphics +import Defaults +import AppKit +import AVFoundation + +final class SystemChangesObserver: MediaKeyInterceptorDelegate { + private weak var coordinator: DynamicIslandViewCoordinator? + private let volumeController = SystemVolumeController.shared + private let brightnessController = SystemBrightnessController.shared + private let keyboardBacklightController = SystemKeyboardBacklightController.shared + private let mediaKeyInterceptor = MediaKeyInterceptor.shared + private let volumeFeedbackPlayer = VolumeFeedbackPlayer.shared + + private static let headsetIconSymbols: Set = [ + "airpods", + "airpodspro", + "airpodsmax", + "beats.headphones", + "headphones" + ] + + private var standardVolumeStep: Float { + Float(Defaults[.volumeStepPercent]) / 100.0 + } + private var fineVolumeStep: Float { + Float(Defaults[.volumeFineStepPercent]) / 100.0 + } + private var standardBrightnessStep: Float { + Float(Defaults[.brightnessStepPercent]) / 100.0 + } + private var fineBrightnessStep: Float { + Float(Defaults[.brightnessFineStepPercent]) / 100.0 + } + + private var volumeEnabled = false + private var brightnessEnabled = false + private var keyboardBacklightEnabled = false + + init(coordinator: DynamicIslandViewCoordinator) { + self.coordinator = coordinator + } + + func startObserving(volumeEnabled: Bool, brightnessEnabled: Bool, keyboardBacklightEnabled: Bool) { + self.volumeEnabled = volumeEnabled + self.brightnessEnabled = brightnessEnabled + self.keyboardBacklightEnabled = keyboardBacklightEnabled + + volumeController.onVolumeChange = { [weak self] volume, muted in + guard let self, self.volumeEnabled else { return } + let value = muted ? 0 : volume + Task { @MainActor in + self.sendVolumeNotification(value: value, isMuted: muted) + } + } + volumeController.onRouteChange = { [weak self] in + guard let self, self.volumeEnabled else { return } + let muted = self.volumeController.isMuted + Task { @MainActor in + self.sendVolumeNotification(value: muted ? 0 : self.volumeController.currentVolume, isMuted: muted) + } + } + volumeController.start() + + brightnessController.onBrightnessChange = { [weak self] brightness in + guard let self, self.brightnessEnabled else { return } + self.sendBrightnessNotification(value: brightness) + } + brightnessController.start() + + configureKeyboardBacklightCallback() + if keyboardBacklightEnabled { + keyboardBacklightController.start() + } + + mediaKeyInterceptor.delegate = self + let tapStarted = mediaKeyInterceptor.start() + if !tapStarted { + NSLog("⚠️ Media key interception unavailable; system HUD will remain visible") + } + mediaKeyInterceptor.configuration = MediaKeyConfiguration( + interceptVolume: volumeEnabled, + interceptBrightness: brightnessEnabled, + interceptCommandModifiedBrightness: keyboardBacklightEnabled + ) + } + + func update(volumeEnabled: Bool, brightnessEnabled: Bool, keyboardBacklightEnabled: Bool) { + self.volumeEnabled = volumeEnabled + self.brightnessEnabled = brightnessEnabled + let backlightStateChanged = self.keyboardBacklightEnabled != keyboardBacklightEnabled + self.keyboardBacklightEnabled = keyboardBacklightEnabled + + if keyboardBacklightEnabled { + configureKeyboardBacklightCallback() + } else { + keyboardBacklightController.onBacklightChange = nil + } + + if backlightStateChanged { + if keyboardBacklightEnabled { + keyboardBacklightController.start() + } else { + keyboardBacklightController.stop() + } + } + + mediaKeyInterceptor.configuration = MediaKeyConfiguration( + interceptVolume: volumeEnabled, + interceptBrightness: brightnessEnabled, + interceptCommandModifiedBrightness: keyboardBacklightEnabled + ) + } + + func stopObserving() { + mediaKeyInterceptor.stop() + mediaKeyInterceptor.delegate = nil + + volumeController.stop() + volumeController.onVolumeChange = nil + volumeController.onRouteChange = nil + + brightnessController.stop() + brightnessController.onBrightnessChange = nil + + keyboardBacklightController.stop() + keyboardBacklightController.onBacklightChange = nil + } + + // MARK: - MediaKeyInterceptorDelegate + + func mediaKeyInterceptor( + _ interceptor: MediaKeyInterceptor, + didReceiveVolumeCommand direction: MediaKeyDirection, + step: MediaKeyStep, + isRepeat: Bool, + modifiers: NSEvent.ModifierFlags + ) { + guard volumeEnabled else { return } + + // Elastic Limit Detection (Vertical HUD) + if Defaults[.enableVerticalHUD] { + let volume = volumeController.currentVolume + if direction == .up && volume >= 0.99 { + Task { @MainActor in VerticalHUDWindowManager.shared.triggerBump(direction: 1) } + } else if direction == .down && volume <= 0.01 { + Task { @MainActor in VerticalHUDWindowManager.shared.triggerBump(direction: -1) } + } + } + + let baseStep = volumeStep(for: step) + let delta = direction == .up ? baseStep : -baseStep + volumeController.adjust(by: delta) + volumeFeedbackPlayer.playIfNeeded() + } + + func mediaKeyInterceptorDidToggleMute(_ interceptor: MediaKeyInterceptor) { + guard volumeEnabled else { return } + volumeController.toggleMute() + } + + func mediaKeyInterceptor( + _ interceptor: MediaKeyInterceptor, + didReceiveBrightnessCommand direction: MediaKeyDirection, + step: MediaKeyStep, + isRepeat: Bool, + modifiers: NSEvent.ModifierFlags + ) { + // Elastic Limit Detection (Vertical HUD) + if Defaults[.enableVerticalHUD] { + let brightness = brightnessController.currentBrightness + if direction == .up && brightness >= 0.99 { + Task { @MainActor in VerticalHUDWindowManager.shared.triggerBump(direction: 1) } + } else if direction == .down && brightness <= 0.01 { + Task { @MainActor in VerticalHUDWindowManager.shared.triggerBump(direction: -1) } + } + } + + let baseStep = brightnessStep(for: step) + let delta = direction == .up ? baseStep : -baseStep + if modifiers.contains(.command) && keyboardBacklightEnabled { + keyboardBacklightController.adjust(by: delta) + } else if brightnessEnabled { + brightnessController.adjust(by: delta) + } + } + + // MARK: - HUD Dispatch + + @MainActor + private func sendVolumeNotification(value: Float, isMuted: Bool) { + if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD { + return + } + + // Send to Circular HUD if enabled + if Defaults[.enableCircularHUD] && Defaults[.enableVolumeHUD] { + Task { @MainActor in + CircularHUDWindowManager.shared.show(type: .volume, value: CGFloat(value)) + } + return + } + + // Send to Vertical HUD if enabled + if Defaults[.enableVerticalHUD] && Defaults[.enableVolumeHUD] { + let icon = resolvedVolumeIcon(isMuted: isMuted) + VerticalHUDWindowManager.shared.show(type: .volume, value: CGFloat(value), icon: icon) + return + } + + // Send to custom OSD if enabled + if Defaults[.enableCustomOSD] && Defaults[.enableOSDVolume] { + Task { @MainActor in + CustomOSDWindowManager.shared.showVolume(value: CGFloat(value)) + } + } + + // Send to notch HUD if enabled and OSD/Vertical/Circular is not enabled + if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] && Defaults[.enableVolumeHUD] { + Task { @MainActor in + guard let coordinator else { return } + coordinator.toggleSneakPeek( + status: true, + type: .volume, + value: CGFloat(value), + icon: "" + ) + } + } + } + + @MainActor + private func resolvedVolumeIcon(isMuted: Bool) -> String { + guard let icon = BluetoothAudioManager.shared.activeDeviceIconSymbol() else { return "" } + if isMuted && Self.headsetIconSymbols.contains(icon) { + return "headphones.slash" + } + return icon + } + + private func sendBrightnessNotification(value: Float) { + // Send to Circular HUD if enabled + if Defaults[.enableCircularHUD] && Defaults[.enableBrightnessHUD] { + Task { @MainActor in + CircularHUDWindowManager.shared.show(type: .brightness, value: CGFloat(value)) + } + return + } + + // Send to Vertical HUD if enabled + if Defaults[.enableVerticalHUD] && Defaults[.enableBrightnessHUD] { + Task { @MainActor in + VerticalHUDWindowManager.shared.show(type: .brightness, value: CGFloat(value)) + } + return + } + + // Send to custom OSD if enabled + if Defaults[.enableCustomOSD] && Defaults[.enableOSDBrightness] { + Task { @MainActor in + CustomOSDWindowManager.shared.showBrightness(value: CGFloat(value)) + } + } + + // Send to notch HUD if enabled and OSD/Vertical/Circular is not enabled + if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] && Defaults[.enableBrightnessHUD] { + Task { @MainActor in + guard let coordinator else { return } + coordinator.toggleSneakPeek( + status: true, + type: .brightness, + value: CGFloat(value), + icon: "" + ) + } + } + } + + private func sendKeyboardBacklightNotification(value: Float) { + // Send to Circular HUD if enabled + if Defaults[.enableCircularHUD] && Defaults[.enableKeyboardBacklightHUD] { + Task { @MainActor in + CircularHUDWindowManager.shared.show(type: .backlight, value: CGFloat(value)) + } + return + } + + // Send to Vertical HUD if enabled + if Defaults[.enableVerticalHUD] && Defaults[.enableKeyboardBacklightHUD] { + Task { @MainActor in + VerticalHUDWindowManager.shared.show(type: .backlight, value: CGFloat(value)) + } + return + } + + // Send to custom OSD if enabled + if Defaults[.enableCustomOSD] && Defaults[.enableOSDKeyboardBacklight] { + Task { @MainActor in + CustomOSDWindowManager.shared.showBacklight(value: CGFloat(value)) + } + } + + // Send to notch HUD if enabled and OSD/Vertical/Circular is not enabled + if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] && Defaults[.enableKeyboardBacklightHUD] { + Task { @MainActor in + guard let coordinator else { return } + coordinator.toggleSneakPeek( + status: true, + type: .backlight, + value: CGFloat(value), + icon: "" + ) + } + } + } + + private func configureKeyboardBacklightCallback() { + if keyboardBacklightEnabled { + keyboardBacklightController.onBacklightChange = { [weak self] value in + guard let self, self.keyboardBacklightEnabled else { return } + self.sendKeyboardBacklightNotification(value: value) + } + } else { + keyboardBacklightController.onBacklightChange = nil + } + } +} + +private extension SystemChangesObserver { + func volumeStep(for step: MediaKeyStep) -> Float { + switch step { + case .standard: + return standardVolumeStep + case .fine: + return fineVolumeStep + } + } + + func brightnessStep(for step: MediaKeyStep) -> Float { + switch step { + case .standard: + return standardBrightnessStep + case .fine: + return fineBrightnessStep + } + } +} + +private final class VolumeFeedbackPlayer { + static let shared = VolumeFeedbackPlayer() + + private let queue = DispatchQueue(label: "com.dynamicisland.volume-feedback-player") + private var player: AVAudioPlayer? + private var lastPlayDate: Date = .distantPast + private let minimumInterval: TimeInterval = 0.08 + private var didLogMissingAsset = false + + private init() {} + + func playIfNeeded() { + guard Defaults[.playVolumeChangeFeedback] else { return } + queue.async { [weak self] in + guard let self else { return } + let now = Date() + guard now.timeIntervalSince(self.lastPlayDate) >= self.minimumInterval else { return } + self.lastPlayDate = now + self.preparePlayerIfNeeded() + guard let player = self.player else { return } + player.currentTime = 0 + player.play() + } + } + + private func preparePlayerIfNeeded() { + if player != nil { return } + guard let url = Bundle.main.url(forResource: "audio-feedback", withExtension: "m4a") else { + if !didLogMissingAsset { + NSLog("⚠️ audio-feedback.m4a is missing from the app bundle; volume feedback disabled.") + didLogMissingAsset = true + } + return + } + + do { + let audioPlayer = try AVAudioPlayer(contentsOf: url) + audioPlayer.volume = 1 + audioPlayer.prepareToPlay() + player = audioPlayer + } catch { + if !didLogMissingAsset { + NSLog("⚠️ Failed to initialize volume feedback player: \(error.localizedDescription)") + didLogMissingAsset = true + } + } + } +} + + diff --git a/DynamicIsland/managers/SystemDisplayManager.swift b/DynamicIsland/managers/SystemDisplayManager.swift new file mode 100644 index 00000000..93fa3773 --- /dev/null +++ b/DynamicIsland/managers/SystemDisplayManager.swift @@ -0,0 +1,83 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Cocoa + +class SystemDisplayManager { + private init() {} + + private static var method = SensorMethod.standard + + static func getDisplayBrightness() throws -> Float { + switch SystemDisplayManager.method { + case .standard: + do { + return try getStandardDisplayBrightness() + } catch { + method = .m1 + } + case .m1: + do { + return try getM1DisplayBrightness() + } catch { + method = .allFailed + } + case .allFailed: + throw SensorError.Display.notFound + } + return try getDisplayBrightness() + } + + private static func getStandardDisplayBrightness() throws -> Float { + var brightness: float_t = 1 + let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IODisplayConnect")) + defer { + IOObjectRelease(service) + } + + let result = IODisplayGetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, &brightness) + if result != kIOReturnSuccess { + throw SensorError.Display.notStandard + } + return brightness + } + + private static func getM1DisplayBrightness() throws -> Float { + let task = Process() + task.launchPath = "/usr/libexec/corebrightnessdiag" + task.arguments = ["status-info"] + let pipe = Pipe() + task.standardOutput = pipe + try task.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + + if let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? NSDictionary, + let displays = plist["CBDisplays"] as? [String: [String: Any]] { + for display in displays.values { + if let displayInfo = display["Display"] as? [String: Any], + displayInfo["DisplayServicesIsBuiltInDisplay"] as? Bool == true, + let brightness = displayInfo["DisplayServicesBrightness"] as? Float { + return brightness + } + } + } + throw SensorError.Display.notSilicon + } +} \ No newline at end of file diff --git a/DynamicIsland/managers/SystemHUDManager.swift b/DynamicIsland/managers/SystemHUDManager.swift new file mode 100644 index 00000000..b7243e6a --- /dev/null +++ b/DynamicIsland/managers/SystemHUDManager.swift @@ -0,0 +1,320 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Defaults +import Combine + +class SystemHUDManager { + static let shared = SystemHUDManager() + + private var changesObserver: SystemChangesObserver? + private weak var coordinator: DynamicIslandViewCoordinator? + private var isSetupComplete = false + private var isSystemOperationInProgress = false + + private init() { + // Set up observer for settings changes + setupSettingsObserver() + } + + private func setupSettingsObserver() { + // Observe Vertical HUD toggle + Defaults.publisher(.enableVerticalHUD, options: []).sink { [weak self] change in + guard let self = self, self.isSetupComplete else { + return + } + Task { @MainActor in + if change.newValue { + // Always restart to ensure correct flags are applied + await self.startSystemObserver() + } else if Defaults[.enableSystemHUD] || Defaults[.enableCustomOSD] || Defaults[.enableCircularHUD] { + // Restart to apply flags for the remaining active HUD + await self.startSystemObserver() + } else { + await self.stopSystemObserver() + } + } + }.store(in: &cancellables) + + // Observe master HUD toggle + Defaults.publisher(.enableSystemHUD, options: []).sink { [weak self] change in + guard let self = self, self.isSetupComplete else { + return + } + Task { @MainActor in + if change.newValue && !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] { + // Always restart to ensure correct flags are applied + await self.startSystemObserver() + } else if !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] { + // Only stop if others are also disabled + await self.stopSystemObserver() + } else if change.newValue == false && (Defaults[.enableCustomOSD] || Defaults[.enableVerticalHUD] || Defaults[.enableCircularHUD]) { + // If disabling system HUD but others are active, restart to update flags + await self.startSystemObserver() + } + } + }.store(in: &cancellables) + + // Observe OSD toggle + Defaults.publisher(.enableCustomOSD, options: []).sink { [weak self] change in + guard let self = self, self.isSetupComplete else { + return + } + Task { @MainActor in + if change.newValue { + // Always restart to ensure correct flags are applied + await self.startSystemObserver() + } else if Defaults[.enableSystemHUD] || Defaults[.enableVerticalHUD] || Defaults[.enableCircularHUD] { + // Restart to apply flags for the remaining active HUD + await self.startSystemObserver() + } else { + await self.stopSystemObserver() + } + } + }.store(in: &cancellables) + + // Observe Circular HUD toggle + Defaults.publisher(.enableCircularHUD, options: []).sink { [weak self] change in + guard let self = self, self.isSetupComplete else { + return + } + Task { @MainActor in + if change.newValue { + // Always restart to ensure correct flags are applied + await self.startSystemObserver() + } else if Defaults[.enableSystemHUD] || Defaults[.enableCustomOSD] || Defaults[.enableVerticalHUD] { + // Restart to apply flags for the remaining active HUD + await self.startSystemObserver() + } else { + await self.stopSystemObserver() + } + } + }.store(in: &cancellables) + + // Observe individual HUD toggles + Defaults.publisher(.enableVolumeHUD, options: []).sink { [weak self] _ in + guard let self = self, self.isSetupComplete, self.requiresSystemToggleHandling else { + return + } + let flags = self.resolvedControlFlags() + self.changesObserver?.update( + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight + ) + }.store(in: &cancellables) + + Defaults.publisher(.enableBrightnessHUD, options: []).sink { [weak self] _ in + guard let self = self, self.isSetupComplete, self.requiresSystemToggleHandling else { + return + } + let flags = self.resolvedControlFlags() + self.changesObserver?.update( + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight + ) + }.store(in: &cancellables) + + Defaults.publisher(.enableKeyboardBacklightHUD, options: []).sink { [weak self] _ in + guard let self = self, self.isSetupComplete, self.requiresSystemToggleHandling else { + return + } + let flags = self.resolvedControlFlags() + self.changesObserver?.update( + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight + ) + }.store(in: &cancellables) + + // Observe individual OSD toggles + Defaults.publisher(.enableOSDVolume, options: []).sink { [weak self] _ in + guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else { + return + } + let flags = self.resolvedControlFlags() + self.changesObserver?.update( + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight + ) + }.store(in: &cancellables) + + Defaults.publisher(.enableOSDBrightness, options: []).sink { [weak self] _ in + guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else { + return + } + let flags = self.resolvedControlFlags() + self.changesObserver?.update( + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight + ) + }.store(in: &cancellables) + + Defaults.publisher(.enableOSDKeyboardBacklight, options: []).sink { [weak self] _ in + guard let self = self, self.isSetupComplete, Defaults[.enableCustomOSD] else { + return + } + let flags = self.resolvedControlFlags() + self.changesObserver?.update( + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight + ) + }.store(in: &cancellables) + + // Restart observer when third-party DDC integration state changes. + Defaults.publisher(.enableThirdPartyDDCIntegration, options: []).sink { [weak self] _ in + guard let self = self, self.isSetupComplete else { return } + Task { @MainActor in + await self.startSystemObserver() + } + }.store(in: &cancellables) + + Defaults.publisher(.thirdPartyDDCProvider, options: []).sink { [weak self] _ in + guard let self = self, self.isSetupComplete else { return } + Task { @MainActor in + await self.startSystemObserver() + } + }.store(in: &cancellables) + + Defaults.publisher(.enableExternalVolumeControlListener, options: []).sink { [weak self] _ in + guard let self = self, self.isSetupComplete else { return } + Task { @MainActor in + await self.startSystemObserver() + } + }.store(in: &cancellables) + } + + private var cancellables = Set() + + private var requiresSystemToggleHandling: Bool { + Defaults[.enableSystemHUD] || Defaults[.enableVerticalHUD] || Defaults[.enableCircularHUD] + } + + /// Public property to check if system operations are in progress + var isOperationInProgress: Bool { + return isSystemOperationInProgress + } + + func setup(coordinator: DynamicIslandViewCoordinator) { + self.coordinator = coordinator + + // Initialize OSD manager + Task { @MainActor in + CustomOSDWindowManager.shared.initialize() + } + + // Start observer if any HUD/OSD is enabled + if Defaults[.enableSystemHUD] || Defaults[.enableCustomOSD] || Defaults[.enableVerticalHUD] || Defaults[.enableCircularHUD] { + Task { @MainActor in + await startSystemObserver() + self.isSetupComplete = true + } + } else { + isSetupComplete = true + } + } + + /// Resolves the effective control flags, applying third-party DDC overrides. + private func resolvedControlFlags() -> (volume: Bool, brightness: Bool, backlight: Bool) { + var volumeEnabled: Bool + var brightnessEnabled: Bool + var keyboardBacklightEnabled: Bool + + if Defaults[.enableCircularHUD] || Defaults[.enableVerticalHUD] { + volumeEnabled = Defaults[.enableVolumeHUD] + brightnessEnabled = Defaults[.enableBrightnessHUD] + keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD] + } else if Defaults[.enableCustomOSD] { + volumeEnabled = Defaults[.enableOSDVolume] + brightnessEnabled = Defaults[.enableOSDBrightness] + keyboardBacklightEnabled = Defaults[.enableOSDKeyboardBacklight] + } else { + volumeEnabled = Defaults[.enableVolumeHUD] + brightnessEnabled = Defaults[.enableBrightnessHUD] + keyboardBacklightEnabled = Defaults[.enableKeyboardBacklightHUD] + } + + // When third-party DDC integration is on, stop intercepting brightness and + // Cmd+Brightness keys so the external app receives them and sends DDC/OSD events. + if Defaults[.enableThirdPartyDDCIntegration] { + brightnessEnabled = false + keyboardBacklightEnabled = false + + // Optional: route volume exclusively from the selected external provider. + if Defaults[.enableExternalVolumeControlListener] { + volumeEnabled = false + } + } + + return (volumeEnabled, brightnessEnabled, keyboardBacklightEnabled) + } + + @MainActor + private func startSystemObserver() async { + guard let coordinator = coordinator, !isSystemOperationInProgress else { return } + + isSystemOperationInProgress = true + + // Stop any existing observer directly (cannot call stopSystemObserver() + // because isSystemOperationInProgress is already true). + changesObserver?.stopObserving() + changesObserver = nil + + changesObserver = SystemChangesObserver(coordinator: coordinator) + + let flags = resolvedControlFlags() + changesObserver?.startObserving( + volumeEnabled: flags.volume, + brightnessEnabled: flags.brightness, + keyboardBacklightEnabled: flags.backlight + ) + + // Force disable system HUD to ensure no duplicates + SystemOSDManager.disableSystemHUD() + + print("System observer started (HUD: \(Defaults[.enableSystemHUD]), OSD: \(Defaults[.enableCustomOSD]), Vertical: \(Defaults[.enableVerticalHUD]), ThirdPartyDDC: \(Defaults[.enableThirdPartyDDCIntegration]), Provider: \(Defaults[.thirdPartyDDCProvider].displayName), ExternalVolumeListener: \(Defaults[.enableExternalVolumeControlListener]))") + isSystemOperationInProgress = false + } + + @MainActor + private func stopSystemObserver() async { + guard !isSystemOperationInProgress else { return } + + isSystemOperationInProgress = true + changesObserver?.stopObserving() + changesObserver = nil + + // Re-enable system HUD when we stop observing + SystemOSDManager.enableSystemHUD() + + print("System observer stopped") + isSystemOperationInProgress = false + } + + deinit { + cancellables.removeAll() + Task { @MainActor in + await stopSystemObserver() + } + } +} \ No newline at end of file diff --git a/DynamicIsland/managers/SystemKeyObserver.swift b/DynamicIsland/managers/SystemKeyObserver.swift new file mode 100644 index 00000000..6cba1a05 --- /dev/null +++ b/DynamicIsland/managers/SystemKeyObserver.swift @@ -0,0 +1,54 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Cocoa + +class SystemKeyObserver: NSApplication { + static let volumeChanged = Notification.Name("DynamicIsland.volumeChanged") + static let brightnessChanged = Notification.Name("DynamicIsland.brightnessChanged") + static let keyboardIlluminationChanged = Notification.Name("DynamicIsland.keyboardIlluminationChanged") + + override func sendEvent(_ event: NSEvent) { + if event.type == .systemDefined && event.subtype.rawValue == 8 { + let keyCode = ((event.data1 & 0xFFFF0000) >> 16) + let keyFlags = (event.data1 & 0x0000FFFF) + // Get the key state. 0xA is KeyDown, OxB is KeyUp + let keyState = ((keyFlags & 0xFF00) >> 8) == 0xA + let keyRepeat = keyFlags & 0x1 + mediaKeyEvent(key: Int32(keyCode), state: keyState, keyRepeat: Bool(truncating: keyRepeat as NSNumber)) + } + + super.sendEvent(event) + } + + func mediaKeyEvent(key: Int32, state: Bool, keyRepeat: Bool) { + // Only send events on KeyDown. Without this check, these events will happen twice + if state { + switch key { + case NX_KEYTYPE_SOUND_DOWN, NX_KEYTYPE_SOUND_UP, NX_KEYTYPE_MUTE: + NotificationCenter.default.post(name: SystemKeyObserver.volumeChanged, object: self) + case NX_KEYTYPE_BRIGHTNESS_UP, NX_KEYTYPE_BRIGHTNESS_DOWN: + NotificationCenter.default.post(name: SystemKeyObserver.brightnessChanged, object: self) + case NX_KEYTYPE_ILLUMINATION_DOWN, NX_KEYTYPE_ILLUMINATION_UP: + NotificationCenter.default.post(name: SystemKeyObserver.keyboardIlluminationChanged, object: self) + default: + break + } + } + } +} \ No newline at end of file diff --git a/DynamicIsland/managers/SystemKeyboardBacklightController.swift b/DynamicIsland/managers/SystemKeyboardBacklightController.swift new file mode 100644 index 00000000..1c4af940 --- /dev/null +++ b/DynamicIsland/managers/SystemKeyboardBacklightController.swift @@ -0,0 +1,84 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +extension Notification.Name { + static let keyboardBacklightDidChange = Notification.Name("DynamicIsland.keyboardBacklightDidChange") +} + +final class SystemKeyboardBacklightController { + static let shared = SystemKeyboardBacklightController() + + var onBacklightChange: ((Float) -> Void)? + + private let workerQueue = DispatchQueue(label: "com.atoll.keyboardBacklight", qos: .userInitiated) + private let notificationCenter = NotificationCenter.default + private var isRunning = false + + private init() {} + + func start() { + guard !isRunning else { return } + isRunning = true + notifyCurrentLevel() + } + + func stop() { + guard isRunning else { return } + isRunning = false + } + + func adjust(by delta: Float) { + let target = currentLevel + delta + setLevel(target) + } + + func setLevel(_ value: Float) { + let clamped = max(0, min(1, value)) + workerQueue.async { + do { + try KeyboardBrightnessSensor.setLevel(clamped) + let level = (try? KeyboardBrightnessSensor.currentLevel()) ?? clamped + self.emitChange(level: level) + } catch { + NSLog("⚠️ Failed to set keyboard backlight: \(error)") + } + } + } + + var currentLevel: Float { + (try? KeyboardBrightnessSensor.currentLevel()) ?? 0 + } + + private func notifyCurrentLevel() { + workerQueue.async { + let level = (try? KeyboardBrightnessSensor.currentLevel()) ?? 0 + self.emitChange(level: level) + } + } + + private func emitChange(level: Float) { + guard isRunning else { return } + DispatchQueue.main.async { [weak self] in + guard let self, self.isRunning else { return } + self.onBacklightChange?(level) + self.notificationCenter.post(name: .keyboardBacklightDidChange, object: nil, userInfo: ["value": level]) + } + } +} diff --git a/DynamicIsland/managers/SystemMediaControllers.swift b/DynamicIsland/managers/SystemMediaControllers.swift new file mode 100644 index 00000000..8104c3ad --- /dev/null +++ b/DynamicIsland/managers/SystemMediaControllers.swift @@ -0,0 +1,796 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import CoreAudio +import CoreGraphics +import IOKit + +extension Notification.Name { + static let systemVolumeDidChange = Notification.Name("DynamicIsland.systemVolumeDidChange") + static let systemBrightnessDidChange = Notification.Name("DynamicIsland.systemBrightnessDidChange") + 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() + + var onVolumeChange: ((Float, Bool) -> Void)? + var onRouteChange: (() -> Void)? + + private let callbackQueue = DispatchQueue(label: "com.dynamicisland.volume-listener") + private var currentDeviceID: AudioDeviceID = 0 + private var listenersInstalled = false + private var volumeElement: AudioObjectPropertyElement? + private var muteElement: AudioObjectPropertyElement? + private let silenceThreshold: Float = 0.001 // Treat very low values as mute requests. + + private let candidateElements: [AudioObjectPropertyElement] = [ + kAudioObjectPropertyElementMain, + AudioObjectPropertyElement(1), + AudioObjectPropertyElement(2) + ] + + private init() { + currentDeviceID = resolveDefaultDevice() + refreshPropertyElements() + installDefaultDeviceListener() + installVolumeListeners(for: currentDeviceID) + notifyCurrentState() + } + + func start() { + // Listeners are installed during init, nothing else required. + } + + func stop() { + // We keep listeners alive for the app lifetime; clearing closures prevents UI updates. + onVolumeChange = nil + onRouteChange = nil + } + + 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) + } + + func toggleMute() { + setMuted(!isMuted) + } + + var currentVolume: Float { + getVolume() + } + + var isMuted: Bool { + getMuteState() + } + + func setVolume(_ value: Float) { + let clamped = max(0, min(1, value)) + let currentlyMuted = isMuted + + if clamped <= silenceThreshold { + if !currentlyMuted { + setMuted(true) + } + } else if currentlyMuted { + setMuted(false) + } + + let elements = volumeElements() + + if elements.isEmpty { + var volume = clamped + let status = setData(selector: kAudioDevicePropertyVolumeScalar, data: &volume) + if status != noErr { + NSLog("⚠️ Failed to set volume: \(status)") + } + } else { + for element in elements { + var volume = clamped + let status = setData(selector: kAudioDevicePropertyVolumeScalar, element: element, data: &volume) + if status != noErr { + NSLog("⚠️ Failed to set volume for element \(element): \(status)") + } else { + cache(element: element, for: kAudioDevicePropertyVolumeScalar) + } + } + } + notifyCurrentState() + } + + func setMuted(_ muted: Bool) { + var muteFlag: UInt32 = muted ? 1 : 0 + let elements = muteElements() + + if elements.isEmpty { + let status = setData(selector: kAudioDevicePropertyMute, data: &muteFlag) + if status != noErr { + NSLog("⚠️ Failed to set mute state: \(status)") + } + return + } + + for element in elements { + var value = muteFlag + let status = setData(selector: kAudioDevicePropertyMute, element: element, data: &value) + if status != noErr { + NSLog("⚠️ Failed to set mute state for element \(element): \(status)") + } else { + cache(element: element, for: kAudioDevicePropertyMute) + } + } + } + + // MARK: - Private + + private func resolveDefaultDevice() -> AudioDeviceID { + var deviceID = AudioDeviceID() + var size = UInt32(MemoryLayout.size(ofValue: deviceID)) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + let status = AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID) + if status != noErr { + NSLog("⚠️ Unable to fetch default audio device: \(status)") + } + return deviceID + } + + private func installDefaultDeviceListener() { + guard !listenersInstalled else { return } + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + let status = AudioObjectAddPropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &address, + callbackQueue + ) { [weak self] _, _ in + guard let self else { return } + self.handleDefaultDeviceChanged() + } + if status != noErr { + NSLog("⚠️ Failed to install default device listener: \(status)") + } + listenersInstalled = true + } + + private func installVolumeListeners(for deviceID: AudioDeviceID) { + if let element = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: deviceID) { + volumeElement = element + var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element) + AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue) { [weak self] _, _ in + self?.notifyCurrentState() + } + } + + if let element = resolveElement(selector: kAudioDevicePropertyMute, deviceID: deviceID) { + muteElement = element + var address = makeAddress(selector: kAudioDevicePropertyMute, element: element) + AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue) { [weak self] _, _ in + self?.notifyCurrentState() + } + } + } + + private func handleDefaultDeviceChanged() { + callbackQueue.async { [weak self] in + guard let self else { return } + self.currentDeviceID = self.resolveDefaultDevice() + self.refreshPropertyElements() + self.installVolumeListeners(for: self.currentDeviceID) + self.notifyCurrentState() + DispatchQueue.main.async { + self.onRouteChange?() + NotificationCenter.default.post(name: .systemAudioRouteDidChange, object: nil) + } + } + } + + private func notifyCurrentState() { + let volume = getVolume() + let muted = getMuteState() + DispatchQueue.main.async { + self.onVolumeChange?(volume, muted) + NotificationCenter.default.post(name: .systemVolumeDidChange, object: nil, userInfo: ["value": volume, "muted": muted]) + } + } + + private func getVolume() -> Float { + 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 + } + + var masterVolume: Float? + var accumulator: Float = 0 + var count: Float = 0 + + for element in elements { + var value = Float32(0) + let status = getData(selector: kAudioDevicePropertyVolumeScalar, element: element, data: &value) + if status == noErr { + if element == kAudioObjectPropertyElementMaster { + masterVolume = value + } + accumulator += value + count += 1 + } + } + + if let masterVolume { + return masterVolume + } + + if count > 0 { + return accumulator / count + } + + var fallback = Float32(0) + let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &fallback) + if status != noErr { + NSLog("⚠️ Unable to fetch fallback volume: \(status)") + } + return fallback + } + + private func getMuteState() -> Bool { + let elements = muteElements() + + if elements.isEmpty { + var mute: UInt32 = 0 + let status = getData(selector: kAudioDevicePropertyMute, data: &mute) + if status != noErr { + return false + } + return mute != 0 + } + + var retrieved = false + var allMuted = true + + for element in elements { + var value: UInt32 = 0 + let status = getData(selector: kAudioDevicePropertyMute, element: element, data: &value) + if status == noErr { + retrieved = true + if value == 0 { + allMuted = false + } + } + } + + if retrieved { + return allMuted + } + + var fallback: UInt32 = 0 + let status = getData(selector: kAudioDevicePropertyMute, data: &fallback) + if status != noErr { + return false + } + return fallback != 0 + } + + private func refreshPropertyElements() { + volumeElement = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: currentDeviceID) + muteElement = resolveElement(selector: kAudioDevicePropertyMute, deviceID: currentDeviceID) + } + + private func resolveElement(selector: AudioObjectPropertySelector, deviceID: AudioDeviceID) -> AudioObjectPropertyElement? { + for element in candidateElements { + var address = makeAddress(selector: selector, element: element) + if propertyExists(deviceID: deviceID, address: &address) { + return element + } + } + return nil + } + + private func preferredElements(for selector: AudioObjectPropertySelector) -> [AudioObjectPropertyElement] { + if let cached = cachedElement(for: selector) { + return [cached] + candidateElements.filter { $0 != cached } + } + return candidateElements + } + + private func cachedElement(for selector: AudioObjectPropertySelector) -> AudioObjectPropertyElement? { + switch selector { + case kAudioDevicePropertyVolumeScalar: + return volumeElement + case kAudioDevicePropertyMute: + return muteElement + default: + return nil + } + } + + private func cache(element: AudioObjectPropertyElement, for selector: AudioObjectPropertySelector) { + switch selector { + case kAudioDevicePropertyVolumeScalar: + volumeElement = element + case kAudioDevicePropertyMute: + muteElement = element + default: + break + } + } + + private func makeAddress(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement) -> AudioObjectPropertyAddress { + AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioDevicePropertyScopeOutput, + mElement: element + ) + } + + private func propertyExists(deviceID: AudioDeviceID, address: inout AudioObjectPropertyAddress) -> Bool { + withUnsafePointer(to: &address) { pointer in + AudioObjectHasProperty(deviceID, pointer) + } + } + + private func getData(selector: AudioObjectPropertySelector, data: inout T) -> OSStatus { + var lastStatus: OSStatus = kAudioHardwareUnspecifiedError + for element in preferredElements(for: selector) { + var address = makeAddress(selector: selector, element: element) + guard propertyExists(deviceID: currentDeviceID, address: &address) else { continue } + var size = UInt32(MemoryLayout.size) + lastStatus = AudioObjectGetPropertyData(currentDeviceID, &address, 0, nil, &size, &data) + if lastStatus == noErr { + cache(element: element, for: selector) + return lastStatus + } + } + return lastStatus + } + + private func setData(selector: AudioObjectPropertySelector, data: inout T) -> OSStatus { + var lastStatus: OSStatus = kAudioHardwareUnspecifiedError + for element in preferredElements(for: selector) { + var address = makeAddress(selector: selector, element: element) + guard propertyExists(deviceID: currentDeviceID, address: &address) else { continue } + let size = UInt32(MemoryLayout.size) + lastStatus = AudioObjectSetPropertyData(currentDeviceID, &address, 0, nil, size, &data) + if lastStatus == noErr { + cache(element: element, for: selector) + return lastStatus + } + } + 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 { + static let shared = SystemBrightnessController() + + var onBrightnessChange: ((Float) -> Void)? + + private let notificationCenter = NotificationCenter.default + 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 var currentBrightnessAnimationDuration: TimeInterval = 0.18 + private let brightnessAnimationSteps = 10 + private let minimumBrightnessAnimationDuration: TimeInterval = 0.08 + private let maximumBrightnessAnimationDuration: TimeInterval = 0.3 + private let brightnessAnimationDurationScale: TimeInterval = 1.6 + private var lastEmittedBrightness: Float = 0.5 + private let coreBrightnessClient = CoreBrightnessDisplayClient.shared + private var pollTimer: Timer? + private let pollInterval: TimeInterval = 0.15 + private let pollChangeThreshold: Float = 0.005 + + // MARK: - User-initiated brightness gate + // When true, brightness changes detected via polling / notifications will + // trigger the HUD. Auto-resets after `userInitiatedWindow` seconds. + private var userInitiatedBrightnessChange = false + private var userInitiatedResetTimer: Timer? + private let userInitiatedWindow: TimeInterval = 1.5 + private var didLogPollingFallback = false + + // MARK: - Emission throttling + // Prevent notification storms when the animation timer fires rapidly. + private var lastEmissionDate: Date = .distantPast + private let minimumEmissionInterval: TimeInterval = 0.04 // ~25 fps max + + private init() { + registerExternalNotifications() + lastEmittedBrightness = currentBrightness + } + + func start() { + if coreBrightnessClient.isAvailable { + NSLog("✅ SystemBrightnessController: CoreBrightnessDisplayClient is available — using notification-driven detection") + } else { + NSLog("⚠️ SystemBrightnessController: CoreBrightnessDisplayClient unavailable; will rely on DisplayServices / IODisplay + polling fallback") + } + notifyCurrentBrightness() + // Only start polling as a fallback when CoreBrightness notifications + // are unavailable. When CoreBrightness IS available the distributed + // notifications (registerExternalNotifications) handle detection. + if !coreBrightnessClient.isAvailable { + startPolling() + } + } + + func stop() { + onBrightnessChange = nil + brightnessAnimationTimer?.invalidate() + brightnessAnimationTimer = nil + pollTimer?.invalidate() + pollTimer = nil + userInitiatedResetTimer?.invalidate() + userInitiatedResetTimer = nil + userInitiatedBrightnessChange = false + } + + func adjust(by delta: Float) { + // Refresh baseline to avoid jumping if auto-brightness changed the level. + syncWithSystemBrightnessIfNeeded() + markUserInitiated() + let target = max(0, min(1, lastEmittedBrightness + delta)) + DispatchQueue.main.async { [weak self] in + self?.beginBrightnessAnimation(to: target) + } + } + + func setBrightness(_ value: Float) { + let clamped = max(0, min(1, value)) + markUserInitiated() + DispatchQueue.main.async { [weak self] in + self?.beginBrightnessAnimation(to: clamped) + } + } + + // MARK: - User-initiated helpers + + /// Marks the current brightness change as user-initiated (key press). + /// Automatically resets after `userInitiatedWindow` seconds. + private func markUserInitiated() { + userInitiatedBrightnessChange = true + userInitiatedResetTimer?.invalidate() + userInitiatedResetTimer = Timer.scheduledTimer(withTimeInterval: userInitiatedWindow, repeats: false) { [weak self] _ in + self?.userInitiatedBrightnessChange = false + } + } + + var currentBrightness: Float { + if let level = coreBrightnessClient.currentBrightness() { + return level + } + if let level = getBrightnessViaDisplayServices() { + return level + } + guard let service = displayService() else { return 0.5 } + var brightness: Float = 0 + let result = IODisplayGetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, &brightness) + IOObjectRelease(service) + if result != kIOReturnSuccess { + return 0.5 + } + return brightness + } + + private func notifyCurrentBrightness() { + let brightness = currentBrightness + emitBrightnessChange(value: brightness) + } + + private func syncWithSystemBrightnessIfNeeded() { + // Align our internal baseline with the actual system brightness so that + // subsequent adjustments apply deltas from the true value (important when + // auto-brightness has changed the level behind our back). + let systemLevel = currentBrightness + if abs(systemLevel - lastEmittedBrightness) > 0.001 { + // Only update the baseline — don't emit to avoid spurious HUD flashes. + lastEmittedBrightness = systemLevel + } + } + + private func beginBrightnessAnimation(to target: Float) { + brightnessAnimationTimer?.invalidate() + + // Refresh baseline from system in case auto-brightness adjusted it. + syncWithSystemBrightnessIfNeeded() + + let start = lastEmittedBrightness + if abs(start - target) <= 0.0005 { + applyBrightness(target) + emitBrightnessChange(value: target) + return + } + + brightnessAnimationStart = start + brightnessAnimationTarget = target + brightnessAnimationStartDate = Date() + currentBrightnessAnimationDuration = animationDuration(forDelta: abs(target - start)) + + let interval = currentBrightnessAnimationDuration / Double(brightnessAnimationSteps) + let timer = Timer(timeInterval: interval, repeats: true) { [weak self] timer in + guard let self else { + timer.invalidate() + return + } + guard let startDate = self.brightnessAnimationStartDate else { + timer.invalidate() + self.brightnessAnimationTimer = nil + return + } + let elapsed = Date().timeIntervalSince(startDate) + let progress = min(elapsed / self.currentBrightnessAnimationDuration, 1) + let eased = self.ease(progress) + let value = self.brightnessAnimationStart + (self.brightnessAnimationTarget - self.brightnessAnimationStart) * Float(eased) + self.applyBrightness(value) + if progress >= 1 { + // Final value — force-emit to guarantee the UI reaches the target. + self.emitBrightnessChange(value: value, force: true) + timer.invalidate() + self.brightnessAnimationTimer = nil + } else { + // Intermediate step — throttled emission. + self.emitBrightnessChange(value: value) + } + } + brightnessAnimationTimer = timer + RunLoop.main.add(timer, forMode: .common) + timer.fire() + } + + private func animationDuration(forDelta delta: Float) -> TimeInterval { + let scaled = minimumBrightnessAnimationDuration + TimeInterval(delta) * brightnessAnimationDurationScale + return min(maximumBrightnessAnimationDuration, max(minimumBrightnessAnimationDuration, scaled)) + } + + private func applyBrightness(_ value: Float) { + let clamped = max(0, min(1, value)) + if coreBrightnessClient.setBrightness(clamped) { + return + } + 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, force: Bool = false) { + let clamped = max(0, min(1, value)) + lastEmittedBrightness = clamped + + // Throttle rapid emissions to avoid notification storms when the + // animation timer fires ~10 times per step during key-spam. + if !force { + let now = Date() + guard now.timeIntervalSince(lastEmissionDate) >= minimumEmissionInterval else { return } + lastEmissionDate = now + } + + let dispatchBlock = { [weak self] in + guard let self else { return } + 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? { + var iterator = io_iterator_t() + guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IODisplayConnect"), &iterator) == KERN_SUCCESS else { + return nil + } + let service = IOIteratorNext(iterator) + IOObjectRelease(iterator) + return service + } + + private func setBrightnessViaDisplayServices(_ value: Float) -> Bool { + guard let status = DisplayServicesDynamic.shared.setBrightness(displayID: displayID, value: value) else { + return false + } + if status == kIOReturnSuccess { + return true + } + // Attempt to refresh display ID in case the main display changed + displayID = CGMainDisplayID() + guard let retry = DisplayServicesDynamic.shared.setBrightness(displayID: displayID, value: value) else { + NSLog("⚠️ DisplayServicesSetBrightness unavailable after display refresh") + return false + } + if retry != kIOReturnSuccess { + NSLog("⚠️ DisplayServicesSetBrightness failed: \(retry)") + return false + } + return true + } + + private func getBrightnessViaDisplayServices() -> Float? { + guard let result = DisplayServicesDynamic.shared.getBrightness(displayID: displayID) else { + return nil + } + if result.status == kIOReturnSuccess { + return result.value + } + displayID = CGMainDisplayID() + guard let retry = DisplayServicesDynamic.shared.getBrightness(displayID: displayID) else { + NSLog("⚠️ DisplayServicesGetBrightness unavailable after display refresh") + return nil + } + if retry.status == kIOReturnSuccess { + return retry.value + } + NSLog("⚠️ DisplayServicesGetBrightness failed: \(retry.status)") + return nil + } + + private func registerExternalNotifications() { + guard !notificationsInstalled else { return } + let names = [ + Notification.Name("com.apple.BezelEngine.BrightnessChanged"), + Notification.Name("com.apple.BezelServices.BrightnessChanged"), + Notification.Name("com.apple.controlcenter.display.brightness"), + Notification.Name("com.apple.CoreBrightness.DisplayBrightnessChanged") + ] + observers = names.map { name in + DistributedNotificationCenter.default().addObserver(forName: name, object: nil, queue: .main) { [weak self] notification in + guard let self else { return } + // Always keep our baseline in sync with the actual system brightness + // so that subsequent key-press deltas are accurate, but only fire the + // HUD callback when the change was user-initiated (key press). + let system = self.currentBrightness + if self.userInitiatedBrightnessChange { + self.notifyCurrentBrightness() + } else { + // Silently absorb auto-brightness change — update baseline only. + self.lastEmittedBrightness = max(0, min(1, system)) + } + } + } + notificationsInstalled = true + } + + private func startPolling() { + guard pollTimer == nil else { return } + NSLog("ℹ️ SystemBrightnessController: Starting polling-driven brightness detection as fallback (interval: %.2fs)", pollInterval) + pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in + guard let self else { return } + // Skip polling while an animation is actively running — the + // animation timer already handles emission during key presses. + guard self.brightnessAnimationTimer == nil else { return } + let system = self.currentBrightness + guard abs(system - self.lastEmittedBrightness) > self.pollChangeThreshold else { return } + + if self.userInitiatedBrightnessChange { + // User recently pressed a brightness key — show the HUD. + if !self.didLogPollingFallback { + NSLog("ℹ️ SystemBrightnessController: Brightness change detected via polling fallback (value: %.3f)", system) + self.didLogPollingFallback = true + } + self.emitBrightnessChange(value: system) + } else { + // Auto-brightness or external change — absorb silently. + self.lastEmittedBrightness = max(0, min(1, system)) + } + } + } + + deinit { + brightnessAnimationTimer?.invalidate() + pollTimer?.invalidate() + userInitiatedResetTimer?.invalidate() + observers.forEach { DistributedNotificationCenter.default().removeObserver($0) } + } +} diff --git a/DynamicIsland/managers/SystemOSDManager.swift b/DynamicIsland/managers/SystemOSDManager.swift new file mode 100644 index 00000000..9281fda0 --- /dev/null +++ b/DynamicIsland/managers/SystemOSDManager.swift @@ -0,0 +1,264 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import os + +class SystemOSDManager { + private init() {} + + // Tracks the PID we most recently suspended. macOS jetsam-exits OSDUIHelper + // when idle and launchd respawns it on the next media-key press as a fresh + // process, so we need to re-SIGSTOP every new incarnation. + private struct SuppressionState { + var task: Task? + var lastSuspendedPID: Int32 = -1 + } + private static let suppressionState = OSAllocatedUnfairLock(initialState: SuppressionState()) + + /// Re-enables the system HUD by restarting OSDUIHelper + public static func enableSystemHUD() { + stopSuppressionWatcher() + Task.detached(priority: .background) { + await enableSystemHUDAsync() + } + } + + private static func enableSystemHUDAsync() async { + do { + // First, stop any existing OSDUIHelper process + let stopTask = Process() + stopTask.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + stopTask.arguments = ["-9", "OSDUIHelper"] + try stopTask.run() + stopTask.waitUntilExit() + + // Small delay to ensure process is fully stopped + try await Task.sleep(nanoseconds: 200_000_000) // 200ms + + // Then kickstart it again to ensure it's running properly + let kickstart = Process() + kickstart.executableURL = URL(fileURLWithPath: "/bin/launchctl") + kickstart.arguments = ["kickstart", "gui/\(getuid())/com.apple.OSDUIHelper"] + try kickstart.run() + kickstart.waitUntilExit() + + // Additional delay to ensure service is fully started + try await Task.sleep(nanoseconds: 300_000_000) // 300ms + + await MainActor.run { + print("✅ System HUD re-enabled") + } + } catch { + await MainActor.run { + NSLog("❌ Error while trying to re-enable OSDUIHelper: \(error)") + } + + // Fallback: Try to restart the service using launchctl load + do { + let fallbackTask = Process() + fallbackTask.executableURL = URL(fileURLWithPath: "/bin/launchctl") + fallbackTask.arguments = ["load", "-w", "/System/Library/LaunchAgents/com.apple.OSDUIHelper.plist"] + try fallbackTask.run() + fallbackTask.waitUntilExit() + + await MainActor.run { + print("✅ System HUD re-enabled via fallback method") + } + } catch { + await MainActor.run { + NSLog("❌ Fallback method also failed: \(error)") + } + } + } + } + + /// Disables the system HUD by stopping OSDUIHelper, and starts a + /// background watcher that re-suspends any future incarnation launchd + /// spawns (macOS auto-exits OSDUIHelper on idle). + public static func disableSystemHUD() { + Task.detached(priority: .background) { + await disableSystemHUDAsync() + } + startSuppressionWatcher() + } + + private static func disableSystemHUDAsync() async { + do { + let kickstart = Process() + kickstart.executableURL = URL(fileURLWithPath: "/bin/launchctl") + // When macOS boots, OSDUIHelper does not start until a volume button is pressed. We can workaround this by kickstarting it. + kickstart.arguments = ["kickstart", "gui/\(getuid())/com.apple.OSDUIHelper"] + try kickstart.run() + kickstart.waitUntilExit() + + // launchctl kickstart returns once the request is queued, not after + // OSDUIHelper has actually forked. At cold boot the helper can take + // a while to appear — a fixed sleep races and the SIGSTOP misses, + // letting the native OSD render on the first volume/brightness key. + // Poll for the PID up to ~5s, then suspend, and retry if launchd + // respawned a fresh copy between kickstart and SIGSTOP. + var attempts = 0 + while attempts < 3 { + let appeared = await waitForOSDUIHelper(timeoutMillis: 5000) + if !appeared { + await MainActor.run { + NSLog("⚠️ OSDUIHelper did not appear within timeout; retrying SIGSTOP anyway") + } + } + + suspendOSDUIHelper() + + // Settle, then confirm a process is actually present (and thus + // suspended). If none is running, launchd hasn't spawned it yet + // or the prior STOP raced — loop and try again. + try await Task.sleep(nanoseconds: 250_000_000) // 250ms + if let pid = osduiHelperPID() { + suppressionState.withLock { $0.lastSuspendedPID = pid } + break + } + attempts += 1 + } + + await MainActor.run { + print("✅ System HUD disabled") + } + } catch { + await MainActor.run { + NSLog("❌ Error while trying to hide OSDUIHelper: \(error)") + } + } + } + + /// Polls for an OSDUIHelper process, returning true as soon as one appears + /// or false if `timeoutMillis` elapses with no match. + private static func waitForOSDUIHelper(timeoutMillis: Int) async -> Bool { + let pollIntervalNanos: UInt64 = 200_000_000 // 200ms + let maxAttempts = max(1, timeoutMillis / 200) + for _ in 0.. Task? in + let prior = state.task + state.task = newTask + return prior + } + previous?.cancel() + } + + private static func stopSuppressionWatcher() { + let previous = suppressionState.withLock { state -> Task? in + let prior = state.task + state.task = nil + state.lastSuspendedPID = -1 + return prior + } + previous?.cancel() + } + + /// Returns the newest OSDUIHelper PID, or nil if none. + private static func osduiHelperPID() -> Int32? { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") + task.arguments = ["-n", "OSDUIHelper"] + let pipe = Pipe() + task.standardOutput = pipe + do { + try task.run() + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let trimmed = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return Int32(trimmed) + } catch { + return nil + } + } + + /// Sends SIGSTOP to all OSDUIHelper processes. Idempotent. + private static func suspendOSDUIHelper() { + let stop = Process() + stop.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + stop.arguments = ["-STOP", "OSDUIHelper"] + do { + try stop.run() + stop.waitUntilExit() + } catch { + NSLog("Suppression watcher: failed to SIGSTOP OSDUIHelper: \(error)") + } + } + + /// Check if OSDUIHelper is currently running + public static func isOSDUIHelperRunning() -> Bool { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") + task.arguments = ["OSDUIHelper"] + + let pipe = Pipe() + task.standardOutput = pipe + + do { + try task.run() + task.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + + return task.terminationStatus == 0 && !output!.isEmpty + } catch { + return false + } + } + + /// Async version of status checking to avoid main thread blocking + public static func isOSDUIHelperRunningAsync() async -> Bool { + return await withCheckedContinuation { continuation in + Task.detached(priority: .background) { + let result = isOSDUIHelperRunning() + continuation.resume(returning: result) + } + } + } +} \ No newline at end of file diff --git a/DynamicIsland/managers/SystemTimerBridge.swift b/DynamicIsland/managers/SystemTimerBridge.swift new file mode 100644 index 00000000..29cb381a --- /dev/null +++ b/DynamicIsland/managers/SystemTimerBridge.swift @@ -0,0 +1,1059 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import ApplicationServices +import Combine +import Defaults +import Foundation +import OSLog +import os + +final class SystemTimerBridge { + static let shared = SystemTimerBridge() + + private let logger = os.Logger(subsystem: "com.Ebullioscopic.Atoll", category: "SystemTimerBridge") + + private struct TimerMetadata: Equatable { + enum State: Int { + case stopped = 1 + case running = 2 + case paused = 3 + case fired = 4 + case unknown = 0 + + var isActive: Bool { + switch self { + case .running, .paused: + return true + default: + return false + } + } + } + + let identifier: String + let title: String + let duration: TimeInterval + let lastModified: Date? + let firedDate: Date? + let state: State + } + + private struct ParsedTimerString { + let raw: String + let remaining: TimeInterval + let paused: Bool + } + + private struct LogTimerEntry { + let identifier: String + let state: String? + let title: String? + let duration: Double? + } + + private let domain = "com.apple.mobiletimerd" as CFString + private let preferencesPath = (NSHomeDirectory() as NSString) + .appendingPathComponent("Library/Preferences/com.apple.mobiletimerd.plist") + private let queue = DispatchQueue(label: "com.dynamicisland.systemtimer.bridge", qos: .userInitiated) + + private var metadata: TimerMetadata? + private var initialTotalDuration: TimeInterval? + private var latestRemaining: TimeInterval? + private var latestPaused: Bool = false + private var latestUpdateTimestamp: Date? + + private var logProcess: Process? + private var logPipe: Pipe? + private var logBuffer = Data() + private var logRestartWorkItem: DispatchWorkItem? + private var logPreferredName: String? + private var logPreferredDuration: TimeInterval? + private var logIdentifier: String? + private var logDidCompleteActiveTimer = false + private var nonJSONLogLineCount = 0 + + private var menuExtra: AXUIElement? + private var fileDescriptor: CInt = -1 + private var fileMonitor: DispatchSourceFileSystemObject? + private var ticker: DispatchSourceTimer? + private var defaultsCancellable: AnyCancellable? + + private var didWarnAboutAccessibility = false + + private init() { + logDebug("Initializing SystemTimerBridge (mirror enabled: \(Defaults[.mirrorSystemTimer]))") + defaultsCancellable = Defaults.publisher(.mirrorSystemTimer, options: []) + .sink { [weak self] change in + if change.newValue { + self?.logDebug("mirrorSystemTimer enabled via Defaults change") + self?.startIfNeeded() + } else { + self?.logDebug("mirrorSystemTimer disabled via Defaults change; stopping monitor") + self?.stopMonitoring(clearTimer: true) + } + } + + if Defaults[.mirrorSystemTimer] { + startIfNeeded() + } + } + + private func logDebug(_ message: String) { + logger.debug("\(message, privacy: OSLogPrivacy.public)") + } + + private func logInfo(_ message: String) { + logger.info("\(message, privacy: OSLogPrivacy.public)") + } + + private func logError(_ message: String) { + logger.error("\(message, privacy: OSLogPrivacy.public)") + } + + private func startIfNeeded() { + queue.async { [weak self] in + guard let self else { return } + guard !self.isMonitoring else { return } + self.logInfo("Starting monitoring pipeline") + + self.refreshMetadata() + self.startFileMonitor() + + let logStarted = self.startLogStream() + let hasAccessibility = AXIsProcessTrusted() + + self.logDebug("Log stream started: \(logStarted), AX trusted: \(hasAccessibility)") + + if !logStarted { + if hasAccessibility { + self.logInfo("Falling back to Accessibility polling ticker") + self.setupTicker() + } else { + self.logError("Unable to start log stream and missing Accessibility permission; timer mirroring inactive") + self.postAccessibilityWarningIfNeeded() + } + } else if !hasAccessibility { + self.logDebug("Log stream active but Accessibility not trusted; AX fallback disabled") + self.postAccessibilityWarningIfNeeded() + } + } + } + + private var isMonitoring: Bool { + logProcess != nil || ticker != nil || fileMonitor != nil + } + + private func stopMonitoring(clearTimer: Bool) { + queue.async { [weak self] in + guard let self else { return } + self.logInfo("Stopping monitoring pipeline (clearTimer: \(clearTimer))") + + self.stopLogStream() + + self.ticker?.cancel() + self.ticker = nil + + self.fileMonitor?.cancel() + self.fileMonitor = nil + + if self.fileDescriptor != -1 { + close(self.fileDescriptor) + self.fileDescriptor = -1 + } + + self.menuExtra = nil + self.initialTotalDuration = nil + self.metadata = nil + self.latestRemaining = nil + self.latestPaused = false + self.latestUpdateTimestamp = nil + self.logPreferredName = nil + self.logPreferredDuration = nil + self.logIdentifier = nil + self.logDidCompleteActiveTimer = false + self.logRestartWorkItem?.cancel() + self.logRestartWorkItem = nil + + if clearTimer { + self.logDebug("Clearing external timer state via TimerManager") + DispatchQueue.main.async { + TimerManager.shared.endExternalTimer(triggerSmoothClose: false) + } + } + } + } + + private func setupTicker() { + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now(), repeating: .seconds(1), leeway: .milliseconds(150)) + timer.setEventHandler { [weak self] in + self?.pollMenuExtra() + } + timer.resume() + ticker = timer + logInfo("Accessibility ticker active") + } + + @discardableResult + private func startLogStream() -> Bool { + if let process = logProcess, process.isRunning { + return true + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/log") + let predicate = "subsystem == \"com.apple.mobiletimer.logging\"" + process.arguments = [ + "stream", + "--style", + "ndjson", + "--predicate", + predicate, + "--level", + "debug" + ] + + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = Pipe() + + logBuffer.removeAll(keepingCapacity: false) + nonJSONLogLineCount = 0 + logPipe = outputPipe + + outputPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + self?.queue.async { [weak self] in + self?.consumeLogData(data) + } + } + + process.terminationHandler = { [weak self] _ in + self?.queue.async { [weak self] in + self?.handleLogStreamTermination() + } + } + + do { + try process.run() + logProcess = process + logInfo("Log stream started (pid: \(process.processIdentifier))") + return true + } catch { + logError("Failed to start log stream: \(error.localizedDescription)") + outputPipe.fileHandleForReading.readabilityHandler = nil + outputPipe.fileHandleForReading.closeFile() + logPipe = nil + return false + } + } + + private func stopLogStream() { + logRestartWorkItem?.cancel() + logRestartWorkItem = nil + + logPipe?.fileHandleForReading.readabilityHandler = nil + logPipe?.fileHandleForReading.closeFile() + logPipe = nil + + if let process = logProcess { + process.terminationHandler = nil + if process.isRunning { + process.terminate() + process.waitUntilExit() + logInfo("Log stream terminated (pid: \(process.processIdentifier))") + } + } + + logProcess = nil + logBuffer.removeAll(keepingCapacity: false) + } + + private func handleLogStreamTermination() { + logPipe?.fileHandleForReading.readabilityHandler = nil + logPipe?.fileHandleForReading.closeFile() + logPipe = nil + logProcess = nil + logBuffer.removeAll(keepingCapacity: false) + logError("Log stream exited unexpectedly; scheduling restart") + + guard Defaults[.mirrorSystemTimer] else { return } + + if AXIsProcessTrusted(), ticker == nil { + setupTicker() + } + + scheduleLogStreamRestart() + } + + private func scheduleLogStreamRestart() { + logRestartWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.logRestartWorkItem = nil + let started = self.startLogStream() + if !started, AXIsProcessTrusted(), self.ticker == nil { + self.setupTicker() + } + } + + logRestartWorkItem = workItem + queue.asyncAfter(deadline: .now() + 2.0, execute: workItem) + logDebug("Scheduled log stream restart in 2s") + } + + private func consumeLogData(_ data: Data) { + logBuffer.append(data) + + while let newlineIndex = logBuffer.firstIndex(of: UInt8(10)) { + let lineData = Data(logBuffer[.. 0, (initialTotalDuration ?? 0) < durationFromMetadata { + initialTotalDuration = durationFromMetadata + } + } + + if initialTotalDuration == nil { + initialTotalDuration = max(durationFromMetadata, remaining) + } else if remaining > (initialTotalDuration ?? 0) { + initialTotalDuration = remaining + } + + let baseTotal = max(durationFromMetadata, remaining > 0 ? remaining : 0) + let total = initialTotalDuration ?? baseTotal + + let trimmedLogName = logPreferredName?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedMetadataName = metadata?.title.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName: String + + if let name = trimmedLogName, !name.isEmpty { + displayName = name + } else if let name = trimmedMetadataName, !name.isEmpty { + displayName = name + } else { + displayName = "Clock Timer" + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if TimerManager.shared.isExternalTimerActive { + self.logDebug("Updating existing external timer (total: \(total))") + TimerManager.shared.updateExternalTimer( + remaining: remaining, + totalDuration: total, + isPaused: paused, + name: displayName + ) + } else { + self.logInfo("Adopting system timer as external source (name: \(displayName))") + TimerManager.shared.adoptExternalTimer( + name: displayName, + totalDuration: total, + remaining: remaining, + isPaused: paused + ) + } + + if remaining <= 0 { + self.logDidCompleteActiveTimer = true + self.logInfo("System timer reported completion") + TimerManager.shared.completeExternalTimer() + } else { + self.logDidCompleteActiveTimer = false + } + } + } + + private func handleMissingMenuExtra() { + guard logIdentifier == nil else { return } + guard TimerManager.shared.isExternalTimerActive else { return } + + logDebug("Timer menu extra missing; ending external timer") + DispatchQueue.main.async { + TimerManager.shared.endExternalTimer(triggerSmoothClose: true) + } + } + + private func handleLogEvent(_ payload: [String: Any]) { + guard Defaults[.mirrorSystemTimer] else { return } + guard !TimerManager.shared.hasManualTimerRunning else { + logDebug("Ignoring log event while manual timer active") + return + } + guard let message = payload["eventMessage"] as? String, !message.isEmpty else { return } + + logDebug("Log event: \(message)") + + if message.contains("scheduled timers:") { + handleScheduledTimersMessage(message) + } + + if message.contains("Timer will fire") { + handleTimerWillFireMessage(message) + } + + if message.contains("remainingTime:") { + handleRemainingTimeMessage(message) + } + + if message.contains("next timer changed:") { + handleNextTimerChangedMessage(message) + } + + if message.contains("started timer:") { + handleTimerStartedMessage(message) + } + + if message.contains("Timer stopped") { + handleTimerStoppedMessage(message) + } + } + + private func handleScheduledTimersMessage(_ message: String) { + let entries = parseLogTimerEntries(from: message) + guard !entries.isEmpty else { + logDebug("Scheduled timers event without timer entries; preserving state") + return + } + + let normalizedCurrentId = logIdentifier?.uppercased() + + let selectedEntry: LogTimerEntry? + + if let currentId = normalizedCurrentId, + let current = entries.first(where: { $0.identifier == currentId }) { + selectedEntry = current + } else if let active = entries.first(where: { ($0.state == "running" || $0.state == "paused") }) { + selectedEntry = active + } else if normalizedCurrentId == nil, + let fired = entries.first(where: { $0.state == "fired" }) { + selectedEntry = fired + } else { + selectedEntry = nil + } + + guard let entry = selectedEntry else { + logDebug("Scheduled timers event did not reference an active timer; keeping existing state") + return + } + + if let state = entry.state { + logDebug("Scheduled timers entry for ID \(entry.identifier) with state \(state)") + } + + if normalizedCurrentId == nil { + if let state = entry.state, state == "running" || state == "paused" || state == "fired" { + setLogIdentifier(entry.identifier) + } + } else if let currentId = normalizedCurrentId, currentId != entry.identifier { + if let state = entry.state, state == "running" || state == "paused" { + setLogIdentifier(entry.identifier) + } else { + logDebug("Ignoring timer entry for ID \(entry.identifier) because state is \(entry.state ?? "unknown")") + } + } + + if let title = entry.title { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, trimmed != "(null)" { + logPreferredName = trimmed + logDebug("Updated timer name from log: \(trimmed)") + } + } + + if let duration = entry.duration { + if let existing = logPreferredDuration { + logPreferredDuration = max(existing, duration) + } else { + logPreferredDuration = duration + } + + if (initialTotalDuration ?? 0) < duration { + initialTotalDuration = duration + } + + logDebug("Recorded duration from log: \(duration)") + } + + guard let state = entry.state else { return } + + switch state { + case "paused": + if let remaining = latestRemaining { + applyLogDrivenUpdate(remaining: remaining, isPaused: true) + } else { + latestPaused = true + } + case "running": + if let remaining = latestRemaining { + applyLogDrivenUpdate(remaining: remaining, isPaused: false) + } else { + latestPaused = false + } + case "fired": + latestPaused = false + applyLogDrivenUpdate(remaining: 0, isPaused: false) + case "stopped": + latestPaused = false + if entry.identifier == normalizedCurrentId { + clearLogState(triggerSmoothClose: true) + } else { + logDebug("Ignoring stopped state for unrelated timer ID: \(entry.identifier)") + } + default: + break + } + } + + private func handleTimerWillFireMessage(_ message: String) { + guard logIdentifier != nil else { return } + guard let minutesString = captureFirstMatch(pattern: "Timer will fire\\s+([0-9.]+)\\s+minutes?", in: message), + let minutes = Double(minutesString) else { return } + + let seconds = minutes * 60 + logDebug("Timer will fire in \(seconds) seconds") + applyLogDrivenUpdate(remaining: seconds, isPaused: latestPaused) + } + + private func handleRemainingTimeMessage(_ message: String) { + guard let valueString = captureFirstMatch(pattern: "remainingTime:\\s*([-0-9.]+)", in: message), + let remaining = Double(valueString) else { return } + + logDebug("Remaining time from log: \(remaining)") + applyLogDrivenUpdate(remaining: remaining, isPaused: latestPaused) + } + + private func handleNextTimerChangedMessage(_ message: String) { + guard let token = captureFirstMatch(pattern: "next timer changed:\\s*([^\n]+)", in: message) else { return } + let trimmed = token.trimmingCharacters(in: CharacterSet(charactersIn: " <>")) + if trimmed.isEmpty { return } + + logDebug("Next timer changed token: \(trimmed)") + + if trimmed.lowercased().contains("null") { + logDebug("Next timer null marker received; retaining current identifier") + return + } + + setLogIdentifier(trimmed) + } + + private func handleTimerStartedMessage(_ message: String) { + guard let identifier = captureFirstMatch(pattern: "started timer:\\s*([A-Fa-f0-9\\-]+)", in: message) else { return } + logDebug("Timer started for ID: \(identifier)") + setLogIdentifier(identifier) + } + + private func handleTimerStoppedMessage(_ message: String) { + guard logIdentifier != nil else { + logDebug("Timer stopped event received without active identifier; ignoring") + return + } + + logDebug("Timer stopped event received; clearing external timer state") + clearLogState(triggerSmoothClose: true) + } + + private func applyLogDrivenUpdate(remaining: TimeInterval, isPaused: Bool, nameOverride: String? = nil, durationOverride: TimeInterval? = nil) { + guard Defaults[.mirrorSystemTimer] else { return } + guard !TimerManager.shared.hasManualTimerRunning else { return } + guard logIdentifier != nil else { return } + + if let nameOverride, !nameOverride.isEmpty { + logPreferredName = nameOverride + logDebug("Applying name override: \(nameOverride)") + } + + if let durationOverride { + if let existing = logPreferredDuration { + logPreferredDuration = max(existing, durationOverride) + } else { + logPreferredDuration = durationOverride + } + logDebug("Applying duration override: \(durationOverride)") + } + + let previousRemaining = latestRemaining + var resolvedPaused = isPaused + + if resolvedPaused { + if let previous = previousRemaining, remaining < previous - 0.75 { + logDebug("Remaining dropped from \(previous) to \(remaining) despite paused state; treating as running") + resolvedPaused = false + } else if metadata?.state == .running { + logDebug("Metadata reports running; overriding paused state from log") + resolvedPaused = false + } + } + + latestPaused = resolvedPaused + latestRemaining = remaining + logDebug("Log-driven update (remaining: \(remaining), paused: \(resolvedPaused), rawPaused: \(isPaused))") + + if let duration = logPreferredDuration, (initialTotalDuration ?? 0) < duration { + initialTotalDuration = duration + } + + if remaining > (initialTotalDuration ?? 0) { + initialTotalDuration = remaining + } + + applyTimerUpdate(remaining: remaining, paused: resolvedPaused) + } + + private func setLogIdentifier(_ identifier: String) { + let cleaned = identifier + .trimmingCharacters(in: CharacterSet(charactersIn: " <>")) + .uppercased() + guard !cleaned.isEmpty else { return } + + if logIdentifier != cleaned { + logInfo("Tracking system timer ID: \(cleaned)") + logIdentifier = cleaned + logDidCompleteActiveTimer = false + initialTotalDuration = nil + latestRemaining = nil + logPreferredDuration = nil + logPreferredName = nil + } + } + + private func clearLogState(triggerSmoothClose: Bool) { + logInfo("Clearing log-derived timer state (smooth close: \(triggerSmoothClose))") + let didComplete = logDidCompleteActiveTimer + + logIdentifier = nil + logPreferredName = nil + logPreferredDuration = nil + initialTotalDuration = nil + latestRemaining = nil + latestPaused = false + latestUpdateTimestamp = nil + logDidCompleteActiveTimer = false + + guard triggerSmoothClose else { return } + + DispatchQueue.main.async { + guard TimerManager.shared.isExternalTimerActive else { return } + if didComplete { + self.logDebug("Timer finished; scheduling delayed cleanup") + DispatchQueue.main.asyncAfter(deadline: .now() + 3.2) { + if TimerManager.shared.isExternalTimerActive { + self.logDebug("Delayed cleanup firing; ending external timer") + TimerManager.shared.endExternalTimer(triggerSmoothClose: false) + } + } + } else { + self.logDebug("Ending external timer immediately") + TimerManager.shared.endExternalTimer(triggerSmoothClose: true) + } + } + } + + private func captureFirstMatch(pattern: String, in text: String, options: NSRegularExpression.Options = []) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else { return nil } + let range = NSRange(text.startIndex..= 2, + let swiftRange = Range(match.range(at: 1), in: text) else { + return nil + } + + return String(text[swiftRange]) + } + + private func parseLogTimerEntries(from message: String) -> [LogTimerEntry] { + guard let regex = try? NSRegularExpression(pattern: "]+>", options: []) else { + return [] + } + + let range = NSRange(message.startIndex.. 0, (initialTotalDuration ?? 0) < metadata.duration { + initialTotalDuration = metadata.duration + } + + if TimerManager.shared.isExternalTimerActive { + let remaining = latestRemaining ?? metadata.duration + DispatchQueue.main.async { + self.logDebug("Syncing metadata update with TimerManager (remaining: \(remaining))") + TimerManager.shared.updateExternalTimer( + remaining: remaining, + totalDuration: metadata.duration, + isPaused: self.latestPaused, + name: metadata.title + ) + } + } + } + + private func fetchMetadata() -> TimerMetadata? { + CFPreferencesAppSynchronize(domain) + guard let container = CFPreferencesCopyAppValue("MTTimers" as CFString, domain) as? [String: Any], + let rawTimers = container["MTTimers"] as? [[String: Any]] else { + return nil + } + + let records: [TimerMetadata] = rawTimers.compactMap { entry in + guard let timer = entry["$MTTimer"] as? [String: Any] else { return nil } + let identifier = timer["MTTimerID"] as? String ?? UUID().uuidString + let rawTitle = (timer["MTTimerTitle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let title = (rawTitle?.isEmpty == false) ? rawTitle! : "Clock Timer" + let duration = timer["MTTimerDuration"] as? TimeInterval ?? 0 + let lastModified = timer["MTTimerLastModifiedDate"] as? Date + let firedDate = timer["MTTimerFiredDate"] as? Date + let stateRaw = timer["MTTimerState"] as? Int ?? 0 + let state = TimerMetadata.State(rawValue: stateRaw) ?? .unknown + + return TimerMetadata( + identifier: identifier, + title: title, + duration: duration, + lastModified: lastModified, + firedDate: firedDate, + state: state + ) + } + + if let active = records.first(where: { $0.state.isActive }) { + logDebug("Found active timer in preferences (state: \(active.state.rawValue))") + return active + } + + return records.sorted { (lhs, rhs) -> Bool in + (lhs.lastModified ?? .distantPast) > (rhs.lastModified ?? .distantPast) + }.first + } + + private func locateTimerMenuExtra() -> AXUIElement? { + let systemWide = AXUIElementCreateSystemWide() + guard let menuBar: AXUIElement = copyAttribute(kAXMenuBarAttribute as CFString, from: systemWide) else { + return nil + } + + guard let items: [AXUIElement] = copyAttribute(kAXChildrenAttribute as CFString, from: menuBar) else { + return nil + } + + for item in items { + if isTimerMenuExtra(item) { + return item + } + } + + return nil + } + + private func isTimerMenuExtra(_ element: AXUIElement) -> Bool { + if let identifier: String = copyAttribute(kAXIdentifierAttribute as CFString, from: element), + identifier.lowercased().contains("timer") { + return true + } + + if let title: String = copyAttribute(kAXTitleAttribute as CFString, from: element), + matchesTimerText(title) { + return true + } + + if let value: String = copyAttribute(kAXValueAttribute as CFString, from: element), + matchesTimerText(value) { + return true + } + + if let children: [AXUIElement] = copyAttribute(kAXChildrenAttribute as CFString, from: element) { + for child in children { + if let value: String = copyAttribute(kAXValueAttribute as CFString, from: child), matchesTimerText(value) { + return true + } + if let title: String = copyAttribute(kAXTitleAttribute as CFString, from: child), matchesTimerText(title) { + return true + } + } + } + + return false + } + + private func extractTimerString(from element: AXUIElement) -> ParsedTimerString? { + if let value: String = copyAttribute(kAXValueAttribute as CFString, from: element), + let parsed = parseTimerString(value) { + return parsed + } + + if let title: String = copyAttribute(kAXTitleAttribute as CFString, from: element), + let parsed = parseTimerString(title) { + return parsed + } + + if let children: [AXUIElement] = copyAttribute(kAXChildrenAttribute as CFString, from: element) { + for child in children { + if let value: String = copyAttribute(kAXValueAttribute as CFString, from: child), + let parsed = parseTimerString(value) { + return parsed + } + if let title: String = copyAttribute(kAXTitleAttribute as CFString, from: child), + let parsed = parseTimerString(title) { + return parsed + } + } + } + + return nil + } + + private func parseTimerString(_ raw: String) -> ParsedTimerString? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let lower = trimmed.lowercased() + let paused = lower.contains("pause") || lower.contains("stopped") + + if let seconds = extractSeconds(from: trimmed) { + return ParsedTimerString(raw: trimmed, remaining: seconds, paused: paused) + } + + return nil + } + + private func extractSeconds(from text: String) -> TimeInterval? { + let numericPattern = "[0-9]+(?::[0-9]{2}){0,2}" + if let match = text.range(of: numericPattern, options: .regularExpression) { + let token = String(text[match]) + let parts = token.split(separator: ":").compactMap { Double($0) } + switch parts.count { + case 3: + return parts[0] * 3600 + parts[1] * 60 + parts[2] + case 2: + return parts[0] * 60 + parts[1] + case 1: + return parts[0] + default: + break + } + } + + let suffixPattern = "([0-9]+)([hms])" + let regex = try? NSRegularExpression(pattern: suffixPattern, options: []) + let range = NSRange(text.startIndex.. 0 ? total : nil + } + + private func matchesTimerText(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + + if trimmed.lowercased().contains("timer") { + return true + } + + let pattern = "[0-9]+(?::[0-9]{2}){0,2}" + return trimmed.range(of: pattern, options: .regularExpression) != nil + } + + private func copyAttribute(_ attribute: CFString, from element: AXUIElement) -> T? { + var value: CFTypeRef? + let status = AXUIElementCopyAttributeValue(element, attribute, &value) + guard status == .success, let typed = value as? T else { + return nil + } + return typed + } + + private func postAccessibilityWarningIfNeeded() { + guard !didWarnAboutAccessibility else { return } + didWarnAboutAccessibility = true + logError("Accessibility permission missing; prompt user to enable it") + DispatchQueue.main.async { + debugPrint("[SystemTimerBridge] Accessibility permission is required to mirror Clock timers. Grant access in System Settings → Privacy & Security → Accessibility.") + } + } + +} diff --git a/DynamicIsland/managers/SystemVolumeManager.swift b/DynamicIsland/managers/SystemVolumeManager.swift new file mode 100644 index 00000000..1f008b5b --- /dev/null +++ b/DynamicIsland/managers/SystemVolumeManager.swift @@ -0,0 +1,45 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +class SystemVolumeManager { + private init() {} + + static func isMuted() -> Bool { + do { + return try AppleScriptRunner.run(script: "return output muted of (get volume settings)") == "true" + } catch { + NSLog("Error while trying to retrieve muted properties of device: \(error). Returning default value false.") + return false + } + } + + static func getOutputVolume() -> Float { + do { + if let volumeStr = Float(try AppleScriptRunner.run(script: "return output volume of (get volume settings)")) { + return volumeStr / 100 + } else { + NSLog("Error while trying to parse volume string value. Returning default volume value 1.") + } + } catch { + NSLog("Error while trying to retrieve volume properties of device: \(error). Returning default volume value 1.") + } + return 0.01 + } +} \ No newline at end of file diff --git a/DynamicIsland/managers/TerminalManager.swift b/DynamicIsland/managers/TerminalManager.swift new file mode 100644 index 00000000..a5f47a42 --- /dev/null +++ b/DynamicIsland/managers/TerminalManager.swift @@ -0,0 +1,670 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * Adapted from SwiftTerm (https://github.com/migueldeicaza/SwiftTerm) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Foundation +import SwiftUI +import SwiftTerm +import Defaults + +// MARK: - Stable Container + +/// Container NSView that shields its terminal child from zero-sized frames. +/// +/// When the notch closes, SwiftUI removes the `NSViewRepresentable` from the +/// hierarchy — but later re-adds the same `containerView` on reopen. During +/// that insertion, SwiftUI momentarily sets the frame to `.zero`. Without +/// protection, AppKit's autoresizing mask propagates the zero frame to the +/// terminal child, causing SwiftTerm's `processSizeChange` to resize the +/// emulator to 2 cols × 1 row (the enforced minimum), which destroys the +/// scrollback buffer. +/// +/// We bypass `super.resizeSubviews(withOldSize:)` entirely and manually set +/// children to fill the container's bounds. This avoids the autoresizing +/// calculation which produces corrupt geometry when `oldSize` was `.zero` +/// (recorded during the transient removal) but the child kept its previous +/// large frame. +final class StableTerminalContainerView: NSView { + /// Identifies the gutter tint view so `resizeSubviews` can keep its ring mask in sync. + static let gutterTintIdentifier = NSUserInterfaceItemIdentifier("terminalGutterTint") + /// Identifies the custom scroll knob overlay so `resizeSubviews` leaves its frame + /// alone (it's positioned from the scroll state, not stretched to fill the bounds). + static let scrollKnobIdentifier = NSUserInterfaceItemIdentifier("terminalScrollKnob") + + override func resizeSubviews(withOldSize oldSize: NSSize) { + let size = bounds.size + // Block degenerate sizes — the terminal would collapse to 2×1 + guard size.width >= 10, size.height >= 10 else { return } + // Manually fill children to bounds — bypasses autoresizing math + // that breaks when oldSize was zero but child kept its old frame. + // The terminal view gets an inner inset so glyphs don't hug the edge; + // the frosted blur underlay and the gutter tint stay full-bleed to the + // rounded clip (the tint is masked to the ring so it only fills the inset + // gutter, matching the terminal background without doubling over it). + let inset = notchTerminalInnerTextInset + let terminalFrame = bounds.insetBy(dx: inset, dy: inset) + for child in subviews { + if child is LocalProcessTerminalView { + child.frame = terminalFrame + } else if child.identifier == Self.scrollKnobIdentifier { + // Positioned from scroll state in `updateScrollKnob`; don't stretch it. + continue + } else { + child.frame = bounds + if child.identifier == Self.gutterTintIdentifier { + Self.applyGutterMask(to: child, bounds: bounds, hole: terminalFrame) + } + } + } + MainActor.assumeIsolated { + TerminalManager.shared.refreshScrollKnobLayout() + } + } + + /// Masks `view` to the gutter ring: the full bounds minus the inset `hole` + /// where the terminal sits, using the even-odd fill rule. + static func applyGutterMask(to view: NSView, bounds: CGRect, hole: CGRect) { + let localBounds = CGRect(origin: .zero, size: bounds.size) + let mask = (view.layer?.mask as? CAShapeLayer) ?? CAShapeLayer() + mask.fillRule = .evenOdd + let path = CGMutablePath() + path.addRect(localBounds) + path.addRect(CGRect(x: hole.minX, y: hole.minY, width: hole.width, height: hole.height)) + mask.path = path + mask.frame = localBounds + view.layer?.mask = mask + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window != nil else { return } + // When re-inserted into a window (notch reopened), force a full + // redraw so SwiftTerm re-renders the terminal buffer contents. + for child in subviews { + child.needsDisplay = true + } + Task { @MainActor in + TerminalManager.shared.refreshTerminalAppearanceIfNeeded() + } + } +} + +// MARK: - Scroll Knob Overlay + +/// Layer-backed rounded knob drawn on top of the terminal as a custom scrollbar. +/// +/// SwiftTerm's built-in `NSScroller` is layer-backed and renders its knob via CoreUI, +/// which ignores `drawKnob`/`drawKnobSlot` overrides — so we can't restyle it directly. +/// Instead we hide that scroller and render this view ourselves, driven by the +/// scroller's live values (`doubleValue`, `knobProportion`, `isEnabled`) via KVO. +/// +/// `hitTest` returns nil so clicks and scroll-wheel events pass straight through to +/// the terminal underneath. +final class TerminalScrollKnobView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +// MARK: - Terminal Manager + +/// Manages the Guake-style dropdown terminal session lifecycle. +/// The terminal is lazily created when the user first switches to the terminal tab, +/// and the process is kept alive across notch open/close cycles. +/// +/// Uses a stable `StableTerminalContainerView` as the host so that SwiftUI's +/// `NSViewRepresentable` lifecycle (make/update/dismantle) never tears down +/// the actual terminal. The `LocalProcessTerminalView` is added as a subview +/// of the container and survives notch close/open cycles. +@MainActor +class TerminalManager: ObservableObject { + static let shared = TerminalManager() + + /// Whether a shell process is currently running. + @Published var isProcessRunning: Bool = false + + /// The current terminal title reported by the shell. + @Published var terminalTitle: String = "Terminal" + + /// Stable container returned to SwiftUI — never deallocated. + /// Uses `StableTerminalContainerView` to prevent zero-frame transients + /// from destroying the scrollback buffer. + let containerView: StableTerminalContainerView = { + let v = StableTerminalContainerView(frame: .zero) + v.autoresizingMask = [.width, .height] + v.wantsLayer = true + v.layer?.backgroundColor = NSColor.clear.cgColor + return v + }() + + /// The actual terminal view (child of `containerView`). + private(set) var terminalView: LocalProcessTerminalView? + + /// SwiftTerm's built-in scroller (kept as the data source for our custom knob). + private weak var terminalScroller: NSScroller? + + /// KVO of the scroller's value/proportion/enabled state that drives the knob. + private var scrollerObservations: [NSKeyValueObservation] = [] + + /// Custom rounded scrollbar knob rendered on top of the terminal. + private lazy var scrollKnobView: TerminalScrollKnobView = { + let v = TerminalScrollKnobView() + v.identifier = StableTerminalContainerView.scrollKnobIdentifier + v.wantsLayer = true + v.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.45).cgColor + v.alphaValue = 0 + return v + }() + + /// Thickness of the custom scroll knob, and its margins inside the terminal. + private let scrollKnobThickness: CGFloat = 6 + private let scrollKnobEndInset: CGFloat = 3 + private let scrollKnobRightMargin: CGFloat = 2 + + /// Frosted blur of desktop/content behind the terminal; always below `terminalView`. + private lazy var terminalBackgroundEffectView: NSVisualEffectView = { + let v = NSVisualEffectView() + // Minimal frosted look with very light blur/vibrancy. + v.material = .underWindowBackground + v.blendingMode = .behindWindow + v.state = .active + v.autoresizingMask = [.width, .height] + return v + }() + + /// Tinted ring that fills the inset gutter with the terminal's background color + /// so the inset text isn't surrounded by a bare-blur "ring". Sits above the + /// blur and below `terminalView`; masked to the gutter so it never doubles the + /// translucent background over the terminal's own cells. + private lazy var terminalGutterTintView: NSView = { + let v = NSView() + v.identifier = StableTerminalContainerView.gutterTintIdentifier + v.wantsLayer = true + v.layer?.backgroundColor = NSColor.clear.cgColor + v.autoresizingMask = [.width, .height] + return v + }() + + private init() {} + + // MARK: - Lifecycle + + /// Ensures the terminal view exists inside the container and returns the container. + /// Call this from the `NSViewRepresentable` wrapper. + func ensureTerminalView(delegate: LocalProcessTerminalViewDelegate) { + if let existing = terminalView, existing.superview === containerView { + // Already mounted — just re-wire the delegate in case the coordinator changed. + existing.processDelegate = delegate + // Re-apply opacity/translucency after hide/show cycles (e.g. shortcut toggle). + refreshTerminalOpacityAndTranslucency(for: existing) + return + } + + // Use the container's current bounds if valid, otherwise a reasonable + // default. SwiftTerm's init calls setupOptions() which reads the + // frame — a zero frame creates a 2×1 terminal that is corrected + // once the container gets its proper layout size. + let initialFrame = containerView.bounds.size.width >= 10 + ? containerView.bounds + : CGRect(x: 0, y: 0, width: 400, height: 300) + + // Replace only the terminal view - keep the blur underlay between restarts. + terminalView?.removeFromSuperview() + + let view = LocalProcessTerminalView(frame: initialFrame) + view.autoresizingMask = [.width, .height] + + // Apply all settings from Defaults + applyAllSettings(to: view) + + view.processDelegate = delegate + + if terminalBackgroundEffectView.superview == nil { + containerView.addSubview(terminalBackgroundEffectView) + } + if terminalGutterTintView.superview == nil { + containerView.addSubview( + terminalGutterTintView, + positioned: .above, + relativeTo: terminalBackgroundEffectView + ) + } + updateGutterTintColor() + containerView.addSubview(view) + terminalView = view + + // Keep the custom scroll knob above the terminal (re-add to bring to front). + scrollKnobView.removeFromSuperview() + containerView.addSubview(scrollKnobView) + if let scroller = terminalScroller { + updateScrollKnob(from: scroller) + } + + // If the container already has a valid size, snap the children to it. + // Blur + gutter tint fill the full bounds; the terminal is inset so its + // glyphs don't hug the edge, and the gutter tint is masked to the ring. + let containerSize = containerView.bounds.size + if containerSize.width >= 10, containerSize.height >= 10 { + let bounds = containerView.bounds + let terminalFrame = bounds.insetBy( + dx: notchTerminalInnerTextInset, + dy: notchTerminalInnerTextInset + ) + terminalBackgroundEffectView.frame = bounds + terminalGutterTintView.frame = bounds + StableTerminalContainerView.applyGutterMask( + to: terminalGutterTintView, + bounds: bounds, + hole: terminalFrame + ) + view.frame = terminalFrame + } + + // Apply translucency immediately and again on the next run loop tick. + // The deferred pass handles AppKit/SwiftUI mount timing where layer + // changes can otherwise be lost on first open. + refreshTerminalOpacityAndTranslucency(for: view) + DispatchQueue.main.async { [weak self, weak view] in + guard let self, let view, self.terminalView === view else { return } + self.refreshTerminalOpacityAndTranslucency(for: view) + } + } + + /// Starts the shell process if not already running. + func startShellProcess() { + guard let view = terminalView, !isProcessRunning else { return } + + let shell = Defaults[.terminalShellPath] + let execName = "-" + (shell as NSString).lastPathComponent // login shell convention + + view.startProcess( + executable: shell, + args: [], + environment: buildEnvironment(), + execName: execName + ) + isProcessRunning = true + } + + /// Called when the shell process terminates. + func processDidTerminate(exitCode: Int32?) { + isProcessRunning = false + } + + /// Restarts the shell by tearing down the old terminal and creating a fresh one. + /// + /// Instead of bumping a generation counter (which forced SwiftUI to destroy + /// and recreate the `NSViewRepresentable` — recycling the same `containerView` + /// across identities which broke layout), we simply nil out `terminalView` + /// and change `@Published` state. SwiftUI's `updateNSView` will fire, + /// see the nil terminal, and call `ensureTerminalView` to mount a new one. + func restartShell() { + // Terminate the running process gracefully + terminalView?.terminate() + // Remove old terminal from container + terminalView?.removeFromSuperview() + terminalView = nil + isProcessRunning = false + terminalTitle = "Terminal" + } + + /// Updates the terminal title from the shell escape sequence. + func updateTitle(_ title: String) { + terminalTitle = title + } + + // MARK: - Font Resolution + + /// Resolves the terminal font from the user's chosen family and size. + /// + /// - If `family` is empty, returns the system monospaced font. + /// - Otherwise tries `NSFont(name:size:)` and falls back to system monospaced + /// when the name is invalid or the font is not installed. + private func resolveFont(family: String, size: CGFloat) -> NSFont { + if !family.isEmpty, let custom = NSFont(name: family, size: size) { + return custom + } + return NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + } + + /// Background color with opacity slider applied to alpha only (glyphs stay fully opaque). + private func resolvedTerminalBackgroundNSColor( + baseColor: NSColor? = nil, + opacitySlider: CGFloat? = nil + ) -> NSColor { + let raw = baseColor ?? NSColor(Defaults[.terminalBackgroundColor]) + let rgb = raw.usingColorSpace(.deviceRGB) ?? raw.usingColorSpace(.sRGB) ?? raw + let opacity = opacitySlider ?? CGFloat(Defaults[.terminalOpacity]) + let alpha = CGFloat(rgb.cgColor.alpha) * opacity + return rgb.withAlphaComponent(alpha) + } + + /// Composites the terminal buffer over `terminalBackgroundEffectView` without dimming glyphs. + /// SwiftTerm’s `TerminalView.isOpaque` is read-only; translucency relies on alpha in `nativeBackgroundColor`. + private func applyTerminalBackgroundAppearance(to view: LocalProcessTerminalView) { + view.layer?.opacity = 1 + view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor() + } + + /// Upstream `TerminalView.setupOptions()` assigns `layer.backgroundColor` from `nativeBackgroundColor`, which + /// prevents the `NSVisualEffectView` under the terminal from showing through. Re-clear after our color updates. + /// Default-cell alpha and attribute-cache invalidation on native color changes may still need a SwiftTerm PR + private func applyTerminalLayerTranslucencyHacks(to view: LocalProcessTerminalView) { + view.layer?.backgroundColor = NSColor.clear.cgColor + view.layer?.isOpaque = false + } + + /// Best-effort redraw. `TerminalView.installColors(_:)` could clear SwiftTerm color caches but needs 16 + /// `SwiftTerm.Color` values; `Terminal.ansiColors` is not public, so we do not duplicate a palette here. + private func synchronizeTerminalTranslucencyPresentation(to view: LocalProcessTerminalView) { + applyTerminalLayerTranslucencyHacks(to: view) + view.setNeedsDisplay(view.bounds) + } + + /// Re-applies backdrop opacity + translucency hacks after remount/reopen paths. + private func refreshTerminalOpacityAndTranslucency(for view: LocalProcessTerminalView) { + applyTerminalBackgroundAppearance(to: view) + synchronizeTerminalTranslucencyPresentation(to: view) + updateGutterTintColor() + } + + /// Keeps the gutter tint ring matching the terminal's resolved (translucent) + /// background color so the inset gutter blends seamlessly with the cells. + private func updateGutterTintColor() { + terminalGutterTintView.layer?.backgroundColor = resolvedTerminalBackgroundNSColor().cgColor + } + + // MARK: - Settings Application + + /// Applies all persisted settings to a terminal view at creation time. + private func applyAllSettings(to view: LocalProcessTerminalView) { + // Font + let fontSize = CGFloat(Defaults[.terminalFontSize]) + let fontFamily = Defaults[.terminalFontFamily] + view.font = resolveFont(family: fontFamily, size: fontSize) + + // Colors (background opacity is separate from terminal opacity slider — see `applyTerminalBackgroundAppearance`) + applyTerminalBackgroundAppearance(to: view) + view.nativeForegroundColor = NSColor(Defaults[.terminalForegroundColor]) + view.caretColor = NSColor(Defaults[.terminalCursorColor]) + + // Cursor style + let cursorStyle = TerminalCursorStyleOption(rawValue: Defaults[.terminalCursorStyle]) + ?? .blinkBlock + view.getTerminal().setCursorStyle(cursorStyle.swiftTermStyle) + + // Keep drawing the selected cursor style when TerminalView.hasFocus is false; SwiftTerm + // otherwise uses an unfocused/outline caret while tracksFocus is true. + view.caretViewTracksFocus = false + + // Scrollback + let scrollback = Defaults[.terminalScrollbackLines] + view.getTerminal().buffer.changeHistorySize(scrollback) + view.getTerminal().options.scrollback = scrollback + + // Input behavior + view.optionAsMetaKey = Defaults[.terminalOptionAsMeta] + view.allowMouseReporting = Defaults[.terminalMouseReporting] + + // Rendering + view.useBrightColors = Defaults[.terminalBoldAsBright] + + refreshTerminalOpacityAndTranslucency(for: view) + styleTerminalScroller(for: view) + } + + /// Hides SwiftTerm's built-in scroller and wires KVO so its live scroll state + /// drives our custom `scrollKnobView` (see `TerminalScrollKnobView`). + private func styleTerminalScroller(for view: LocalProcessTerminalView) { + guard let scroller = view.subviews.compactMap({ $0 as? NSScroller }).first else { return } + terminalScroller = scroller + // Hide SwiftTerm's own scroller drawing; it stays alive as our data source. + scroller.alphaValue = 0 + + scrollerObservations.forEach { $0.invalidate() } + scrollerObservations = [] + let onChange: (NSScroller) -> Void = { scroller in + MainActor.assumeIsolated { + TerminalManager.shared.updateScrollKnob(from: scroller) + } + } + scrollerObservations = [ + scroller.observe(\.doubleValue, options: [.new]) { s, _ in onChange(s) }, + scroller.observe(\.knobProportion, options: [.new]) { s, _ in onChange(s) }, + scroller.observe(\.isEnabled, options: [.new]) { s, _ in onChange(s) } + ] + updateScrollKnob(from: scroller) + } + + /// Re-runs the knob layout using the current scroller state (e.g. after a resize). + func refreshScrollKnobLayout() { + guard let scroller = terminalScroller else { return } + updateScrollKnob(from: scroller) + } + + /// Positions and shows/hides the custom scroll knob from the scroller's state. + private func updateScrollKnob(from scroller: NSScroller) { + guard scrollKnobView.superview === containerView else { return } + + guard scroller.isEnabled else { + scrollKnobView.animator().alphaValue = 0 + return + } + + let bounds = containerView.bounds + guard bounds.width >= 10, bounds.height >= 10 else { return } + + // Track lives inside the inset terminal area, along its right edge. + let terminalFrame = bounds.insetBy( + dx: notchTerminalInnerTextInset, + dy: notchTerminalInnerTextInset + ) + let thickness = scrollKnobThickness + let trackTopY = terminalFrame.maxY - scrollKnobEndInset // visual top (non-flipped) + let trackBottomY = terminalFrame.minY + scrollKnobEndInset + let trackHeight = max(0, trackTopY - trackBottomY) + guard trackHeight > thickness else { + scrollKnobView.animator().alphaValue = 0 + return + } + + let proportion = max(0.04, min(1.0, CGFloat(scroller.knobProportion))) + let knobHeight = max(thickness * 2, proportion * trackHeight) + let travel = max(0, trackHeight - knobHeight) + // doubleValue: 0 = top of scrollback, 1 = bottom (current); y grows upward. + let value = max(0, min(1, CGFloat(scroller.doubleValue))) + let knobMinY = trackBottomY + (1 - value) * travel + let x = terminalFrame.maxX - thickness - scrollKnobRightMargin + + scrollKnobView.frame = NSRect(x: x, y: knobMinY, width: thickness, height: knobHeight) + scrollKnobView.layer?.cornerRadius = thickness / 2 + scrollKnobView.animator().alphaValue = 1 + } + + /// Updates font size on the live terminal view. + func applyFontSize(_ size: Double) { + guard let view = terminalView else { return } + let fontFamily = Defaults[.terminalFontFamily] + view.font = resolveFont(family: fontFamily, size: CGFloat(size)) + } + + /// Updates font family on the live terminal view. + func applyFontFamily(_ family: String) { + guard let view = terminalView else { return } + let fontSize = CGFloat(Defaults[.terminalFontSize]) + view.font = resolveFont(family: family, size: fontSize) + } + + /// Updates background opacity on the live terminal view (glyphs stay fully opaque). + func applyOpacity(_ opacity: Double) { + guard let view = terminalView else { return } + view.layer?.opacity = 1 + view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(opacitySlider: CGFloat(opacity)) + refreshTerminalOpacityAndTranslucency(for: view) + } + + /// Updates cursor style on the live terminal view. + func applyCursorStyle(_ style: TerminalCursorStyleOption) { + guard let view = terminalView else { return } + view.getTerminal().setCursorStyle(style.swiftTermStyle) + view.setNeedsDisplay(view.bounds) + } + + /// Updates scrollback buffer size on the live terminal view. + func applyScrollback(_ lines: Int) { + guard let view = terminalView else { return } + view.getTerminal().buffer.changeHistorySize(lines) + view.getTerminal().options.scrollback = lines + } + + /// Updates option-as-meta on the live terminal view. + func applyOptionAsMeta(_ enabled: Bool) { + guard let view = terminalView else { return } + view.optionAsMetaKey = enabled + } + + /// Updates mouse reporting on the live terminal view. + func applyMouseReporting(_ enabled: Bool) { + guard let view = terminalView else { return } + view.allowMouseReporting = enabled + } + + /// Updates bold-as-bright on the live terminal view. + func applyBoldAsBright(_ enabled: Bool) { + guard let view = terminalView else { return } + view.useBrightColors = enabled + } + + /// Updates background color on the live terminal view. + func applyBackgroundColor(_ color: SwiftUI.Color) { + guard let view = terminalView else { return } + view.layer?.opacity = 1 + view.nativeBackgroundColor = resolvedTerminalBackgroundNSColor(baseColor: NSColor(color)) + refreshTerminalOpacityAndTranslucency(for: view) + } + + /// Updates foreground color on the live terminal view. + func applyForegroundColor(_ color: SwiftUI.Color) { + guard let view = terminalView else { return } + view.nativeForegroundColor = NSColor(color) + synchronizeTerminalTranslucencyPresentation(to: view) + } + + /// Called by UI lifecycle hooks after tab re-appearance to keep opacity stable. + func refreshTerminalAppearanceIfNeeded() { + guard let view = terminalView else { return } + refreshTerminalOpacityAndTranslucency(for: view) + } + + /// Makes terminal view first responder when terminal tab opens. + /// Retries a few times to cover mount/animation timing so typing works immediately. + func focusTerminalIfPossible() { + focusTerminalIfPossible(attemptsRemaining: 3) + } + + private func focusTerminalIfPossible(attemptsRemaining: Int) { + guard let view = terminalView else { return } + guard let window = containerView.window ?? view.window else { + guard attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.focusTerminalIfPossible(attemptsRemaining: attemptsRemaining - 1) + } + return + } + + if !NSApp.isActive { + NSApp.activate(ignoringOtherApps: true) + } + if !window.isKeyWindow { + window.makeKeyAndOrderFront(nil) + } + + guard window.firstResponder !== view else { return } + if !window.makeFirstResponder(view), attemptsRemaining > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.focusTerminalIfPossible(attemptsRemaining: attemptsRemaining - 1) + } + } + } + + /// Resigns terminal focus before closing notch to avoid stale responder state. + func resignTerminalFirstResponderIfNeeded() { + guard let view = terminalView else { return } + guard let window = containerView.window ?? view.window else { return } + guard window.firstResponder === view else { return } + _ = window.makeFirstResponder(nil) + } + + /// True when the terminal view is currently first responder. + func isTerminalFirstResponder() -> Bool { + guard let view = terminalView else { return false } + guard let window = containerView.window ?? view.window else { return false } + return window.firstResponder === view + } + + /// Updates cursor color on the live terminal view. + func applyCursorColor(_ color: SwiftUI.Color) { + guard let view = terminalView else { return } + view.caretColor = NSColor(color) + } + + // MARK: - Environment + + /// Builds the environment for the child shell process. + private func buildEnvironment() -> [String] { + var env = ProcessInfo.processInfo.environment + env["TERM"] = "xterm-256color" + env["LANG"] = env["LANG"] ?? "en_US.UTF-8" + // Remove TERM_PROGRAM if set by a parent terminal + env.removeValue(forKey: "TERM_PROGRAM") + return env.map { "\($0.key)=\($0.value)" } + } +} + +// MARK: - Cursor Style Bridge + +/// Codable-friendly cursor style enum that bridges to SwiftTerm's `CursorStyle`. +enum TerminalCursorStyleOption: String, CaseIterable, Defaults.Serializable { + case blinkBlock = "blinkBlock" + case steadyBlock = "steadyBlock" + case blinkUnderline = "blinkUnderline" + case steadyUnderline = "steadyUnderline" + case blinkBar = "blinkBar" + case steadyBar = "steadyBar" + + var swiftTermStyle: CursorStyle { + switch self { + case .blinkBlock: return .blinkBlock + case .steadyBlock: return .steadyBlock + case .blinkUnderline: return .blinkUnderline + case .steadyUnderline: return .steadyUnderline + case .blinkBar: return .blinkBar + case .steadyBar: return .steadyBar + } + } + + var displayName: String { + switch self { + case .blinkBlock: return "Block (blinking)" + case .steadyBlock: return "Block (steady)" + case .blinkUnderline: return "Underline (blinking)" + case .steadyUnderline: return "Underline (steady)" + case .blinkBar: return "Bar (blinking)" + case .steadyBar: return "Bar (steady)" + } + } +} diff --git a/DynamicIsland/managers/TimerControlWindowManager.swift b/DynamicIsland/managers/TimerControlWindowManager.swift new file mode 100644 index 00000000..35193cbd --- /dev/null +++ b/DynamicIsland/managers/TimerControlWindowManager.swift @@ -0,0 +1,244 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#if os(macOS) +import AppKit +import SwiftUI +import SkyLightWindow +import QuartzCore + +struct TimerControlWindowMetrics: Equatable { + 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 !LockScreenManager.shared.currentLockStatus else { + hide(animated: false) + return false + } + 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) + } + + if let lastMetrics, lastMetrics == metrics { + return true + } + + return present(using: viewModel, metrics: metrics) + } + + func hide(animated: Bool = true, tearDown: Bool = true) { + guard let window else { return } + + guard animated, window.alphaValue > 0.01 else { + window.orderOut(nil) + window.alphaValue = 0 + if tearDown { + tearDownWindowResources(using: window) + } + 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: { [weak self] in + window.setFrame(originalFrame, display: false) + window.orderOut(nil) + window.alphaValue = 0 + if tearDown { + self?.tearDownWindowResources(using: window) + } + } + } + + private func tearDownWindowResources(using window: NSWindow? = nil) { + let targetWindow = window ?? self.window + targetWindow?.contentView = nil + targetWindow?.orderOut(nil) + hostingView = nil + lastMetrics = nil + self.window = nil + hasDelegated = false + } + + 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 + + ScreenCaptureVisibilityManager.shared.register(window, scope: .entireInterface) + + 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 1901bff2..88fe3876 100644 --- a/DynamicIsland/managers/TimerManager.swift +++ b/DynamicIsland/managers/TimerManager.swift @@ -1,15 +1,27 @@ -// -// TimerManager.swift -// DynamicIsland -// -// Timer management for the Dynamic Island -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation import Combine import SwiftUI import AVFoundation import AppKit +import Defaults class TimerManager: ObservableObject { // MARK: - Properties @@ -24,6 +36,8 @@ 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? + @Published private(set) var activeSource: TimerSource = .none // Timer progress (0.0 to 1.0, or >1.0 for overtime) var progress: Double { @@ -111,7 +125,6 @@ class TimerManager: ObservableObject { private var timerInstance: Timer? private var cancellables = Set() private var soundPlayer: AVAudioPlayer? - // MARK: - Initialization private init() { // Simple initialization @@ -124,12 +137,19 @@ class TimerManager: ObservableObject { } // MARK: - Timer Methods - func startTimer(duration: TimeInterval, name: String = "Timer") { + func startTimer(duration: TimeInterval, name: String = "Timer", preset: TimerPreset? = nil) { + if activeSource == .external { + endExternalTimer(triggerSmoothClose: false) + } + // Stop any existing timer timerInstance?.invalidate() // Start new timer - isTimerActive = true + withAnimation(.smooth) { + isTimerActive = true + } + activeSource = .manual isFinished = false isOvertime = false timerName = name @@ -138,8 +158,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 @@ -174,6 +194,11 @@ class TimerManager: ObservableObject { } func stopTimer() { + if activeSource == .external { + endExternalTimer(triggerSmoothClose: true) + return + } + timerInstance?.invalidate() timerInstance = nil soundPlayer?.stop() @@ -187,15 +212,23 @@ class TimerManager: ObservableObject { } func forceStopTimer() { + if activeSource == .external { + endExternalTimer(triggerSmoothClose: false) + return + } + // Immediate stop for user action (stop button) timerInstance?.invalidate() timerInstance = nil soundPlayer?.stop() - isTimerActive = false + withAnimation(.smooth) { + isTimerActive = false + } resetTimer() } func pauseTimer() { + guard activeSource == .manual else { return } guard isTimerActive && !isPaused else { return } isPaused = true timerInstance?.invalidate() @@ -203,6 +236,7 @@ class TimerManager: ObservableObject { } func resumeTimer() { + guard activeSource == .manual else { return } guard isTimerActive && isPaused else { return } isPaused = false lastUpdated = Date() @@ -234,9 +268,83 @@ class TimerManager: ObservableObject { } } } + + func adoptExternalTimer(name: String, totalDuration: TimeInterval, remaining: TimeInterval, isPaused: Bool) { + guard activeSource != .manual else { return } + + timerInstance?.invalidate() + timerInstance = nil + soundPlayer?.stop() + + activeSource = .external + + let clampedTotal = totalDuration > 0 ? totalDuration : max(totalDuration, remaining) + + withAnimation(.smooth) { + isTimerActive = true + } + + timerName = name.isEmpty ? "Clock Timer" : name + self.totalDuration = clampedTotal + remainingTime = remaining + elapsedTime = max(0, clampedTotal - remaining) + self.isPaused = isPaused + isFinished = false + isOvertime = remaining < 0 + activePresetId = nil + lastUpdated = Date() + } + + func updateExternalTimer(remaining: TimeInterval, totalDuration: TimeInterval?, isPaused paused: Bool, name: String? = nil) { + guard activeSource == .external else { return } + + if let name, !name.isEmpty, name != timerName { + timerName = name + } + + if let totalDuration, totalDuration > 0 { + self.totalDuration = max(totalDuration, self.totalDuration) + } else if self.totalDuration <= 0 { + self.totalDuration = max(self.totalDuration, remaining) + } + + remainingTime = remaining + elapsedTime = max(0, self.totalDuration - remaining) + isOvertime = remaining < 0 + isPaused = paused + isFinished = remaining <= 0 && !isOvertime + lastUpdated = Date() + } + + func completeExternalTimer() { + guard activeSource == .external else { return } + + remainingTime = 0 + elapsedTime = totalDuration + isOvertime = false + isFinished = true + lastUpdated = Date() + scheduleSmoothClose() + } + + func endExternalTimer(triggerSmoothClose: Bool) { + guard activeSource == .external else { return } + + if triggerSmoothClose && isTimerActive { + scheduleSmoothClose() + } else { + withAnimation(.smooth) { + isTimerActive = false + } + } + + resetTimer() + } private func resetTimer() { - isTimerActive = false + withAnimation(.smooth) { + isTimerActive = false + } timerName = "Timer" totalDuration = 0 remainingTime = 0 @@ -244,8 +352,34 @@ class TimerManager: ObservableObject { isPaused = false isFinished = false isOvertime = false + activePresetId = nil + activeSource = .none } - + + // MARK: - Derived State + var activePreset: TimerPreset? { + guard let presetId = activePresetId else { return nil } + return Defaults[.timerPresets].first { $0.id == presetId } + } + + var isExternalTimerActive: Bool { + activeSource == .external && isTimerActive + } + + var hasManualTimerRunning: Bool { + activeSource == .manual && isTimerActive + } + + var allowsManualInteraction: Bool { + activeSource != .external + } + + enum TimerSource: String { + case none + case manual + case external + } + private func scheduleSmoothClose() { // Wait 3 seconds then smoothly close the live activity DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { @@ -256,38 +390,42 @@ class TimerManager: ObservableObject { } private func playTimerSound() { - // Play default macOS timer sound - guard let soundURL = Bundle.main.url(forResource: "dynamic", withExtension: "m4a") else { - // Fallback to system sound + var soundURL: URL? + + // Check for custom timer sound first + let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") + if let customPath = customTimerSoundPath, !customPath.isEmpty { + // Use custom sound file + soundURL = URL(fileURLWithPath: customPath) + + // Verify the file exists + if !FileManager.default.fileExists(atPath: customPath) { + soundURL = nil + } + } + + // Fall back to default sound if no custom sound or custom sound doesn't exist + if soundURL == nil { + soundURL = Bundle.main.url(forResource: "timer", withExtension: "mp3") + ?? Bundle.main.url(forResource: "dynamic", withExtension: "m4a") + } + + guard let finalSoundURL = soundURL else { + // Final fallback to system sound NSSound.beep() return } do { - soundPlayer = try AVAudioPlayer(contentsOf: soundURL) + soundPlayer = try AVAudioPlayer(contentsOf: finalSoundURL) soundPlayer?.numberOfLoops = -1 // Loop indefinitely soundPlayer?.play() } catch { - // Fallback to system sound + // Fallback to system sound if there's an error playing the custom sound NSSound.beep() } } - 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/managers/VerticalHUDWindowManager.swift b/DynamicIsland/managers/VerticalHUDWindowManager.swift new file mode 100644 index 00000000..ec61c246 --- /dev/null +++ b/DynamicIsland/managers/VerticalHUDWindowManager.swift @@ -0,0 +1,262 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#if os(macOS) +import AppKit +import SwiftUI +import SkyLightWindow +import QuartzCore +import Defaults +import Combine + +@MainActor +final class VerticalHUDWindowManager { + static let shared = VerticalHUDWindowManager() + + // Properties + private var windows: [NSScreen: OSDWindow] = [:] + private var hideWorkItem: DispatchWorkItem? + private let displayDuration: TimeInterval = 2.0 + private let animationDuration: TimeInterval = 0.2 + + private var cancellables = Set() + + private init() { + setupPositionObserver() + } + + // Helper Struct + private struct OSDWindow { + let nsWindow: NSWindow + let hostingView: NSHostingView + let state: VerticalHUDState + } + + private func setupPositionObserver() { + // Observe layout keys individually + let updateBlock: (Any) -> Void = { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateWindowLayout() + } + } + + Defaults.publisher(.verticalHUDPosition, options: []).sink(receiveValue: updateBlock).store(in: &cancellables) + Defaults.publisher(.verticalHUDHeight, options: []).sink(receiveValue: updateBlock).store(in: &cancellables) + Defaults.publisher(.verticalHUDWidth, options: []).sink(receiveValue: updateBlock).store(in: &cancellables) + Defaults.publisher(.verticalHUDPadding, options: []).sink(receiveValue: updateBlock).store(in: &cancellables) + Defaults.publisher(.verticalHUDInteractive, options: []).sink(receiveValue: updateBlock).store(in: &cancellables) + + Defaults.publisher(.enableVerticalHUD, options: []).sink { [weak self] change in + Task { @MainActor [weak self] in + self?.handleEnablementChange(change.newValue) + } + }.store(in: &cancellables) + } + + private func updateWindowLayout() { + guard !windows.isEmpty else { return } + + for (screen, window) in windows { + let screenFrame = screen.frame + let position = Defaults[.verticalHUDPosition] + let width = Defaults[.verticalHUDWidth] + let height = Defaults[.verticalHUDHeight] + let padding = Defaults[.verticalHUDPadding] + + // Add padding for shadow and elastic stretch + // We use 120px total padding (60px per side) to absolutely guarantee no clipping. + let shadowPadding: CGFloat = 120 + let totalWidth = width + shadowPadding + let totalHeight = height + shadowPadding + + let x: CGFloat + if position == "left" { + // Left edge of HUD is at (minX + padding). + // Window edge = HUD Edge - half shadow padding + x = screenFrame.minX + padding - (shadowPadding / 2) + } else { + // Right edge of HUD is at (maxX - padding). + // Left edge of HUD = maxX - padding - width. + // Window edge = HUD Edge - half shadow padding + x = screenFrame.maxX - width - padding - (shadowPadding / 2) + } + + let y = screenFrame.midY - (totalHeight / 2) + let newFrame = NSRect(x: x, y: y, width: totalWidth, height: totalHeight) + + window.nsWindow.setFrame(newFrame, display: true, animate: true) + applyInteractivity(window) + } + } + + private func handleEnablementChange(_ isEnabled: Bool) { + if isEnabled { + updateWindowLayout() + } else { + teardownWindows() + } + } + + private func applyInteractivity(_ window: OSDWindow, visibleOverride: Bool? = nil) { + let isVisible = visibleOverride ?? (window.nsWindow.alphaValue > 0.01) + let shouldAllowInteraction = Defaults[.enableVerticalHUD] && Defaults[.verticalHUDInteractive] && isVisible + window.nsWindow.ignoresMouseEvents = !shouldAllowInteraction + } + + private func updateWindowInteractivity() { + for window in windows.values { + applyInteractivity(window) + } + } + + private func teardownWindows() { + hideWorkItem?.cancel() + hideWorkItem = nil + + for window in windows.values { + window.nsWindow.orderOut(nil) + window.nsWindow.ignoresMouseEvents = true + } + + windows.removeAll() + } + + func show(type: SneakContentType, value: CGFloat, icon: String = "", onScreen targetScreen: NSScreen? = nil) { + guard Defaults[.enableVerticalHUD] else { return } + + let screens = targetScreen.map { [$0] } ?? NSScreen.screens + guard !screens.isEmpty else { return } + + // Show on target screen(s) + for screen in screens { + let windowContext = ensureWindow(for: type, screen: screen) + updateContent(window: windowContext, type: type, value: value, icon: icon) + + // Ensure visible + windowContext.nsWindow.orderFrontRegardless() + applyInteractivity(windowContext, visibleOverride: true) + + NSAnimationContext.runAnimationGroup { context in + context.duration = animationDuration + windowContext.nsWindow.animator().alphaValue = 1 + } + } + + scheduleHide() + } + + func triggerBump(direction: Int) { + guard Defaults[.enableVerticalHUD] else { return } + Task { @MainActor in + for window in windows.values { + window.state.bumpEvent = BumpEvent(direction: direction, timestamp: Date()) + } + } + } + + private func ensureWindow(for type: SneakContentType, screen: NSScreen) -> OSDWindow { + if let existing = windows[screen] { + return existing + } + + let state = VerticalHUDState(type: type, value: 0, icon: "") + let osdView = VerticalHUDView(state: state) + let hostingView = NSHostingView(rootView: osdView) + + // Calculate frame + let screenFrame = screen.frame + let position = Defaults[.verticalHUDPosition] + let width = Defaults[.verticalHUDWidth] + let height = Defaults[.verticalHUDHeight] + let padding = Defaults[.verticalHUDPadding] + + // Add padding for shadow and elastic stretch + // We use 120px total padding (60px per side) to absolutely guarantee no clipping. + let shadowPadding: CGFloat = 120 + let totalWidth = width + shadowPadding + let totalHeight = height + shadowPadding + + let x: CGFloat + if position == "left" { + x = screenFrame.minX + padding - (shadowPadding / 2) + } else { + x = screenFrame.maxX - width - padding - (shadowPadding / 2) + } + + // Center vertically + let y = screenFrame.midY - (totalHeight / 2) + let frame = NSRect(x: x, y: y, width: totalWidth, height: totalHeight) + + let win = NSWindow( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + win.isOpaque = false + win.backgroundColor = NSColor.clear + win.level = NSWindow.Level.screenSaver + win.collectionBehavior = [NSWindow.CollectionBehavior.canJoinAllSpaces, NSWindow.CollectionBehavior.stationary, NSWindow.CollectionBehavior.ignoresCycle, NSWindow.CollectionBehavior.fullScreenAuxiliary] + win.hasShadow = false + win.contentView = hostingView + win.alphaValue = 0 + + // Default to ignoring events until explicitly shown + win.ignoresMouseEvents = true + + SkyLightOperator.shared.delegateWindow(win) + + let windowStruct = OSDWindow(nsWindow: win, hostingView: hostingView, state: state) + windows[screen] = windowStruct + return windowStruct + } + + private func updateContent(window: OSDWindow, type: SneakContentType, value: CGFloat, icon: String) { + // Update state instead of replacing view + window.state.type = type + window.state.value = value + window.state.icon = icon + } + + private func scheduleHide() { + hideWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + Task { @MainActor in + self.hideWindow() + } + } + + hideWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + displayDuration, execute: workItem) + } + + private func hideWindow() { + for window in windows.values { + NSAnimationContext.runAnimationGroup { context in + context.duration = animationDuration + window.nsWindow.animator().alphaValue = 0 + } completionHandler: { [weak self] in + self?.applyInteractivity(window, visibleOverride: false) + } + } + } +} +#endif diff --git a/DynamicIsland/managers/WebcamManager.swift b/DynamicIsland/managers/WebcamManager.swift index ea4fc830..1865d60e 100644 --- a/DynamicIsland/managers/WebcamManager.swift +++ b/DynamicIsland/managers/WebcamManager.swift @@ -1,11 +1,28 @@ -// -// WebcamManager.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 19/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + import AVFoundation import SwiftUI +import Defaults class WebcamManager: NSObject, ObservableObject { static let shared = WebcamManager() @@ -35,6 +52,12 @@ class WebcamManager: NSObject, ObservableObject { } } + @Published var availableCameras: [AVCaptureDevice] = [] { + didSet { + objectWillChange.send() + } + } + private let sessionQueue = DispatchQueue(label: "DynamicIsland.WebcamManager.SessionQueue", qos: .userInitiated) private var isCleaningUp: Bool = false @@ -116,7 +139,7 @@ class WebcamManager: NSObject, ObservableObject { /// Checks if any camera devices are available and sets up capture session if needed func checkCameraAvailability() { let availableDevices = AVCaptureDevice.DiscoverySession( - deviceTypes: [.external, .builtInWideAngleCamera], + deviceTypes: [.external, .builtInWideAngleCamera, .deskViewCamera, .externalUnknown], mediaType: .video, position: .unspecified ).devices @@ -124,6 +147,7 @@ class WebcamManager: NSObject, ObservableObject { let hasAvailableDevices = !availableDevices.isEmpty DispatchQueue.main.async { + self.availableCameras = availableDevices self.cameraAvailable = hasAvailableDevices } } @@ -142,14 +166,24 @@ class WebcamManager: NSObject, ObservableObject { let session = AVCaptureSession() do { - // Get available devices and prefer external camera if available + // Get available devices let discoverySession = AVCaptureDevice.DiscoverySession( - deviceTypes: [.external, .builtInWideAngleCamera], + deviceTypes: [.external, .builtInWideAngleCamera, .deskViewCamera, .externalUnknown], mediaType: .video, position: .unspecified ) - guard let videoDevice = discoverySession.devices.first else { + let devices = discoverySession.devices + let selectedID = Defaults[.selectedCameraID] + + let videoDevice: AVCaptureDevice? + if !selectedID.isEmpty { + videoDevice = devices.first(where: { $0.uniqueID == selectedID }) ?? devices.first + } else { + videoDevice = devices.first + } + + guard let videoDevice = videoDevice else { NSLog("No video devices available") DispatchQueue.main.async { self.isSessionRunning = false @@ -159,7 +193,7 @@ class WebcamManager: NSObject, ObservableObject { return } - NSLog("Using camera: \(videoDevice.localizedName)") + NSLog("Using camera: \(videoDevice.localizedName) (ID: \(videoDevice.uniqueID))") // Lock device for configuration try videoDevice.lockForConfiguration() diff --git a/DynamicIsland/menu/StatusBarMenu.swift b/DynamicIsland/menu/StatusBarMenu.swift index dbadd330..27c16b42 100644 --- a/DynamicIsland/menu/StatusBarMenu.swift +++ b/DynamicIsland/menu/StatusBarMenu.swift @@ -1,6 +1,28 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + import Cocoa -class DynamicIslandStatusMenu: NSMenu { +class StatusBarMenu: NSMenu { var statusItem: NSStatusItem! @@ -11,7 +33,6 @@ class DynamicIslandStatusMenu: NSMenu { convenience init() { self.init(title: "DynamicIsland") - setupStatusItem() } required init(coder: NSCoder) { @@ -23,22 +44,14 @@ class DynamicIslandStatusMenu: NSMenu { // Initialize the status item statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + // Set the menu bar icon if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "music.note", accessibilityDescription: "DynamicIsland") - button.action = #selector(showMenu) + button.image = NSImage(named: "logo") } // Set up the menu - self.addItem(NSMenuItem(title: "Quit", action: #selector(quitAction), keyEquivalent: "q")) + self.addItem(NSMenuItem(title: "Quit", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q")) statusItem.menu = self } - - @objc func showMenu() { - // Handle menu show action - } - - @objc func quitAction() { - NSApp.terminate(nil) - } } diff --git a/DynamicIsland/metal/visualizer.metal b/DynamicIsland/metal/visualizer.metal index 9bd88b03..459682ff 100644 --- a/DynamicIsland/metal/visualizer.metal +++ b/DynamicIsland/metal/visualizer.metal @@ -1,6 +1,6 @@ // // visualizer.metal -// boringNotch +// DynamicIsland // // Created by Harsh Vardhan Goswami on 28/08/24. // diff --git a/DynamicIsland/models/BatteryStatusViewModel.swift b/DynamicIsland/models/BatteryStatusViewModel.swift index f4392483..b83fb429 100644 --- a/DynamicIsland/models/BatteryStatusViewModel.swift +++ b/DynamicIsland/models/BatteryStatusViewModel.swift @@ -1,16 +1,45 @@ -import Foundation +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + import Cocoa -import SwiftUI -import IOKit.ps import Defaults +import Foundation +import IOKit.ps +import SwiftUI + +enum BatteryTemporaryHUDKind: Equatable { + case charging + case lowBattery + case fullBattery +} /// A view model that manages and monitors the battery status of the device class BatteryStatusViewModel: ObservableObject { - + private var wasCharging: Bool = false private var powerSourceChangedCallback: IOPowerSourceCallbackType? private var runLoopSource: Unmanaged? var animations: DynamicIslandAnimations = DynamicIslandAnimations() + private let lowBatteryAlertSoundPlayer = AudioPlayer() @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @@ -22,14 +51,19 @@ class BatteryStatusViewModel: ObservableObject { @Published private(set) var isInitial: Bool = false @Published private(set) var timeToFullCharge: Int = 0 @Published private(set) var statusText: String = "" - + @Published private(set) var activeTemporaryHUDKind: BatteryTemporaryHUDKind? + @Published private(set) var activeTemporaryHUDToken: UUID = UUID() + @Published private(set) var activeTemporaryHUDTargetScreenName: String? + @Published private(set) var activeTemporaryHUDLevelOverride: Int? + @Published private(set) var activeTemporaryHUDLowPowerModeOverride: Bool? + private let managerBattery = BatteryActivityManager.shared private var managerBatteryId: Int? - + static let shared = BatteryStatusViewModel() - /// Initializes the view model with a given DynamicIslandViewModel instance - /// - Parameter vm: The DynamicIslandViewModel instance + /// Initializes the view model with a given BoringViewModel instance + /// - Parameter vm: The BoringViewModel instance private init() { setupPowerStatus() setupMonitor() @@ -48,58 +82,69 @@ class BatteryStatusViewModel: ObservableObject { self.handleBatteryEvent(event) } } - + /// Handles battery events and updates the corresponding properties /// - Parameter event: The battery event to handle - private func handleBatteryEvent(_ event: BatteryActivityManager.BatteryEvent) { + private func handleBatteryEvent(_ event: BatteryActivityManager.BatteryEvent) { switch event { - case .powerSourceChanged(let isPluggedIn): - print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")") - withAnimation { - self.isPluggedIn = isPluggedIn - self.statusText = isPluggedIn ? "Plugged In" : "Unplugged" - self.notifyImportanChangeStatus() - } - - case .batteryLevelChanged(let level): - print("🔋 Battery level: \(Int(level))%") - withAnimation { - self.levelBattery = level - } - - case .lowPowerModeChanged(let isEnabled): - print("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")") - self.notifyImportanChangeStatus() - withAnimation { - self.isInLowPowerMode = isEnabled - self.statusText = "Low Power: \(self.isInLowPowerMode ? "On" : "Off")" - } - - case .isChargingChanged(let isCharging): - print("🔌 Charging: \(isCharging ? "Yes" : "No")") - print("maxCapacity: \(self.maxCapacity)") - print("levelBattery: \(self.levelBattery)") - self.notifyImportanChangeStatus() - withAnimation { - self.isCharging = isCharging - self.statusText = isCharging ? "Charging battery" : (self.levelBattery < self.maxCapacity ? "Not charging" : "Full charge") - } - - case .timeToFullChargeChanged(let time): - print("🕒 Time to full charge: \(time) minutes") - withAnimation { - self.timeToFullCharge = time - } - - case .maxCapacityChanged(let capacity): - print("🔋 Max capacity: \(capacity)") - withAnimation { - self.maxCapacity = capacity - } - - case .error(let description): - print("⚠️ Error: \(description)") + case .powerSourceChanged(let isPluggedIn): + print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")") + let wasPluggedIn = self.isPluggedIn + withAnimation { + self.isPluggedIn = isPluggedIn + self.statusText = isPluggedIn ? String(localized: "Plugged In") : String(localized: "Unplugged") + } + if !wasPluggedIn && isPluggedIn { + presentTemporaryBatteryHUDIfNeeded(kind: .charging) + } + + case .batteryLevelChanged(let level): + print("🔋 Battery level: \(Int(level))%") + let previousLevel = self.levelBattery + withAnimation { + self.levelBattery = level + } + self.handleLowBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level) + self.handleFullBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level) + + case .lowPowerModeChanged(let isEnabled): + print("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")") + let wasEnabled = self.isInLowPowerMode + withAnimation { + self.isInLowPowerMode = isEnabled + self.statusText = String(localized: "Low Power: \(self.isInLowPowerMode ? String(localized: "On") : String(localized: "Off"))") + } + if !wasEnabled && isEnabled { + presentTemporaryBatteryHUDIfNeeded(kind: .lowBattery) } + + case .isChargingChanged(let isCharging): + print("🔌 Charging: \(isCharging ? "Yes" : "No")") + print("maxCapacity: \(self.maxCapacity)") + print("levelBattery: \(self.levelBattery)") + withAnimation { + self.isCharging = isCharging + self.statusText = + isCharging + ? String(localized: "Charging battery") + : (self.levelBattery < self.maxCapacity ? String(localized: "Not charging") : String(localized: "Full charge")) + } + + case .timeToFullChargeChanged(let time): + print("🕒 Time to full charge: \(time) minutes") + withAnimation { + self.timeToFullCharge = time + } + + case .maxCapacityChanged(let capacity): + print("🔋 Max capacity: \(capacity)") + withAnimation { + self.maxCapacity = capacity + } + + case .error(let description): + print("⚠️ Error: \(description)") + } } /// Updates the battery information with the given BatteryInfo instance @@ -112,29 +157,137 @@ class BatteryStatusViewModel: ObservableObject { self.isInLowPowerMode = batteryInfo.isInLowPowerMode self.timeToFullCharge = batteryInfo.timeToFullCharge self.maxCapacity = batteryInfo.maxCapacity - self.statusText = batteryInfo.isPluggedIn ? "Plugged In" : "Unplugged" + self.statusText = batteryInfo.isPluggedIn ? String(localized: "Plugged In") : String(localized: "Unplugged") } - notifyImportanChangeStatus(delay: coordinator.firstLaunch ? 6 : 0.0) - withAnimation { - if self.isCharging { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.statusText = "Charging: Yes" - } - } - if self.isInLowPowerMode { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.statusText = "Low Power: On" - } - } + } + + private func presentTemporaryBatteryHUDIfNeeded(kind: BatteryTemporaryHUDKind) { + presentTemporaryBatteryHUDIfNeeded(kind: kind, force: false) + } + + func triggerTestHUD(kind: BatteryTemporaryHUDKind) { + let previewLevel: Int + + switch kind { + case .charging: + previewLevel = max(12, min(95, Int(levelBattery.rounded()))) + case .lowBattery: + previewLevel = max(5, min(20, Defaults[.lowBatteryHUDThreshold])) + case .fullBattery: + previewLevel = 100 } + + presentTemporaryBatteryHUDIfNeeded( + kind: kind, + force: true, + levelOverride: previewLevel, + lowPowerModeOverride: kind == .lowBattery ? isInLowPowerMode : nil + ) } - - /// Notifies important changes in the battery status with an optional delay - /// - Parameter delay: The delay before notifying the change, default is 0.0 - private func notifyImportanChangeStatus(delay: Double = 0.0) { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - self.coordinator.toggleExpandingView(status: true, type: .battery) + + private func presentTemporaryBatteryHUDIfNeeded( + kind: BatteryTemporaryHUDKind, + force: Bool, + levelOverride: Int? = nil, + lowPowerModeOverride: Bool? = nil + ) { + guard force || Defaults[.showPowerStatusNotifications] else { return } + + let duration: Int + let isEnabled: Bool + + switch kind { + case .charging: + duration = Defaults[.chargingBatteryHUDDuration] + isEnabled = Defaults[.showChargingBatteryHUD] + case .lowBattery: + duration = Defaults[.lowBatteryHUDDuration] + isEnabled = Defaults[.showLowBatteryHUD] + case .fullBattery: + duration = Defaults[.fullBatteryHUDDuration] + isEnabled = Defaults[.showFullBatteryHUD] } + + guard force || isEnabled else { return } + + activeTemporaryHUDKind = kind + activeTemporaryHUDToken = UUID() + activeTemporaryHUDTargetScreenName = resolvedTemporaryHUDTargetScreenName() + activeTemporaryHUDLevelOverride = levelOverride + activeTemporaryHUDLowPowerModeOverride = lowPowerModeOverride + coordinator.toggleExpandingView( + status: true, + type: .battery, + autoHideDuration: TimeInterval(max(1, duration)) + ) + } + + private func resolvedTemporaryHUDTargetScreenName() -> String? { + if Defaults[.showOnAllDisplays] { + return nil + } + + let preferredNames = [ + coordinator.selectedScreen, + coordinator.preferredScreen, + NSScreen.main?.localizedName + ] + .compactMap { $0 } + + for candidate in preferredNames where NSScreen.screens.contains(where: { $0.localizedName == candidate }) { + return candidate + } + + return NSScreen.screens.first?.localizedName + } + + private func preferredDynamicIslandTargetScreenName() -> String? { + let mainScreenName = NSScreen.main?.localizedName + let preferredNames = [coordinator.selectedScreen, coordinator.preferredScreen] + + for candidate in preferredNames where shouldUseDynamicIslandMode(for: candidate) { + return candidate + } + + if let externalDynamicIslandScreen = NSScreen.screens.first(where: { + $0.localizedName != mainScreenName && shouldUseDynamicIslandMode(for: $0.localizedName) + }) { + return externalDynamicIslandScreen.localizedName + } + + if let anyDynamicIslandScreen = NSScreen.screens.first(where: { + shouldUseDynamicIslandMode(for: $0.localizedName) + }) { + return anyDynamicIslandScreen.localizedName + } + + return nil + } + + private func handleLowBatteryAlertIfNeeded(previousLevel: Float, newLevel: Float) { + guard !isPluggedIn, !isCharging else { return } + guard newLevel < previousLevel else { return } + let threshold = Float(Defaults[.lowBatteryHUDThreshold]) + guard previousLevel > threshold && newLevel <= threshold else { return } + + self.statusText = String(localized: "Low battery") + presentTemporaryBatteryHUDIfNeeded(kind: .lowBattery) + if Defaults[.playLowBatteryAlertSound] { + playLowBatteryAlertSound() + } + } + + private func handleFullBatteryAlertIfNeeded(previousLevel: Float, newLevel: Float) { + guard newLevel > previousLevel else { return } + let threshold = Float(Defaults[.fullBatteryHUDThreshold]) + guard previousLevel < threshold && newLevel >= threshold else { return } + + self.statusText = String(localized: "Full charge") + presentTemporaryBatteryHUDIfNeeded(kind: .fullBattery) + } + + private func playLowBatteryAlertSound() { + lowBatteryAlertSoundPlayer.play(fileName: "lowbattery", fileExtension: "mp3") } deinit { @@ -143,5 +296,5 @@ class BatteryStatusViewModel: ObservableObject { managerBattery.removeObserver(byId: managerBatteryId) } } - + } diff --git a/DynamicIsland/models/CPUMetrics.swift b/DynamicIsland/models/CPUMetrics.swift new file mode 100644 index 00000000..3cbddc97 --- /dev/null +++ b/DynamicIsland/models/CPUMetrics.swift @@ -0,0 +1,48 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +struct CPULoadBreakdown: Equatable { + var user: Double + var system: Double + var idle: Double + + static let zero = CPULoadBreakdown(user: 0, system: 0, idle: 100) + + var activeUsage: Double { + let active = user + system + return min(max(active, 0), 100) + } + + var normalizedSegments: (user: Double, system: Double, idle: Double) { + let total = max(user + system + idle, 0.001) + let userFraction = min(max(user / total, 0), 1) + let systemFraction = min(max(system / total, 0), 1) + let idleFraction = min(max(idle / total, 0), 1) + return (userFraction, systemFraction, idleFraction) + } +} + +struct LoadAverage: Equatable { + var oneMinute: Double + var fiveMinutes: Double + var fifteenMinutes: Double + + static let zero = LoadAverage(oneMinute: 0, fiveMinutes: 0, fifteenMinutes: 0) +} diff --git a/DynamicIsland/models/CalendarModel.swift b/DynamicIsland/models/CalendarModel.swift index 26418fc8..132ec3b3 100644 --- a/DynamicIsland/models/CalendarModel.swift +++ b/DynamicIsland/models/CalendarModel.swift @@ -1,17 +1,32 @@ -// -// CalendarModel.swift -// Calendr -// -// Created by Paker on 31/12/20. -// Original source: https://github.com/pakerwreah/Calendr -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Cocoa -struct CalendarModel: Equatable { +struct CalendarModel: Identifiable, Hashable { + let accountName: String let id: String - let account: String let title: String let color: NSColor let isSubscribed: Bool + let isReminder: Bool // true if this is a reminder calendar } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index c7c6a1c0..586eaa61 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -1,12 +1,29 @@ -// -// Constants.swift -// DynamicIsland -// -// Created by Richard Kunkli on 2024. 10. 17.. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import SwiftUI import Defaults +import Lottie +import Foundation private let availableDirectories = FileManager .default @@ -25,11 +42,352 @@ struct CustomVisualizer: Codable, Hashable, Equatable, Defaults.Serializable { var speed: CGFloat = 1.0 } +struct CustomAppIcon: Codable, Hashable, Equatable, Defaults.Serializable, Identifiable { + let id: UUID + var name: String + var fileName: String + var addedAt: Date + + init(id: UUID = UUID(), name: String, fileName: String, addedAt: Date = .now) { + self.id = id + self.name = name + self.fileName = fileName + self.addedAt = addedAt + } + + static let iconDirectory: URL = { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let dir = base.appendingPathComponent("DynamicIsland", isDirectory: true) + .appendingPathComponent("AppIcons", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + + var fileURL: URL { + Self.iconDirectory.appendingPathComponent(fileName) + } +} + +// MARK: - Custom Idle Animation Models +struct CustomIdleAnimation: Codable, Hashable, Equatable, Defaults.Serializable, Identifiable { + let id: UUID + var name: String + var source: AnimationSource + var speed: CGFloat = 1.0 + var isBuiltIn: Bool = false // Track if it's bundled vs user-added + + init(id: UUID = UUID(), name: String, source: AnimationSource, speed: CGFloat = 1.0, isBuiltIn: Bool = false) { + self.id = id + self.name = name + self.source = source + self.speed = speed + self.isBuiltIn = isBuiltIn + } + + /// Get the effective transform config (override or default) + func getTransformConfig() -> AnimationTransformConfig { + let override = Defaults[.animationTransformOverrides][id.uuidString] + if let override = override { + print("📋 [CustomIdleAnimation] Found override for '\(name)': \(override)") + } else { + print("📋 [CustomIdleAnimation] No override for '\(name)', using default") + } + return override ?? .default + } +} + +struct AnimationTransformConfig: Codable, Hashable, Equatable, Defaults.Serializable { + var scale: CGFloat = 1.0 + var offsetX: CGFloat = 0 + var offsetY: CGFloat = 0 + var cropWidth: CGFloat = 30 + var cropHeight: CGFloat = 20 + var rotation: CGFloat = 0 + var opacity: CGFloat = 1.0 + var paddingBottom: CGFloat = 0 // Allow adjustment to fill notch from bottom + var expandWithAnimation: Bool = false // Whether notch should expand horizontally with animation + var loopMode: AnimationLoopMode = .loop // Loop mode for animation + + static let `default` = AnimationTransformConfig() +} + +enum AnimationLoopMode: String, Codable, CaseIterable { + case loop = "Loop" + case playOnce = "Play Once" + case autoReverse = "Auto Reverse" + + var lottieLoopMode: LottieLoopMode { + switch self { + case .loop: return .loop + case .playOnce: return .playOnce + case .autoReverse: return .autoReverse + } + } +} + +enum AnimationSource: Codable, Hashable, Equatable { + case lottieFile(URL) // Local file (in app support or bundle) + case lottieURL(URL) // Remote URL + + var displayType: String { + switch self { + case .lottieFile: return "Local" + case .lottieURL: return "Remote" + } + } +} + +// MARK: - Extension Authorization Models + +enum ExtensionPermissionScope: String, CaseIterable, Codable, Defaults.Serializable { + case liveActivities + case lockScreenWidgets + case notchExperiences + case fileSharing + + var displayName: String { + switch self { + case .liveActivities: return "Live Activities" + case .lockScreenWidgets: return "Lock Screen Widgets" + case .notchExperiences: return "Notch Experiences" + case .fileSharing: return "File Sharing" + } + } +} + +enum ExtensionAuthorizationStatus: String, CaseIterable, Codable, Defaults.Serializable { + case pending + case authorized + case denied + case revoked + + var isActive: Bool { + switch self { + case .authorized: return true + case .pending, .denied, .revoked: return false + } + } +} + +struct ExtensionAuthorizationEntry: Codable, Defaults.Serializable, Identifiable, Hashable { + let bundleIdentifier: String + var appName: String + var status: ExtensionAuthorizationStatus + var allowedScopes: Set + var requestedAt: Date + var grantedAt: Date? + var lastActivityAt: Date? + var lastDeniedReason: String? + var notes: String? + + var id: String { bundleIdentifier } + + init( + bundleIdentifier: String, + appName: String, + status: ExtensionAuthorizationStatus, + allowedScopes: Set = Set(ExtensionPermissionScope.allCases), + requestedAt: Date = .now, + grantedAt: Date? = nil, + lastActivityAt: Date? = nil, + lastDeniedReason: String? = nil, + notes: String? = nil + ) { + self.bundleIdentifier = bundleIdentifier + self.appName = appName + self.status = status + self.allowedScopes = allowedScopes + self.requestedAt = requestedAt + self.grantedAt = grantedAt + self.lastActivityAt = lastActivityAt + self.lastDeniedReason = lastDeniedReason + self.notes = notes + } + + var isAuthorized: Bool { status.isActive } +} + +struct ExtensionRateLimitRecord: Codable, Defaults.Serializable, Hashable, Identifiable { + let bundleIdentifier: String + var activityTimestamps: [Date] + var widgetTimestamps: [Date] + var notchExperienceTimestamps: [Date] + + var id: String { bundleIdentifier } + + init( + bundleIdentifier: String, + activityTimestamps: [Date] = [], + widgetTimestamps: [Date] = [], + notchExperienceTimestamps: [Date] = [] + ) { + self.bundleIdentifier = bundleIdentifier + self.activityTimestamps = activityTimestamps + self.widgetTimestamps = widgetTimestamps + self.notchExperienceTimestamps = notchExperienceTimestamps + } + + private enum CodingKeys: String, CodingKey { + case bundleIdentifier + case activityTimestamps + case widgetTimestamps + case notchExperienceTimestamps + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + bundleIdentifier = try container.decode(String.self, forKey: .bundleIdentifier) + activityTimestamps = try container.decodeIfPresent([Date].self, forKey: .activityTimestamps) ?? [] + widgetTimestamps = try container.decodeIfPresent([Date].self, forKey: .widgetTimestamps) ?? [] + notchExperienceTimestamps = try container.decodeIfPresent([Date].self, forKey: .notchExperienceTimestamps) ?? [] + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(bundleIdentifier, forKey: .bundleIdentifier) + try container.encode(activityTimestamps, forKey: .activityTimestamps) + try container.encode(widgetTimestamps, forKey: .widgetTimestamps) + try container.encode(notchExperienceTimestamps, forKey: .notchExperienceTimestamps) + } +} + enum CalendarSelectionState: Codable, Defaults.Serializable { case all case selected(Set) } +enum FantasticalViewStyle: String, CaseIterable, Codable, Defaults.Serializable { + case mini = "mini" + case calendar = "calendar" + + var displayName: String { + switch self { + case .mini: return "Mini View" + case .calendar: return "Full Calendar" + } + } +} + +enum ThirdPartyCalendarApp: String, CaseIterable, Codable, Defaults.Serializable, Identifiable { + case fantastical = "fantastical" + case notionCalendar = "notionCalendar" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .fantastical: return "Fantastical" + case .notionCalendar: return "Notion Calendar" + } + } + + /// Bundle identifiers to try when looking up the app icon (first match wins). + var bundleIdentifiers: [String] { + switch self { + case .fantastical: return ["com.flexibits.fantastical2.mac", "com.flexibits.fantastical"] + case .notionCalendar: return ["com.cron.electron"] + } + } + + var fallbackIconName: String { + switch self { + case .fantastical: return "calendar.badge.clock" + case .notionCalendar: return "calendar.badge.plus" + } + } + + var fallbackIconColor: Color { + switch self { + case .fantastical: return .red + case .notionCalendar: return .blue + } + } +} + +enum ClipboardDisplayMode: String, CaseIterable, Codable, Defaults.Serializable { + case popover = "popover" // Traditional popover attached to button + case panel = "panel" // Floating panel near notch + case separateTab = "separateTab" // Separate tab in Dynamic Island + + var displayName: String { + switch self { + case .popover: return String(localized: "Popover") + case .panel: return String(localized: "Panel") + case .separateTab: return String(localized: "Separate Tab") + } + } + + var description: String { + switch self { + case .popover: return "Shows clipboard as a dropdown attached to the clipboard button" + case .panel: return "Shows clipboard in a floating panel near the notch" + case .separateTab: return "Shows copied items in a separate tab within the Dynamic Island (merges with Notes if enabled)" + } + } +} + +enum ScreenAssistantDisplayMode: String, CaseIterable, Codable, Defaults.Serializable { + case popover = "popover" // Traditional popover attached to button + case panel = "panel" // Floating panel near notch + + var displayName: String { + switch self { + case .popover: return String(localized: "Popover") + case .panel: return String(localized: "Panel") + } + } + + var description: String { + switch self { + case .popover: return String(localized: "Shows screen assistant as a dropdown attached to the AI button") + case .panel: return String(localized: "Shows screen assistant in a floating panel near the notch") + } + } +} + +enum ColorPickerDisplayMode: String, CaseIterable, Codable, Defaults.Serializable { + case popover = "popover" // Traditional popover attached to button + case panel = "panel" // Floating panel near notch + + var displayName: String { + switch self { + case .popover: return "Popover" + case .panel: return "Panel" + } + } + + var description: String { + switch self { + case .popover: return "Shows color picker as a dropdown attached to the color picker button" + case .panel: return "Shows color picker in a floating panel near the notch" + } + } +} + +enum ThirdPartyDDCProvider: String, CaseIterable, Codable, Defaults.Serializable, Identifiable { + case betterDisplay + case lunar + + var id: String { rawValue } + + var displayName: String { + switch self { + case .betterDisplay: + return "BetterDisplay" + case .lunar: + return "Lunar" + } + } + + /// Bundle identifiers to try when looking up the app icon. + var bundleIdentifiers: [String] { + switch self { + case .betterDisplay: return ["pro.betterdisplay.BetterDisplay"] + case .lunar: return ["fyi.lunar.Lunar"] + } + } +} + enum HideNotchOption: String, Defaults.Serializable { case always case nowPlayingOnly @@ -47,8 +405,19 @@ enum MediaControllerType: String, CaseIterable, Identifiable, Defaults.Serializa case appleMusic = "Apple Music" case spotify = "Spotify" case youtubeMusic = "Youtube Music" + case amazonMusic = "Amazon Music" var id: String { self.rawValue } + + var localizedName: String { + switch self { + case .nowPlaying: return String(localized: "Now Playing") + case .appleMusic: return String(localized: "Apple Music") + case .spotify: return String(localized: "Spotify") + case .youtubeMusic: return String(localized: "Youtube Music") + case .amazonMusic: return String(localized: "Amazon Music") + } + } } // Sneak peek styles for selection in settings @@ -57,6 +426,363 @@ enum SneakPeekStyle: String, CaseIterable, Identifiable, Defaults.Serializable { case inline = "Inline" var id: String { self.rawValue } + + var LocalizedName : String { + switch self { + case .standard: return String(localized: "Default") + case .inline: return String(localized: "Inline") + } + } +} + +enum CapsLockIndicatorTintMode: String, CaseIterable, Identifiable, Defaults.Serializable { + case green + case accent + case white + + var id: String { rawValue } + + var displayName: String { + switch self { + case .green: + return String(localized: "Green") + case .accent: + return String(localized: "Accent") + case .white: + return String(localized: "White") + } + } + + var color: Color { + switch self { + case .green: + return .green + case .accent: + return .accentColor + case .white: + return .white + } + } +} + +enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable { + case hierarchical = "Hierarchical" + case gradient = "Gradient" + case segmented = "Segmented" + + var id: String { self.rawValue } +} + +enum BatteryNotificationStyle: String, CaseIterable, Identifiable, Defaults.Serializable { + case standard + case compact + + var id: String { rawValue } + + var title: String { + switch self { + case .standard: + return String(localized: "Standard") + case .compact: + return String(localized: "Compact") + } + } +} + +enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Serializable { + case shuffle + case repeatMode + case mediaOutput + case lyrics + + var id: String { rawValue } + + var displayName: String { + switch self { + case .shuffle: + return "Shuffle" + case .repeatMode: + return "Repeat" + case .mediaOutput: + return "Media Output" + case .lyrics: + return "Lyrics" + } + } + + var symbolName: String { + switch self { + case .shuffle: + return "shuffle" + case .repeatMode: + return "repeat" + case .mediaOutput: + return "laptopcomputer" + case .lyrics: + return "quote.bubble" + } + } + + static func alternative( + excluding control: MusicAuxiliaryControl, + preferring candidate: MusicAuxiliaryControl? = nil + ) -> MusicAuxiliaryControl { + if let candidate, candidate != control { + return candidate + } + + return allCases.first { $0 != control } ?? .shuffle + } +} + +enum MusicSkipBehavior: String, CaseIterable, Identifiable, Defaults.Serializable { + case track + case tenSecond + + var id: String { rawValue } + + var displayName: String { + switch self { + case .track: + return String(localized: "Track Skip") + case .tenSecond: + return String(localized: "±10 Seconds") + } + } + + var description: String { + switch self { + case .track: + return String(localized: "Standard previous/next track controls") + case .tenSecond: + return String(localized: "Skip forward or backward by ten seconds") + } + } +} + +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 String(localized:"Adaptive gradient") + case .solid: return String(localized:"Solid colour") + } + } +} + +enum TimerProgressStyle: String, CaseIterable, Identifiable, Defaults.Serializable { + case bar = "Bar" + case ring = "Ring" + + var id: String { rawValue } + + var localizedName: String { + switch self { + case .bar: return String(localized:"Bar") + case .ring: return String(localized:"Ring") + } + } +} + +enum FocusMonitoringMode: String, CaseIterable, Identifiable, Defaults.Serializable { + case withoutDevTools = "withoutDevTools" + case useDevTools = "useDevTools" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .withoutDevTools: + return "Use without DevTools" + case .useDevTools: + return "Use DevTools" + } + } +} + +enum ReminderPresentationStyle: String, CaseIterable, Identifiable, Defaults.Serializable { + case ringCountdown = "Ring" + case digital = "Digital" + case minutes = "Minutes" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .ringCountdown: + return String(localized: "Ring") + case .digital: + return String(localized: "Digital") + case .minutes: + return String(localized: "Minutes") + } + } +} + +// AI Model types for screen assistant +enum AIModelProvider: String, CaseIterable, Identifiable, Defaults.Serializable { + case gemini = "Gemini" + case openai = "OpenAI GPT" + case claude = "Claude" + case local = "Local Model" + case groq = "Groq" + + var id: String { self.rawValue } + + var displayName: String { + return self.rawValue + } + + var description: String { + switch self { + case .gemini: return "Google's Gemini AI with multimodal capabilities" + case .openai: return "OpenAI's GPT models with advanced reasoning" + case .claude: return "Anthropic's Claude with strong analytical skills" + case .local: return "Local AI model (Ollama or similar)" + case .groq: return "Groq's fast inference for OpenAI-compatible models" + } + } + + var supportedModels: [AIModel] { + switch self { + case .gemini: + return [ + // Gemini 2.5 Models (Latest) + AIModel(id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", supportsThinking: true), + AIModel(id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", supportsThinking: true), + AIModel(id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash-Lite", supportsThinking: false), + AIModel(id: "gemini-2.5-flash-live", name: "Gemini 2.5 Flash Live", supportsThinking: false), + AIModel(id: "gemini-2.5-flash-native-audio", name: "Gemini 2.5 Flash Native Audio", supportsThinking: true), + + // Gemini 2.0 Models + AIModel(id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", supportsThinking: false), + AIModel(id: "gemini-2.0-flash-lite", name: "Gemini 2.0 Flash-Lite", supportsThinking: false), + AIModel(id: "gemini-2.0-flash-live", name: "Gemini 2.0 Flash Live", supportsThinking: false), + + // Legacy 1.5 Models (for compatibility) + AIModel(id: "gemini-1.5-pro", name: "Gemini 1.5 Pro", supportsThinking: false), + AIModel(id: "gemini-1.5-flash", name: "Gemini 1.5 Flash", supportsThinking: false) + ] + case .openai: + return [ + AIModel(id: "gpt-4o", name: "GPT-4o", supportsThinking: false), + AIModel(id: "gpt-4o-mini", name: "GPT-4o Mini", supportsThinking: false), + AIModel(id: "o1-preview", name: "o1 Preview", supportsThinking: true), + AIModel(id: "o1-mini", name: "o1 Mini", supportsThinking: true) + ] + case .claude: + return [ + AIModel(id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", supportsThinking: false), + AIModel(id: "claude-3-haiku", name: "Claude 3 Haiku", supportsThinking: false) + ] + case .local: + return [ + AIModel(id: "llama3.2", name: "Llama 3.2", supportsThinking: false), + AIModel(id: "qwen2.5", name: "Qwen 2.5", supportsThinking: false) + ] + case .groq: + return [ + AIModel(id: "llama-3.3-70b-versatile", name: "Llama 3.3 70B Versatile", supportsThinking: false), + AIModel(id: "llama-3.1-8b-instant", name: "Llama 3.1 8B Instant", supportsThinking: false), + AIModel(id: "qwen-qwq-32b", name: "Qwen QWQ 32B", supportsThinking: false), + AIModel(id: "mixtral-8x7b-32768", name: "Mixtral 8x7B", supportsThinking: false) + ] + } + } +} + +struct AIModel: Codable, Identifiable, Defaults.Serializable { + let id: String + let name: String + let supportsThinking: Bool + + var displayName: String { + return name + (supportsThinking ? " (Thinking)" : "") + } +} + +struct NoteItem: Codable, Identifiable, Defaults.Serializable, Hashable { + var id: UUID = UUID() + var title: String + var content: String + var creationDate: Date + var colorIndex: Int // 0: Yellow, 1: Blue, 2: Red, 3: Green + var isPinned: Bool = false + var imageFileName: String? = nil // Store filename instead of raw data + + // Internal property for migration + private enum CodingKeys: String, CodingKey { + case id, title, content, creationDate, colorIndex, isPinned, imageFileName, imageData + } + + init(id: UUID = UUID(), title: String, content: String, creationDate: Date, colorIndex: Int, isPinned: Bool = false, imageFileName: String? = nil) { + self.id = id + self.title = title + self.content = content + self.creationDate = creationDate + self.colorIndex = colorIndex + self.isPinned = isPinned + self.imageFileName = imageFileName + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + title = try container.decode(String.self, forKey: .title) + content = try container.decode(String.self, forKey: .content) + creationDate = try container.decode(Date.self, forKey: .creationDate) + colorIndex = try container.decode(Int.self, forKey: .colorIndex) + isPinned = try container.decode(Bool.self, forKey: .isPinned) + + // Migration logic: if imageData exists but imageFileName doesn't, save it to disk + if let data = try container.decodeIfPresent(Data.self, forKey: .imageData) { + let fileName = "note_image_\(id.uuidString).png" + let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName) + try? data.write(to: fileURL) + imageFileName = fileName + } else { + imageFileName = try container.decodeIfPresent(String.self, forKey: .imageFileName) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(title, forKey: .title) + try container.encode(content, forKey: .content) + try container.encode(creationDate, forKey: .creationDate) + try container.encode(colorIndex, forKey: .colorIndex) + try container.encode(isPinned, forKey: .isPinned) + try container.encode(imageFileName, forKey: .imageFileName) + } + + static let colors: [Color] = [.yellow, .blue, .red, .green, .purple, .orange] + + // Directory for storing note image files + static let noteImageDataDirectory: URL = { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let notesDir = documentsPath.appendingPathComponent("NoteImages") + try? FileManager.default.createDirectory(at: notesDir, withIntermediateDirectories: true) + return notesDir + }() + + var color: Color { + if colorIndex >= 0 && colorIndex < NoteItem.colors.count { + return NoteItem.colors[colorIndex] + } + return .yellow + } + + // Helper to get image data from file + func getImageData() -> Data? { + guard let fileName = imageFileName else { return nil } + let fileURL = NoteItem.noteImageDataDirectory.appendingPathComponent(fileName) + return try? Data(contentsOf: fileURL) + } } extension Defaults.Keys { @@ -64,13 +790,19 @@ extension Defaults.Keys { static let menubarIcon = Key("menubarIcon", default: true) static let showOnAllDisplays = Key("showOnAllDisplays", default: false) static let automaticallySwitchDisplay = Key("automaticallySwitchDisplay", default: true) - static let releaseName = Key("releaseName", default: "Wolf Painting 🐺🎨") + static let releaseName = Key("releaseName", default: "Fiji") + static let hideDynamicIslandFromScreenCapture = Key("hideDynamicIslandFromScreenCapture", default: false) // MARK: Behavior static let minimumHoverDuration = Key("minimumHoverDuration", default: 0.3) static let enableHaptics = Key("enableHaptics", default: true) static let openNotchOnHover = Key("openNotchOnHover", default: true) static let extendHoverArea = Key("extendHoverArea", default: false) + static let externalDisplayStyle = Key( + "externalDisplayStyle", + default: .notch + ) + static let hideNonNotchUntilHover = Key("hideNonNotchUntilHover", default: false) static let notchHeightMode = Key( "notchHeightMode", default: WindowHeightMode.matchRealNotchSize @@ -81,6 +813,7 @@ extension Defaults.Keys { ) static let nonNotchHeight = Key("nonNotchHeight", default: 32) static let notchHeight = Key("notchHeight", default: 32) + static let openNotchWidth = Key("openNotchWidth", default: 640) //static let openLastTabByDefault = Key("openLastTabByDefault", default: false) // MARK: Appearance @@ -88,6 +821,7 @@ extension Defaults.Keys { //static let alwaysShowTabs = Key("alwaysShowTabs", default: true) static let showMirror = Key("showMirror", default: false) static let mirrorShape = Key("mirrorShape", default: MirrorShapeEnum.rectangle) + static let selectedCameraID = Key("selectedCameraID", default: "") static let settingsIconInNotch = Key("settingsIconInNotch", default: true) static let lightingEffect = Key("lightingEffect", default: true) static let accentColor = Key("accentColor", default: Color.blue) @@ -95,8 +829,13 @@ extension Defaults.Keys { static let cornerRadiusScaling = Key("cornerRadiusScaling", default: true) static let useModernCloseAnimation = Key("useModernCloseAnimation", default: true) static let showNotHumanFace = Key("showNotHumanFace", default: false) + static let customIdleAnimations = Key<[CustomIdleAnimation]>("customIdleAnimations", default: []) + static let selectedIdleAnimation = Key("selectedIdleAnimation", default: nil) + static let animationTransformOverrides = Key<[String: AnimationTransformConfig]>("animationTransformOverrides", default: [:]) static let tileShowLabels = Key("tileShowLabels", default: false) - static let showCalendar = Key("showCalendar", default: false) + static let showCalendar = Key("showCalendar", default: true) + static let hideCompletedReminders = Key("hideCompletedReminders", default: true) + static let hideAllDayEvents = Key("hideAllDayEvents", default: false) static let sliderColor = Key( "sliderUseAlbumArtColor", default: SliderColorEnum.white @@ -105,24 +844,139 @@ extension Defaults.Keys { static let useMusicVisualizer = Key("useMusicVisualizer", default: true) static let customVisualizers = Key<[CustomVisualizer]>("customVisualizers", default: []) static let selectedVisualizer = Key("selectedVisualizer", default: nil) + static let customAppIcons = Key<[CustomAppIcon]>("customAppIcons", default: []) + static let selectedAppIconID = Key("selectedAppIconID", default: nil) // MARK: Gestures static let enableGestures = Key("enableGestures", default: true) static let closeGestureEnabled = Key("closeGestureEnabled", default: true) static let gestureSensitivity = Key("gestureSensitivity", default: 200.0) + static let enableHorizontalMusicGestures = Key("enableHorizontalMusicGestures", default: true) + static let musicGestureBehavior = Key("musicGestureBehavior", default: .track) + static let reverseSwipeGestures = Key("reverseSwipeGestures", default: false) + static let reverseScrollGestures = Key("reverseScrollGestures", default: false) // MARK: Media playback static let coloredSpectrogram = Key("coloredSpectrogram", default: true) + static let enableRealTimeWaveform = Key("enableRealTimeWaveform", default: false) static let enableSneakPeek = Key("enableSneakPeek", default: false) static let sneakPeekStyles = Key("sneakPeekStyles", default: .standard) + static let showSneakPeekOnTrackChange = Key("showSneakPeekOnTrackChange", default: true) static let enableFullscreenMediaDetection = Key("enableFullscreenMediaDetection", default: true) + static let parallaxEffectIntensity = Key("parallaxEffectIntensity", default: 6.0) static let waitInterval = Key("waitInterval", default: 3) + static let showShuffleAndRepeat = Key("showShuffleAndRepeat", default: true) + static let showMediaOutputControl = Key("showMediaOutputControl", default: true) + static let musicAuxLeftControl = Key("musicAuxLeftControl", default: .shuffle) + static let musicAuxRightControl = Key("musicAuxRightControl", default: .repeatMode) + static let didMigrateMusicAuxControls = Key("didMigrateMusicAuxControls", default: false) + static let musicControlSlots = Key<[MusicControlButton]>("musicControlSlots", default: MusicControlButton.defaultLayout) + static let didMigrateMusicControlSlots = Key("didMigrateMusicControlSlots", default: false) + static let musicSkipBehavior = Key("musicSkipBehavior", default: .track) + static let musicControlWindowEnabled = Key("musicControlWindowEnabled", default: false) + static let showStandardMediaControls = Key("showStandardMediaControls", default: true) + static let autoHideInactiveNotchMediaPlayer = Key("autoHideInactiveNotchMediaPlayer", default: true) + static let cachedMusicLiveActivityPreference = Key("cachedMusicLiveActivityPreference", default: nil) + static let cachedLockScreenMediaWidgetPreference = Key("cachedLockScreenMediaWidgetPreference", default: nil) + static let cachedMusicControlWindowPreference = Key("cachedMusicControlWindowPreference", default: nil) + // 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 enableLockScreenFocusWidget = Key("enableLockScreenFocusWidget", default: true) + static let enableLockScreenReminderWidget = Key("enableLockScreenReminderWidget", default: true) + static let enableLockScreenTimerWidget = Key("enableLockScreenTimerWidget", default: true) + static let lockScreenWeatherRefreshInterval = Key("lockScreenWeatherRefreshInterval", default: 30 * 60) + static let lockScreenWeatherShowsLocation = Key("lockScreenWeatherShowsLocation", default: true) + static let lockScreenWeatherShowsSunrise = Key("lockScreenWeatherShowsSunrise", default: true) + static let lockScreenWeatherWidgetStyle = Key("lockScreenWeatherWidgetStyle", default: .inline) + static let lockScreenWeatherTemperatureUnit = Key("lockScreenWeatherTemperatureUnit", default: .celsius) + static let lockScreenWeatherShowsAQI = Key("lockScreenWeatherShowsAQI", default: true) + static let lockScreenWeatherAQIScale = Key("lockScreenWeatherAQIScale", default: .us) + static let lockScreenWeatherUsesGaugeTint = Key("lockScreenWeatherUsesGaugeTint", default: false) + static let lockScreenWeatherProviderSource = Key("lockScreenWeatherProviderSource", default: .openMeteo) + static let lockScreenWeatherVerticalOffset = Key("lockScreenWeatherVerticalOffset", default: 0) + static let lockScreenMusicVerticalOffset = Key("lockScreenMusicVerticalOffset", default: 0) + static let lockScreenMusicPanelWidth = Key("lockScreenMusicPanelWidth", default: 350) + static let lockScreenMusicAlbumParallaxEnabled = Key("lockScreenMusicAlbumParallaxEnabled", default: false) + static let lockScreenTimerVerticalOffset = Key("lockScreenTimerVerticalOffset", default: 0) + static let lockScreenTimerWidgetWidth = Key("lockScreenTimerWidgetWidth", default: 350) + static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) + static let lockScreenGlassCustomizationMode = Key( + "lockScreenGlassCustomizationMode", + default: .standard + ) + static let lockScreenTimerGlassStyle = Key("lockScreenTimerGlassStyle", default: .frosted) + static let lockScreenTimerGlassCustomizationMode = Key( + "lockScreenTimerGlassCustomizationMode", + default: .standard + ) + static let lockScreenMusicLiquidGlassVariant = Key( + "lockScreenMusicLiquidGlassVariant", + default: .defaultVariant + ) + static let lockScreenTimerLiquidGlassVariant = Key( + "lockScreenTimerLiquidGlassVariant", + default: .defaultVariant + ) + static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) + static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: false) + static let lockScreenMusicUsesEnhancedLiquidBorder = Key( + "lockScreenMusicUsesEnhancedLiquidBorder", + default: true + ) + static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true) + static let lockScreenMusicMergedAirPlayOutput = Key("lockScreenMusicMergedAirPlayOutput", default: true) + static let lockScreenMusicFullscreenArtworkEnabled = Key("lockScreenMusicFullscreenArtworkEnabled", default: true) + static let lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork = Key("lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork", default: true) + static let lockScreenMusicFullscreenVideoArtwork = Key("lockScreenMusicFullscreenVideoArtwork", default: true) + static let lockScreenUseArtworkLayoutOverFullscreenCanvas = Key("lockScreenShowCenteredAlbumArtOverFullscreenCanvas", default: true) + static let lockScreenTimerWidgetUsesBlur = Key("lockScreenTimerWidgetUsesBlur", default: false) + static let lockScreenReminderChipStyle = Key("lockScreenReminderChipStyle", default: .eventColor) + static let lockScreenReminderWidgetHorizontalAlignment = Key("lockScreenReminderWidgetHorizontalAlignment", default: "center") + static let lockScreenReminderWidgetVerticalOffset = Key("lockScreenReminderWidgetVerticalOffset", default: 0) + static let lockScreenShowCalendarEvent = Key("lockScreenShowCalendarEvent", default: true) + static let lockScreenCalendarEventLookaheadWindow = Key("lockScreenCalendarEventLookaheadWindow", default: "3h") + static let lockScreenCalendarSelectionMode = Key("lockScreenCalendarSelectionMode", default: "all") + static let lockScreenSelectedCalendarIDs = Key>("lockScreenSelectedCalendarIDs", default: []) + static let lockScreenShowCalendarCountdown = Key("lockScreenShowCalendarCountdown", default: true) + static let lockScreenShowCalendarEventEntireDuration = Key("lockScreenShowCalendarEventEntireDuration", default: true) + static let lockScreenShowCalendarEventAfterStartEnabled = Key("lockScreenShowCalendarEventAfterStartEnabled", default: false) + static let lockScreenShowCalendarEventAfterStartWindow = Key("lockScreenShowCalendarEventAfterStartWindow", default: "5m") + static let lockScreenShowCalendarTimeRemaining = Key("lockScreenShowCalendarTimeRemaining", default: true) + static let lockScreenShowCalendarStartTimeAfterBegins = Key("lockScreenShowCalendarStartTimeAfterBegins", default: true) + static let lockScreenWeatherWidgetRowOrder = Key("lockScreenWeatherWidgetRowOrder", default: "weather_calendar_focus") + + // MARK: Third-party Calendar Integration + static let enableThirdPartyCalendarApp = Key("enableThirdPartyCalendarApp", default: false) + static let selectedCalendarApp = Key("selectedCalendarApp", default: .fantastical) + static let fantasticalDefaultView = Key("fantasticalDefaultView", default: .mini) // MARK: Battery static let showPowerStatusNotifications = Key("showPowerStatusNotifications", default: true) - static let showBatteryIndicator = Key("showBatteryIndicator", default: true) + static let showBatteryIndicator = Key("showBatteryIndicator", default: BatteryActivityManager.shared.hasBattery()) static let showBatteryPercentage = Key("showBatteryPercentage", default: true) + static let showBatteryPercentInside = Key("showBatteryPercentInside", default: true) static let showPowerStatusIcons = Key("showPowerStatusIcons", default: true) + static let playLowBatteryAlertSound = Key("playLowBatteryAlertSound", default: true) + static let showChargingBatteryHUD = Key("showChargingBatteryHUD", default: true) + static let showLowBatteryHUD = Key("showLowBatteryHUD", default: true) + static let showFullBatteryHUD = Key("showFullBatteryHUD", default: true) + static let chargingBatteryHUDDuration = Key("chargingBatteryHUDDuration", default: 3) + static let lowBatteryHUDDuration = Key("lowBatteryHUDDuration", default: 3) + static let fullBatteryHUDDuration = Key("fullBatteryHUDDuration", default: 3) + static let lowBatteryHUDThreshold = Key("lowBatteryHUDThreshold", default: 20) + static let fullBatteryHUDThreshold = Key("fullBatteryHUDThreshold", default: 100) + static let lowBatteryHUDStyle = Key("lowBatteryHUDStyle", default: .standard) + static let fullBatteryHUDStyle = Key("fullBatteryHUDStyle", default: .standard) + + static let lockScreenBatteryShowsBatteryGauge = Key( + "lockScreenWeatherShowsBatteryGauge", + default: BatteryActivityManager.shared.hasBattery() + ) + static let lockScreenBatteryUsesLaptopSymbol = Key("lockScreenWeatherBatteryUsesLaptopSymbol", default: true) + static let lockScreenBatteryShowsCharging = Key("lockScreenWeatherShowsCharging", default: true) + static let lockScreenBatteryShowsChargingPercentage = Key("lockScreenWeatherShowsChargingPercentage", default: true) + static let lockScreenBatteryShowsBluetooth = Key("lockScreenWeatherShowsBluetooth", default: true) // MARK: Downloads static let enableDownloadListener = Key("enableDownloadListener", default: true) @@ -131,17 +985,29 @@ extension Defaults.Keys { static let selectedDownloadIconStyle = Key("selectedDownloadIconStyle", default: DownloadIconStyle.onlyAppIcon) // MARK: HUD - static let inlineHUD = Key("inlineHUD", default: false) + static let inlineHUD = Key("inlineHUD", default: true) + static let progressBarStyle = Key("progressBarStyle", default: .hierarchical) + // Legacy support - keeping for backward compatibility 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: true) // MARK: Shelf static let dynamicShelf = Key("dynamicShelf", default: true) static let openShelfByDefault = Key("openShelfByDefault", default: true) + static let quickShareProvider = Key("quickShareProvider", default: "AirDrop") + static let localSendSelectedDeviceID = Key("localSendSelectedDeviceID", default: "") + static let localSendDevicePickerGlassMode = Key("localSendDevicePickerGlassMode", default: .standard) + static let localSendDevicePickerLiquidGlassVariant = Key("localSendDevicePickerLiquidGlassVariant", default: .v11) + static let copyOnDrag = Key("copyOnDrag", default: false) + static let autoRemoveShelfItems = Key("autoRemoveShelfItems", default: false) + static let expandedDragDetection = Key("expandedDragDetection", default: true) // MARK: Calendar static let calendarSelectionState = Key("calendarSelectionState", default: .all) + static let showFullEventTitles = Key("showFullEventTitles", default: false) + static let autoScrollToNextEvent = Key("autoScrollToNextEvent", default: true) // MARK: Fullscreen Media Detection static let alwaysHideInFullscreen = Key("alwaysHideInFullscreen", default: false) @@ -153,6 +1019,228 @@ extension Defaults.Keys { // MARK: Media Controller static let mediaController = Key("mediaController", default: defaultMediaController) + static let spotifySPDCCookie = Key("spotifySPDCCookie", default: "") + static let spotifyAuthAccessToken = Key("spotifyAuthAccessToken", default: "") + static let spotifyAuthAccessTokenExpiration = Key("spotifyAuthAccessTokenExpiration", default: 0) + static let spotifyAuthLastValidatedAt = Key("spotifyAuthLastValidatedAt", default: 0) + + // MARK: Bluetooth Audio Devices + static let showBluetoothDeviceConnections = Key("showBluetoothDeviceConnections", default: true) + static let useColorCodedBatteryDisplay = Key("useColorCodedBatteryDisplay", default: true) + static let useColorCodedVolumeDisplay = Key("useColorCodedVolumeDisplay", default: true) + static let useSmoothColorGradient = Key("useSmoothColorGradient", default: true) + static let useCircularBluetoothBatteryIndicator = Key("useCircularBluetoothBatteryIndicator", default: true) + static let showBluetoothBatteryPercentageText = Key("showBluetoothBatteryPercentageText", default: false) + static let showBluetoothDeviceNameMarquee = Key("showBluetoothDeviceNameMarquee", default: false) + static let useBluetoothHUD3DIcon = Key("useBluetoothHUD3DIcon", default: true) + + // MARK: Stats Feature + static let enableStatsFeature = Key("enableStatsFeature", default: false) + static let autoStartStatsMonitoring = Key("autoStartStatsMonitoring", default: true) + static let statsStopWhenNotchCloses = Key("statsStopWhenNotchCloses", default: true) + static let statsUpdateInterval = Key("statsUpdateInterval", default: 1.0) + static let showCpuGraph = Key("showCpuGraph", default: true) + static let showMemoryGraph = Key("showMemoryGraph", default: true) + static let showGpuGraph = Key("showGpuGraph", default: true) + static let showNetworkGraph = Key("showNetworkGraph", default: false) + static let showDiskGraph = Key("showDiskGraph", default: false) + static let cpuTemperatureUnit = Key("cpuTemperatureUnit", default: .celsius) + + // MARK: Terminal Feature + static let enableTerminalFeature = Key("enableTerminalFeature", default: false) + static let terminalShellPath = Key("terminalShellPath", default: "/bin/zsh") + static let terminalFontFamily = Key("terminalFontFamily", default: "") + static let terminalFontSize = Key("terminalFontSize", default: 12.0) + static let terminalOpacity = Key("terminalOpacity", default: 1.0) + static let terminalMaxHeightFraction = Key("terminalMaxHeightFraction", default: 0.4) + static let terminalCursorStyle = Key("terminalCursorStyle", default: "blinkBlock") + static let terminalScrollbackLines = Key("terminalScrollbackLines", default: 1000) + static let terminalOptionAsMeta = Key("terminalOptionAsMeta", default: true) + static let terminalMouseReporting = Key("terminalMouseReporting", default: true) + static let terminalBoldAsBright = Key("terminalBoldAsBright", default: true) + static let terminalBackgroundColor = Key("terminalBackgroundColor", default: .black) + static let terminalForegroundColor = Key("terminalForegroundColor", default: .white) + static let terminalCursorColor = Key("terminalCursorColor", default: Color(.selectedControlColor)) + static let terminalStickyMode = Key("terminalStickyMode", default: false) + + // MARK: Timer Feature + static let enableTimerFeature = Key("enableTimerFeature", default: true) + static let timerDisplayMode = Key("timerDisplayMode", default: .tab) + static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets) + static let showTimerPresetsInNotchTab = Key("showTimerPresetsInNotchTab", default: true) + 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: false) + static let timerShowsProgress = Key("timerShowsProgress", default: true) + static let timerProgressStyle = Key("timerProgressStyle", default: .bar) + static let mirrorSystemTimer = Key("mirrorSystemTimer", default: true) + + // MARK: Reminder Live Activity + static let enableReminderLiveActivity = Key("enableReminderLiveActivity", default: true) + static let reminderPresentationStyle = Key("reminderPresentationStyle", default: .ringCountdown) + static let reminderLeadTime = Key("reminderLeadTime", default: 5) + static let reminderSneakPeekDuration = Key("reminderSneakPeekDuration", default: 5) + static let timerControlWindowEnabled = Key("timerControlWindowEnabled", default: true) + + // MARK: ColorPicker Feature + static let enableColorPickerFeature = Key("enableColorPickerFeature", default: true) + static let showColorFormats = Key("showColorFormats", default: true) + static let colorPickerDisplayMode = Key("colorPickerDisplayMode", default: .panel) + static let colorHistorySize = Key("colorHistorySize", default: 10) + static let showColorPickerIcon = Key("showColorPickerIcon", default: true) + + // MARK: Clipboard Feature + static let enableClipboardManager = Key("enableClipboardManager", default: true) + static let clipboardHistorySize = Key("clipboardHistorySize", default: 3) + static let showClipboardIcon = Key("showClipboardIcon", default: true) + static let clipboardDisplayMode = Key("clipboardDisplayMode", default: .panel) + + // MARK: Screen Assistant Feature + static let enableScreenAssistant = Key("enableScreenAssistant", default: true) + static let screenAssistantDisplayMode = Key("screenAssistantDisplayMode", default: .panel) + static let geminiApiKey = Key("geminiApiKey", default: "") + static let openaiApiKey = Key("openaiApiKey", default: "") + static let claudeApiKey = Key("claudeApiKey", default: "") + static let groqApiKey = Key("groqApiKey", default: "") + static let selectedAIProvider = Key("selectedAIProvider", default: .gemini) + static let selectedAIModel = Key("selectedAIModel", default: nil) + static let enableThinkingMode = Key("enableThinkingMode", default: false) + static let localModelEndpoint = Key("localModelEndpoint", default: "http://localhost:11434") + + // MARK: Third-Party Extensions + static let enableThirdPartyExtensions = Key("enableThirdPartyExtensions", default: true) + static let enableExtensionLiveActivities = Key("enableExtensionLiveActivities", default: true) + static let enableExtensionLockScreenWidgets = Key("enableExtensionLockScreenWidgets", default: true) + static let enableExtensionNotchExperiences = Key("enableExtensionNotchExperiences", default: true) + static let enableExtensionNotchTabs = Key("enableExtensionNotchTabs", default: true) + static let enableExtensionNotchMinimalisticOverrides = Key("enableExtensionNotchMinimalisticOverrides", default: true) + static let enableExtensionNotchInteractiveWebViews = Key("enableExtensionNotchInteractiveWebViews", default: true) + static let extensionAuthorizationEntries = Key<[ExtensionAuthorizationEntry]>("extensionAuthorizationEntries", default: []) + static let extensionRateLimitRecords = Key<[ExtensionRateLimitRecord]>("extensionRateLimitRecords", default: []) + static let extensionDiagnosticsLoggingEnabled = Key("extensionDiagnosticsLoggingEnabled", default: true) + static let extensionLiveActivityCapacity = Key("extensionLiveActivityCapacity", default: 4) + static let extensionLockScreenWidgetCapacity = Key("extensionLockScreenWidgetCapacity", default: 4) + static let extensionNotchExperienceCapacity = Key("extensionNotchExperienceCapacity", default: 2) + static let enableExtensionFileSharing = Key("enableExtensionFileSharing", default: true) + + // MARK: Keyboard Shortcuts + static let enableShortcuts = Key("enableShortcuts", default: true) + + // MARK: System HUD Feature + static let enableSystemHUD = Key("enableSystemHUD", default: true) + static let enableVolumeHUD = Key("enableVolumeHUD", default: true) + static let enableBrightnessHUD = Key("enableBrightnessHUD", default: true) + static let enableKeyboardBacklightHUD = Key("enableKeyboardBacklightHUD", default: true) + static let systemHUDSensitivity = Key("systemHUDSensitivity", default: 5) + static let playVolumeChangeFeedback = Key("playVolumeChangeFeedback", default: false) + + // Step sizes for hardware media keys (percent of full range, 1-25) + static let volumeStepPercent = Key("volumeStepPercent", default: 6) + static let volumeFineStepPercent = Key("volumeFineStepPercent", default: 2) + static let brightnessStepPercent = Key("brightnessStepPercent", default: 6) + static let brightnessFineStepPercent = Key("brightnessFineStepPercent", default: 2) + + // MARK: Custom OSD Window Feature + static let enableCustomOSD = Key("enableCustomOSD", default: false) + static let enableVerticalHUD = Key("enableVerticalHUD", default: false) + static let enableCircularHUD = Key("enableCircularHUD", default: false) + static let verticalHUDPosition = Key("verticalHUDPosition", default: "right") // "left" or "right" + + // Vertical HUD Customization + static let verticalHUDShowValue = Key("verticalHUDShowValue", default: true) + static let verticalHUDInteractive = Key("verticalHUDInteractive", default: true) + static let verticalHUDHeight = Key("verticalHUDHeight", default: 160) + static let verticalHUDWidth = Key("verticalHUDWidth", default: 36) + static let verticalHUDPadding = Key("verticalHUDPadding", default: 24) + static let verticalHUDUseAccentColor = Key("verticalHUDUseAccentColor", default: false) + static let verticalHUDMaterial = Key("verticalHUDMaterial", default: .frosted) + static let verticalHUDLiquidGlassCustomizationMode = Key( + "verticalHUDLiquidGlassCustomizationMode", + default: .standard + ) + static let verticalHUDLiquidGlassVariant = Key( + "verticalHUDLiquidGlassVariant", + default: .defaultVariant + ) + + // Circular HUD Customization + static let circularHUDShowValue = Key("circularHUDShowValue", default: true) + static let circularHUDSize = Key("circularHUDSize", default: 65) + static let circularHUDStrokeWidth = Key("circularHUDStrokeWidth", default: 4) + static let circularHUDUseAccentColor = Key("circularHUDUseAccentColor", default: true) + + // MARK: Third-Party DDC Integration + static let enableThirdPartyDDCIntegration = Key("enableThirdPartyDDCIntegration", default: false) + static let thirdPartyDDCProvider = Key("thirdPartyDDCProvider", default: .betterDisplay) + static let enableExternalVolumeControlListener = Key("enableExternalVolumeControlListener", default: false) + static let didMigrateThirdPartyDDCIntegration = Key("didMigrateThirdPartyDDCIntegration", default: false) + + // Legacy keys retained for migration/backward compatibility + static let enableBetterDisplayIntegration = Key("enableBetterDisplayIntegration", default: false) + static let enableLunarIntegration = Key("enableLunarIntegration", default: false) + + static let hasSeenOSDAlphaWarning = Key("hasSeenOSDAlphaWarning", default: false) + static let enableOSDVolume = Key("enableOSDVolume", default: true) + static let enableOSDBrightness = Key("enableOSDBrightness", default: true) + static let enableOSDKeyboardBacklight = Key("enableOSDKeyboardBacklight", default: true) + static let osdMaterial = Key("osdMaterial", default: .frosted) + static let osdLiquidGlassCustomizationMode = Key( + "osdLiquidGlassCustomizationMode", + default: .standard + ) + static let osdLiquidGlassVariant = Key( + "osdLiquidGlassVariant", + default: .defaultVariant + ) + static let osdIconColorStyle = Key("osdIconColorStyle", default: .white) + + // MARK: Screen Recording Detection Feature + static let enableScreenRecordingDetection = Key("enableScreenRecordingDetection", default: true) + static let showRecordingIndicator = Key("showRecordingIndicator", default: true) + // Polling removed - now uses event-driven private API detection (CGSIsScreenWatcherPresent) + // static let enableScreenRecordingPolling = Key("enableScreenRecordingPolling", default: false) + + // MARK: Focus / Do Not Disturb Detection + static let enableDoNotDisturbDetection = Key("enableDoNotDisturbDetection", default: true) + static let focusMonitoringMode = Key("focusMonitoringMode", default: .withoutDevTools) + static let showDoNotDisturbIndicator = Key("showDoNotDisturbIndicator", default: true) + static let showDoNotDisturbLabel = Key("showDoNotDisturbLabel", default: true) + static let focusIndicatorNonPersistent = Key("focusIndicatorNonPersistent", default: false) + + // MARK: Privacy Indicators (Camera & Microphone Detection) + static let enableCameraDetection = Key("enableCameraDetection", default: true) + static let enableMicrophoneDetection = Key("enableMicrophoneDetection", default: true) + + // MARK: Lock Screen Features + static let enableLockScreenLiveActivity = Key("enableLockScreenLiveActivity", default: true) + static let enableLockSounds = Key("enableLockSounds", default: true) + + // MARK: Caps Lock Indicator + static let enableCapsLockIndicator = Key("enableCapsLockIndicator", default: true) + static let capsLockIndicatorUseGreenColor = Key("capsLockIndicatorUseGreenColor", default: false) // Legacy toggle + static let capsLockIndicatorTintMode = Key("capsLockIndicatorTintMode", default: .white) + static let didMigrateCapsLockTintMode = Key("didMigrateCapsLockTintMode", default: false) + static let showCapsLockLabel = Key("showCapsLockLabel", default: false) + + // MARK: ImageService + static let didClearLegacyURLCacheV1 = Key("didClearLegacyURLCacheV1", default: false) + + // MARK: Minimalistic UI Mode + static let enableMinimalisticUI = Key("enableMinimalisticUI", default: false) + + // MARK: Lyrics Feature + static let enableLyrics = Key("enableLyrics", default: false) + static let showLiveCanvasInDynamicIsland = Key("showLiveCanvasInDynamicIsland", default: false) + + // MARK: Notes Feature + static let enableNotes = Key("enableNotes", default: false) + static let enableNotePinning = Key("enableNotePinning", default: true) + static let enableNoteSearch = Key("enableNoteSearch", default: false) + static let enableNoteColorFiltering = Key("enableNoteColorFiltering", default: false) + static let enableCreateFromClipboard = Key("enableCreateFromClipboard", default: true) + static let enableNoteCharCount = Key("enableNoteCharCount", default: true) + static let savedNotes = Key<[NoteItem]>("savedNotes", default: []) // Helper to determine the default media controller based on macOS version static var defaultMediaController: MediaControllerType { @@ -162,4 +1250,88 @@ extension Defaults.Keys { return .nowPlaying } } + + // Migration helper to convert from legacy enableGradient Boolean to new ProgressBarStyle enum + static func migrateProgressBarStyle() { + // Check if migration is needed by seeing if the old Boolean was set to gradient + let wasGradientEnabled = Defaults[.enableGradient] + + // Only migrate if we're still using the default hierarchical value but gradient was enabled + if wasGradientEnabled && Defaults[.progressBarStyle] == .hierarchical { + Defaults[.progressBarStyle] = .gradient + } + } + + static func migrateMusicAuxControls() { + if Defaults[.didMigrateMusicAuxControls] == false { + if Defaults[.showMediaOutputControl] { + Defaults[.musicAuxRightControl] = .mediaOutput + } + + Defaults[.didMigrateMusicAuxControls] = true + } + + normalizeMusicAuxControls() + } + + static func migrateCapsLockTintMode() { + guard Defaults[.didMigrateCapsLockTintMode] == false else { return } + + let legacyGreen = Defaults[.capsLockIndicatorUseGreenColor] + Defaults[.capsLockIndicatorTintMode] = legacyGreen ? .green : .white + Defaults[.didMigrateCapsLockTintMode] = true + } + + static func migrateMusicControlSlots() { + guard Defaults[.didMigrateMusicControlSlots] == false else { return } + + let allowMediaOutput = Defaults[.showMediaOutputControl] + let baseLayout: [MusicControlButton] + + if Defaults[.showShuffleAndRepeat] { + var slots = MusicControlButton.defaultLayout + let left = MusicControlButton(auxiliaryControl: Defaults[.musicAuxLeftControl]) + let right = MusicControlButton(auxiliaryControl: Defaults[.musicAuxRightControl]) + slots[0] = left + slots[4] = right + baseLayout = slots + } else { + baseLayout = MusicControlButton.minimalLayout + } + + Defaults[.musicControlSlots] = baseLayout.normalized(allowingMediaOutput: allowMediaOutput) + Defaults[.didMigrateMusicControlSlots] = true + } + + static func migrateThirdPartyDDCIntegration() { + if Defaults[.didMigrateThirdPartyDDCIntegration] == false { + let legacyBetterDisplayEnabled = Defaults[.enableBetterDisplayIntegration] + let legacyLunarEnabled = Defaults[.enableLunarIntegration] + + if legacyBetterDisplayEnabled || legacyLunarEnabled { + Defaults[.enableThirdPartyDDCIntegration] = true + Defaults[.thirdPartyDDCProvider] = (legacyLunarEnabled && !legacyBetterDisplayEnabled) ? .lunar : .betterDisplay + } + + Defaults[.didMigrateThirdPartyDDCIntegration] = true + } + + syncLegacyThirdPartyDDCKeys() + } + + static func syncLegacyThirdPartyDDCKeys() { + let isIntegrationEnabled = Defaults[.enableThirdPartyDDCIntegration] + let selectedProvider = Defaults[.thirdPartyDDCProvider] + Defaults[.enableBetterDisplayIntegration] = isIntegrationEnabled && selectedProvider == .betterDisplay + Defaults[.enableLunarIntegration] = isIntegrationEnabled && selectedProvider == .lunar + } + + private static func normalizeMusicAuxControls() { + guard Defaults[.musicAuxLeftControl] == Defaults[.musicAuxRightControl] else { return } + + let current = Defaults[.musicAuxLeftControl] + let fallback = MusicAuxiliaryControl.alternative(excluding: current) + Defaults[.musicAuxRightControl] = fallback + } + static let showSongMetadataInClosedNotch = Key("showSongMetadataInClosedNotch", default: false) } diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index a7efe001..aa101ef7 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -1,15 +1,26 @@ -// -// DynamicIslandViewModel.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 04/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Combine import Defaults import SwiftUI -import TheBoringWorkerNotifier +@MainActor class DynamicIslandViewModel: NSObject, ObservableObject { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var detector = FullscreenMediaDetector.shared @@ -29,12 +40,75 @@ class DynamicIslandViewModel: NSObject, ObservableObject { @Published var hideOnClosed: Bool = true @Published var isHoveringCalendar: Bool = false @Published var isBatteryPopoverActive: Bool = false + @Published var isClipboardPopoverActive: Bool = false + @Published var isColorPickerPopoverActive: Bool = false + @Published var isStatsPopoverActive: Bool = false + @Published var isReminderPopoverActive: Bool = false + @Published var isMediaOutputPopoverActive: Bool = false + @Published var isTimerPopoverActive: Bool = false + @Published var shouldRecheckHover: Bool = false + @Published var isScrollGestureActive: Bool = false + private var scrollGestureSuppressionTokens: Set = [] + @Published private(set) var isAutoCloseSuppressed: Bool = false + private var autoCloseSuppressionTokens: Set = [] + private let clipboardFocusWindow: TimeInterval = 10 + + func setScrollGestureSuppression(_ active: Bool, token: UUID) { + if active { + let inserted = scrollGestureSuppressionTokens.insert(token).inserted + if inserted { + isScrollGestureActive = true + } + } else { + if scrollGestureSuppressionTokens.remove(token) != nil { + isScrollGestureActive = !scrollGestureSuppressionTokens.isEmpty + } + } + } + + private func resetScrollGestureSuppression() { + scrollGestureSuppressionTokens.removeAll() + isScrollGestureActive = false + } + + func setAutoCloseSuppression(_ active: Bool, token: UUID) { + if active { + let inserted = autoCloseSuppressionTokens.insert(token).inserted + if inserted { + isAutoCloseSuppressed = true + } + } else if autoCloseSuppressionTokens.remove(token) != nil { + isAutoCloseSuppressed = !autoCloseSuppressionTokens.isEmpty + } + } + + private func resetAutoCloseSuppression() { + autoCloseSuppressionTokens.removeAll() + isAutoCloseSuppressed = false + } + + private func focusClipboardTabIfNeeded() { + guard !Defaults[.enableMinimalisticUI] else { return } + guard Defaults[.enableClipboardManager] else { return } + guard Defaults[.clipboardDisplayMode] == .separateTab else { return } + guard let lastCopyDate = ClipboardManager.shared.lastCopiedItemDate else { return } + guard Date().timeIntervalSince(lastCopyDate) <= clipboardFocusWindow else { return } + guard coordinator.currentView != .notes else { return } + withAnimation(.smooth) { + coordinator.currentView = .notes + } + } + + let webcamManager = WebcamManager.shared + @Published var isCameraExpanded: Bool = false + @Published var isRequestingAuthorization: Bool = false @Published var screen: String? @Published var notchSize: CGSize = getClosedNotchSize() @Published var closedNotchSize: CGSize = getClosedNotchSize() + @MainActor deinit { destroy() } @@ -61,6 +135,143 @@ class DynamicIslandViewModel: NSObject, ObservableObject { .store(in: &cancellables) setupDetectorObserver() + + ReminderLiveActivityManager.shared.$activeWindowReminders + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + let updatedTarget = self.calculateDynamicNotchSize() + guard self.notchState == .open else { return } + guard self.notchSize != updatedTarget else { return } + withAnimation(.smooth) { + self.notchSize = updatedTarget + } + if let delegate = AppDelegate.shared { + delegate.ensureWindowSize( + addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), + animated: true, + force: false + ) + } + } + .store(in: &cancellables) + + // Observe settings + lyrics changes to dynamically resize the notch + let enableLyricsPublisher = Defaults.publisher(.enableLyrics).map { $0.newValue } + + enableLyricsPublisher + .combineLatest(MusicManager.shared.$currentLyrics) + .removeDuplicates { $0.0 == $1.0 && $0.1 == $1.1 } + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + guard Defaults[.enableMinimalisticUI] else { return } + let updatedTarget = self.calculateDynamicNotchSize() + guard self.notchState == .open else { return } + guard self.notchSize != updatedTarget else { return } + withAnimation(.smooth) { + self.notchSize = updatedTarget + } + if let delegate = AppDelegate.shared { + delegate.ensureWindowSize( + addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), + animated: true, + force: false + ) + } + } + .store(in: &cancellables) + + TimerManager.shared.$activeSource + .combineLatest(TimerManager.shared.$isTimerActive) + .receive(on: RunLoop.main) + .sink { [weak self] _, _ in + self?.handleMinimalisticTimerHeightChange() + } + .store(in: &cancellables) + + coordinator.$statsSecondRowExpansion + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + guard self.notchState == .open else { return } + let updatedTarget = self.calculateDynamicNotchSize() + guard self.notchSize != updatedTarget else { return } + withAnimation(.easeInOut(duration: 0.3)) { + self.notchSize = updatedTarget + } + if let delegate = AppDelegate.shared { + delegate.ensureWindowSize( + addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), + animated: false, + force: false + ) + } + } + .store(in: &cancellables) + + coordinator.$notesLayoutState + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + guard self.notchState == .open else { return } + let updatedTarget = self.calculateDynamicNotchSize() + guard self.notchSize != updatedTarget else { return } + withAnimation(.easeInOut(duration: 0.25)) { + self.notchSize = updatedTarget + } + if let delegate = AppDelegate.shared { + delegate.ensureWindowSize( + addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), + animated: true, + force: false + ) + } + } + .store(in: &cancellables) + + Defaults.publisher(.openNotchWidth, options: []) + .map { $0.newValue } + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + guard self.notchState == .open else { return } + guard !Defaults[.enableMinimalisticUI] else { return } + let updatedTarget = self.calculateDynamicNotchSize() + guard self.notchSize != updatedTarget else { return } + withAnimation(.smooth) { + self.notchSize = updatedTarget + } + if let delegate = AppDelegate.shared { + delegate.ensureWindowSize( + addShadowPadding(to: updatedTarget, isMinimalistic: false), + animated: true, + force: false + ) + } + } + .store(in: &cancellables) + } + + private func handleMinimalisticTimerHeightChange() { + guard Defaults[.enableMinimalisticUI] else { return } + guard notchState == .open else { return } + let updatedTarget = calculateDynamicNotchSize() + guard notchSize != updatedTarget else { return } + withAnimation(.smooth) { + notchSize = updatedTarget + } + if let delegate = AppDelegate.shared { + delegate.ensureWindowSize( + addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), + animated: true, + force: false + ) + } } private func setupDetectorObserver() { @@ -114,38 +325,129 @@ class DynamicIslandViewModel: NSObject, ObservableObject { } func open() { - withAnimation(.bouncy) { - self.notchSize = openNotchSize - self.notchState = .open + let targetSize = calculateDynamicNotchSize() + + let applyWindowResize: () -> Void = { + guard let delegate = AppDelegate.shared else { return } + delegate.ensureWindowSize( + addShadowPadding(to: targetSize, isMinimalistic: Defaults[.enableMinimalisticUI]), + animated: false, + force: true + ) } - + + if Thread.isMainThread { + applyWindowResize() + } else { + DispatchQueue.main.async(execute: applyWindowResize) + } + + notchSize = targetSize + notchState = .open + // Force music information update when notch is opened MusicManager.shared.forceUpdate() + focusClipboardTabIfNeeded() } + + private func calculateDynamicNotchSize() -> CGSize { + let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize + var adjustedSize = baseSize - func close() { - withAnimation(.smooth) { [weak self] in - guard let self = self else { return } - self.notchSize = getClosedNotchSize(screen: self.screen) - self.closedNotchSize = self.notchSize - self.notchState = .closed + if coordinator.currentView == .notes || coordinator.currentView == .clipboard { + let preferred = coordinator.notesLayoutState.preferredHeight + adjustedSize.height = max(adjustedSize.height, preferred) + return adjustedSize } + return statsAdjustedNotchSize( + from: adjustedSize, + isStatsTabActive: coordinator.currentView == .stats, + secondRowProgress: coordinator.statsSecondRowExpansion + ) + } + + func close() { + let targetSize = getClosedNotchSize(screen: screen) + notchSize = targetSize + closedNotchSize = targetSize + notchState = .closed + resetScrollGestureSuppression() + resetAutoCloseSuppression() + // Set the current view to shelf if it contains files and the user enables openShelfByDefault // Otherwise, if the user has not enabled openLastShelfByDefault, set the view to home - if !TrayDrop.shared.isEmpty && Defaults[.openShelfByDefault] { + if !ShelfStateViewModel.shared.isEmpty && Defaults[.openShelfByDefault] && !Defaults[.enableMinimalisticUI] { coordinator.currentView = .shelf } else if !coordinator.openLastTabByDefault { coordinator.currentView = .home } } + func closeForLockScreen() { + let targetSize = getClosedNotchSize(screen: screen) + withAnimation(.none) { + notchSize = targetSize + closedNotchSize = targetSize + notchState = .closed + resetScrollGestureSuppression() + resetAutoCloseSuppression() + } + } + + private var helloCloseScheduled = false + func closeHello() { + guard !helloCloseScheduled else { return } + helloCloseScheduled = true DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in - self?.coordinator.firstLaunch = false - withAnimation(self?.animationLibrary.animation) { - self?.close() + guard let self else { return } + self.coordinator.firstLaunch = false + withAnimation(self.animationLibrary.animation) { + self.close() } } } + + func toggleCameraPreview() { + if isRequestingAuthorization { + return + } + + switch webcamManager.authorizationStatus { + case .authorized: + if webcamManager.isSessionRunning { + webcamManager.stopSession() + isCameraExpanded = false + } else if webcamManager.cameraAvailable { + webcamManager.startSession() + isCameraExpanded = true + } + + case .denied, .restricted: + DispatchQueue.main.async { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + + let alert = NSAlert() + alert.messageText = "Camera Access Required" + alert.informativeText = "Please allow camera access in System Settings." + alert.addButton(withTitle: "OK") + alert.runModal() + + NSApp.setActivationPolicy(.accessory) + NSApp.deactivate() + } + + case .notDetermined: + isRequestingAuthorization = true + webcamManager.checkAndRequestVideoAuthorization() + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.isRequestingAuthorization = false + } + + default: + break + } + } } diff --git a/DynamicIsland/models/EventModel.swift b/DynamicIsland/models/EventModel.swift index 40e7574d..7fbc6cbb 100644 --- a/DynamicIsland/models/EventModel.swift +++ b/DynamicIsland/models/EventModel.swift @@ -1,15 +1,30 @@ -// -// EventModel.swift -// Calendr -// -// Created by Paker on 24/12/20. -// Original source: https://github.com/pakerwreah/Calendr -// Modified by Alexander on 2025-05-18. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation +import Defaults +import AppKit -struct EventModel: Equatable { +struct EventModel: Equatable, Identifiable { let id: String let start: Date let end: Date @@ -24,6 +39,7 @@ struct EventModel: Equatable { let timeZone: TimeZone? let hasRecurrenceRules: Bool let priority: Priority? + let conferenceURL: URL? } enum AttendanceStatus: Comparable { @@ -83,7 +99,20 @@ extension EventModel { var isMeeting: Bool { !participants.isEmpty } func calendarAppURL() -> URL? { - + // Check if a third-party calendar app is enabled + if Defaults[.enableThirdPartyCalendarApp] { + switch Defaults[.selectedCalendarApp] { + case .fantastical: + return fantasticalURL() + case .notionCalendar: + return notionCalendarURL() + } + } + return appleCalendarURL() + } + + /// Returns URL to open event in Apple Calendar + private func appleCalendarURL() -> URL? { guard let id = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { return nil } @@ -109,6 +138,76 @@ extension EventModel { } return URL(string: "ical://ekevent\(date)/\(id)?method=show&options=more") } + + /// Returns URL to open date in Fantastical + private func fantasticalURL() -> URL? { + // Reminders still use Apple's Reminders app + guard !type.isReminder else { + return URL(string: "x-apple-reminderkit://remcdreminder/\(id)") + } + + let viewStyle = Defaults[.fantasticalDefaultView] + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let dateString = dateFormatter.string(from: start) + + // x-fantastical3://show/mini/yyyy-MM-dd or x-fantastical3://show/calendar/yyyy-MM-dd + return URL(string: "x-fantastical3://show/\(viewStyle.rawValue)/\(dateString)") + } + + /// Returns URL to open Fantastical at current date (for general calendar access) + static func fantasticalShowURL(for date: Date? = nil) -> URL? { + let viewStyle = Defaults[.fantasticalDefaultView] + if let date = date { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let dateString = dateFormatter.string(from: date) + return URL(string: "x-fantastical3://show/\(viewStyle.rawValue)/\(dateString)") + } + return URL(string: "x-fantastical3://show/\(viewStyle.rawValue)") + } + + /// Returns URL to open event in Notion Calendar (formerly Cron) + private func notionCalendarURL() -> URL? { + // Reminders still use Apple's Reminders app + guard !type.isReminder else { + return URL(string: "x-apple-reminderkit://remcdreminder/\(id)") + } + + let formatter = ISO8601DateFormatter() + + var components = URLComponents() + components.scheme = "cron" + components.host = "showEvent" + components.queryItems = [ + URLQueryItem(name: "accountEmail", value: calendar.accountName), + URLQueryItem(name: "iCalUID", value: id), + URLQueryItem(name: "startDate", value: formatter.string(from: start)), + URLQueryItem(name: "endDate", value: formatter.string(from: end)), + URLQueryItem(name: "title", value: title) + ] + + if let url = components.url { + return url + } + + // Fallback: just launch Notion Calendar app + Self.launchNotionCalendar() + return nil + } + + /// Launches Notion Calendar app directly (fallback or general access) + static func launchNotionCalendar() { + // Launch via bundle identifier (Notion Calendar's bundle ID is "com.cron.electron") + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.cron.electron") { + let config = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.openApplication(at: url, configuration: config) { _, error in + if let error = error { + print("Error launching Notion Calendar: \(error.localizedDescription)") + } + } + } + } } struct Participant: Hashable { diff --git a/DynamicIsland/models/LockScreenReminderWidgetSnapshot.swift b/DynamicIsland/models/LockScreenReminderWidgetSnapshot.swift new file mode 100644 index 00000000..99f2ebc1 --- /dev/null +++ b/DynamicIsland/models/LockScreenReminderWidgetSnapshot.swift @@ -0,0 +1,49 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import AppKit + +struct LockScreenReminderWidgetSnapshot: Equatable { + struct RGBAColor: Equatable { + let red: Double + let green: Double + let blue: Double + let alpha: Double + + init(nsColor: NSColor) { + let color = nsColor.usingColorSpace(.sRGB) ?? nsColor + self.red = Double(color.redComponent) + self.green = Double(color.greenComponent) + self.blue = Double(color.blueComponent) + self.alpha = Double(color.alphaComponent) + } + + var color: Color { + Color(red: red, green: green, blue: blue, opacity: alpha) + } + } + + let title: String + let eventTimeText: String + let relativeDescription: String? + let accent: RGBAColor + let chipStyle: LockScreenReminderChipStyle + let isCritical: Bool + let iconName: String +} diff --git a/DynamicIsland/models/MusicControlButton.swift b/DynamicIsland/models/MusicControlButton.swift new file mode 100644 index 00000000..fb5555b4 --- /dev/null +++ b/DynamicIsland/models/MusicControlButton.swift @@ -0,0 +1,168 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Defaults + +enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.Serializable { + case shuffle + case trackBackward + case playPause + case trackForward + case repeatMode + case mediaOutput + case airPlay + case lyrics + case seekBackward + case seekForward + case none + + static let slotCount = 5 + + static let defaultLayout: [MusicControlButton] = [ + .shuffle, + .trackBackward, + .playPause, + .trackForward, + .repeatMode + ] + + static let minimalLayout: [MusicControlButton] = [ + .none, + .trackBackward, + .playPause, + .trackForward, + .none + ] + + static let pickerOptions: [MusicControlButton] = [ + .trackBackward, + .playPause, + .trackForward, + .seekBackward, + .seekForward, + .shuffle, + .repeatMode, + .lyrics, + .mediaOutput, + .airPlay + ] + + /// Controls that are only available when Apple Music is the active media source. + var isAppleMusicExclusive: Bool { + self == .airPlay + } + + var id: String { rawValue } + + var label: String { + switch self { + case .shuffle: + return String(localized: "Shuffle") + case .trackBackward: + return String(localized: "Previous Track") + case .playPause: + return String(localized: "Play / Pause") + case .trackForward: + return String(localized: "Next Track") + case .repeatMode: + return String(localized: "Repeat") + case .mediaOutput: + return String(localized: "Change Media Output") + case .airPlay: + return String(localized: "AirPlay") + case .lyrics: + return String(localized: "Lyrics") + case .seekBackward: + return String(localized: "Rewind 10s") + case .seekForward: + return String(localized: "Forward 10s") + case .none: + return String(localized: "Empty Slot") + } + } + + var iconName: String { + switch self { + case .shuffle: + return "shuffle" + case .trackBackward: + return "backward.fill" + case .playPause: + return "playpause" + case .trackForward: + return "forward.fill" + case .repeatMode: + return "repeat" + case .mediaOutput: + return "speaker.wave.2" + case .airPlay: + return "airplayaudio" + case .lyrics: + return "quote.bubble" + case .seekBackward: + return "gobackward.10" + case .seekForward: + return "goforward.10" + case .none: + return "" + } + } + + var prefersLargeScale: Bool { + self == .playPause + } +} + +extension Array where Element == MusicControlButton { + func normalized(allowingMediaOutput: Bool, isAppleMusicActive: Bool = true) -> [MusicControlButton] { + var sanitized = map { button -> MusicControlButton in + if button == .mediaOutput && !allowingMediaOutput { return .none } + if button.isAppleMusicExclusive && !isAppleMusicActive { return .none } + return button + } + + if sanitized.count < MusicControlButton.slotCount { + sanitized.append(contentsOf: Array(repeating: .none, count: MusicControlButton.slotCount - sanitized.count)) + } + + if sanitized.count > MusicControlButton.slotCount { + sanitized = Array(sanitized.prefix(MusicControlButton.slotCount)) + } + + return sanitized + } +} + +extension MusicControlButton { + init(auxiliaryControl: MusicAuxiliaryControl) { + switch auxiliaryControl { + case .shuffle: + self = .shuffle + case .repeatMode: + self = .repeatMode + case .mediaOutput: + self = .mediaOutput + case .lyrics: + self = .lyrics + } + } +} diff --git a/DynamicIsland/models/PickedColor.swift b/DynamicIsland/models/PickedColor.swift new file mode 100644 index 00000000..575d64d3 --- /dev/null +++ b/DynamicIsland/models/PickedColor.swift @@ -0,0 +1,209 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Foundation +import Defaults + +struct PickedColor: Identifiable, Codable, Hashable, Defaults.Serializable { + let id = UUID() + let red: Double + let green: Double + let blue: Double + let alpha: Double + let timestamp: Date + let displayPoint: CGPoint + + // Computed properties for different color formats + var nsColor: NSColor { + NSColor(red: red, green: green, blue: blue, alpha: alpha) + } + + var color: Color { + Color(red: red, green: green, blue: blue, opacity: alpha) + } + + var hexString: String { + let r = Int(red * 255) + let g = Int(green * 255) + let b = Int(blue * 255) + + if alpha < 1.0 { + let a = Int(alpha * 255) + return String(format: "#%02X%02X%02X%02X", r, g, b, a) + } else { + return String(format: "#%02X%02X%02X", r, g, b) + } + } + + var rgbString: String { + let r = Int(red * 255) + let g = Int(green * 255) + let b = Int(blue * 255) + return "rgb(\(r), \(g), \(b))" + } + + var rgbaString: String { + let r = Int(red * 255) + let g = Int(green * 255) + let b = Int(blue * 255) + return String(format: "rgba(%d, %d, %d, %.2f)", r, g, b, alpha) + } + + var hslString: String { + let (h, s, l) = rgbToHsl(red: red, green: green, blue: blue) + return String(format: "hsl(%.0f, %.0f%%, %.0f%%)", h, s * 100, l * 100) + } + + var hslaString: String { + let (h, s, l) = rgbToHsl(red: red, green: green, blue: blue) + return String(format: "hsla(%.0f, %.0f%%, %.0f%%, %.2f)", h, s * 100, l * 100, alpha) + } + + var hsvString: String { + let (h, s, v) = rgbToHsv(red: red, green: green, blue: blue) + return String(format: "hsv(%.0f, %.0f%%, %.0f%%)", h, s * 100, v * 100) + } + + var swiftUIString: String { + return String(format: "Color(red: %.3f, green: %.3f, blue: %.3f, opacity: %.3f)", red, green, blue, alpha) + } + + var uiColorString: String { + return String(format: "UIColor(red: %.3f, green: %.3f, blue: %.3f, alpha: %.3f)", red, green, blue, alpha) + } + + var allFormats: [ColorFormat] { + return [ + ColorFormat(name: "HEX", value: hexString, copyValue: hexString), + ColorFormat(name: "RGB", value: rgbString, copyValue: rgbString), + ColorFormat(name: "RGBA", value: rgbaString, copyValue: rgbaString), + ColorFormat(name: "HSL", value: hslString, copyValue: hslString), + ColorFormat(name: "HSLA", value: hslaString, copyValue: hslaString), + ColorFormat(name: "HSV", value: hsvString, copyValue: hsvString), + ColorFormat(name: "SwiftUI", value: swiftUIString, copyValue: swiftUIString), + ColorFormat(name: "UIColor", value: uiColorString, copyValue: uiColorString) + ] + } + + init(nsColor: NSColor, point: CGPoint) { + let rgba = nsColor.usingColorSpace(.sRGB) ?? nsColor + self.red = Double(rgba.redComponent) + self.green = Double(rgba.greenComponent) + self.blue = Double(rgba.blueComponent) + self.alpha = Double(rgba.alphaComponent) + self.timestamp = Date() + self.displayPoint = point + } + + init(red: Double, green: Double, blue: Double, alpha: Double = 1.0, point: CGPoint) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + self.timestamp = Date() + self.displayPoint = point + } + + // Helper functions for color space conversion + private func rgbToHsl(red: Double, green: Double, blue: Double) -> (h: Double, s: Double, l: Double) { + let max = Swift.max(red, green, blue) + let min = Swift.min(red, green, blue) + + let lightness = (max + min) / 2 + + if max == min { + return (0, 0, lightness) // achromatic + } + + let delta = max - min + let saturation: Double + + if lightness > 0.5 { + saturation = delta / (2 - max - min) + } else { + saturation = delta / (max + min) + } + + var hue: Double + switch max { + case red: + hue = (green - blue) / delta + (green < blue ? 6 : 0) + case green: + hue = (blue - red) / delta + 2 + case blue: + hue = (red - green) / delta + 4 + default: + hue = 0 + } + + hue *= 60 + + return (hue, saturation, lightness) + } + + private func rgbToHsv(red: Double, green: Double, blue: Double) -> (h: Double, s: Double, v: Double) { + let max = Swift.max(red, green, blue) + let min = Swift.min(red, green, blue) + + let value = max + let delta = max - min + + let saturation = max == 0 ? 0 : delta / max + + if delta == 0 { + return (0, saturation, value) // achromatic + } + + var hue: Double + switch max { + case red: + hue = (green - blue) / delta + (green < blue ? 6 : 0) + case green: + hue = (blue - red) / delta + 2 + case blue: + hue = (red - green) / delta + 4 + default: + hue = 0 + } + + hue *= 60 + + return (hue, saturation, value) + } +} + +struct ColorFormat: Identifiable { + let id = UUID() + let name: String + let value: String + let copyValue: String +} + +// Extension for preview/testing +extension PickedColor { + static var sampleColors: [PickedColor] { + return [ + PickedColor(red: 1.0, green: 0.0, blue: 0.0, point: CGPoint(x: 100, y: 100)), + PickedColor(red: 0.0, green: 1.0, blue: 0.0, point: CGPoint(x: 200, y: 100)), + PickedColor(red: 0.0, green: 0.0, blue: 1.0, point: CGPoint(x: 300, y: 100)), + PickedColor(red: 1.0, green: 1.0, blue: 0.0, point: CGPoint(x: 400, y: 100)), + PickedColor(red: 1.0, green: 0.0, blue: 1.0, point: CGPoint(x: 500, y: 100)) + ] + } +} diff --git a/DynamicIsland/models/PlaybackState.swift b/DynamicIsland/models/PlaybackState.swift index d758cf7e..312a7088 100644 --- a/DynamicIsland/models/PlaybackState.swift +++ b/DynamicIsland/models/PlaybackState.swift @@ -1,23 +1,67 @@ -// -// PlaybackState.swift -// DynamicIsland -// -// Created by Alexander Greco on 2025-03-29. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation +enum RepeatMode: Int, Codable { + case off = 1 + case one = 2 + case all = 3 +} + struct PlaybackState { var bundleIdentifier: String var isPlaying: Bool = false var title: String = "I'm Handsome" var artist: String = "Me" var album: String = "Self Love" + var contentIdentifier: String? + var contentURL: String? + var isExplicit: Bool? var currentTime: Double = 0 var duration: Double = 0 var playbackRate: Double = 1 - var isShuffled: Bool? = nil - var isRepeating: Bool? = nil + var isShuffled: Bool = false + var repeatMode: RepeatMode = .off var lastUpdated: Date = Date.distantPast var artwork: Data? + var liveArtworkURL: URL? +} + +extension PlaybackState: Equatable { + static func == (lhs: PlaybackState, rhs: PlaybackState) -> Bool { + return lhs.bundleIdentifier == rhs.bundleIdentifier + && lhs.isPlaying == rhs.isPlaying + && lhs.title == rhs.title + && lhs.artist == rhs.artist + && lhs.album == rhs.album + && lhs.contentIdentifier == rhs.contentIdentifier + && lhs.contentURL == rhs.contentURL + && lhs.isExplicit == rhs.isExplicit + && lhs.currentTime == rhs.currentTime + && lhs.duration == rhs.duration + && lhs.isShuffled == rhs.isShuffled + && lhs.repeatMode == rhs.repeatMode + && lhs.artwork == rhs.artwork + && lhs.liveArtworkURL == rhs.liveArtworkURL + } } diff --git a/DynamicIsland/models/SharingStateManager.swift b/DynamicIsland/models/SharingStateManager.swift new file mode 100644 index 00000000..659130d9 --- /dev/null +++ b/DynamicIsland/models/SharingStateManager.swift @@ -0,0 +1,172 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Combine +import Foundation + +extension Notification.Name { + static let sharingDidFinish = Notification.Name("com.ebullioscopic.sharingDidFinish") +} + +@MainActor +final class SharingStateManager: ObservableObject { + static let shared = SharingStateManager() + + private var activeSessions: Int = 0 { + didSet { + let newValue = activeSessions > 0 + if newValue != preventNotchClose { + preventNotchClose = newValue + if !newValue { + NotificationCenter.default.post(name: .sharingDidFinish, object: nil) + } + } + } + } + + @Published var preventNotchClose: Bool = false + + private var activeDelegates: [UUID: SharingLifecycleDelegate] = [:] + + private init() {} + + func requestCloseIfReady() { + if !preventNotchClose { + NotificationCenter.default.post(name: .sharingDidFinish, object: nil) + } + } + + func beginInteraction() { + activeSessions += 1 + } + + func endInteraction() { + if activeSessions > 0 { activeSessions -= 1 } + } + + func makeDelegate(onEnd: (() -> Void)? = nil) -> SharingLifecycleDelegate { + let id = UUID() + let delegate = SharingLifecycleDelegate(id: id, onEnd: { [weak self] in + onEnd?() + self?.unregisterDelegate(id: id) + }, onBegin: { [weak self] in + self?.beginInteraction() + }, onFinish: { [weak self] in + self?.endInteraction() + }) + activeDelegates[id] = delegate + return delegate + } + + private func unregisterDelegate(id: UUID) { + activeDelegates.removeValue(forKey: id) + } +} + +final class SharingLifecycleDelegate: NSObject, NSSharingServiceDelegate, NSSharingServicePickerDelegate { + let id: UUID + private let onEnd: () -> Void + private let onBegin: () -> Void + private let onFinish: () -> Void + + private var pickerActive = false + private var serviceInProgress = false + private var finished = false + private var timeoutTask: Task? + + init(id: UUID, onEnd: @escaping () -> Void, onBegin: @escaping () -> Void, onFinish: @escaping () -> Void) { + self.id = id + self.onEnd = onEnd + self.onBegin = onBegin + self.onFinish = onFinish + } + + deinit { + timeoutTask?.cancel() + } + + func markPickerBegan() { + guard !pickerActive else { return } + pickerActive = true + onBegin() + } + + func markServiceBegan() { + guard !serviceInProgress else { return } + serviceInProgress = true + onBegin() + startTimeoutFallback() + } + + private func startTimeoutFallback() { + timeoutTask?.cancel() + timeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(2)) + guard let self = self, !Task.isCancelled else { return } + if !self.finished { + self.finishIfNeeded() + } + } + } + + private func finishIfNeeded() { + guard !finished else { return } + finished = true + timeoutTask?.cancel() + onFinish() + onEnd() + } + + // MARK: - NSSharingServicePickerDelegate + + func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, didChoose service: NSSharingService?) { + if service == nil { + if pickerActive && !serviceInProgress { + finishIfNeeded() + } + return + } + + service?.delegate = self + serviceInProgress = true + startTimeoutFallback() + } + + // MARK: - NSSharingServiceDelegate + + func sharingService(_ sharingService: NSSharingService, willShareItems items: [Any]) { + if !pickerActive && !serviceInProgress { + onBegin() + } + serviceInProgress = true + } + + func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { + finishIfNeeded() + } + + func sharingService(_ sharingService: NSSharingService, didFailToShareItems items: [Any], error: Error) { + finishIfNeeded() + } +} + diff --git a/DynamicIsland/models/TimerPreset.swift b/DynamicIsland/models/TimerPreset.swift new file mode 100644 index 00000000..cfd0b9fb --- /dev/null +++ b/DynamicIsland/models/TimerPreset.swift @@ -0,0 +1,104 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +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: String(localized:"Focus"), duration: 25 * 60, color: Color.orange), + TimerPreset(name: String(localized:"Break"), duration: 5 * 60, color: Color.green), + TimerPreset(name: String(localized:"Deep Work"), duration: 45 * 60, color: Color.purple) + ] +} diff --git a/DynamicIsland/observers/FullscreenMediaDetection.swift b/DynamicIsland/observers/FullscreenMediaDetection.swift index 3541758a..1e9d8a6b 100644 --- a/DynamicIsland/observers/FullscreenMediaDetection.swift +++ b/DynamicIsland/observers/FullscreenMediaDetection.swift @@ -1,66 +1,89 @@ -// -// FullscreenMediaDetection.swift -// DynamicIsland -// -// Created by Richard Kunkli on 06/09/2024. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ -import Accessibility -import Cocoa -import CoreAudio import Defaults import MacroVisionKit import SwiftUI -@MainActor class FullscreenMediaDetector: ObservableObject { static let shared = FullscreenMediaDetector() private let detector: MacroVisionKit - - // changed from [NSScreen:Bool] to [screenName:Bool] - @Published private(set) var fullscreenStatus: [String: Bool] = [:] + @ObservedObject private var musicManager = MusicManager.shared + @MainActor @Published private(set) var fullscreenStatus: [String: Bool] = [:] + private var notificationTask: Task? private init() { self.detector = MacroVisionKit.shared detector.configuration.includeSystemApps = true setupNotificationObservers() - updateFullScreenStatus() // now on MainActor + updateFullScreenStatus() } private func setupNotificationObservers() { - let nc = NSWorkspace.shared.notificationCenter - nc.addObserver(self, selector: #selector(handleChange), - name: NSWorkspace.activeSpaceDidChangeNotification, object: nil) - nc.addObserver(self, selector: #selector(handleChange), - name: NSApplication.didChangeScreenParametersNotification, object: nil) + notificationTask = Task { @Sendable [weak self] in + await withTaskGroup(of: Void.self) { group in + group.addTask { + let activeSpaceNotifications = NSWorkspace.shared.notificationCenter.notifications( + named: NSWorkspace.activeSpaceDidChangeNotification + ) + + for await _ in activeSpaceNotifications { + await self?.handleChange() + } + } + + group.addTask { + let screenParameterNotifications = NSWorkspace.shared.notificationCenter.notifications( + named: NSApplication.didChangeScreenParametersNotification + ) + + for await _ in screenParameterNotifications { + await self?.handleChange() + } + } + } + } } - @objc private func handleChange() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.updateFullScreenStatus() - } + private func handleChange() async { + try? await Task.sleep(for: .milliseconds(500)) + self.updateFullScreenStatus() } private func updateFullScreenStatus() { - NSLog("🔄 Fullscreen status update triggered") guard Defaults[.enableFullscreenMediaDetection] else { - NSLog("❌ Fullscreen media detection disabled") - let reset = Dictionary(uniqueKeysWithValues: NSScreen.screens.map { ($0.localizedName, false) } - ) + let reset = Dictionary(uniqueKeysWithValues: NSScreen.screens.map { ($0.localizedName, false) }) if reset != fullscreenStatus { fullscreenStatus = reset - NSLog("⏸ Fullscreen disabled → reset all screens") } return } - NSLog("🔄 Fullscreen media detection enabled") let apps = detector.detectFullscreenApps(debug: false) let names = NSScreen.screens.map { $0.localizedName } var newStatus: [String: Bool] = [:] for name in names { - newStatus[name] = apps.contains { $0.screen.localizedName == name && $0.bundleIdentifier != "com.apple.finder" } + newStatus[name] = apps.contains { $0.screen.localizedName == name && $0.bundleIdentifier != "com.apple.finder" && ($0.bundleIdentifier == musicManager.bundleIdentifier || Defaults[.hideNotchOption] == .always) } } if newStatus != fullscreenStatus { @@ -69,15 +92,6 @@ class FullscreenMediaDetector: ObservableObject { } } - func isScreenInFullScreen(_ screen: NSScreen) -> Bool { - let screenName = screen.localizedName - return fullscreenStatus[screenName] ?? false - } - - func isAnyScreenInFullScreen() -> Bool { - return fullscreenStatus.values.contains(true) - } - private func cleanupNotificationObservers() { NSWorkspace.shared.notificationCenter.removeObserver(self) } diff --git a/DynamicIsland/private/CGSSpace.swift b/DynamicIsland/private/CGSSpace.swift index 720d5589..1ccc9903 100644 --- a/DynamicIsland/private/CGSSpace.swift +++ b/DynamicIsland/private/CGSSpace.swift @@ -1,13 +1,24 @@ -// -// CGSSpace.swift -// DynamicIsland -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. -// -// Original source: https://github.com/avaidyam/Parrot/ -// Modified by Alexander Greco on 2024-10-27 +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import AppKit diff --git a/DynamicIsland/services/Extensions/AtollDistributedNotifications.swift b/DynamicIsland/services/Extensions/AtollDistributedNotifications.swift new file mode 100644 index 00000000..fda5463b --- /dev/null +++ b/DynamicIsland/services/Extensions/AtollDistributedNotifications.swift @@ -0,0 +1,20 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +import Foundation + +enum AtollDistributedNotifications { + static let didBecomeActive = Notification.Name("com.ebullioscopic.Atoll.lifecycle.didBecomeActive") + static let didBecomeIdle = Notification.Name("com.ebullioscopic.Atoll.lifecycle.didBecomeIdle") + + enum UserInfoKey { + static let sourcePID = "sourcePID" + } +} diff --git a/DynamicIsland/services/Extensions/ExtensionRPCMessages.swift b/DynamicIsland/services/Extensions/ExtensionRPCMessages.swift new file mode 100644 index 00000000..8a97145c --- /dev/null +++ b/DynamicIsland/services/Extensions/ExtensionRPCMessages.swift @@ -0,0 +1,184 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +// MARK: - JSON-RPC 2.0 Message Types + +/// Incoming JSON-RPC 2.0 request from a client. +struct RPCRequest: Codable { + let jsonrpc: String + let method: String + let params: RPCParams? + let id: String +} + +/// Outgoing JSON-RPC 2.0 success response. +struct RPCSuccessResponse: Codable { + let jsonrpc: String + let result: [String: RPCValue] + let id: String + + init(result: [String: RPCValue], id: String) { + self.jsonrpc = "2.0" + self.result = result + self.id = id + } +} + +/// Outgoing JSON-RPC 2.0 error response. +struct RPCErrorResponse: Codable { + let jsonrpc: String + let error: RPCErrorObject + let id: String? + + init(error: RPCErrorObject, id: String?) { + self.jsonrpc = "2.0" + self.error = error + self.id = id + } +} + +/// JSON-RPC error object. +struct RPCErrorObject: Codable { + let code: Int + let message: String + let data: String? + + init(code: Int, message: String, data: String? = nil) { + self.code = code + self.message = message + self.data = data + } +} + +/// Outgoing JSON-RPC 2.0 notification (server → client, no id). +struct RPCNotification: Codable { + let jsonrpc: String + let method: String + let params: [String: RPCValue] + + init(method: String, params: [String: RPCValue]) { + self.jsonrpc = "2.0" + self.method = method + self.params = params + } +} + +// MARK: - Standard Error Codes + +enum RPCErrorCode { + static let parseError = -32700 + static let invalidRequest = -32600 + static let methodNotFound = -32601 + static let invalidParams = -32602 + static let internalError = -32603 + // Application-specific codes + static let unauthorized = -32001 + static let featureDisabled = -32002 + static let capacityExceeded = -32003 + static let descriptorInvalid = -32004 + static let unsupported = -32005 +} + +// MARK: - Flexible JSON Value + +/// Type-erased JSON value for RPC params and results. +enum RPCValue: Codable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case null + case object([String: RPCValue]) + case array([RPCValue]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let b = try? container.decode(Bool.self) { + self = .bool(b) + } else if let i = try? container.decode(Int.self) { + self = .int(i) + } else if let d = try? container.decode(Double.self) { + self = .double(d) + } else if let s = try? container.decode(String.self) { + self = .string(s) + } else if let obj = try? container.decode([String: RPCValue].self) { + self = .object(obj) + } else if let arr = try? container.decode([RPCValue].self) { + self = .array(arr) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON type") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let s): try container.encode(s) + case .int(let i): try container.encode(i) + case .double(let d): try container.encode(d) + case .bool(let b): try container.encode(b) + case .null: try container.encodeNil() + case .object(let o): try container.encode(o) + case .array(let a): try container.encode(a) + } + } + + var stringValue: String? { + if case .string(let s) = self { return s } + return nil + } + var boolValue: Bool? { + if case .bool(let b) = self { return b } + return nil + } + var objectValue: [String: RPCValue]? { + if case .object(let o) = self { return o } + return nil + } +} + +// MARK: - Flexible Params + +/// JSON-RPC params can be an object or individual values. +struct RPCParams: Codable { + let values: [String: RPCValue] + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.values = try container.decode([String: RPCValue].self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(values) + } + + subscript(key: String) -> RPCValue? { + values[key] + } + + /// Extract a JSON sub-object as raw Data for use with JSONDecoder. + func jsonData(for key: String) -> Data? { + guard let value = values[key] else { return nil } + return try? JSONEncoder().encode(value) + } +} diff --git a/DynamicIsland/services/Extensions/ExtensionRPCServer.swift b/DynamicIsland/services/Extensions/ExtensionRPCServer.swift new file mode 100644 index 00000000..c1bc1b5a --- /dev/null +++ b/DynamicIsland/services/Extensions/ExtensionRPCServer.swift @@ -0,0 +1,319 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Network +import Defaults + +/// WebSocket server for Atoll RPC. +/// Uses Apple's Network.framework (`NWListener`) — no external dependencies. +/// Listens on localhost:9020 for JSON-RPC 2.0 requests over WebSocket. +@MainActor +final class ExtensionRPCServer { + static let shared = ExtensionRPCServer() + + private var listener: NWListener? + private var connections: [UUID: RPCClientConnection] = [:] + private var shelfSubscribers: Set = [] // bundleIdentifiers subscribed to shelf events + private let port: UInt16 = 9020 + private let queue = DispatchQueue(label: "com.ebullioscopic.Atoll.rpc.server", qos: .userInitiated) + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + private init() {} + + // MARK: - Lifecycle + + func start() { + guard listener == nil else { + logDiagnostics("RPC server already running") + return + } + + let params = NWParameters(tls: nil) + let wsOptions = NWProtocolWebSocket.Options() + wsOptions.autoReplyPing = true + params.defaultProtocolStack.applicationProtocols.insert(wsOptions, at: 0) + + do { + listener = try NWListener(using: params, on: NWEndpoint.Port(integerLiteral: port)) + } catch { + Logger.log("Failed to create RPC listener: \(error.localizedDescription)", category: .extensions) + return + } + + listener?.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + self?.handleListenerState(state) + } + } + + listener?.newConnectionHandler = { [weak self] connection in + Task { @MainActor in + self?.handleNewConnection(connection) + } + } + + listener?.start(queue: queue) + } + + func stop() { + listener?.cancel() + listener = nil + for (_, conn) in connections { + conn.connection.cancel() + } + connections.removeAll() + shelfSubscribers.removeAll() + logDiagnostics("RPC server stopped") + } + + // MARK: - Client Notifications + + func notifyActivityDismiss(bundleIdentifier: String, activityID: String) { + sendNotification( + to: bundleIdentifier, + method: "atoll.activityDidDismiss", + params: [ + "bundleIdentifier": .string(bundleIdentifier), + "activityID": .string(activityID) + ] + ) + } + + func notifyWidgetDismiss(bundleIdentifier: String, widgetID: String) { + sendNotification( + to: bundleIdentifier, + method: "atoll.widgetDidDismiss", + params: [ + "bundleIdentifier": .string(bundleIdentifier), + "widgetID": .string(widgetID) + ] + ) + } + + func notifyNotchExperienceDismiss(bundleIdentifier: String, experienceID: String) { + sendNotification( + to: bundleIdentifier, + method: "atoll.notchExperienceDidDismiss", + params: [ + "bundleIdentifier": .string(bundleIdentifier), + "experienceID": .string(experienceID) + ] + ) + } + + func notifyAuthorizationChange(bundleIdentifier: String, isAuthorized: Bool) { + sendNotification( + to: bundleIdentifier, + method: "atoll.authorizationDidChange", + params: [ + "bundleIdentifier": .string(bundleIdentifier), + "isAuthorized": .bool(isAuthorized) + ] + ) + } + + // MARK: - Shelf Event Subscriptions + + func registerShelfSubscription(for bundleIdentifier: String) { + shelfSubscribers.insert(bundleIdentifier) + logDiagnostics("Registered shelf subscription for \(bundleIdentifier)") + } + + func notifyShelfItemsChanged(itemIDs: [String], action: String) { + guard !shelfSubscribers.isEmpty else { return } + let params: [String: RPCValue] = [ + "action": .string(action), + "itemIDs": .array(itemIDs.map { .string($0) }) + ] + for subscriber in shelfSubscribers { + sendNotification( + to: subscriber, + method: "atoll.shelfItemsDidChange", + params: params + ) + } + logDiagnostics("Notified \(shelfSubscribers.count) subscriber(s) of shelf change (\(action), \(itemIDs.count) items)") + } + + // MARK: - Connection Handling + + private func handleListenerState(_ state: NWListener.State) { + switch state { + case .ready: + Logger.log("Started Atoll RPC WebSocket server on port \(port)", category: .extensions) + case .failed(let error): + Logger.log("RPC server failed: \(error.localizedDescription)", category: .extensions) + // Attempt restart after delay + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.listener = nil + self?.start() + } + case .cancelled: + logDiagnostics("RPC server listener cancelled") + default: + break + } + } + + private func handleNewConnection(_ nwConnection: NWConnection) { + let connID = UUID() + let clientConn = RPCClientConnection( + id: connID, + connection: nwConnection, + bundleIdentifier: nil + ) + connections[connID] = clientConn + + nwConnection.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + self?.handleConnectionState(connID: connID, state: state) + } + } + + nwConnection.start(queue: queue) + receiveMessage(connID: connID) + logDiagnostics("RPC client connected (id: \(connID.uuidString.prefix(8)))") + } + + private func handleConnectionState(connID: UUID, state: NWConnection.State) { + switch state { + case .failed, .cancelled: + connections.removeValue(forKey: connID) + logDiagnostics("RPC client disconnected (id: \(connID.uuidString.prefix(8)))") + default: + break + } + } + + private func receiveMessage(connID: UUID) { + guard let clientConn = connections[connID] else { return } + let connection = clientConn.connection + + connection.receiveMessage { [weak self] content, context, isComplete, error in + guard let self else { return } + + if let error { + Task { @MainActor in + self.logDiagnostics("RPC receive error for \(connID.uuidString.prefix(8)): \(error.localizedDescription)") + self.connections.removeValue(forKey: connID) + } + return + } + + if let data = content, !data.isEmpty { + Task { @MainActor in + self.processMessage(data: data, connID: connID) + } + } + + // Continue receiving + Task { @MainActor in + self.receiveMessage(connID: connID) + } + } + } + + private func processMessage(data: Data, connID: UUID) { + guard var clientConn = connections[connID] else { return } + + // Parse JSON-RPC request + guard let request = try? decoder.decode(RPCRequest.self, from: data) else { + let errorResponse = RPCErrorResponse( + error: RPCErrorObject(code: RPCErrorCode.parseError, message: "Invalid JSON-RPC request"), + id: nil + ) + sendResponse(errorResponse, to: connID) + return + } + + // Resolve bundle identifier from first authorization request + if clientConn.bundleIdentifier == nil, + let params = request.params, + let bi = params["bundleIdentifier"]?.stringValue { + clientConn.bundleIdentifier = bi + connections[connID] = clientConn + } + + let service = ExtensionRPCService( + bundleIdentifier: clientConn.bundleIdentifier ?? "unknown", + server: self + ) + + let responseData = service.handleRequest(request) + sendRawData(responseData, to: connID) + } + + // MARK: - Send Helpers + + private func sendResponse(_ response: Codable, to connID: UUID) { + guard let data = try? encoder.encode(response) else { return } + sendRawData(data, to: connID) + } + + private func sendRawData(_ data: Data, to connID: UUID) { + guard let clientConn = connections[connID] else { return } + + let metadata = NWProtocolWebSocket.Metadata(opcode: .text) + let context = NWConnection.ContentContext(identifier: "rpc-response", metadata: [metadata]) + + clientConn.connection.send( + content: data, + contentContext: context, + isComplete: true, + completion: .contentProcessed { error in + if let error { + Task { @MainActor in + self.logDiagnostics("RPC send error: \(error.localizedDescription)") + } + } + } + ) + } + + private func sendNotification(to bundleIdentifier: String, method: String, params: [String: RPCValue]) { + let notification = RPCNotification(method: method, params: params) + guard let data = try? encoder.encode(notification) else { return } + + for (_, clientConn) in connections where clientConn.bundleIdentifier == bundleIdentifier { + let metadata = NWProtocolWebSocket.Metadata(opcode: .text) + let context = NWConnection.ContentContext(identifier: "rpc-notification", metadata: [metadata]) + + clientConn.connection.send( + content: data, + contentContext: context, + isComplete: true, + completion: .contentProcessed { _ in } + ) + } + } + + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } +} + +// MARK: - Client Connection + +struct RPCClientConnection { + let id: UUID + let connection: NWConnection + var bundleIdentifier: String? +} diff --git a/DynamicIsland/services/Extensions/ExtensionRPCService.swift b/DynamicIsland/services/Extensions/ExtensionRPCService.swift new file mode 100644 index 00000000..a72757ab --- /dev/null +++ b/DynamicIsland/services/Extensions/ExtensionRPCService.swift @@ -0,0 +1,710 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import AppKit +import Defaults +import AtollExtensionKit +import UniformTypeIdentifiers + +/// Handles JSON-RPC method calls for a single WebSocket connection. +/// Mirrors the functionality of `ExtensionXPCService` but uses JSON-RPC transport. +@MainActor +final class ExtensionRPCService { + let bundleIdentifier: String + private weak var server: ExtensionRPCServer? + + private let liveActivityManager = ExtensionLiveActivityManager.shared + private let widgetManager = ExtensionLockScreenWidgetManager.shared + private let notchManager = ExtensionNotchExperienceManager.shared + private let authorizationManager = ExtensionAuthorizationManager.shared + + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + // Keys whose values represent Swift enums that use {"type":...} in client format. + // These need to be transformed to Swift Codable format: {"caseName": {params}}. + private static let enumKeys: Set = [ + "leadingIcon", "trailingContent", "progressIndicator", + "badgeIcon", "leadingContent", "icon", "tint" + ] + + // Enum-typed fields that can appear inside arrays (like content elements) + private static let contentElementTypeFields: Set = [ + "text", "icon", "progress", "graph", "gauge", "spacer", "divider", "webView" + ] + + init(bundleIdentifier: String, server: ExtensionRPCServer) { + self.bundleIdentifier = bundleIdentifier + self.server = server + } + + // MARK: - Method Routing + + func handleRequest(_ request: RPCRequest) -> Data { + let result: Codable + + switch request.method { + case "atoll.getVersion": + result = handleGetVersion(id: request.id) + + case "atoll.requestAuthorization": + result = handleRequestAuthorization(params: request.params, id: request.id) + + case "atoll.checkAuthorization": + result = handleCheckAuthorization(params: request.params, id: request.id) + + case "atoll.presentLiveActivity": + result = handlePresentLiveActivity(params: request.params, id: request.id) + + case "atoll.updateLiveActivity": + result = handleUpdateLiveActivity(params: request.params, id: request.id) + + case "atoll.dismissLiveActivity": + result = handleDismissLiveActivity(params: request.params, id: request.id) + + case "atoll.presentLockScreenWidget": + result = handlePresentLockScreenWidget(params: request.params, id: request.id) + + case "atoll.updateLockScreenWidget": + result = handleUpdateLockScreenWidget(params: request.params, id: request.id) + + case "atoll.dismissLockScreenWidget": + result = handleDismissLockScreenWidget(params: request.params, id: request.id) + + case "atoll.presentNotchExperience": + result = handlePresentNotchExperience(params: request.params, id: request.id) + + case "atoll.updateNotchExperience": + result = handleUpdateNotchExperience(params: request.params, id: request.id) + + case "atoll.dismissNotchExperience": + result = handleDismissNotchExperience(params: request.params, id: request.id) + + // MARK: File Sharing + case "atoll.getShelfItems": + result = handleGetShelfItems(params: request.params, id: request.id) + + case "atoll.getShelfItemData": + result = handleGetShelfItemData(params: request.params, id: request.id) + + case "atoll.showFilePicker": + result = handleShowFilePicker(params: request.params, id: request.id) + + case "atoll.shareShelfItems": + result = handleShareShelfItems(params: request.params, id: request.id) + + case "atoll.addFilesToShelf": + result = handleAddFilesToShelf(params: request.params, id: request.id) + + case "atoll.subscribeShelfEvents": + result = handleSubscribeShelfEvents(params: request.params, id: request.id) + + default: + result = RPCErrorResponse( + error: RPCErrorObject(code: RPCErrorCode.methodNotFound, message: "Method not found: \(request.method)"), + id: request.id + ) + } + + return (try? encoder.encode(result)) ?? Data() + } + + // MARK: - Version + + private func handleGetVersion(id: String) -> RPCSuccessResponse { + RPCSuccessResponse( + result: ["version": .string(AtollExtensionKitVersion)], + id: id + ) + } + + // MARK: - Authorization + + private func handleRequestAuthorization(params: RPCParams?, id: String) -> Codable { + guard Defaults[.enableThirdPartyExtensions] else { + return RPCErrorResponse( + error: RPCErrorObject(code: RPCErrorCode.featureDisabled, message: "Extensions are disabled"), + id: id + ) + } + + let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier + let entry = authorizationManager.ensureEntryExists(bundleIdentifier: bi, appName: bi) + + if entry.isAuthorized { + return RPCSuccessResponse(result: ["authorized": .bool(true)], id: id) + } + + // Auto-authorize for now (user can revoke in settings) + authorizationManager.authorize(bundleIdentifier: bi, appName: bi) + logDiagnostics("Authorized RPC client \(bi)") + + return RPCSuccessResponse(result: ["authorized": .bool(true)], id: id) + } + + private func handleCheckAuthorization(params: RPCParams?, id: String) -> Codable { + let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier + + guard Defaults[.enableThirdPartyExtensions] else { + return RPCSuccessResponse(result: ["authorized": .bool(false)], id: id) + } + + let entry = authorizationManager.authorizationEntry(for: bi) + let authorized = entry?.isAuthorized ?? false + + return RPCSuccessResponse(result: ["authorized": .bool(authorized)], id: id) + } + + // MARK: - Live Activities + + private func handlePresentLiveActivity(params: RPCParams?, id: String) -> Codable { + guard let descriptorData = params?.jsonData(for: "descriptor") else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id) + } + + do { + let transformed = Self.transformClientJSON(descriptorData) + let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: transformed) + try ExtensionDescriptorValidator.validate(descriptor) + try liveActivityManager.present(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) + logDiagnostics("RPC: Presented live activity \(descriptor.id) for \(descriptor.bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } catch let error as ExtensionValidationError { + return errorResponse(from: error, id: id) + } catch { + logDiagnostics("RPC: Decode error for presentLiveActivity: \(error)") + logDiagnostics("RPC: Raw payload: \(String(data: descriptorData, encoding: .utf8) ?? "")") + return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) + } + } + + private func handleUpdateLiveActivity(params: RPCParams?, id: String) -> Codable { + guard let descriptorData = params?.jsonData(for: "descriptor") else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id) + } + + do { + let transformed = Self.transformClientJSON(descriptorData) + let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: transformed) + try liveActivityManager.update(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) + logDiagnostics("RPC: Updated live activity \(descriptor.id) for \(descriptor.bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } catch let error as ExtensionValidationError { + return errorResponse(from: error, id: id) + } catch { + logDiagnostics("RPC: Decode error for updateLiveActivity: \(error)") + return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) + } + } + + private func handleDismissLiveActivity(params: RPCParams?, id: String) -> Codable { + guard let activityID = params?["activityID"]?.stringValue else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing activityID", id: id) + } + let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier + liveActivityManager.dismiss(activityID: activityID, bundleIdentifier: bi) + logDiagnostics("RPC: Dismissed live activity \(activityID) for \(bi)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } + + // MARK: - Lock Screen Widgets + + private func handlePresentLockScreenWidget(params: RPCParams?, id: String) -> Codable { + guard let descriptorData = params?.jsonData(for: "descriptor") else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id) + } + + do { + let transformed = Self.transformClientJSON(descriptorData) + let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: transformed) + try ExtensionDescriptorValidator.validate(descriptor) + try widgetManager.present(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) + logDiagnostics("RPC: Presented widget \(descriptor.id) for \(descriptor.bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } catch let error as ExtensionValidationError { + return errorResponse(from: error, id: id) + } catch { + logDiagnostics("RPC: Decode error for presentWidget: \(error)") + logDiagnostics("RPC: Raw payload: \(String(data: descriptorData, encoding: .utf8) ?? "")") + return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) + } + } + + private func handleUpdateLockScreenWidget(params: RPCParams?, id: String) -> Codable { + guard let descriptorData = params?.jsonData(for: "descriptor") else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id) + } + + do { + let transformed = Self.transformClientJSON(descriptorData) + let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: transformed) + try widgetManager.update(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) + logDiagnostics("RPC: Updated widget \(descriptor.id) for \(descriptor.bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } catch let error as ExtensionValidationError { + return errorResponse(from: error, id: id) + } catch { + logDiagnostics("RPC: Decode error for updateWidget: \(error)") + return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) + } + } + + private func handleDismissLockScreenWidget(params: RPCParams?, id: String) -> Codable { + guard let widgetID = params?["widgetID"]?.stringValue else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing widgetID", id: id) + } + let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier + widgetManager.dismiss(widgetID: widgetID, bundleIdentifier: bi) + logDiagnostics("RPC: Dismissed widget \(widgetID) for \(bi)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } + + // MARK: - Notch Experiences + + private func handlePresentNotchExperience(params: RPCParams?, id: String) -> Codable { + guard let descriptorData = params?.jsonData(for: "descriptor") else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id) + } + + do { + let transformed = Self.transformClientJSON(descriptorData) + let descriptor = try decoder.decode(AtollNotchExperienceDescriptor.self, from: transformed) + try ExtensionDescriptorValidator.validate(descriptor) + try notchManager.present(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) + logDiagnostics("RPC: Presented notch experience \(descriptor.id) for \(descriptor.bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } catch let error as ExtensionValidationError { + return errorResponse(from: error, id: id) + } catch { + logDiagnostics("RPC: Decode error for presentNotchExperience: \(error)") + logDiagnostics("RPC: Raw payload: \(String(data: descriptorData, encoding: .utf8) ?? "")") + return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) + } + } + + private func handleUpdateNotchExperience(params: RPCParams?, id: String) -> Codable { + guard let descriptorData = params?.jsonData(for: "descriptor") else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id) + } + + do { + let transformed = Self.transformClientJSON(descriptorData) + let descriptor = try decoder.decode(AtollNotchExperienceDescriptor.self, from: transformed) + try notchManager.update(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) + logDiagnostics("RPC: Updated notch experience \(descriptor.id) for \(descriptor.bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } catch let error as ExtensionValidationError { + return errorResponse(from: error, id: id) + } catch { + logDiagnostics("RPC: Decode error for updateNotchExperience: \(error)") + return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) + } + } + + private func handleDismissNotchExperience(params: RPCParams?, id: String) -> Codable { + guard let experienceID = params?["experienceID"]?.stringValue else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing experienceID", id: id) + } + let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier + notchManager.dismiss(experienceID: experienceID, bundleIdentifier: bi) + logDiagnostics("RPC: Dismissed notch experience \(experienceID) for \(bi)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } + + // MARK: - Helpers + + private func errorResponse(code: Int, message: String, id: String) -> RPCErrorResponse { + RPCErrorResponse( + error: RPCErrorObject(code: code, message: message), + id: id + ) + } + + private func errorResponse(from error: ExtensionValidationError, id: String) -> RPCErrorResponse { + let code: Int + switch error { + case .featureDisabled: code = RPCErrorCode.featureDisabled + case .unauthorized: code = RPCErrorCode.unauthorized + case .invalidDescriptor: code = RPCErrorCode.descriptorInvalid + case .exceedsCapacity: code = RPCErrorCode.capacityExceeded + case .unsupportedContent: code = RPCErrorCode.unsupported + case .rateLimited: code = RPCErrorCode.internalError + case .duplicateIdentifier: code = RPCErrorCode.descriptorInvalid + } + return RPCErrorResponse( + error: RPCErrorObject(code: code, message: error.localizedDescription), + id: id + ) + } + + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } + + // MARK: - File Sharing Authorization Check + + private func checkFileSharingAuthorization(id: String) -> RPCErrorResponse? { + guard Defaults[.enableThirdPartyExtensions] else { + return errorResponse(code: RPCErrorCode.featureDisabled, message: "Extensions are disabled", id: id) + } + guard Defaults[.enableExtensionFileSharing] else { + return errorResponse(code: RPCErrorCode.featureDisabled, message: "Extension file sharing is disabled", id: id) + } + let entry = authorizationManager.authorizationEntry(for: bundleIdentifier) + guard entry?.isAuthorized == true else { + return errorResponse(code: RPCErrorCode.unauthorized, message: "Extension not authorized", id: id) + } + guard entry?.allowedScopes.contains(.fileSharing) == true else { + return errorResponse(code: RPCErrorCode.unauthorized, message: "File sharing scope not granted", id: id) + } + return nil + } + + // MARK: - File Sharing Handlers + + private func handleGetShelfItems(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + let items = ShelfStateViewModel.shared.items + var result: [RPCValue] = [] + + for item in items { + var entry: [String: RPCValue] = [ + "id": .string(item.id.uuidString), + "name": .string(item.displayName) + ] + switch item.kind { + case .file(let bookmark): + entry["kind"] = .string("file") + if let url = Bookmark(data: bookmark).resolveURL() { + entry["path"] = .string(url.path) + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? Int { + entry["size"] = .int(size) + } + } + case .text(let string): + entry["kind"] = .string("text") + entry["textContent"] = .string(string) + case .link(let url): + entry["kind"] = .string("link") + entry["url"] = .string(url.absoluteString) + } + result.append(.object(entry)) + } + + logDiagnostics("RPC: getShelfItems returned \(result.count) items for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["items": .array(result)], id: id) + } + + private func handleGetShelfItemData(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + guard let itemID = params?["itemID"]?.stringValue, + let uuid = UUID(uuidString: itemID) else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing or invalid itemID", id: id) + } + + guard let item = ShelfStateViewModel.shared.items.first(where: { $0.id == uuid }) else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Item not found", id: id) + } + + switch item.kind { + case .file(let bookmark): + guard let url = Bookmark(data: bookmark).resolveURL() else { + return errorResponse(code: RPCErrorCode.internalError, message: "Cannot resolve file bookmark", id: id) + } + guard let fileData = url.accessSecurityScopedResource(accessor: { try? Data(contentsOf: $0) }) else { + return errorResponse(code: RPCErrorCode.internalError, message: "Cannot read file data", id: id) + } + let base64 = fileData.base64EncodedString() + logDiagnostics("RPC: getShelfItemData returned \(fileData.count) bytes for \(bundleIdentifier)") + return RPCSuccessResponse(result: [ + "data": .string(base64), + "fileName": .string(url.lastPathComponent), + "mimeType": .string(url.mimeType ?? "application/octet-stream") + ], id: id) + case .text(let string): + return RPCSuccessResponse(result: [ + "data": .string(Data(string.utf8).base64EncodedString()), + "fileName": .string("text.txt"), + "mimeType": .string("text/plain") + ], id: id) + case .link(let url): + return RPCSuccessResponse(result: [ + "data": .string(Data(url.absoluteString.utf8).base64EncodedString()), + "fileName": .string("link.url"), + "mimeType": .string("text/uri-list") + ], id: id) + } + } + + private func handleShowFilePicker(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = true + panel.canChooseFiles = true + panel.title = "Select files to share" + + let response = panel.runModal() + guard response == .OK, !panel.urls.isEmpty else { + return RPCSuccessResponse(result: ["itemIDs": .array([])], id: id) + } + + var newItemIDs: [RPCValue] = [] + var newItems: [ShelfItem] = [] + + for url in panel.urls { + if let bookmark = try? Bookmark(url: url) { + let item = ShelfItem(kind: .file(bookmark: bookmark.data)) + newItems.append(item) + newItemIDs.append(.string(item.id.uuidString)) + } + } + + ShelfStateViewModel.shared.add(newItems) + logDiagnostics("RPC: showFilePicker added \(newItems.count) items for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["itemIDs": .array(newItemIDs)], id: id) + } + + private func handleShareShelfItems(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + guard let itemIDsValue = params?["itemIDs"], + case .array(let itemIDArray) = itemIDsValue else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing itemIDs array", id: id) + } + + let provider = params?["provider"]?.stringValue ?? "AirDrop" + let itemIDs = itemIDArray.compactMap { $0.stringValue }.compactMap { UUID(uuidString: $0) } + let items = ShelfStateViewModel.shared.items.filter { itemIDs.contains($0.id) } + + guard !items.isEmpty else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "No matching shelf items found", id: id) + } + + let urls = ShelfStateViewModel.shared.resolveFileURLs(for: items) + guard !urls.isEmpty else { + return errorResponse(code: RPCErrorCode.internalError, message: "Could not resolve file URLs", id: id) + } + + // Find and invoke the sharing service + QuickShareService.shared.ensureDiscovered() + if let shareProvider = QuickShareService.shared.availableProviders.first(where: { $0.id == provider }) { + Task { + await QuickShareService.shared.shareFilesOrText(urls, using: shareProvider, from: nil) + } + logDiagnostics("RPC: shareShelfItems sharing \(urls.count) files via \(provider) for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true), "fileCount": .int(urls.count)], id: id) + } else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Provider '\(provider)' not found", id: id) + } + } + + private func handleAddFilesToShelf(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + var newItems: [ShelfItem] = [] + var newItemIDs: [RPCValue] = [] + + // Handle file paths + if let pathsValue = params?["paths"], + case .array(let pathArray) = pathsValue { + for pathValue in pathArray { + guard let path = pathValue.stringValue else { continue } + let url = URL(fileURLWithPath: path) + guard FileManager.default.fileExists(atPath: path) else { continue } + if let bookmark = try? Bookmark(url: url) { + let item = ShelfItem(kind: .file(bookmark: bookmark.data)) + newItems.append(item) + newItemIDs.append(.string(item.id.uuidString)) + } + } + } + + // Handle base64-encoded file data + if let filesValue = params?["files"], + case .array(let filesArray) = filesValue { + for fileValue in filesArray { + guard case .object(let fileObj) = fileValue, + let dataStr = fileObj["data"]?.stringValue, + let fileName = fileObj["fileName"]?.stringValue, + let fileData = Data(base64Encoded: dataStr) else { continue } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("AtollExtensionFiles", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent(fileName) + guard (try? fileData.write(to: fileURL)) != nil else { continue } + + if let bookmark = try? Bookmark(url: fileURL) { + let item = ShelfItem(kind: .file(bookmark: bookmark.data), isTemporary: true) + newItems.append(item) + newItemIDs.append(.string(item.id.uuidString)) + } + } + } + + // Handle text content + if let text = params?["text"]?.stringValue, !text.isEmpty { + let item = ShelfItem(kind: .text(string: text)) + newItems.append(item) + newItemIDs.append(.string(item.id.uuidString)) + } + + ShelfStateViewModel.shared.add(newItems) + logDiagnostics("RPC: addFilesToShelf added \(newItems.count) items for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["itemIDs": .array(newItemIDs)], id: id) + } + + private func handleSubscribeShelfEvents(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + server?.registerShelfSubscription(for: bundleIdentifier) + logDiagnostics("RPC: subscribeShelfEvents registered for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["subscribed": .bool(true)], id: id) + } + + // MARK: - Client JSON Transformation + + /// Converts client wire format to Swift Codable format for enum types. + /// + /// Client sends: `{ "type": "symbol", "name": "timer", "size": 16 }` + /// Swift expects: `{ "symbol": { "name": "timer", "size": 16 } }` + /// + /// For enums with unnamed parameters (e.g., `case text(String, font:, color:)`): + /// Client sends: `{ "type": "text", "text": "LIVE", "font": {...} }` + /// Swift expects: `{ "text": { "_0": "LIVE", "font": {...} } }` + static func transformClientJSON(_ data: Data) -> Data { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return data + } + let transformed = transformObject(json) + return (try? JSONSerialization.data(withJSONObject: transformed)) ?? data + } + + private static func transformObject(_ obj: [String: Any]) -> [String: Any] { + var result: [String: Any] = [:] + + for (key, value) in obj { + if let dict = value as? [String: Any] { + if enumKeys.contains(key), let typeName = dict["type"] as? String { + // Transform: { "type": "symbol", ... } → { "symbol": { ... } } + result[key] = transformEnumValue(dict, typeName: typeName) + } else { + result[key] = transformObject(dict) + } + } else if let arr = value as? [[String: Any]] { + // Arrays like "content", "elements", "sections" + result[key] = arr.map { item in + if let typeName = item["type"] as? String, + (key == "content" || key == "elements") { + return transformEnumValue(item, typeName: typeName) + } + return transformObject(item) + } + } else { + result[key] = value + } + } + + return result + } + + /// Transforms `{ "type": "caseName", ...params }` → `{ "caseName": { ...params } }`. + /// Handles special cases where Swift enum cases have unnamed first parameters: + /// - `text` / `marquee`: `text` field → `_0` + /// - `icon`: `icon` field → `_0` (AtollIconDescriptor) + /// - `progress`: `indicator` field → `_0` (AtollProgressIndicator) + /// - `webView`: `content` field → `_0` (AtollWidgetWebContentDescriptor) + /// - `animation`: `data` field → `_0` (Data) + private static func transformEnumValue(_ dict: [String: Any], typeName: String) -> [String: Any] { + var inner: [String: Any] = [:] + + for (k, v) in dict where k != "type" { + if let nestedDict = v as? [String: Any] { + // Check if this nested dict is an enum-typed field + if (enumKeys.contains(k) || k == "indicator"), + let nestedType = nestedDict["type"] as? String { + inner[k] = transformEnumValue(nestedDict, typeName: nestedType) + } else { + inner[k] = transformObject(nestedDict) + } + } else if let nestedArr = v as? [[String: Any]] { + inner[k] = nestedArr.map { item in + if let t = item["type"] as? String { + return transformEnumValue(item, typeName: t) + } + return transformObject(item) + } + } else { + inner[k] = v + } + } + + // Handle unnamed first parameter for specific enum cases. + // Swift's auto-synthesized Codable encodes unnamed associated values as `_0`, `_1`, etc. + switch typeName { + case "text": + // AtollTrailingContent.text(String, ...) AND AtollWidgetContentElement.text(String, ...) + if let textValue = inner.removeValue(forKey: "text") { + inner["_0"] = textValue + } + case "marquee": + // AtollTrailingContent.marquee(String, ...) + if let textValue = inner.removeValue(forKey: "text") { + inner["_0"] = textValue + } + case "icon": + // AtollWidgetContentElement.icon(AtollIconDescriptor, tint:) + if let iconValue = inner.removeValue(forKey: "icon") { + inner["_0"] = iconValue + } + case "progress": + // AtollWidgetContentElement.progress(AtollProgressIndicator, value:, color:) + if let indicatorValue = inner.removeValue(forKey: "indicator") { + inner["_0"] = indicatorValue + } + case "webView": + // AtollWidgetContentElement.webView(AtollWidgetWebContentDescriptor) + if let contentValue = inner.removeValue(forKey: "content") { + inner["_0"] = contentValue + } + case "animation": + // AtollTrailingContent.animation(data:, size:) — data is named but let's be safe + break + default: + break + } + + return [typeName: inner] + } +} + +// MARK: - URL MIME Type Helper + +private extension URL { + var mimeType: String? { + guard let uti = UTType(filenameExtension: self.pathExtension) else { return nil } + return uti.preferredMIMEType + } +} diff --git a/DynamicIsland/services/Extensions/ExtensionXPCService.swift b/DynamicIsland/services/Extensions/ExtensionXPCService.swift new file mode 100644 index 00000000..7bc005b1 --- /dev/null +++ b/DynamicIsland/services/Extensions/ExtensionXPCService.swift @@ -0,0 +1,247 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Foundation +import Defaults +import AtollExtensionKit + +@MainActor +final class ExtensionXPCService: NSObject, AtollXPCServiceProtocol { + private let bundleIdentifier: String + private weak var host: ExtensionXPCServiceHost? + private weak var connection: NSXPCConnection? + + private let authorizationManager = ExtensionAuthorizationManager.shared + private let liveActivityManager = ExtensionLiveActivityManager.shared + private let widgetManager = ExtensionLockScreenWidgetManager.shared + private let notchExperienceManager = ExtensionNotchExperienceManager.shared + private let decoder = JSONDecoder() + + init(bundleIdentifier: String, host: ExtensionXPCServiceHost, connection: NSXPCConnection) { + self.bundleIdentifier = bundleIdentifier + self.host = host + self.connection = connection + super.init() + } + + // MARK: Authorization + + func requestAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { + Task { @MainActor [weak self] in + guard let self else { return } + guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + + guard self.authorizationManager.isExtensionsFeatureEnabled else { + reply(false, ExtensionValidationError.featureDisabled.asNSError) + return + } + + let entry = self.authorizationManager.ensureEntryExists(bundleIdentifier: self.bundleIdentifier, appName: self.resolvedApplicationName()) + + if entry.status == .pending { + self.authorizationManager.authorize(bundleIdentifier: self.bundleIdentifier, appName: self.resolvedApplicationName()) + self.host?.notifyAuthorizationChange(bundleIdentifier: self.bundleIdentifier, isAuthorized: true) + reply(true, nil) + return + } + + reply(entry.isAuthorized, entry.isAuthorized ? nil : ExtensionValidationError.unauthorized.asNSError) + } + } + + func checkAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool) -> Void) { + Task { @MainActor [weak self] in + guard let self else { return } + guard providedBundleIdentifier == self.bundleIdentifier else { + reply(false) + return + } + + let isAuthorized = self.authorizationManager.authorizationEntry(for: self.bundleIdentifier)?.isAuthorized ?? false + reply(isAuthorized) + } + } + + // MARK: Live Activities + + func presentLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + service.logDiagnostics("Received live activity payload from \(service.bundleIdentifier) (id: \(descriptor.id), priority: \(descriptor.priority.rawValue), coexistence: \(descriptor.allowsMusicCoexistence))") + try service.liveActivityManager.present(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Live activity \(descriptor.id) stored for \(service.bundleIdentifier); active activities: \(service.liveActivityManager.activeActivities.count)") + } + } + + func updateLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + service.logDiagnostics("Received live activity update from \(service.bundleIdentifier) (id: \(descriptor.id))") + try service.liveActivityManager.update(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Live activity \(descriptor.id) updated for \(service.bundleIdentifier)") + } + } + + func dismissLiveActivity(activityID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { + Task { @MainActor [weak self] in + guard let self else { return } + guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + + self.logDiagnostics("Received live activity dismissal from \(self.bundleIdentifier) (id: \(activityID))") + self.liveActivityManager.dismiss(activityID: activityID, bundleIdentifier: self.bundleIdentifier) + self.logDiagnostics("Live activity \(activityID) dismissed for \(self.bundleIdentifier)") + reply(true, nil) + } + } + + // MARK: Lock Screen Widgets + + func presentLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + service.logDiagnostics("Received lock screen widget payload from \(service.bundleIdentifier) (id: \(descriptor.id), style: \(descriptor.layoutStyle))") + try service.widgetManager.present(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Lock screen widget \(descriptor.id) stored for \(service.bundleIdentifier); active widgets: \(service.widgetManager.activeWidgets.count)") + } + } + + func updateLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + service.logDiagnostics("Received lock screen widget update from \(service.bundleIdentifier) (id: \(descriptor.id))") + try service.widgetManager.update(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Lock screen widget \(descriptor.id) updated for \(service.bundleIdentifier)") + } + } + + func dismissLockScreenWidget(widgetID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { + Task { @MainActor [weak self] in + guard let self else { return } + guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + + self.logDiagnostics("Received lock screen widget dismissal from \(self.bundleIdentifier) (id: \(widgetID))") + self.widgetManager.dismiss(widgetID: widgetID, bundleIdentifier: self.bundleIdentifier) + self.logDiagnostics("Lock screen widget \(widgetID) dismissed for \(self.bundleIdentifier)") + reply(true, nil) + } + } + + // MARK: Notch Experiences + + func presentNotchExperience(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollNotchExperienceDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + service.logDiagnostics("Received notch experience payload from \(service.bundleIdentifier) (id: \(descriptor.id), priority: \(descriptor.priority.rawValue))") + try service.notchExperienceManager.present(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Notch experience \(descriptor.id) stored for \(service.bundleIdentifier); active experiences: \(service.notchExperienceManager.activeExperiences.count)") + } + } + + func updateNotchExperience(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollNotchExperienceDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + service.logDiagnostics("Received notch experience update from \(service.bundleIdentifier) (id: \(descriptor.id))") + try service.notchExperienceManager.update(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Notch experience \(descriptor.id) updated for \(service.bundleIdentifier)") + } + } + + func dismissNotchExperience(experienceID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { + Task { @MainActor [weak self] in + guard let self else { return } + guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + + self.logDiagnostics("Received notch experience dismissal from \(self.bundleIdentifier) (id: \(experienceID))") + self.notchExperienceManager.dismiss(experienceID: experienceID, bundleIdentifier: self.bundleIdentifier) + self.logDiagnostics("Notch experience \(experienceID) dismissed for \(self.bundleIdentifier)") + reply(true, nil) + } + } + + // MARK: Diagnostics + + func getVersion(reply: @escaping (String) -> Void) { + Task { @MainActor in + reply(appVersion) + } + } + + // MARK: Helpers + + private func respond(reply: @escaping (Bool, Error?) -> Void, operation: @escaping (ExtensionXPCService) throws -> Void) { + Task { @MainActor [weak self] in + guard let self else { return } + do { + try operation(self) + reply(true, nil) + } catch { + if Defaults[.extensionDiagnosticsLoggingEnabled] { + Logger.log("Extension XPC request failed: \(error)", category: .extensions) + } + reply(false, error.asNSError) + } + } + } + + private func validate(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) -> Bool { + guard providedBundleIdentifier == bundleIdentifier else { + let error = ExtensionXPCServiceError.bundleMismatch(expected: bundleIdentifier, received: providedBundleIdentifier) + logDiagnostics("Rejected XPC request due to bundle mismatch. Expected \(bundleIdentifier) received \(providedBundleIdentifier)") + reply(false, error.asNSError) + return false + } + return true + } + + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } + + private func resolvedApplicationName() -> String { + guard let processIdentifier = connection?.processIdentifier, + processIdentifier != 0, + let app = NSRunningApplication(processIdentifier: pid_t(processIdentifier)), + let name = app.localizedName else { + return bundleIdentifier + } + return name + } +} + +private enum ExtensionXPCServiceError: LocalizedError { + case bundleMismatch(expected: String, received: String) + + var errorDescription: String? { + switch self { + case let .bundleMismatch(expected, received): + return "Bundle identifier mismatch. Expected \(expected) but received \(received)." + } + } +} + +private extension Error { + var asNSError: NSError { self as NSError } +} diff --git a/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift b/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift new file mode 100644 index 00000000..a1c99f65 --- /dev/null +++ b/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift @@ -0,0 +1,155 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Foundation +import AtollExtensionKit + +/// Shared constants for the Atoll extension XPC service. +enum ExtensionXPCServiceConstants { + static let machServiceName = "com.ebullioscopic.Atoll.xpc" +} + +@MainActor +final class ExtensionXPCServiceHost: NSObject, NSXPCListenerDelegate { + static let shared = ExtensionXPCServiceHost() + + private final class ClientContext { + weak var connection: NSXPCConnection? + let bundleIdentifier: String + + init(connection: NSXPCConnection, bundleIdentifier: String) { + self.connection = connection + self.bundleIdentifier = bundleIdentifier + } + } + + private var listener: NSXPCListener? + private var clientContexts: [ObjectIdentifier: ClientContext] = [:] + + func start() { + guard listener == nil else { return } + + let listener = NSXPCListener(machServiceName: ExtensionXPCServiceConstants.machServiceName) + listener.delegate = self + self.listener = listener + listener.resume() + + Logger.log("Started Atoll XPC listener", category: .extensions) + } + + func stop() { + listener?.invalidate() + listener = nil + clientContexts.removeAll() + Logger.log("Stopped Atoll XPC listener", category: .extensions) + } + + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { + guard let bundleIdentifier = resolveBundleIdentifier(for: connection) else { + Logger.log("Rejected XPC connection without bundle identifier", category: .extensions) + return false + } + + let service = ExtensionXPCService(bundleIdentifier: bundleIdentifier, host: self, connection: connection) + connection.exportedInterface = NSXPCInterface(with: AtollXPCServiceProtocol.self) + connection.exportedObject = service + connection.remoteObjectInterface = NSXPCInterface(with: AtollXPCClientProtocol.self) + + connection.invalidationHandler = { [weak self, weak connection] in + guard let connection else { return } + Task { @MainActor [weak self] in + self?.removeConnection(connection) + } + } + + connection.interruptionHandler = { [weak self, weak connection] in + guard let connection else { return } + Task { @MainActor [weak self] in + self?.removeConnection(connection) + } + } + + connection.resume() + clientContexts[ObjectIdentifier(connection)] = ClientContext(connection: connection, bundleIdentifier: bundleIdentifier) + Logger.log("Accepted XPC connection from \(bundleIdentifier)", category: .extensions) + return true + } + + func notifyAuthorizationChange(bundleIdentifier: String, isAuthorized: Bool) { + deliver(to: bundleIdentifier) { client in + client.authorizationDidChange(isAuthorized: isAuthorized) + } + } + + func notifyActivityDismiss(bundleIdentifier: String, activityID: String) { + deliver(to: bundleIdentifier) { client in + client.activityDidDismiss(activityID: activityID) + } + } + + func notifyWidgetDismiss(bundleIdentifier: String, widgetID: String) { + deliver(to: bundleIdentifier) { client in + client.widgetDidDismiss(widgetID: widgetID) + } + } + + func notifyNotchExperienceDismiss(bundleIdentifier: String, experienceID: String) { + deliver(to: bundleIdentifier) { client in + client.notchExperienceDidDismiss(experienceID: experienceID) + } + } + + private func resolveBundleIdentifier(for connection: NSXPCConnection) -> String? { + let processIdentifier = connection.processIdentifier + guard processIdentifier != 0 else { return nil } + + if let app = NSRunningApplication(processIdentifier: pid_t(processIdentifier)), + let bundleIdentifier = app.bundleIdentifier { + return bundleIdentifier + } + + return nil + } + + private func deliver(to bundleIdentifier: String, send block: (AtollXPCClientProtocol) -> Void) { + var staleIdentifiers: [ObjectIdentifier] = [] + + for (identifier, context) in clientContexts where context.bundleIdentifier == bundleIdentifier { + guard let connection = context.connection else { + staleIdentifiers.append(identifier) + continue + } + + guard let client = connection.remoteObjectProxyWithErrorHandler({ error in + Logger.log("Failed to deliver XPC callback to \(bundleIdentifier): \(error)", category: .extensions) + }) as? AtollXPCClientProtocol else { + continue + } + + block(client) + } + + staleIdentifiers.forEach { clientContexts.removeValue(forKey: $0) } + } + + private func removeConnection(_ connection: NSXPCConnection) { + clientContexts.removeValue(forKey: ObjectIdentifier(connection)) + Logger.log("Removed XPC connection", category: .extensions) + } +} diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index f943f85f..84b30918 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -1,9 +1,24 @@ -// -// sizeMatters.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 05/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Defaults import Foundation @@ -12,8 +27,261 @@ import SwiftUI let downloadSneakSize: CGSize = .init(width: 65, height: 1) let batterySneakSize: CGSize = .init(width: 160, height: 1) -let openNotchSize: CGSize = .init(width: 610, height: 200) +var openNotchSize: CGSize { + let storedWidth = Defaults[.openNotchWidth] + let minWidth = currentRecommendedMinimumNotchWidth() + let maxWidth = maxAllowedNotchWidth() + let width = min(max(storedWidth, minWidth), maxWidth) + return .init(width: width, height: 200) +} + +/// Maximum notch width based on the current screen's point width. +/// Prevents the notch from extending beyond the screen on scaled displays. +func maxAllowedNotchWidth(for screenName: String? = nil) -> CGFloat { + let screen: NSScreen? + if let screenName { + screen = NSScreen.screens.first { $0.localizedName == screenName } + } else { + screen = NSScreen.main + } + guard let screenWidth = screen?.frame.width, screenWidth > 0 else { + return 900 + } + return max(screenWidth - 60, 400) +} + +/// Convenience for the main screen. +func maxAllowedNotchWidth() -> CGFloat { + maxAllowedNotchWidth(for: nil) +} + +// MARK: - Tab-Based Notch Width + +/// Counts the number of currently enabled standard notch tabs. +/// Mirrors the tab-building logic in ``TabSelectionView``. +func enabledStandardTabCount() -> Int { + var count = 0 + + // Home tab + if Defaults[.showStandardMediaControls] || Defaults[.showCalendar] || Defaults[.showMirror] { + count += 1 + } + + // Shelf tab + if Defaults[.dynamicShelf] { + count += 1 + } + + // Timer tab (only in .tab display mode) + if Defaults[.enableTimerFeature] && Defaults[.timerDisplayMode] == .tab { + count += 1 + } + + // Stats tab + if Defaults[.enableStatsFeature] { + count += 1 + } + + // Notes / Clipboard tab + if Defaults[.enableNotes] || (Defaults[.enableClipboardManager] && Defaults[.clipboardDisplayMode] == .separateTab) { + count += 1 + } + + // Terminal tab + if Defaults[.enableTerminalFeature] { + count += 1 + } + + return count +} + +/// Returns the recommended minimum notch width for the given tab count. +func recommendedMinimumNotchWidth(forTabCount count: Int) -> CGFloat { + if count >= 6 { return 770 } + if count >= 5 { return 690 } + return 640 +} + +/// Returns the recommended minimum notch width for the current tab configuration. +func currentRecommendedMinimumNotchWidth() -> CGFloat { + recommendedMinimumNotchWidth(forTabCount: enabledStandardTabCount()) +} + +/// Enforces the minimum notch width based on current tab count. +/// Also clamps to screen width so the notch never exceeds the display. +/// Only adjusts when not in minimalistic mode. +func enforceMinimumNotchWidth() { + guard !Defaults[.enableMinimalisticUI] else { return } + let minWidth = currentRecommendedMinimumNotchWidth() + let maxWidth = maxAllowedNotchWidth() + var width = Defaults[.openNotchWidth] + if width < minWidth { width = minWidth } + if width > maxWidth { width = maxWidth } + if Defaults[.openNotchWidth] != width { + Defaults[.openNotchWidth] = width + } +} +private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180) +private let minimalisticLyricsExtraHeight: CGFloat = 40 +let minimalisticTimerCountdownTopPadding: CGFloat = 12 +let minimalisticTimerCountdownContentHeight: CGFloat = 82 +let minimalisticTimerCountdownBlockHeight: CGFloat = minimalisticTimerCountdownTopPadding + minimalisticTimerCountdownContentHeight +let statsSecondRowContentHeight: CGFloat = 120 +let statsGridSpacingHeight: CGFloat = 12 +let notchShadowPaddingStandard: CGFloat = 18 +let notchShadowPaddingMinimalistic: CGFloat = 12 + +@MainActor +var minimalisticOpenNotchSize: CGSize { + var size = minimalisticBaseOpenNotchSize + + if Defaults[.enableLyrics] { + size.height += minimalisticLyricsExtraHeight + } + + let reminderCount = ReminderLiveActivityManager.shared.activeWindowReminders.count + if reminderCount > 0 { + let reminderHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: reminderCount) + size.height += reminderHeight + } + + if DynamicIslandViewCoordinator.shared.timerLiveActivityEnabled && TimerManager.shared.isExternalTimerActive { + size.height += minimalisticTimerCountdownBlockHeight + } + + return size +} let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14)) +let minimalisticCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 35, bottom: 35), closed: cornerRadiusInsets.closed) + +// MARK: - Terminal tab clip (notch surface) + +/// Padding on the terminal block inside the notch. Inner corner radius = outer shell radius on that edge, minus the matching edge padding. +let notchTerminalContentEdgePadding: (top: CGFloat, horizontal: CGFloat, bottom: CGFloat) = (4, 8, 8) + +/// Inner margin (all edges) between the SwiftTerm view's glyphs and the terminal block edge. +/// Applied to the LocalProcessTerminalView frame only; the frosted blur underlay stays full-bleed. +let notchTerminalInnerTextInset: CGFloat = 6 + +/// Bottom radii for the shell (outer) and the terminal ``clipShape`` (inner), per design: inner = outer shell bottom radius − `notchTerminalContentEdgePadding.bottom`. +func notchTerminalBottomCornerRadii( + isDynamicIslandMode: Bool, + notchState: NotchState, + cornerRadiusScaling: Bool, + enableMinimalisticUI: Bool, + closedNotchHeight: CGFloat +) -> (outerBottom: CGFloat, innerBottom: CGFloat) { + let p = notchTerminalContentEdgePadding.bottom + if isDynamicIslandMode { + let outer: CGFloat + if notchState == .open { + outer = enableMinimalisticUI + ? minimalisticCornerRadiusInsets.opened.top + : dynamicIslandPillCornerRadiusInsets.opened + } else { + outer = max(closedNotchHeight / 2, dynamicIslandPillCornerRadiusInsets.closed.standard) + } + return (outer, max(0, outer - p)) + } + let active: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = { + if enableMinimalisticUI { + return (opened: minimalisticCornerRadiusInsets.opened, closed: cornerRadiusInsets.closed) + } + return cornerRadiusInsets + }() + let outerBottom: CGFloat + if notchState == .open && cornerRadiusScaling { + outerBottom = active.opened.bottom + } else { + outerBottom = active.closed.bottom + } + return (outerBottom, max(0, outerBottom - p)) +} + +func statsAdjustedNotchSize( + from baseSize: CGSize, + isStatsTabActive: Bool, + secondRowProgress: CGFloat +) -> CGSize { + guard isStatsTabActive, Defaults[.enableStatsFeature] else { + return baseSize + } + + let enabledGraphsCount = [ + Defaults[.showCpuGraph], + Defaults[.showMemoryGraph], + Defaults[.showGpuGraph], + Defaults[.showNetworkGraph], + Defaults[.showDiskGraph] + ].filter { $0 }.count + + guard enabledGraphsCount >= 4 else { + return baseSize + } + + let clampedProgress = max(0, min(secondRowProgress, 1)) + guard clampedProgress > 0 else { + return baseSize + } + + var adjustedSize = baseSize + let extraHeight = (statsSecondRowContentHeight + statsGridSpacingHeight) * clampedProgress + adjustedSize.height += extraHeight + return adjustedSize +} + +func notchShadowPaddingValue(isMinimalistic: Bool) -> CGFloat { + isMinimalistic ? notchShadowPaddingMinimalistic : notchShadowPaddingStandard +} + +func addShadowPadding(to size: CGSize, isMinimalistic: Bool) -> CGSize { + CGSize(width: size.width, height: size.height + notchShadowPaddingValue(isMinimalistic: isMinimalistic)) +} + +/// Determines whether a specific screen should render the Dynamic Island pill +/// shape instead of the standard notch shape. +/// +/// Returns `true` only when ALL of these conditions are met: +/// 1. The user has selected `.dynamicIsland` in `externalDisplayStyle` +/// 2. The screen does NOT have a physical notch (safeAreaInsets.top == 0) +/// +/// Screens with a physical notch always use the standard notch shape. +func shouldUseDynamicIslandMode(for screenName: String?) -> Bool { + guard Defaults[.externalDisplayStyle] == .dynamicIsland else { + return false + } + + var selectedScreen: NSScreen? = NSScreen.main + if let screenName { + selectedScreen = NSScreen.screens.first(where: { $0.localizedName == screenName }) + } + + guard let screen = selectedScreen else { + // No screen found — fallback to standard notch + return false + } + + // Physical notch screens always use standard notch shape + return screen.safeAreaInsets.top <= 0 +} + +/// Corner radius insets for the Dynamic Island pill shape. +/// - closed: half the closed notch height for a true capsule look +/// - opened: generous radius for smooth expanded pill +let dynamicIslandPillCornerRadiusInsets: (opened: CGFloat, closed: (standard: CGFloat, minimalistic: CGFloat)) = ( + opened: 24, + closed: (standard: 16, minimalistic: 16) +) + +/// Vertical offset from the top screen edge for the Dynamic Island pill. +/// Creates a visual gap so the pill floats below the menu bar, mimicking +/// the iPhone's Dynamic Island detachment from the physical screen edge. +let dynamicIslandTopOffset: CGFloat = 6 + +/// Extra horizontal padding applied OUTSIDE the pill clip shape in Dynamic +/// Island mode so the drop shadow has room to render without being clipped +/// by the outer frame constraint. +let dynamicIslandShadowInset: CGFloat = 14 enum MusicPlayerImageSizes { static let cornerRadiusInset: (opened: CGFloat, closed: CGFloat) = (opened: 13.0, closed: 4.0) diff --git a/DynamicIsland/strings/constants.swift b/DynamicIsland/strings/constants.swift index 29f78f7c..5065b10e 100644 --- a/DynamicIsland/strings/constants.swift +++ b/DynamicIsland/strings/constants.swift @@ -1,13 +1,24 @@ -// -// constants.swift -// DynamicIsland -// -// Created by Harsh Vardhan Goswami on 04/08/24. -// +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ import Foundation -let productPage = URL(string: "https://github.com/TheBoredTeam/DynamicIsland")! -let sponsorPage = URL(string: "https://buymeacoffee.com/jfxh67wvfxq")! +let productPage = URL(string: "https://github.com/Ebullioscopic/DynamicIsland")! +let sponsorPage = URL(string: "https://www.buymeacoffee.com/kryoscopic")! diff --git a/DynamicIsland/timer.mp3 b/DynamicIsland/timer.mp3 new file mode 100644 index 00000000..41293e23 Binary files /dev/null and b/DynamicIsland/timer.mp3 differ diff --git a/DynamicIsland/tr.lproj/InfoPlist.strings b/DynamicIsland/tr.lproj/InfoPlist.strings new file mode 100644 index 00000000..000e9245 --- /dev/null +++ b/DynamicIsland/tr.lproj/InfoPlist.strings @@ -0,0 +1,14 @@ +"CFBundleDisplayName" = "Atoll"; +"NSAppleEventsUsageDescription" = "Atoll, Spotify ve Apple Music'i kontrol etmek için AppleScript kullanır."; +"NSBluetoothAlwaysUsageDescription" = "Atoll, ses aygıtları bağlandığında bunları algılamak ve HUD'da pil durumlarını göstermek için Bluetooth erişimine ihtiyaç duyar."; +"NSCalendarsFullAccessUsageDescription" = "Takvim etkinliklerini çentik içinde gösterir."; +"NSCalendarsUsageDescription" = "Takvim modu için gereklidir."; +"NSCameraUsageDescription" = "Ayna modu için kameranıza ihtiyaç duyuyoruz."; +"NSDesktopFolderUsageDescription" = "Atoll, ekran görüntülerini masaüstünüze kaydetmek için erişime ihtiyaç duyar."; +"NSDocumentsFolderUsageDescription" = "Atoll, rafa sürüklenen dosyaları kaydetmek ve okumak için erişime ihtiyaç duyar."; +"NSDownloadsFolderUsageDescription" = "Atoll, İndirilenler klasöründeki dosyaları okumak için erişime ihtiyaç duyar."; +"NSLocationUsageDescription" = "Atoll, doğru kilit ekranı hava durumunu göstermek için konumunuzu kullanır."; +"NSLocationWhenInUseUsageDescription" = "Atoll, doğru kilit ekranı hava durumunu göstermek için konumunuzu kullanır."; +"NSRemindersUsageDescription" = "Atoll, anımsatıcılarınızı göstermek için Anımsatıcılar erişimine ihtiyaç duyar."; +"NSAudioCaptureUsageDescription" = "Atoll, gerçek zamanlı müzik dalga biçimi görselleştiricisini göstermek için ses yakalama iznine ihtiyaç duyar."; +"NSScreenCaptureUsageDescription" = "Atoll, yapay zeka asistanı özelliği için ekran görüntüleri yakalamak üzere ekran kaydı iznine ihtiyaç duyar."; diff --git a/DynamicIsland/unlock.mp3 b/DynamicIsland/unlock.mp3 new file mode 100644 index 00000000..3b1b06d7 Binary files /dev/null and b/DynamicIsland/unlock.mp3 differ diff --git a/DynamicIsland/utils/AppleHardwareInfo.swift b/DynamicIsland/utils/AppleHardwareInfo.swift new file mode 100644 index 00000000..49a6190d --- /dev/null +++ b/DynamicIsland/utils/AppleHardwareInfo.swift @@ -0,0 +1,173 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import IOKit +import Darwin + +enum ApplePlatform: String { + case intel + case m1 + case m1Pro + case m1Max + case m1Ultra + case m2 + case m2Pro + case m2Max + case m2Ultra + case m3 + case m3Pro + case m3Max + case m3Ultra + case m4 + case m4Pro + case m4Max + case m4Ultra +} + +struct CPUClusterFrequencies { + let eCoreFrequencies: [Int32] + let pCoreFrequencies: [Int32] +} + +struct CPUClusterCounts { + let eCores: Int + let pCores: Int +} + +final class AppleHardwareInfo { + static let shared = AppleHardwareInfo() + let cpuName: String? + let platform: ApplePlatform? + let logicalCoreCount: Int + let clusterCounts: CPUClusterCounts + let clusterFrequencies: CPUClusterFrequencies + + private init() { + let name = AppleHardwareInfo.fetchCPUName() + cpuName = name + platform = AppleHardwareInfo.detectPlatform(from: name) + logicalCoreCount = AppleHardwareInfo.fetchLogicalCoreCount() + clusterCounts = AppleHardwareInfo.fetchClusterCounts() + clusterFrequencies = AppleHardwareInfo.fetchClusterFrequencies(cpuName: name ?? "") + } + + private static func fetchCPUName() -> String? { + var size = 0 + sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0) + guard size > 0 else { return nil } + var buffer = [CChar](repeating: 0, count: size) + let result = sysctlbyname("machdep.cpu.brand_string", &buffer, &size, nil, 0) + guard result == 0 else { return nil } + return String(cString: buffer) + } + + private static func fetchLogicalCoreCount() -> Int { + var cores: Int32 = 0 + var size = MemoryLayout.size + let result = sysctlbyname("hw.logicalcpu", &cores, &size, nil, 0) + return result == 0 ? Int(cores) : ProcessInfo.processInfo.processorCount + } + + private static func detectPlatform(from cpuName: String?) -> ApplePlatform? { + guard let name = cpuName?.lowercased() else { return nil } + if name.contains("intel") { return .intel } + if name.contains("m1") { + if name.contains("ultra") { return .m1Ultra } + if name.contains("max") { return .m1Max } + if name.contains("pro") { return .m1Pro } + return .m1 + } + if name.contains("m2") { + if name.contains("ultra") { return .m2Ultra } + if name.contains("max") { return .m2Max } + if name.contains("pro") { return .m2Pro } + return .m2 + } + if name.contains("m3") { + if name.contains("ultra") { return .m3Ultra } + if name.contains("max") { return .m3Max } + if name.contains("pro") { return .m3Pro } + return .m3 + } + if name.contains("m4") { + if name.contains("ultra") { return .m4Ultra } + if name.contains("max") { return .m4Max } + if name.contains("pro") { return .m4Pro } + return .m4 + } + return nil + } + + private static func fetchClusterCounts() -> CPUClusterCounts { + var iterator = io_iterator_t() + guard IOServiceGetMatchingServices(kIOMasterPortDefault, IOServiceMatching("AppleARMPE"), &iterator) == KERN_SUCCESS else { + return CPUClusterCounts(eCores: 0, pCores: 0) + } + defer { IOObjectRelease(iterator) } + var eCoreCount: Int32 = 0 + var pCoreCount: Int32 = 0 + + while case let service = IOIteratorNext(iterator), service != 0 { + defer { IOObjectRelease(service) } + var children = io_iterator_t() + guard IORegistryEntryGetChildIterator(service, kIOServicePlane, &children) == KERN_SUCCESS else { continue } + defer { IOObjectRelease(children) } + + while case let child = IOIteratorNext(children), child != 0 { + defer { IOObjectRelease(child) } + guard let name = di_getIOName(child), name == "cpus", let props = di_getIOProperties(child) else { continue } + if let data = props.object(forKey: "e-core-count") as? Data { + eCoreCount = data.withUnsafeBytes { $0.load(as: Int32.self) } + } + if let data = props.object(forKey: "p-core-count") as? Data { + pCoreCount = data.withUnsafeBytes { $0.load(as: Int32.self) } + } + } + } + + return CPUClusterCounts(eCores: Int(eCoreCount), pCores: Int(pCoreCount)) + } + + private static func fetchClusterFrequencies(cpuName: String) -> CPUClusterFrequencies { + var iterator = io_iterator_t() + guard IOServiceGetMatchingServices(kIOMasterPortDefault, IOServiceMatching("AppleARMIODevice"), &iterator) == KERN_SUCCESS else { + return CPUClusterFrequencies(eCoreFrequencies: [], pCoreFrequencies: []) + } + defer { IOObjectRelease(iterator) } + + let lowerName = cpuName.lowercased() + let isM4 = lowerName.contains("m4") + var eFreq: [Int32] = [] + var pFreq: [Int32] = [] + + while case let service = IOIteratorNext(iterator), service != 0 { + defer { IOObjectRelease(service) } + guard let name = di_getIOName(service), name == "pmgr", let props = di_getIOProperties(service) else { continue } + + if let data = props["voltage-states1-sram"] as? Data { + eFreq = di_convertCFDataToFrequencies(data, isM4: isM4) + } + if let data = props["voltage-states5-sram"] as? Data { + pFreq = di_convertCFDataToFrequencies(data, isM4: isM4) + } + } + + return CPUClusterFrequencies(eCoreFrequencies: eFreq, pCoreFrequencies: pFreq) + } +} diff --git a/DynamicIsland/utils/CPUSensorCollector.swift b/DynamicIsland/utils/CPUSensorCollector.swift new file mode 100644 index 00000000..bf1aa044 --- /dev/null +++ b/DynamicIsland/utils/CPUSensorCollector.swift @@ -0,0 +1,253 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import IOKit + +struct CPUFrequencyMetrics: Equatable { + let overallGHz: Double + let eCoreGHz: Double? + let pCoreGHz: Double? + let maxOverallGHz: Double + let maxECoreGHz: Double? + let maxPCoreGHz: Double? +} + +struct CPUTemperatureMetrics: Equatable { + let celsius: Double? + + var fahrenheit: Double? { + guard let celsius else { return nil } + return celsius * 9.0 / 5.0 + 32.0 + } +} + +final class CPUSensorCollector { + private let hardware = AppleHardwareInfo.shared + private var channels: CFMutableDictionary? + private var subscription: IOReportSubscriptionRef? + private var previousSample: (samples: CFDictionary, time: TimeInterval)? + + init() { + setupFrequencyChannel() + } + + deinit { + if let subscription { + IOReportCreateSamples(subscription, nil, nil) // best-effort flush + } + } + + func readTemperature() -> CPUTemperatureMetrics { + let platform = hardware.platform + if let value = primaryTemperatureKeyCandidates.compactMap({ SMC.shared.getValue($0) }).first(where: { $0 < 110 }) { + return CPUTemperatureMetrics(celsius: value) + } + let list = temperatureFallbackKeys(for: platform) + var total: Double = 0 + var count: Double = 0 + for key in list { + if let value = SMC.shared.getValue(key), value < 110 { + total += value + count += 1 + } + } + guard total > 0, count > 0 else { + return CPUTemperatureMetrics(celsius: nil) + } + return CPUTemperatureMetrics(celsius: total / count) + } + + func readFrequency() -> CPUFrequencyMetrics? { + guard let channels, let subscription else { return nil } + let timestamp = Date().timeIntervalSince1970 + guard let currentSample = IOReportCreateSamples(subscription, channels, nil)?.takeRetainedValue() else { + return nil + } + defer { + previousSample = (currentSample, timestamp) + } + guard let previous = previousSample, + let diff = IOReportCreateSamplesDelta(previous.samples, currentSample, nil)?.takeRetainedValue() else { + return nil + } + + let samples = collectIOSamples(from: diff) + let eFrequencies = hardware.clusterFrequencies.eCoreFrequencies + let pFrequencies = hardware.clusterFrequencies.pCoreFrequencies + let eCount = max(hardware.clusterCounts.eCores, 0) + let pCount = max(hardware.clusterCounts.pCores, 0) + + var eValues: [Double] = [] + var pValues: [Double] = [] + + for sample in samples where sample.group == "CPU Stats" { + if sample.channel.hasPrefix("ECPU") { + let value = calculateFrequency(from: sample.delta, freqs: eFrequencies) + if value > 0 { eValues.append(value) } + } else if sample.channel.hasPrefix("PCPU") { + let value = calculateFrequency(from: sample.delta, freqs: pFrequencies) + if value > 0 { pValues.append(value) } + } + } + + let eAverage = eValues.isEmpty ? nil : (eValues.reduce(0, +) / Double(eValues.count)) + let pAverage = pValues.isEmpty ? nil : (pValues.reduce(0, +) / Double(pValues.count)) + + guard eAverage != nil || pAverage != nil else { + return nil + } + + let overall: Double + if let eAverage, let pAverage, (eCount + pCount) > 0 { + overall = ((eAverage * Double(eCount)) + (pAverage * Double(pCount))) / Double(eCount + pCount) + } else if let eAverage { + overall = eAverage + } else if let pAverage { + overall = pAverage + } else { + return nil + } + + let maxE = eFrequencies.max().map { Double($0) / 1000 } + let maxP = pFrequencies.max().map { Double($0) / 1000 } + let maxOverall: Double + if let maxE, let maxP, (eCount + pCount) > 0 { + maxOverall = ((maxE * Double(eCount)) + (maxP * Double(pCount))) / Double(max(eCount + pCount, 1)) + } else if let maxE { + maxOverall = maxE + } else if let maxP { + maxOverall = maxP + } else { + maxOverall = (overall / 1000) + } + + return CPUFrequencyMetrics( + overallGHz: overall / 1000, + eCoreGHz: eAverage.map { $0 / 1000 }, + pCoreGHz: pAverage.map { $0 / 1000 }, + maxOverallGHz: maxOverall, + maxECoreGHz: maxE, + maxPCoreGHz: maxP + ) + } + + // MARK: - Private Helpers + private func setupFrequencyChannel() { + let names = [ + ("CPU Stats" as CFString, "CPU Complex Performance States" as CFString), + ("CPU Stats" as CFString, "CPU Core Performance States" as CFString) + ] + var mergedChannels: CFMutableDictionary? + var list: [CFDictionary] = [] + for (group, subgroup) in names { + if let channel = IOReportCopyChannelsInGroup(group, subgroup, 0, 0, 0)?.takeRetainedValue() { + list.append(channel) + } + } + guard let first = list.first else { return } + for channel in list.dropFirst() { + IOReportMergeChannels(first, channel, nil) + } + guard let copy = CFDictionaryCreateMutableCopy(kCFAllocatorDefault, CFDictionaryGetCount(first), first) else { + return + } + mergedChannels = copy + channels = mergedChannels + var dictionary: Unmanaged? + subscription = IOReportCreateSubscription(nil, mergedChannels, &dictionary, 0, nil) + dictionary?.release() + } + + private struct IOSample { + let group: String + let subGroup: String + let channel: String + let unit: String + let delta: CFDictionary + } + + private func collectIOSamples(from data: CFDictionary) -> [IOSample] { + let key = "IOReportChannels" as CFString + var rawValue: UnsafeRawPointer? + let found = withUnsafeMutablePointer(to: &rawValue) { pointer -> Bool in + CFDictionaryGetValueIfPresent(data, Unmanaged.passUnretained(key).toOpaque(), pointer) + } + guard found, let rawValue else { return [] } + let array = unsafeBitCast(rawValue, to: CFArray.self) + var result: [IOSample] = [] + for index in 0.. [(label: String, value: Int64)] { + let count = IOReportStateGetCount(dict) + var result: [(String, Int64)] = [] + for index in 0.. Double { + guard !freqs.isEmpty else { return 0 } + let states = getResidencies(dict: dict) + guard let offset = states.firstIndex(where: { !$0.label.elementsEqual("IDLE") && !$0.label.elementsEqual("DOWN") && !$0.label.elementsEqual("OFF") }) else { + return 0 + } + let usage = states.dropFirst(offset).reduce(0.0) { $0 + Double($1.value) } + guard usage > 0 else { return 0 } + var total: Double = 0 + for (index, frequency) in freqs.enumerated() { + let stateIndex = offset + index + guard states.indices.contains(stateIndex) else { continue } + let fraction = Double(states[stateIndex].value) / usage + total += fraction * Double(frequency) + } + return total + } + + private var primaryTemperatureKeyCandidates: [String] { + ["TC0D", "TC0E", "TC0F", "TC0P", "TC0H"] + } + + private func temperatureFallbackKeys(for platform: ApplePlatform?) -> [String] { + switch platform { + case .m1, .m1Pro, .m1Max, .m1Ultra: + return ["Tp09", "Tp0T", "Tp01", "Tp05", "Tp0D", "Tp0H", "Tp0L", "Tp0P", "Tp0X", "Tp0b"] + case .m2, .m2Pro, .m2Max, .m2Ultra: + return ["Tp1h", "Tp1t", "Tp1p", "Tp1l", "Tp01", "Tp05", "Tp09", "Tp0D", "Tp0X", "Tp0b", "Tp0f", "Tp0j"] + case .m3, .m3Pro, .m3Max, .m3Ultra: + return ["Te05", "Te0L", "Te0P", "Te0S", "Tf04", "Tf09", "Tf0A", "Tf0B", "Tf0D", "Tf0E", "Tf44", "Tf49", "Tf4A", "Tf4B", "Tf4D", "Tf4E"] + case .m4, .m4Pro, .m4Max, .m4Ultra: + return ["Te05", "Te09", "Te0H", "Te0S", "Tp01", "Tp05", "Tp09", "Tp0D", "Tp0V", "Tp0Y", "Tp0b", "Tp0e"] + default: + return ["TC0P", "TC0E", "TC0F", "TC0H", "TC0D"] + } + } +} diff --git a/DynamicIsland/utils/Environment+LockScreenReplica.swift b/DynamicIsland/utils/Environment+LockScreenReplica.swift new file mode 100644 index 00000000..9553cde4 --- /dev/null +++ b/DynamicIsland/utils/Environment+LockScreenReplica.swift @@ -0,0 +1,18 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + diff --git a/DynamicIsland/utils/IOKitHelpers.swift b/DynamicIsland/utils/IOKitHelpers.swift new file mode 100644 index 00000000..67c2225f --- /dev/null +++ b/DynamicIsland/utils/IOKitHelpers.swift @@ -0,0 +1,63 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import IOKit +import Darwin + +func di_getIOProperties(_ entry: io_registry_entry_t) -> NSDictionary? { + var properties: Unmanaged? = nil + guard IORegistryEntryCreateCFProperties(entry, &properties, kCFAllocatorDefault, 0) == kIOReturnSuccess else { + return nil + } + defer { properties?.release() } + return properties?.takeUnretainedValue() +} + +func di_getIOName(_ entry: io_registry_entry_t) -> String? { + let pointer = UnsafeMutablePointer.allocate(capacity: 1) + defer { pointer.deallocate() } + let result = IORegistryEntryGetName(entry, pointer) + guard result == kIOReturnSuccess else { + if let message = String(validatingUTF8: mach_error_string(result)) { + print("IORegistryEntryGetName error: \(message)") + } + return nil + } + return String(cString: UnsafeRawPointer(pointer).assumingMemoryBound(to: CChar.self)) +} + +func di_convertCFDataToFrequencies(_ data: Data, isM4: Bool = false) -> [Int32] { + guard !data.isEmpty else { return [] } + let bytes = [UInt8](data) + + let multiplier: UInt32 = isM4 ? 1_000 : 1_000_000 + var frequencies: [Int32] = [] + + for chunkStart in stride(from: 0, to: bytes.count, by: 8) { + let upperBound = min(chunkStart + 8, bytes.count) + let chunk = bytes[chunkStart... + */ + +import Foundation +import Darwin + +public typealias IOReportSubscriptionRef = OpaquePointer + +private enum IOReportLoader { + private static let libraryHandle: UnsafeMutableRawPointer? = { + let paths = [ + "/System/Library/PrivateFrameworks/IOReport.framework/IOReport", + "/usr/lib/libIOReport.dylib" + ] + for path in paths { + if let handle = dlopen(path, RTLD_LAZY | RTLD_LOCAL) { + return handle + } + } + return nil + }() + + static func symbol(_ name: String, as type: T.Type) -> T? { + guard let handle = libraryHandle, let pointer = dlsym(handle, name) else { + return nil + } + return unsafeBitCast(pointer, to: T.self) + } +} + +func IOReportCopyChannelsInGroup(_ group: CFString?, _ subGroup: CFString?, _ a: UInt64, _ b: UInt64, _ c: UInt64) -> Unmanaged? { + typealias Fn = @convention(c) (CFString?, CFString?, UInt64, UInt64, UInt64) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportCopyChannelsInGroup", as: Fn.self) else { return nil } + return fn(group, subGroup, a, b, c) +} + +func IOReportMergeChannels(_ a: CFDictionary, _ b: CFDictionary, _ c: CFTypeRef?) { + typealias Fn = @convention(c) (CFDictionary, CFDictionary, CFTypeRef?) -> Void + guard let fn: Fn = IOReportLoader.symbol("IOReportMergeChannels", as: Fn.self) else { return } + fn(a, b, c) +} + +func IOReportCreateSubscription(_ a: UnsafeMutableRawPointer?, _ b: CFMutableDictionary?, _ c: UnsafeMutablePointer?>?, _ d: UInt64, _ e: CFTypeRef?) -> IOReportSubscriptionRef? { + typealias Fn = @convention(c) (UnsafeMutableRawPointer?, CFMutableDictionary?, UnsafeMutablePointer?>?, UInt64, CFTypeRef?) -> IOReportSubscriptionRef? + guard let fn: Fn = IOReportLoader.symbol("IOReportCreateSubscription", as: Fn.self) else { return nil } + return fn(a, b, c, d, e) +} + +func IOReportCreateSamples(_ subscription: IOReportSubscriptionRef?, _ channels: CFMutableDictionary?, _ client: CFTypeRef?) -> Unmanaged? { + typealias Fn = @convention(c) (IOReportSubscriptionRef?, CFMutableDictionary?, CFTypeRef?) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportCreateSamples", as: Fn.self) else { return nil } + return fn(subscription, channels, client) +} + +func IOReportCreateSamplesDelta(_ previous: CFDictionary, _ current: CFDictionary, _ zone: CFTypeRef?) -> Unmanaged? { + typealias Fn = @convention(c) (CFDictionary, CFDictionary, CFTypeRef?) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportCreateSamplesDelta", as: Fn.self) else { return nil } + return fn(previous, current, zone) +} + +func IOReportChannelGetGroup(_ dictionary: CFDictionary) -> Unmanaged? { + typealias Fn = @convention(c) (CFDictionary) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetGroup", as: Fn.self) else { return nil } + return fn(dictionary) +} + +func IOReportChannelGetSubGroup(_ dictionary: CFDictionary) -> Unmanaged? { + typealias Fn = @convention(c) (CFDictionary) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetSubGroup", as: Fn.self) else { return nil } + return fn(dictionary) +} + +func IOReportChannelGetChannelName(_ dictionary: CFDictionary) -> Unmanaged? { + typealias Fn = @convention(c) (CFDictionary) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetChannelName", as: Fn.self) else { return nil } + return fn(dictionary) +} + +func IOReportChannelGetUnitLabel(_ dictionary: CFDictionary) -> Unmanaged? { + typealias Fn = @convention(c) (CFDictionary) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetUnitLabel", as: Fn.self) else { return nil } + return fn(dictionary) +} + +func IOReportStateGetCount(_ dictionary: CFDictionary) -> Int32 { + typealias Fn = @convention(c) (CFDictionary) -> Int32 + guard let fn: Fn = IOReportLoader.symbol("IOReportStateGetCount", as: Fn.self) else { return 0 } + return fn(dictionary) +} + +func IOReportStateGetNameForIndex(_ dictionary: CFDictionary, _ index: Int32) -> Unmanaged? { + typealias Fn = @convention(c) (CFDictionary, Int32) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportStateGetNameForIndex", as: Fn.self) else { return nil } + return fn(dictionary, index) +} + +func IOReportStateGetResidency(_ dictionary: CFDictionary, _ index: Int32) -> Int64 { + typealias Fn = @convention(c) (CFDictionary, Int32) -> Int64 + guard let fn: Fn = IOReportLoader.symbol("IOReportStateGetResidency", as: Fn.self) else { return 0 } + return fn(dictionary, index) +} + +func IOReportSimpleGetIntegerValue(_ dictionary: CFDictionary, _ index: Int32) -> Int64 { + typealias Fn = @convention(c) (CFDictionary, Int32) -> Int64 + guard let fn: Fn = IOReportLoader.symbol("IOReportSimpleGetIntegerValue", as: Fn.self) else { return 0 } + return fn(dictionary, index) +} diff --git a/DynamicIsland/utils/Logger.swift b/DynamicIsland/utils/Logger.swift index e95cdbad..d110d80e 100644 --- a/DynamicIsland/utils/Logger.swift +++ b/DynamicIsland/utils/Logger.swift @@ -1,4 +1,27 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Originally from boring.notch project + * Modified and adapted for Atoll (DynamicIsland) + * See NOTICE for details. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + import Foundation +import OSLog import SwiftUI enum LogCategory: String { @@ -11,9 +34,42 @@ enum LogCategory: String { case warning = "⚠️" case success = "✅" case debug = "🔍" + case extensions = "🧩" + + var osCategoryName: String { + switch self { + case .lifecycle: return "lifecycle" + case .memory: return "memory" + case .performance: return "performance" + case .ui: return "ui" + case .network: return "network" + case .error: return "error" + case .warning: return "warning" + case .success: return "success" + case .debug: return "debug" + case .extensions: return "extensions" + } + } } struct Logger { + private static let subsystem = "com.ebullioscopic.Atoll" + private static let dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + private static var osLoggerCache: [LogCategory: OSLog] = [:] + + private static func osLogger(for category: LogCategory) -> OSLog { + if let cached = osLoggerCache[category] { + return cached + } + let logger = OSLog(subsystem: subsystem, category: category.osCategoryName) + osLoggerCache[category] = logger + return logger + } + static func log( _ message: String, category: LogCategory, @@ -22,8 +78,14 @@ struct Logger { line: Int = #line ) { let fileName = (file as NSString).lastPathComponent - let timestamp = ISO8601DateFormatter().string(from: Date()) - print("\(category.rawValue) [\(timestamp)] [\(fileName):\(line)] \(function) - \(message)") + let timestamp = dateFormatter.string(from: Date()) + let entry = "\(category.rawValue) [\(timestamp)] [\(fileName):\(line)] \(function) - \(message)" + let logger = osLogger(for: category) + os_log("%{public}@", log: logger, type: .default, entry) + +#if DEBUG + Swift.print(entry) +#endif } static func trackMemory( diff --git a/DynamicIsland/utils/SMC.swift b/DynamicIsland/utils/SMC.swift new file mode 100644 index 00000000..e5ee3162 --- /dev/null +++ b/DynamicIsland/utils/SMC.swift @@ -0,0 +1,539 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import IOKit + +internal enum SMCDataType: String { + case UI8 = "ui8 " + case UI16 = "ui16" + case UI32 = "ui32" + case SP1E = "sp1e" + case SP3C = "sp3c" + case SP4B = "sp5b" + case SP5A = "sp5a" + case SPA5 = "spa5" + case SP69 = "sp669" + case SP78 = "sp78" + case SP87 = "sp87" + case SP96 = "sp96" + case SPB4 = "spb4" + case SPF0 = "spf0" + case FLT = "flt " + case FPE2 = "fpe2" + case FP2E = "fp2e" + case FDS = "{fds" +} + +internal enum SMCKeys: UInt8 { + case kernelIndex = 2 + case readBytes = 5 + case writeBytes = 6 + case readIndex = 8 + case readKeyInfo = 9 + case readPLimit = 11 + case readVers = 12 +} + +public enum FanMode: Int, Codable { + case automatic = 0 + case forced = 1 +} + +internal struct SMCKeyData_t { + typealias SMCBytes_t = (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8) + + struct vers_t { + var major: CUnsignedChar = 0 + var minor: CUnsignedChar = 0 + var build: CUnsignedChar = 0 + var reserved: CUnsignedChar = 0 + var release: CUnsignedShort = 0 + } + + struct LimitData_t { + var version: UInt16 = 0 + var length: UInt16 = 0 + var cpuPLimit: UInt32 = 0 + var gpuPLimit: UInt32 = 0 + var memPLimit: UInt32 = 0 + } + + struct keyInfo_t { + var dataSize: IOByteCount32 = 0 + var dataType: UInt32 = 0 + var dataAttributes: UInt8 = 0 + } + + var key: UInt32 = 0 + var vers = vers_t() + var pLimitData = LimitData_t() + var keyInfo = keyInfo_t() + var padding: UInt16 = 0 + var result: UInt8 = 0 + var status: UInt8 = 0 + var data8: UInt8 = 0 + var data32: UInt32 = 0 + var bytes: SMCBytes_t = (UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0)) +} + +internal struct SMCVal_t { + var key: String + var dataSize: UInt32 = 0 + var dataType: String = "" + var bytes: [UInt8] = Array(repeating: 0, count: 32) + + init(_ key: String) { + self.key = key + } +} + +extension FourCharCode { + init(fromString str: String) { + precondition(str.count == 4) + + self = str.utf8.reduce(0) { sum, character in + return sum << 8 | UInt32(character) + } + } + + func toString() -> String { + return String(describing: UnicodeScalar(self >> 24 & 0xff)!) + + String(describing: UnicodeScalar(self >> 16 & 0xff)!) + + String(describing: UnicodeScalar(self >> 8 & 0xff)!) + + String(describing: UnicodeScalar(self & 0xff)!) + } +} + +extension UInt16 { + init(bytes: (UInt8, UInt8)) { + self = UInt16(bytes.0) << 8 | UInt16(bytes.1) + } +} + +extension UInt32 { + init(bytes: (UInt8, UInt8, UInt8, UInt8)) { + self = UInt32(bytes.0) << 24 | UInt32(bytes.1) << 16 | UInt32(bytes.2) << 8 | UInt32(bytes.3) + } +} + +extension Int { + init(fromFPE2 bytes: (UInt8, UInt8)) { + self = (Int(bytes.0) << 6) + (Int(bytes.1) >> 2) + } +} + +extension Float { + init?(_ bytes: [UInt8]) { + self = bytes.withUnsafeBytes { + return $0.load(fromByteOffset: 0, as: Self.self) + } + } + + var bytes: [UInt8] { + withUnsafeBytes(of: self, Array.init) + } +} + +public class SMC { + public static let shared = SMC() + private var conn: io_connect_t = 0 + + public init() { + var result: kern_return_t + var iterator: io_iterator_t = 0 + let device: io_object_t + + let matchingDictionary: CFMutableDictionary = IOServiceMatching("AppleSMC") + result = IOServiceGetMatchingServices(kIOMasterPortDefault, matchingDictionary, &iterator) + if result != kIOReturnSuccess { + print("Error IOServiceGetMatchingServices(): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + + device = IOIteratorNext(iterator) + IOObjectRelease(iterator) + if device == 0 { + print("Error IOIteratorNext(): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + + result = IOServiceOpen(device, mach_task_self_, 0, &conn) + IOObjectRelease(device) + if result != kIOReturnSuccess { + print("Error IOServiceOpen(): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + } + + deinit { + let result = self.close() + if result != kIOReturnSuccess { + print("error close smc connection: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + } + } + + public func close() -> kern_return_t { + return IOServiceClose(conn) + } + + public func getValue(_ key: String) -> Double? { + var result: kern_return_t = 0 + var val: SMCVal_t = SMCVal_t(key) + + result = read(&val) + if result != kIOReturnSuccess { + print("Error read(\(key)): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return nil + } + + if val.dataSize > 0 { + if val.bytes.first(where: { $0 != 0 }) == nil && val.key != "FS! " && val.key != "F0Md" && val.key != "F1Md" { + return nil + } + + switch val.dataType { + case SMCDataType.UI8.rawValue: + return Double(val.bytes[0]) + case SMCDataType.UI16.rawValue: + return Double(UInt16(bytes: (val.bytes[0], val.bytes[1]))) + case SMCDataType.UI32.rawValue: + return Double(UInt32(bytes: (val.bytes[0], val.bytes[1], val.bytes[2], val.bytes[3]))) + case SMCDataType.SP1E.rawValue: + let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1])) + return Double(result / 16384) + case SMCDataType.SP3C.rawValue: + let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1])) + return Double(result / 4096) + case SMCDataType.SP4B.rawValue: + let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1])) + return Double(result / 2048) + case SMCDataType.SP5A.rawValue: + let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1])) + return Double(result / 1024) + case SMCDataType.SP69.rawValue: + let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1])) + return Double(result / 512) + case SMCDataType.SP78.rawValue: + let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1])) + return Double(intValue / 256) + case SMCDataType.SP87.rawValue: + let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1])) + return Double(intValue / 128) + case SMCDataType.SP96.rawValue: + let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1])) + return Double(intValue / 64) + case SMCDataType.SPA5.rawValue: + let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1])) + return Double(result / 32) + case SMCDataType.SPB4.rawValue: + let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1])) + return Double(intValue / 16) + case SMCDataType.SPF0.rawValue: + let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1])) + return intValue + case SMCDataType.FLT.rawValue: + let value: Float? = Float(val.bytes) + if value != nil { + return Double(value!) + } + return nil + case SMCDataType.FPE2.rawValue: + return Double(Int(fromFPE2: (val.bytes[0], val.bytes[1]))) + default: + return nil + } + } + + return nil + } + + public func getStringValue(_ key: String) -> String? { + var result: kern_return_t = 0 + var val: SMCVal_t = SMCVal_t(key) + + result = read(&val) + if result != kIOReturnSuccess { + print("Error read(): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return nil + } + + if val.dataSize > 0 { + if val.bytes.first(where: { $0 != 0}) == nil { + return nil + } + + switch val.dataType { + case SMCDataType.FDS.rawValue: + let c1 = String(UnicodeScalar(val.bytes[4])) + let c2 = String(UnicodeScalar(val.bytes[5])) + let c3 = String(UnicodeScalar(val.bytes[6])) + let c4 = String(UnicodeScalar(val.bytes[7])) + let c5 = String(UnicodeScalar(val.bytes[8])) + let c6 = String(UnicodeScalar(val.bytes[9])) + let c7 = String(UnicodeScalar(val.bytes[10])) + let c8 = String(UnicodeScalar(val.bytes[11])) + let c9 = String(UnicodeScalar(val.bytes[12])) + let c10 = String(UnicodeScalar(val.bytes[13])) + let c11 = String(UnicodeScalar(val.bytes[14])) + let c12 = String(UnicodeScalar(val.bytes[15])) + + return (c1 + c2 + c3 + c4 + c5 + c6 + c7 + c8 + c9 + c10 + c11 + c12).trimmingCharacters(in: .whitespaces) + default: + print("unsupported data type \(val.dataType) for key: \(key)") + return nil + } + } + + return nil + } + + public func getAllKeys() -> [String] { + var list: [String] = [] + + let keysNum: Double? = self.getValue("#KEY") + if keysNum == nil { + print("ERROR no keys count found") + return list + } + + var result: kern_return_t = 0 + var input: SMCKeyData_t = SMCKeyData_t() + var output: SMCKeyData_t = SMCKeyData_t() + + for i in 0...Int(keysNum!) { + input = SMCKeyData_t() + output = SMCKeyData_t() + + input.data8 = SMCKeys.readIndex.rawValue + input.data32 = UInt32(i) + + result = call(SMCKeys.kernelIndex.rawValue, input: &input, output: &output) + if result != kIOReturnSuccess { + continue + } + + list.append(output.key.toString()) + } + + return list + } + + public func write(_ key: String, _ newValue: Int) -> kern_return_t { + var value = SMCVal_t(key) + value.dataSize = 2 + value.bytes = [UInt8(newValue >> 6), UInt8((newValue << 2) ^ ((newValue >> 6) << 8)), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0)] + + return write(value) + } + + // MARK: - fans + + public func setFanMode(_ id: Int, mode: FanMode) { + if self.getValue("F\(id)Md") != nil { + var result: kern_return_t = 0 + var value = SMCVal_t("F\(id)Md") + + result = read(&value) + if result != kIOReturnSuccess { + print("Error read fan mode: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + + value.bytes = [UInt8(mode.rawValue), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0)] + + result = write(value) + if result != kIOReturnSuccess { + print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + } + + let fansMode = Int(self.getValue("FS! ") ?? 0) + var newMode: UInt8 = 0 + + if fansMode == 0 && id == 0 && mode == .forced { + newMode = 1 + } else if fansMode == 0 && id == 1 && mode == .forced { + newMode = 2 + } else if fansMode == 1 && id == 0 && mode == .automatic { + newMode = 0 + } else if fansMode == 1 && id == 1 && mode == .forced { + newMode = 3 + } else if fansMode == 2 && id == 1 && mode == .automatic { + newMode = 0 + } else if fansMode == 2 && id == 0 && mode == .forced { + newMode = 3 + } else if fansMode == 3 && id == 0 && mode == .automatic { + newMode = 2 + } else if fansMode == 3 && id == 1 && mode == .automatic { + newMode = 1 + } + + if fansMode == newMode { + return + } + + var result: kern_return_t = 0 + var value = SMCVal_t("FS! ") + + result = read(&value) + if result != kIOReturnSuccess { + print("Error read fan mode: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + + value.bytes = [0, newMode, UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0)] + + result = write(value) + if result != kIOReturnSuccess { + print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + } + + public func setFanSpeed(_ id: Int, speed: Int) { + let maxSpeed = Int(self.getValue("F\(id)Mx") ?? 4000) + + if speed > maxSpeed { + print("new fan speed (\(speed)) is more than maximum speed (\(maxSpeed))") + return + } + + var result: kern_return_t = 0 + var value = SMCVal_t("F\(id)Tg") + + result = read(&value) + if result != kIOReturnSuccess { + print("Error read fan value: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + + if value.dataType == "flt " { + let bytes = Float(speed).bytes + value.bytes[0] = bytes[0] + value.bytes[1] = bytes[1] + value.bytes[2] = bytes[2] + value.bytes[3] = bytes[3] + } else if value.dataType == "fpe2" { + value.bytes[0] = UInt8(speed >> 6) + value.bytes[1] = UInt8((speed << 2) ^ ((speed >> 6) << 8)) + value.bytes[2] = UInt8(0) + value.bytes[3] = UInt8(0) + } + + result = write(value) + if result != kIOReturnSuccess { + print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + } + + public func resetFans() { + var value = SMCVal_t("FS! ") + value.dataSize = 2 + + let result = write(value) + if result != kIOReturnSuccess { + print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + } + } + + // MARK: - internal functions + + private func read(_ value: UnsafeMutablePointer) -> kern_return_t { + var result: kern_return_t = 0 + var input = SMCKeyData_t() + var output = SMCKeyData_t() + + input.key = FourCharCode(fromString: value.pointee.key) + input.data8 = SMCKeys.readKeyInfo.rawValue + + result = call(SMCKeys.kernelIndex.rawValue, input: &input, output: &output) + if result != kIOReturnSuccess { + return result + } + + value.pointee.dataSize = UInt32(output.keyInfo.dataSize) + value.pointee.dataType = output.keyInfo.dataType.toString() + input.keyInfo.dataSize = output.keyInfo.dataSize + input.data8 = SMCKeys.readBytes.rawValue + + result = call(SMCKeys.kernelIndex.rawValue, input: &input, output: &output) + if result != kIOReturnSuccess { + return result + } + + memcpy(&value.pointee.bytes, &output.bytes, Int(value.pointee.dataSize)) + + return kIOReturnSuccess + } + + private func write(_ value: SMCVal_t) -> kern_return_t { + var input = SMCKeyData_t() + var output = SMCKeyData_t() + + input.key = FourCharCode(fromString: value.key) + input.data8 = SMCKeys.writeBytes.rawValue + input.keyInfo.dataSize = IOByteCount32(value.dataSize) + input.bytes = (value.bytes[0], value.bytes[1], value.bytes[2], value.bytes[3], value.bytes[4], value.bytes[5], + value.bytes[6], value.bytes[7], value.bytes[8], value.bytes[9], value.bytes[10], value.bytes[11], + value.bytes[12], value.bytes[13], value.bytes[14], value.bytes[15], value.bytes[16], value.bytes[17], + value.bytes[18], value.bytes[19], value.bytes[20], value.bytes[21], value.bytes[22], value.bytes[23], + value.bytes[24], value.bytes[25], value.bytes[26], value.bytes[27], value.bytes[28], value.bytes[29], + value.bytes[30], value.bytes[31]) + + let result = self.call(SMCKeys.kernelIndex.rawValue, input: &input, output: &output) + if result != kIOReturnSuccess { + return result + } + + return kIOReturnSuccess + } + + private func call(_ index: UInt8, input: inout SMCKeyData_t, output: inout SMCKeyData_t) -> kern_return_t { + let inputSize = MemoryLayout.stride + var outputSize = MemoryLayout.stride + + return IOConnectCallStructMethod(conn, UInt32(index), &input, inputSize, &output, &outputSize) + } +} diff --git a/DynamicIslandSamples/clipboardpanel.png b/DynamicIslandSamples/clipboardpanel.png new file mode 100644 index 00000000..09b3605c Binary files /dev/null and b/DynamicIslandSamples/clipboardpanel.png differ diff --git a/DynamicIslandSamples/clipboardpopover.png b/DynamicIslandSamples/clipboardpopover.png new file mode 100644 index 00000000..6477eb95 Binary files /dev/null and b/DynamicIslandSamples/clipboardpopover.png differ diff --git a/DynamicIslandSamples/colorpickerpanel.png b/DynamicIslandSamples/colorpickerpanel.png new file mode 100644 index 00000000..dc9adbc0 Binary files /dev/null and b/DynamicIslandSamples/colorpickerpanel.png differ diff --git a/DynamicIslandSamples/colorpickerpopover.png b/DynamicIslandSamples/colorpickerpopover.png new file mode 100644 index 00000000..b8dfccc8 Binary files /dev/null and b/DynamicIslandSamples/colorpickerpopover.png differ diff --git a/DynamicIslandSamples/dynamicisland-minimalistic.png b/DynamicIslandSamples/dynamicisland-minimalistic.png new file mode 100644 index 00000000..c5867a8c Binary files /dev/null and b/DynamicIslandSamples/dynamicisland-minimalistic.png differ diff --git a/DynamicIslandSamples/dynamicislandscreenrecord.gif b/DynamicIslandSamples/dynamicislandscreenrecord.gif new file mode 100644 index 00000000..7e13136e --- /dev/null +++ b/DynamicIslandSamples/dynamicislandscreenrecord.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a33f7162af40679fe5250196fb6e79d6ee9e08f3a1f69b9af113fb83d55f1939 +size 27231469 diff --git a/DynamicIslandSamples/logo.png b/DynamicIslandSamples/logo.png new file mode 100644 index 00000000..96031471 Binary files /dev/null and b/DynamicIslandSamples/logo.png differ diff --git a/DynamicIslandSamples/media.png b/DynamicIslandSamples/media.png new file mode 100644 index 00000000..14de26aa Binary files /dev/null and b/DynamicIslandSamples/media.png differ diff --git a/DynamicIslandSamples/statsmonitor.png b/DynamicIslandSamples/statsmonitor.png new file mode 100644 index 00000000..ad6deab4 Binary files /dev/null and b/DynamicIslandSamples/statsmonitor.png differ diff --git a/Frameworks/MediaRemoteAdapter.framework/MediaRemoteAdapter b/Frameworks/MediaRemoteAdapter.framework/MediaRemoteAdapter new file mode 120000 index 00000000..65b3513b --- /dev/null +++ b/Frameworks/MediaRemoteAdapter.framework/MediaRemoteAdapter @@ -0,0 +1 @@ +Versions/Current/MediaRemoteAdapter \ No newline at end of file diff --git a/Frameworks/MediaRemoteAdapter.framework/Resources b/Frameworks/MediaRemoteAdapter.framework/Resources new file mode 120000 index 00000000..953ee36f --- /dev/null +++ b/Frameworks/MediaRemoteAdapter.framework/Resources @@ -0,0 +1 @@ +Versions/Current/Resources \ No newline at end of file diff --git a/Frameworks/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter b/Frameworks/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter new file mode 100755 index 00000000..05db77de Binary files /dev/null and b/Frameworks/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter differ diff --git a/Frameworks/MediaRemoteAdapter.framework/Versions/A/Resources/Info.plist b/Frameworks/MediaRemoteAdapter.framework/Versions/A/Resources/Info.plist new file mode 100644 index 00000000..bf2b7b11 --- /dev/null +++ b/Frameworks/MediaRemoteAdapter.framework/Versions/A/Resources/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + MediaRemoteAdapter + CFBundleIconFile + + CFBundleIdentifier + com.vandenbe.MediaRemoteAdapter + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + MediaRemoteAdapter + CFBundlePackageType + FMWK + CFBundleSignature + ???? + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1 + CSResourcesFileMapped + + + diff --git a/Frameworks/MediaRemoteAdapter.framework/Versions/A/_CodeSignature/CodeResources b/Frameworks/MediaRemoteAdapter.framework/Versions/A/_CodeSignature/CodeResources new file mode 100644 index 00000000..16ea63b3 --- /dev/null +++ b/Frameworks/MediaRemoteAdapter.framework/Versions/A/_CodeSignature/CodeResources @@ -0,0 +1,128 @@ + + + + + files + + Resources/Info.plist + + M6AF1VWVJ1A/DSliCSjg170FqsY= + + + files2 + + Resources/Info.plist + + hash2 + + z3yWmTAqjdrPJEZUQ+t6AVPhw0e/I8PAiVr0HIU2ivg= + + + + rules + + ^Resources/ + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ + + nested + + weight + 10 + + ^.* + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^Resources/ + + weight + 20 + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^[^/]+$ + + nested + + weight + 10 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + + diff --git a/Frameworks/MediaRemoteAdapter.framework/Versions/Current b/Frameworks/MediaRemoteAdapter.framework/Versions/Current new file mode 120000 index 00000000..8c7e5a66 --- /dev/null +++ b/Frameworks/MediaRemoteAdapter.framework/Versions/Current @@ -0,0 +1 @@ +A \ No newline at end of file diff --git a/LICENSE b/LICENSE index 1b4affe2..f288702d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,674 @@ -MIT License + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 -Copyright (c) 2024 Hariharan Mudaliar + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + Preamble -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + The GNU General Public License is a free, copyleft license for +software and other kinds of works. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Localizable.xcstrings b/Localizable.xcstrings new file mode 100644 index 00000000..dd41f8c0 --- /dev/null +++ b/Localizable.xcstrings @@ -0,0 +1,92914 @@ +{ + "sourceLanguage": "en", + "strings": { + "": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "en-GB": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + } + } + }, + " – %lld": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": " – %lld" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": " – %lld" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " – %lld" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": " – %lld" + } + }, + "en-GB": { + "stringUnit": { + "state": "needs_review", + "value": " – %lld" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": " – %lld" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " – %lld" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": " – %lld" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": " – %lld" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": " – %lld" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": " – %lld" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": " – %lld" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " – %lld" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": " – %lld" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": " – %lld" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " – %lld" + } + } + } + }, + " %@": { + "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", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " %@" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": " %@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " %@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " %@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " %@" + } + } + } + }, + "-%@": { + "comment": "A label displaying the duration of the current song. The argument is the duration of the song in seconds.", + "extractionState": "manual", + "generatesSymbol": false, + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "-%@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "-%@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "-%@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "-%@" + } + } + }, + "shouldTranslate": false + }, + "—": { + "comment": "A placeholder text used when GPU usage is not available.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "—" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "—" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "—" + } + } + }, + "shouldTranslate": false + }, + "'Now Playing' was the only option on previous versions and works with all media apps.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "'Now Playing' was the only option on previous versions and works with all media apps." + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "\"Nyní hraje\" byla jediná možnost na předchozích verzích a funguje se všemi mediálními aplikacemi." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "\"Jetzt läuft\" war die einzige Option auf vorherigen Versionen und funktioniert mit allen Medien-Apps." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "'Now Playing' was the only option on previous versions and works with all media apps." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "'Now Playing' was the only option on previous versions and works with all media apps." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "'En reproducción' era la única opción en versiones anteriores y funciona con todas las aplicaciones multimedia." + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "«Ahora en reproducción» era la única opción en las versiones anteriores y funciona con todas las aplicaciones multimedia." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Aiemmissa versioissa ainoa vaihtoehto oli \"Now Playing\" (Nyt toistetaan), joka toimii kaikkien mediasovellusten kanssa." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "“À l’écoute” était la seule option dans les précédentes versions et fonctionne avec toutes les applications multimédias." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "'Now Playing' અગાઉની આવૃત્તિઓમાં એકમાત્ર વિકલ્પ હતું અને તે તમામ મીડિયા એપ્લિકેશન્સ સાથે કામ કરે છે." + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "'Now Playing' पिछले संस्करणों में एकमात्र विकल्प था और यह सभी मीडिया ऐप्स के साथ काम करता है।" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "'Now Playing' was the only option on previous versions and works with all media apps." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "'Now Playing' era l'unica opzione sulle versioni precedenti e funziona con tutte le app multimediali." + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "이전 버전에서는 ‘현재 재생 중' 만 선택할 수 있었으며, 모든 미디어 애플리케이션과 호환됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "'Teraz odtwarzane' było jedyną opcją w poprzednich wersjach i działa ze wszystkimi multimediami." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "'Tocando agora' era a única opção em versões anteriores e funciona em todos os aplicativos de mídia." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "«Сейчас играет» было единственным вариантом в предыдущих версиях и работает со всеми медиаприложениями." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "\"Spelar nu\" var det enda alternativet i tidigare versioner och fungerar med alla medieappar." + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "'Şimdi Oynatılıyor' önceki sürümlerdeki tek seçenekti ve tüm medya uygulamalarıyla çalışır." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "'Зараз грає' був єдиним варіантом на попередніх версіях і працює з усіма медіа застосунками." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "“当前播放”是以前版本中唯一的选项,并适用于所有媒体应用。" + } + } + } + }, + "(%@)": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "(%@)" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "(%@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "(%@)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "(%@)" + } + }, + "en-GB": { + "stringUnit": { + "state": "needs_review", + "value": "(%@)" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "(%@)" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "(%@)" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "(%@)" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "(%@)" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "(%@)" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "(%@)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "(%@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "(%@)" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "(%@)" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "(%@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "(%@)" + } + } + } + }, + "%.0f seconds": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "%.0f seconds" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "%.0f sekund" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%.0f Sekunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%.0f seconds" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "%.0f seconds" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "%.0f segundos" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%.0f secondes" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "%.0f seconds" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "%.0f secondi" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "%.0f 초" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "%.0f sekundy" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "%.0f segundos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%.0f сек." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "%.0f seconds" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "%.0f секунд" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "%.0f 秒" + } + } + } + }, + "%.1fs": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "%.1fs" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "%.1fs" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%.1fs" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%.1fs" + } + }, + "en-GB": { + "stringUnit": { + "state": "needs_review", + "value": "%.1fs" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "%.1fs" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%.1fs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "%.1fs" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "%.1fs" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "%.1fs" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "%.1fs" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "%.1fs" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%.1f сек." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "%.1fs" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "%.1fs" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%.1f秒" + } + } + } + }, + "%@": { + "extractionState": "manual", + "generatesSymbol": false, + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "%@" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "%@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@" + } + }, + "en-GB": { + "stringUnit": { + "state": "needs_review", + "value": "%@" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "%@" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "%@" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "%@" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "%@" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "%@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "%@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "%@" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "%@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "%@" + } + } + } + }, + "%@ · %@": { + "comment": "A secondary line of text that provides additional information about the air quality index, including its scale label and category name.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · %2$@" + } + }, + "en": { + "stringUnit": { + "state": "new", + "value": "%1$@ · %2$@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · %2$@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + } + } + }, + "%@ Models": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Model" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "%@ Models" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ modèles" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "%@ મોડેલ્સ" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 모델" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ модели" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 模型" + } + } + } + }, + "%@ Offset": { + "comment": "A control within the lock screen settings that allows the user to adjust the offset for a specific dimension (e.g. width or height). The argument is the name of the dimension, and the second argument is", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "%@ Posun" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Ausgleich" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "%@ Offset" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ Compenser" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ смещение" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 偏移" + } + } + } + }, + "%@ pt": { + "comment": "A text view displaying the current offset value for a specific lock screen control. The argument is the name of the control.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "%@ pt" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ пт" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ pt" + } + } + }, + "shouldTranslate": false + }, + "%@°": { + "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).", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "%@°" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@°" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@°" + } + } + }, + "shouldTranslate": false + }, + "%d%%": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "%d%%" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "%d%%" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d%%" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d%%" + } + }, + "en-GB": { + "stringUnit": { + "state": "needs_review", + "value": "%d%%" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "%d%%" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d%%" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "%d%%" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "%d%%" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "%d%%" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "%d%%" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "%d%%" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d%%" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "%d%%" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "%d%%" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "%d%%" + } + } + } + }, + "%lld": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "%lld" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld" + } + } + }, + "shouldTranslate": false + }, + "%lld animations": { + "comment": "A label displaying the number of custom animations available. The argument is the count of custom animations.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Animation" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "%lld animations" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%lld animations" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "%lld એનિમેશન્સ" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld 애니메이션" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld анимаций" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 个动画" + } + } + } + }, + "%lld colors": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Farben" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "%lld colors" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%lld couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "%lld રંગો" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld цв." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 种颜色" + } + } + } + }, + "%lld min": { + "comment": "A text label displaying the current value of the \"Notify before\" slider. The argument is the current value of the slider, expressed in minutes.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Minuten" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "%lld min" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%lld min" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld мин" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 分钟" + } + } + } + }, + "%lld px": { + "comment": "A label displaying the current expanded notch width value. The value is shown in pixels.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld px" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "%lld px" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld пикс." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 像素" + } + } + } + }, + "%lld s": { + "comment": "A text view displaying the duration of the \"sneak peek\" feature in the reminder live activity section of the calendar settings view. The text is followed by a unit of measurement (\"s\").", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "%lld s" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld с" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lld 秒" + } + } + }, + "shouldTranslate": false + }, + "%lld%%": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "%lld%%" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "%lld%%" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld%%" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld%%" + } + }, + "en-GB": { + "stringUnit": { + "state": "needs_review", + "value": "%lld%%" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "%lld%%" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%lld%%" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "%lld%%" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "%lld%%" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "%lld%%" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "%lld%%" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "%lld%%" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld%%" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "%lld%%" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "%lld%%" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "%lld%%" + } + } + } + }, + "%lldx": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%lldx" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "%lldx" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%lld fois" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lldx" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%lldx" + } + } + } + }, + "•": { + "comment": "A period used to separate two related items.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "•" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "•" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "•" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "•" + } + } + }, + "shouldTranslate": false + }, + "• Bug fixes": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "• Bug fixes" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "• Opravy chyb" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "• Fehlerbehebungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "• Bug fixes" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "• Bug fixes" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "• Corrección de errores" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "• Corrections de bugs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "• बग फिक्स" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "• Bug fixes" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "• Correzione bug" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "수정된 버그들" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Poprawki błędów" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Correções de bugs" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "• Исправления ошибок" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "• Bug fixes" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "• Виправлення помилок" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "• Bug 修复" + } + } + } + }, + "• Frosted Glass: Translucent blur effect": { + "comment": "A bullet point in the \"Appearance\" section of the Custom OSD settings, describing a glass material option.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "• Milchglas: Durchscheinender Unschärfeeffekt" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "• Frosted Glass: Translucent blur effect" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "• Himmeä lasi: läpikuultava hämärtyvä vaikutus" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "• Verre dépoli : effet de flou translucide" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "• ફ્રોસ્ટેડ ગ્લાસ: અર્ધપારદર્શક ધૂંધળું અસર" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "• फ्रॉस्टेड ग्लास: पारभासी धुंधला प्रभाव" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "• 프로스트 유리: 반투명 흐림 효과" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "• Матовое стекло: Эффект полупрозрачного размытия" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "• Frostat glas: Genomskinlig oskärpaeffekt" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "• Buzlu Cam: Yarı saydam bulanıklık efekti" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "• 磨砂玻璃:半透明模糊效果" + } + } + } + }, + "• Improved performance": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "• Improved performance" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "• Zlepšený výkon" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "• Verbesserte Leistung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "• Improved performance" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "• Improved performance" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "• Rendimiento mejorado" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "• Amélioration des performances" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "• बेहतर प्रदर्शन" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "• Improved performance" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "• Prestazioni migliorate" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "• 향상된 성능" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "• Forbedret ytelse" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Poprawiona wydajność" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Desempenho melhorado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "• Улучшена производительность" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "• Förbättrad prestanda" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "• Improved performance" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "• Покращено продуктивність" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "• 提升性能" + } + } + } + }, + "• Liquid Glass: Modern glass effect (macOS 26+)": { + "comment": "A description of the \"Liquid Glass\" option in the Custom OSD settings.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "• Tekuté sklo: Moderní skleněný efekt (macOS 26+)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "• Moderner Glaseffekt (macOS 26+)" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "• Liquid Glass: Modern glass effect (macOS 26+)" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "• Vidrio líquido: efecto de vidrio moderno (macOS 26+)" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "• Nestemäinen lasi: Moderni lasiefekti (macOS 26+)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "• Verre liquide : effet de verre moderne (macOS 26+)" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "• લિક્વિડ ગ્લાસ: આધુનિક ગ્લાસ અસર (macOS 26+)" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "• लिक्विड ग्लास: आधुनिक ग्लास प्रभाव (macOS 26+)" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "• 액체 유리: 현대적인 유리 효과 (macOS 26+)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "• Flytende glass: Moderne glasseffekt (macOS 26+)" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "• Liquid Glass: Современный эффект стекла (macOS 26+)" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "• Flytande glas: Modern glaseffekt (macOS 26+)" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "• Sıvı Cam: Modern cam efekti (macOS 26+)" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "• 液态玻璃:现代玻璃效果(macOS 26+)" + } + } + } + }, + "• New feature 1": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "• New feature 1" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "• Nová funkce 1" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "• Neue Funktion 1" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "• New feature 1" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "• New feature 1" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "• Nueva funcionalidad 1" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvelle fonctionnalité 1" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "• નવી વિશેષતા 1" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "• नई सुविधा 1" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "• New feature 1" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "• Nuova funzionalità 1" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "• 새 기능 1" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Nowa funkcja 1" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Novo recurso 1" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "• Новая функция 1" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "• New feature 1" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "• Нова функція 1" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "• 新功能1" + } + } + } + }, + "• Solid Dark/Light/Auto: Opaque backgrounds": { + "comment": "A bullet point in the \"Appearance\" section of the Custom OSD settings, describing the appearance of the OSD when using solid colors.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "• Solide Dunkel/Hell/Auto: Undurchsichtige Hintergründe" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "• Solid Dark/Light/Auto: Opaque backgrounds" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "• Oscuro/Claro/Automático sólido: fondos opacos." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "• Kiinteä tumma/vaalea/automaattinen: läpinäkymättömät taustat" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "• Solid Dark/Light/Auto : arrière-plans opaques" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "• સોલિડ ડાર્ક/લાઇટ/ઓટો: અપારદર્શક પૃષ્ઠભૂમિઓ" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "• सॉलिड डार्क/लाइट/ऑटो: अपारदर्शी पृष्ठभूमि" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "• 솔리드 다크/라이트/오토: 불투명한 배경" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "• Solid mørk/lys/auto: Ugjennomsiktig bakgrunn" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "• Сплошной Темный/Светлый/Авто: Непрозрачный фон" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "• Solid mörk/ljus/auto: Ogenomskinlig bakgrund" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "• Düz Koyu/Açık/Otomatik: Opak arka planlar" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "• 纯色深色/浅色/自动:不透明背景" + } + } + } + }, + "• Thinking": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "• Denken" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "• Thinking" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "• Pensar" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "• Réflexion" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "• 사고" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "• Размышление" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "• 思考中" + } + } + } + }, + "• Updated %@": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "• Aktualisiert %@" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "• Updated %@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "• Actualizado %@" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "• Mis à jour %@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "• 업데이트됨 %@" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "• Обновлено %@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "• 更新于 %@" + } + } + } + }, + "••••••••": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "••••••••" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "••••••••" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "••••••••" + } + } + }, + "shouldTranslate": false + }, + "+%lld more": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "+%lld mehr" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "+%lld more" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "+%lld en plus" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "еще +%lld" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "+%lld 更多" + } + } + } + }, + "⚠️ WARNING: System HUD may not be working properly!": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "⚠️ WARNUNG: Das System-HUD funktioniert möglicherweise nicht richtig!" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "⚠️ VAROITUS: Järjestelmän HUD ei välttämättä toimi oikein!" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "⚠️ ચેતવણી: સિસ્ટમ HUD યોગ્ય રીતે કામ કરી રહી નથી!" + } + }, + "ru": { + "stringUnit": { + "value": "⚠️ ВНИМАНИЕ: HUD системы может работать неправильно!", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "⚠️ 警告:系统 HUD 可能无法正常工作!" + } + } + } + }, + "⚠️ Error:": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "⚠️ Fehler:" + } + }, + "ru": { + "stringUnit": { + "value": "⚠️ Ошибка:", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "⚠️ 错误:" + } + } + } + }, + "🔋 Max capacity": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "🔋 Maximale Kapazität" + } + }, + "ru": { + "stringUnit": { + "value": "🔋 Макс. емкость", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "🔋 最大容量" + } + } + } + }, + "🕒 Time to full charge:": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "🕒 Zeit bis zur vollständigen Aufladung:" + } + }, + "ru": { + "stringUnit": { + "value": "🕒 Время до полной зарядки:", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "🕒 充满所需时间:" + } + } + } + }, + "0": { + "comment": "A label displayed in a circular gauge to indicate the minimum battery level.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "0" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "0" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "0" + } + } + }, + "shouldTranslate": false + }, + "00:05:00": { + "comment": "A time displayed in the timer panel on a lock screen.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "00:05:00" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "00:05:00" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "00:05:00" + } + } + }, + "shouldTranslate": false + }, + "1x": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "1x" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "1x" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "1x" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "1x" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1x" + } + } + } + }, + "3 items": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "3 Artikel" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "3 items" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "3 éléments" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "3 шт." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "3 个项目" + } + } + } + }, + "5 colors": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "5 Farben" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "5 colors" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "5 couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "5 цв." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "5 种颜色" + } + } + } + }, + "5 items": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "5 Artikel" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "5 items" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "5 éléments" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "5 шт." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "5 个项目" + } + } + } + }, + "7 items": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "7 Artikel" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "7 items" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "7 éléments" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "7 шт." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "7 个项目" + } + } + } + }, + "10 colors": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "10 Farben" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "10 colors" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "10 couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "10 цв." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "10 种颜色" + } + } + } + }, + "10 items": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "10 Artikel" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "10 items" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "10 éléments" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "10 шт." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "10 个项目" + } + } + } + }, + "15 colors": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "15 Farben" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "15 colors" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "15 couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "15 цв." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "15 种颜色" + } + } + } + }, + "20": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "20" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "20" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "20" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "20" + } + } + }, + "shouldTranslate": false + }, + "20 colors": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "20 Farben" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "20 colors" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "20 couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "20 цв." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "20 种颜色" + } + } + } + }, + "30": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "30" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "30" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "30" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "30" + } + } + }, + "shouldTranslate": false + }, + "100": { + "comment": "The text displayed below a 100% battery level in the lock screen.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "100" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "100" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "100" + } + } + }, + "shouldTranslate": false + }, + "100%": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "100%" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "100%" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "100%" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "100%" + } + } + }, + "shouldTranslate": false + }, + "About": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "About" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "O aplikaci" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Über" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "About" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "About" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Acerca de" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "À propos" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "About" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Info" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "설명" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "O programie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Sobre" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "О программе" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Hakkında" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Про додаток" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "关于" + } + } + } + }, + "Accent color": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Accent color" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Barva odstínu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Akzentfarbe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Accent color" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Accent colour" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Color de énfasis" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur d'accentuation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Accent color" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Colore in rilievo" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "강조 색상" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Kolor akcentu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Cor de destaque" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цилиндр акцента" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Accent color" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Колір відтінку" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "首选颜色" + } + } + } + }, + "Accent colour": { + "comment": "A label describing the colour picker for the timer preset.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Akzentfarbe" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Accent colour" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur d'accent" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвет акцента" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点缀色" + } + } + } + }, + "Access Denied": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Access Denied" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Přístup zamítnut" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zugriff verweigert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Access Denied" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Access Denied" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Acceso denegado" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "Acceso denegado" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accès non autorisé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Access Denied" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Accesso Negato" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "권한 거부됨" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Odmowa dostępu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Acesso Negado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Доступ запрещен" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Erişim Reddedildi" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Доступ заборонено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "被拒绝访问" + } + } + } + }, + "Accessibility": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufgeschlossenheit" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Accessibility" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accessibilité" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Универсальный доступ" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "辅助功能" + } + } + } + }, + "Accessibility permission required": { + "comment": "A label explaining that accessibility permissions are required.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يلزم الحصول على إذن الوصول" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zugriffsberechtigung erforderlich" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Accessibility permission required" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "Se requiere permiso de accesibilidad" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autorisation d'accessibilité requise" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "પહોંચની મંજૂરી જરૂરી છે" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "पहुँच की अनुमति आवश्यक है" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "접근 권한이 필요합니다" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуется разрешение на универсальный доступ" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Tillgänglighetstillstånd krävs" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Erişilebilirlik izni gerekli" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要访问权限" + } + } + } + }, + "Actions": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktion" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Actions" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actions" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Действия" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "操作" + } + } + } + }, + "Activate": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktiviert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Activate" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Активировать" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "激活" + } + } + } + }, + "Activate your license": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren Sie Ihre Lizenz" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Activate your license" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer votre license" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Активируйте вашу лицензию" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "激活您的许可证" + } + } + } + }, + "Activation": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivierung " + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Activation" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Активация" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "激活" + } + } + } + }, + "Active": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktiv" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Active" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activité" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Активно" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "活跃" + } + } + } + }, + "Active - No Focus": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktiv - kein Fokus" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Active - No Focus" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actif - Pas de concentration" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Активно — без фокусирования" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "活跃 - 无专注模式" + } + } + } + }, + "Active - No Recording": { + "comment": "A description of the recording detection feature, indicating that recording is not currently active.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktiv - Keine Aufnahme" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Active - No Recording" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actif - Aucun enregistrement" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "सक्रिय - कोई रिकॉर्डिंग नहीं" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Активно — без записи" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "活跃 - 未录制" + } + } + } + }, + "Add": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Add" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Přidat" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Añadir" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "ઉમેરો" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Add" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Aggiungi" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "추가" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Dodaj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Adicionar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Ekle" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Додати" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "添加" + } + } + } + }, + "Add Animation from URL": { + "comment": "A title for the sheet that allows users to add animations from a URL.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Animation von URL hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Add Animation from URL" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter une animation depuis une URL" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "URLમાંથી એનિમેશન ઉમેરો" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить анимацию по URL" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从 URL 添加动画" + } + } + } + }, + "Add files": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datei hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Add files" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter des fichiers" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить файлы" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加文件" + } + } + } + }, + "Add from URL...": { + "comment": "A button label that prompts the user to add a Lottie animation from a URL.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Von URL hinzufügen..." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Add from URL..." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter depuis une URL…" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "URLમાંથી ઉમેરો..." + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить по URL..." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从 URL 添加..." + } + } + } + }, + "Add manually": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Add manually" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Přidat manuálně" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Manuell hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add manually" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Add manually" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Añadir manualmente" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter manuellement" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Add manually" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Aggiungi manualmente" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "수동 추가" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Dodaj ręcznie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Adicionar manualmente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить вручную" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Manuel ekle" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Додати вручну" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "手动添加" + } + } + } + }, + "Add New": { + "comment": "A label displayed below the button to add a new idle animation.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Add New" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить новый" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新增" + } + } + } + }, + "Add new visualizer": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Add new visualizer" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Přidat nový vizualizér" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuen Visualisierer hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add new visualizer" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Add new visualiser" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Añadir nuevo visualizador" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter un nouveau visualiseur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Add new visualizer" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Aggiungi nuovo visualizzatore" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "새 시각화 도구 추가" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Dodaj nowy wizualizer" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Adicionar novo visualizador" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить новый визуализатор" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yeni görselleştirici ekle" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Додати новий візуалізатор" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "添加新的可视化器" + } + } + } + }, + "Add Preset": { + "comment": "A button label that adds a new timer preset.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Voreinstellung hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Add Preset" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter un préréglage" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить пресет" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加预设" + } + } + } + }, + "Additional features": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Additional features" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Další funkce" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zusätzliche Funktionen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Additional features" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Additional features" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Funcionalidades adicionales" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fonctionnalités supplémentaires" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Additional features" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Funzionalità aggiuntive" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "추가 기능들" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Dodatkowe funkcje" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Recursos adicionais" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дополнительные функции" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Ek özellikler" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Додаткові можливості" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "其他功能" + } + } + } + }, + "Adjust settings above to see changes in real-time. The actual OSD appears at the bottom center of your screen.": { + "comment": "A footer in the \"Preview\" section of the Custom OSD settings, explaining how to use the live preview feature.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Upravte výše uvedená nastavení a změny se projeví v reálném čase. Skutečné OSD se zobrazí ve spodní části uprostřed obrazovky." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passen Sie die obigen Einstellungen an, um die Änderungen in Echtzeit zu sehen. Die tatsächliche OSD wird in der unteren Mitte Ihres Bildschirms angezeigt." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Adjust settings above to see changes in real-time. The actual OSD appears at the bottom center of your screen." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "Ajuste la configuración anterior para ver los cambios en tiempo real. El menú en pantalla real aparece en la parte inferior central de la pantalla." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Säädä yllä olevia asetuksia nähdäksesi muutokset reaaliajassa. Todellinen OSD näkyy näytön alareunassa keskellä." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajustez les paramètres ci-dessus pour voir les modifications en temps réel. L'OSD réel apparaît en bas au centre de votre écran." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "વાસ્તવિક સમયે ફેરફારો જોવા માટે ઉપરોક્ત સેટિંગ્સ સમાયોજિત કરો. વાસ્તવિક OSD તમારી સ્ક્રીનના નીચે મધ્યમાં દેખાય છે." + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "वास्तविक समय में परिवर्तन देखने के लिए उपरोक्त सेटिंग्स समायोजित करें। वास्तविक OSD आपकी स्क्रीन के निचले केंद्र में दिखाई देता है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "위 설정을 조정하면 실시간으로 변경 사항을 확인할 수 있습니다. 실제 OSD는 화면 하단 중앙에 표시됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Juster innstillingene ovenfor for å se endringene i sanntid. Den faktiske OSD-menyen vises nederst i midten av skjermen." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройте параметры выше. OSD появится внизу по центру." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Justera inställningarna ovan för att se ändringarna i realtid. Den faktiska OSD-menyn visas längst ned i mitten av skärmen." + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Değişiklikleri gerçek zamanlı olarak görmek için yukarıdaki ayarları ayarlayın. Gerçek OSD, ekranınızın alt ortasında görünür." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "调整上方设置即可实时查看变化。实际屏幕显示菜单(OSD)位于屏幕底部中央位置。" + } + } + } + }, + "AI Assistant": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "KI Assistent" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "AI Assistant" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Assistant IA" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ИИ-помощник" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AI 助手" + } + } + } + }, + "AI is thinking...": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "KI denkt..." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "AI is thinking..." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’IA rélfléchis" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ИИ думает..." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AI 正在思考..." + } + } + } + }, + "AI Model Selection": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "KI-Modellauswahl" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "AI Model Selection" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sélection du modèle d’IA" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인공지능 모델 선정" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбор ИИ-модели" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AI 模型选择" + } + } + } + }, + "AI Provider": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "KI-Anbieter" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "AI Provider" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "Proveedor de IA" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fournisseur des modèles" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Провайдер ИИ" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AI 服务商" + } + } + } + }, + "AI-powered assistant that can analyze files, images, and provide conversational help. Use Cmd+Shift+A to quickly access the assistant.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "KI-gestützter Assistent, der Dateien und Bilder analysieren und Ihnen im Dialog weiterhelfen kann. Mit Cmd+Shift+A können Sie schnell auf den Assistenten zugreifen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "AI-powered assistant that can analyze files, images, and provide conversational help. Use Cmd+Shift+A to quickly access the assistant." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "Asistente con tecnología de inteligencia artificial que puede analizar archivos e imágenes, y proporcionar ayuda conversacional. Utilice Cmd+Shift+A para acceder rápidamente al asistente." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Tiedostoja ja kuvia analysoiva tekoälyavustaja, joka tarjoaa keskusteluapua. Käytä Cmd+Shift+A-näppäinyhdistelmää avustajan nopeaan avaamiseen." + } + }, + "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." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "AI-સંચાલિત સહાયક જે ફાઇલો અને છબીઓનું વિશ્લેષણ કરી શકે છે અને સંવાદાત્મક મદદ પ્રદાન કરી શકે છે. સહાયકને ઝડપથી ઍક્સેસ કરવા માટે Cmd+Shift+A નો ઉપયોગ કરો." + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "एआई-संचालित सहायक जो फ़ाइलों, छवियों का विश्लेषण कर सकता है और संवादात्मक सहायता प्रदान कर सकता है। सहायक तक तुरंत पहुँचने के लिए Cmd+Shift+A का उपयोग करें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파일과 이미지를 분석하고 대화형 지원을 제공하는 AI 기반 어시스턴트입니다. Cmd+Shift+A를 눌러 어시스턴트에 빠르게 접근하세요." + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ИИ-помощник для анализа файлов, изображений и ведения диалога. Нажмите Cmd+Shift+A для быстрого доступа." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "AI-driven assistent som kan analysera filer, bilder och ge hjälp i samtal. Använd Cmd+Shift+A för att snabbt komma åt assistenten." + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dosyaları, görüntüleri analiz edebilen ve konuşma yardımı sağlayabilen yapay zeka destekli asistan. Asistana hızlı bir şekilde erişmek için Cmd+Shift+A tuşlarını kullanın." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "人工智能驱动的助手,能够分析文件和图像,并提供对话式帮助。使用 Cmd+Shift+A 快速访问该助手。" + } + } + } + }, + "Air quality index %d, %@": { + "comment": "Air quality accessibility label", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Luftqualitätsindex %1$d, %2$@" + } + }, + "en": { + "stringUnit": { + "state": "new", + "value": "Air quality index %1$d, %2$@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "Índice de calidad del aire %1$d, %2$@" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Indice de qualité de l'air %1$d, %2$@" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "વાયુ ગુણવત્તા સૂચકાંક %1$d, %2$@" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Индекс качества воздуха %1$d, %2$@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "空气质量指数 %1$d, %2$@" + } + } + } + }, + "Air quality requires the Open Meteo provider.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Luftqualität erfordert den Anbieter von Open Meteo." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Air quality requires the Open Meteo provider." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "La calidad del aire requiere el proveedor Open Meteo." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La qualité de l'air nécessite le fournisseur Open Meteo." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "વાયુની ગુણવત્તા માટે Open Meteo પ્રદાતા જરૂરી છે." + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для данных о качестве воздуха требуется провайдер Open Meteo." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "空气质量需要Open Meteo服务提供商。" + } + } + } + }, + "Air quality scale": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Luftqualitätsskala" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Air quality scale" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "Escala de calidad del aire" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échelle de qualité de l'air" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Шкала качества воздуха" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "空气质量量表" + } + } + } + }, + "AirDrop": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "AirDrop" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "AirDrop" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "AirDrop" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "AirDrop" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "AirDrop" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AirDrop" + } + } + } + }, + "AirDrop service not available": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop service not available" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Služba AirDrop není dostupná" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "AirDrop Funktionen sind nicht verfügbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "AirDrop service not available" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "AirDrop service not available" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Servicio AirDrop no disponible" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Service AirDrop indisponible" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop service not available" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Servizio AirDrop non disponibile" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop 서비스 불가" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Usługa AirDrop nie jest dostępna" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop não disponível" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Служба AirDrop недоступна" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop hizmetine kullanılamıyor" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Служба AirDrop недоступна" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "AirDrop 服务不可用" + } + } + } + }, + "All-day": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "All-day" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Celý den" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ganztägig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All-day" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "All-day" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Todo el día" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Toute la journée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "All-day" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Tutto Il Giorno" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "하루 종일" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Całodniowy" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Dia inteiro" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Весь день" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Gün boyu" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Увесь день" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "全天" + } + } + } + }, + "Allow Access": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Allow Access" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Povolit přístup" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zugriff erlauben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow Access" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Allow Access" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Permitir acceso" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autoriser l'accès" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Allow Access" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Consenti Accesso" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "권한 허용" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zezwól na dostęp" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Permitir acesso" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешить доступ" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Erişime izin ver" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Надати доступ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "允许访问" + } + } + } + }, + "ALPHA": { + "comment": "A label indicating a feature that is still in development.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "ALPHA" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "АЛЬФА" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测试版" + } + } + }, + "shouldTranslate": false + }, + "Alpha Feature Warning": { + "comment": "The title of an alert that warns users about an alpha feature.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alpha-Funktionswarnung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Alpha Feature Warning" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Avertissement concernant la fonctionnalité Alpha" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внимание: альфа-функция" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测试版功能警告" + } + } + } + }, + "Always hide in fullscreen": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Always hide in fullscreen" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Vždy skrýt v režimu celé obrazovky" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Immer verstecken im Vollbildmodus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Always hide in fullscreen" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Always hide in full-screen" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Ocultar siempre en pantalla completa" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Toujours cacher en plein écran" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Always hide in fullscreen" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Nascondi sempre a schermo intero" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "전체 화면에서 항상 숨기기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zawsze ukrywaj podczas pełnego ekranu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Sempre esconder em tela cheia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Всегда скрывать в полноэкранном режиме" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Tam ekranda daima gizle" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Завжди приховувати в повноекранному режимі" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "总是隐藏在全屏" + } + } + } + }, + "Always show tabs": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Always show tabs" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Vždy zobrazovat panely" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Immer Tabs anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Always show tabs" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Always show tabs" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Mostrar siempre las pestañas" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Toujours afficher les onglets" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Always show tabs" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Mostra sempre schede" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "항상 탭 보이기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zawsze pokazuj zakładki" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Permitir mostrar guias" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Всегда показывать вкладки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Sekmeleri daima göster" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Завжди показувати вкладки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "总是显示标签页" + } + } + } + }, + "Amazon Music": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Amazon Music" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Amazon Music" + } + } + } + }, + "Animation": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Animation" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Animation" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Animation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Анимация" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "动画" + } + } + } + }, + "Animation Name": { + "comment": "A label for the name input field in the URL import sheet.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Animationsname" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Animation Name" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom de l'animation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Название анимации" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "动画名称" + } + } + } + }, + "API Configuration": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "API-Konfiguration" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "API Configuration" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Configuration de l'API" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройка API" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "API 配置" + } + } + } + }, + "API Key Required": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "API-Schlüssel erforderlich" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "API Key Required" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clé d’API nécessaire" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуется ключ API" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要 API 密钥" + } + } + } + }, + "App icon": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "App icon" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Ikona aplikace" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App Icon" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "App icon" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "App icon" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Icono de la aplicación" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Icône d'application" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "App icon" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Icona app" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "앱 아이콘" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Ikona aplikacji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Ícone do App" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Иконка приложения" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Uygulama simgesi" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Значок додатка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "应用图标" + } + } + } + }, + "Appearance": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Appearance" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Vzhled" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Darstellung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Apariencia" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Apparence" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Appearance" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Aspetto" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "외관" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wygląd" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Aparência" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внешний вид" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Belirme" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Вигляд" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "外观" + } + } + } + }, + "apperance": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aussehen" + } + }, + "ru": { + "stringUnit": { + "value": "внешний вид", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "外观" + } + } + } + }, + "AQI": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "ru": { + "stringUnit": { + "value": "AQI", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "空气质量指数" + } + } + } + }, + "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": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sind Sie sicher, dass Sie %@löschen möchten? " + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Are you sure you want to delete \"%@\"?" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Voulez-vous vraiment supprimer \"%@\" ?" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "क्या आप \"%@\" को हटाना चाहते हैं?" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Er du sikker på at du vil slette «%@»?" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вы уверены, что хотите удалить «%@»?" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "您确定要删除“%@”吗?" + } + } + } + }, + "Are you sure you want to delete this color from your history?": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sind Sie sicher, dass Sie diese Farbe aus Ihrem Verlauf löschen möchten?" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Are you sure you want to delete this color from your history?" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Estás seguro de que quieres eliminar este color de tu historial?" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "¿Estás seguro de que quieres eliminar este color de tu historial?" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Voulez-vous vraiment supprimer cette couleur de votre historique ?" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "શું તમે ખરેખર તમારી ઇતિહાસમાંથી આ રંગ કાઢી નાખવા માંગો છો?" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "क्या आप निश्चित हैं कि आप अपने इतिहास से इस रंग को हटाना चाहते हैं?" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 색상을 기록에서 삭제하시겠습니까?" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить этот цвет из истории?" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "您确定要从历史记录中删除此颜色吗?" + } + } + } + }, + "Ask me anything...": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Frag mich alles..." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Ask me anything..." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Demandez-moi ce que vous voulez…" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Спросите меня о чем-либо..." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "随便问我..." + } + } + } + }, + "Atoll": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Atoll" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Atoll" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Atoll" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Atoll" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Atoll" + } + } + } + }, + "Atoll needs access to save and read AI assistant files.": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Atoll benötigt Zugriff, um AI-Assistent-Dateien zu speichern und zu lesen." + } + }, + "ru": { + "stringUnit": { + "value": "Atoll нужен доступ для сохранения ИИ-файлов.", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Atoll 需要权限来保存和读取 AI 助手文件。" + } + } + } + }, + "Atoll needs access to save screenshots to your desktop.": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Atoll benötigt Zugriff, um Screenshots auf Ihrem Desktop zu speichern." + } + }, + "ru": { + "stringUnit": { + "value": "Atoll нужен доступ для сохранения снимков экрана.", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Atoll 需要权限将截图保存到桌面。" + } + } + } + }, + "Atoll needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Atoll benötigt Bluetooth-Zugriff, um zu erkennen, wenn Audiogeräte verbunden werden, und deren Akkustatus im HUD anzuzeigen." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "એટોલને બ્લૂટૂથ ઍક્સેસની જરૂર છે જેથી તે ઓડિયો ઉપકરણો જોડાય ત્યારે તેને શોધી શકે અને HUDમાં તેમની બેટરી સ્થિતિ દર્શાવી શકે." + } + }, + "ru": { + "stringUnit": { + "value": "Для отображения батареи аудиоустройств нужен доступ к Bluetooth.", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Atoll 需要蓝牙访问权限来检测音频设备连接并在 HUD 中显示其电池状态。" + } + } + } + }, + "Atoll uses AppleScripts to control Spotify and Apple Music": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Atoll verwendet AppleScripts zur Steuerung von Spotify und Apple Music." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "એટોલ Spotify અને Apple Music ને નિયંત્રિત કરવા માટે AppleScripts નો ઉપયોગ કરે છે." + } + }, + "ru": { + "stringUnit": { + "value": "Atoll использует AppleScript для управления Spotify и Apple Music", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Atoll 使用 AppleScript 控制 Spotify 和 Apple Music" + } + } + } + }, + "Attached Files": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Angehängte Dateien" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Attached Files" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fichiers joints" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вложенные файлы" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "附件" + } + } + } + }, + "Auto-scroll to next event": { + "comment": "A toggle that enables or disables the automatic scrolling to the next event in the calendar view.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisches Scrollen zum nächsten Ereignis" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Auto-scroll to next event" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Défilement automatique vers l'événement suivant" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переходить к следующему событию" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动滚动到下一个事件" + } + } + } + }, + "Automatically check for updates": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Automatically check for updates" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Automaticky kontrolovat aktualizace" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch nach Updates suchen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatically check for updates" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Automatically check for updates" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Comprobar actualizaciones automáticamente" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "Buscar actualizaciones automáticamente" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifier automatiquement les mises à jour" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "स्वचालित रूप से अपडेट की जाँच करें" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Automatically check for updates" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Controlla automaticamente gli aggiornamenti" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "자동으로 업데이트 확인" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Automatycznie sprawdzaj aktualizacje" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Procurar por atualizações automaticamente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматически проверять обновления" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Güncellemeleri otomatik kontrol et" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Автоматично перевіряти наявність оновлень" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "自动检查更新" + } + } + } + }, + "Automatically download updates": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Automatically download updates" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Automaticky stahovat aktualizace" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch nach Updates suchen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatically download updates" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Automatically download updates" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Descargar actualizaciones automáticamente" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Télécharger automatiquement les mises à jour" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Automatically download updates" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Scarica automaticamente gli aggiornamenti" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "자동으로 업데이트 다운로드" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Automatycznie pobieraj aktualizacje" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Baixar atualizações automaticamente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматически загружать обновления" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Güncellemeleri otomatik indir" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Автоматично завантажувати оновлення" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "自动下载更新" + } + } + } + }, + "Automatically switch displays": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigen automatisch umschalten" + } + }, + "ru": { + "stringUnit": { + "value": "Автоматически переключать дисплеи", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动切换显示器" + } + } + } + }, + "Averages": { + "comment": "A label displayed above the average usage and last update information for a GPU.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Durchschnittswerte" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Averages" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Moyennes" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Средние" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "平均值" + } + } + } + }, + "Backlight": { + "comment": "A control for adjusting the keyboard backlight intensity.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hintergrundbeleuchtung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Backlight" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rétroéclairage" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подсветка" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "背光" + } + } + } + }, + "Battery": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Battery" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Baterie" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batterie" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Battery" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Batería" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Batterie" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Battery" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Batteria" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "배터리" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Bateria" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Bateria" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Аккумулятор" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Pil" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Акумулятор" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "电池" + } + } + } + }, + "Battery at %d percent": { + "comment": "Battery percentage", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batterie bei %d Prozent" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Battery at %d percent" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Batterie à %d pour cent" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заряд батареи: %d%%" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电池电量 %d%%" + } + } + } + }, + "Battery charging": { + "comment": "Battery charging without estimate", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batterie lädt" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Battery charging" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Charge de la batterie" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Батарея заряжается" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电池充电中" + } + } + } + }, + "Battery charging, %@ remaining": { + "comment": "Charging time remaining", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batterie lädt, %@ verbleiben" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Battery charging, %@ remaining" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Charge de la batterie, %@ restant" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Зарядка батареи, осталось %@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电池充电中,剩余 %@" + } + } + } + }, + "Battery fully charged": { + "comment": "Battery is full", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batterie voll aufgeladen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Battery fully charged" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Batterie complètement chargée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Батарея полностью заряжена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电池已充满" + } + } + } + }, + "Battery Information": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Battery Information" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Informace o baterii" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batterie Informationen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery Information" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Battery Information" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Información de batería" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Informations de la batterie" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Battery Information" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Informazioni Batteria" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "배터리 정보" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Informacje o baterii" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Informações da Bateria" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Информация о батарее" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Pil bilgisi" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Інформація про акумулятор" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "电池信息" + } + } + } + }, + "Battery Settings": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Battery Settings" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Nastavení baterie" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batterie Einstellungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery Settings" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Battery Settings" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Configuración de batería" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Paramètres de la batterie" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Battery Settings" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Impostazioni Batteria" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "배터리 설정" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Ustawienia baterii" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Configurações da Bateria" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки батареи" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Pil ayarları" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Налаштування акумулятора" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "电池设置" + } + } + } + }, + "Battery Status": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Battery Status" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Stav baterie" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batterie Status" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery Status" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Battery Status" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Estado de batería" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Statut de la batterie" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Battery Status" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Stato Batteria" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "배터리 상태" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Stan baterii" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Status da bateria" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Состояние батареи" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Pil durumu" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Стан заряду акумулятора" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "电池状态" + } + } + } + }, + "Blue dashed box shows actual size in Atoll": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Blau gestriffelte Box zeigt die tatsächliche Größe im Atoll an" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Blue dashed box shows actual size in Atoll" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La caja de puntos azul muestra el tamaño real en Atoll" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La boîte bleue en pointillés affiche la taille actuelle dans la Atoll" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Синяя пунктирная рамка — реальный размер в Atoll" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蓝色虚线框显示 Atoll 中的实际大小" + } + } + } + }, + "Bluetooth Audio Devices": { + "comment": "A section header that lists Bluetooth audio devices.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth-Audiogeräte" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Bluetooth Audio Devices" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appareils audios Bluetooth" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Аудиоустройства Bluetooth" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蓝牙音频设备" + } + } + } + }, + "Bluetooth device %@ at %d percent": { + "comment": "Bluetooth device battery", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth Gerät %1$@ bei %2$d %" + } + }, + "en": { + "stringUnit": { + "state": "new", + "value": "Bluetooth device %1$@ at %2$d percent" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appareil Bluetooth %1$@ à %2$d pour cent" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Устройство Bluetooth %1$@: %2$d%%" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蓝牙设备 %1$@ 已充电至 %2$d 百分比" + } + } + } + }, + "Boost your productivity with Clipboard Manager": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Boost your productivity with Clipboard Manager" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Zvyšte svou produktivitu pomocí správce schránky" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steigere deine Produktivität mit Zwischenablage Manager" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Boost your productivity with Clipboard Manager" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Boost your productivity with Clipboard Manager" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Aumenta tu productividad con Clipboard Manager" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Boostez votre productivité avec le gestionnaire de presse-papiers" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "क्लिपबोर्ड मैनेजर के साथ अपनी उत्पादकता बढ़ाएँ" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Boost your productivity with Clipboard Manager" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Incrementa la tua produttività con il gestore appunti" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "클립보드 매니저로 업무 효율을 높이세요" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zwiększ swoją produktywność dzięki menedżerowi schowka" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Aumente sua produtividade com o Clipboard Manager" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Менеджер буфера обмена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Pano Yöneticisiyle üretkenliğinizi arttırın" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Підвищіть продуктивність за допомогою менеджера буфера обміну" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "使用剪贴板管理器提升你的效率" + } + } + } + }, + "Both": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beide" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Both" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les deux" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Оба" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "两者" + } + } + } + }, + "Bottom Pad:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unteres Pad:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Bottom Pad:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Coussin inférieur :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отступ снизу:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "底部间距:" + } + } + } + }, + "Breakdown": { + "comment": "A label displayed above the breakdown of memory usage.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pause" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Breakdown" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Répartition" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Детализация" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "详细信息" + } + } + } + }, + "Brightness": { + "comment": "A control that adjusts the brightness of the device.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Helligkeit" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Brightness" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Luminosité" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Яркость" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "亮度" + } + } + } + }, + "Brightness HUD": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Helligkeits-HUD" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Brightness HUD" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Luminosité de l’ATH" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "HUD яркости" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "亮度 HUD" + } + } + } + }, + "Brightness OSD": { + "comment": "A label for a toggle that controls whether the brightness OSD is enabled", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Helligkeit OSD" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Brightness OSD" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Luminosité OSD" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "OSD яркости" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "亮度 OSD" + } + } + } + }, + "Built-in": { + "comment": "A label indicating that an animation is a built-in one.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Integriert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Built-in" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Intégré" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Встроенный" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内置" + } + } + } + }, + "Built-in System Monitoring": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Integrierte Systemüberwachung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Built-in System Monitoring" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Système Intégré de Suivi" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Встроенный системный мониторинг" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内置系统监控" + } + } + } + }, + "Built-in system monitoring detects volume, brightness, and keyboard backlight changes directly without requiring external apps.": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die integrierte Systemüberwachung erkennt Änderungen der Lautstärke, Helligkeit und Tastaturbeleuchtung direkt, ohne dass externe Apps erforderlich sind." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Built-in system monitoring detects volume, brightness, and keyboard backlight changes directly without requiring external apps." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El monitoreo del sistema integrado detecta el volumen, el brillo y los cambios de retroiluminación del teclado directamente sin requerir aplicaciones externas." + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "El sistema de monitoreo integrado detecta los cambios de volumen, brillo y retroiluminación del teclado directamente, sin necesidad de aplicaciones externas." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Sisäänrakennettu järjestelmänvalvonta havaitsee äänenvoimakkuuden, kirkkauden ja näppäimistön taustavalon muutokset suoraan ilman ulkoisia sovelluksia." + } + }, + "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." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "બિલ્ટ-ઇન સિસ્ટમ મોનિટરિંગ બાહ્ય એપ્લિકેશન્સની જરૂરિયાત વિના સીધા જ વોલ્યુમ, તેજસ્વિતા અને કીબોર્ડ બેકલાઇટમાં થતા ફેરફારો શોધે છે." + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "अंतर्निर्मित सिस्टम मॉनिटरिंग बाहरी ऐप्स की आवश्यकता के बिना सीधे वॉल्यूम, ब्राइटनेस और कीबोर्ड बैकलाइट परिवर्तनों का पता लगाती है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장 시스템 모니터링 기능은 외부 앱 없이도 볼륨, 밝기 및 키보드 백라이트 변경 사항을 직접 감지합니다." + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Встроенный мониторинг отслеживает громкость и яркость напрямую." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Den inbyggda systemövervakningen känner av förändringar i volym, ljusstyrka och tangentbordets bakgrundsbelysning direkt utan att externa appar behövs." + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yerleşik sistem izleme, harici uygulamalar gerektirmeden ses düzeyi, parlaklık ve klavye arka aydınlatması değişikliklerini doğrudan algılar." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内置系统监控功能可直接检测音量、亮度和键盘背光的变化,无需外部应用程序。" + } + } + } + }, + "Buy us a coffee": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kauf uns einen Kaffee" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Buy us a coffee" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cómpranos un café" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Achetez-nous un café" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Купить нам кофе" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请我们喝杯咖啡" + } + } + } + }, + "Calendar": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Calendar" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Kalendář" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Calendar" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Calendario" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Calendrier" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Calendar" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Calendario" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "캘린더" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Kalendarz" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Calendário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Календарь" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Takvim" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Календар" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "日历" + } + } + } + }, + "Calendar access is denied. Please enable it in System Settings.": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Calendar access is denied. Please enable it in System Settings." + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Přístup do kalendáře byl odepřen. Prosím povolte jej v nastavení systému." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender Zugriff verweigert. Bitte aktiviere es in den Systemeinstellungen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar access is denied. Please enable it in System Settings." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Calendar access is denied. Please enable it in System Settings." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Acceso al calendario denegado. Por favor, habilítelo en Configuración del sistema." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Kalenterin käyttöoikeus on estetty. Ota se käyttöön järjestelmäasetuksissa." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'accès au calendrier est refusé. Veuillez l’autoriser dans les réglages." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Calendar access is denied. Please enable it in System Settings." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "L'accesso al calendario è negato. Si prega di abilitarlo nelle impostazioni di sistema." + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "캘린더 권한 거부됨. 시스템 설정에서 허용해주세요.." + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Dostęp do kalendarza został zablokowany. Proszę odblokować dostęp w Ustawieniach Systemowych." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Acesso ao Calendário negado. Por favor, autorize nas Configurações do Sistema." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Доступ к календарю запрещен. Разрешите его в Системных настройках." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Kalenderåtkomst är nekad. Aktivera den i systeminställningarna." + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Takvim erişimi reddedildi. Lütfen Sistem Ayarlarından aktifleştiriniz." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Доступ до календаря заборонений. Будь ласка, увімкніть його в Налаштуваннях Системи." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "日历访问被拒绝。请在系统设置中启用它。" + } + } + } + }, + "Calendar or Reminder access is denied. Please enable it in System Settings.": { + "comment": "A message displayed when a user has denied access to the calendar or reminders on their device. It instructs the user to open System Settings to grant access.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Přístup k kalendáři nebo připomenutí je odepřen. Povolte jej v nastavení systému." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Zugriff auf Kalender oder Erinnerungen ist verweigert. Bitte aktivieren Sie ihn in den Systemeinstellungen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Calendar or Reminder access is denied. Please enable it in System Settings." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se deniega el acceso al calendario o recordatorio. Por favor, habilítelo en la configuración del sistema." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Kalenterin tai muistutusten käyttö on estetty. Ota se käyttöön järjestelmäasetuksissa." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'accès au calendrier ou au rappel est refusé. Veuillez l'activer dans les paramètres système." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "કેલેન્ડર અથવા રિમાઇન્ડર ઍક્સેસ નકારી દેવામાં આવી છે. કૃપા કરીને તેને સિસ્ટમ સેટિંગ્સમાં સક્રિય કરો." + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "कैलेंडर या रिमाइंडर तक पहुंच अस्वीकार कर दी गई है। कृपया इसे सिस्टम सेटिंग्स में सक्षम करें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Доступ к календарю или напоминаниям запрещен. Включите его в настройках." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Takvim veya Anımsatıcı erişimi reddedildi. Lütfen Sistem Ayarları'nda etkinleştirin." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日历或提醒事项访问被拒绝。请在系统设置中启用。" + } + } + } + }, + "Calendars": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Calendars" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Calendriers" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Календари" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日历" + } + } + } + }, + "Camera": { + "comment": "A label for the camera privacy indicator.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kamera" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Camera" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Caméra" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Камера" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "相机" + } + } + } + }, + "Camera Active": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kamera Aktiv" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Camera Active" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Caméra Active" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Камера активна" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "相机已激活" + } + } + } + }, + "Camera is being used by an application": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Kamera wird von einer Anwendung verwendet." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "એપ્લિકેશન દ્વારા કેમેરાનો ઉપયોગ થઈ રહ્યો છે" + } + }, + "ru": { + "stringUnit": { + "value": "Камера используется приложением", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "相机正被某个应用使用" + } + } + } + }, + "Camera Status": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kamera Status" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Camera Status" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Status de la Caméra" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Состояние камеры" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "相机状态" + } + } + } + }, + "Cancel": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Cancel" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Zrušit" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Cancelar" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Cancel" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Annulla" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "취소" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Anuluj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Cancelar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отмена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "İptal et" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Скасувати" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "取消" + } + } + } + }, + "Center": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mitte" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Center" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Centrer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "По центру" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "居中" + } + } + } + }, + "Change": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Veränderung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Change" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Changer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изменить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更改" + } + } + } + }, + "Charging": { + "comment": "Charging fallback label when no estimate is available", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Charging" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Nabíjení" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Charging" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Cargando" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En charge" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Charging" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Caricamento" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "충전 중" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Ładowanie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Carregando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Зарядка" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Şarj oluyor" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Заряджається" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "充电中" + } + } + } + }, + "Charging on Hold: Desktop Mode": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Charging on Hold: Desktop Mode" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Nabíjení zadrženo: Režim plochy" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladevorgang unterbrochen: Desktop-Modus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging on Hold: Desktop Mode" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Charging on Hold: Desktop Mode" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Carga en Espera: Modo Escritorio" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Charge suspendue : mode bureau" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Charging on Hold: Desktop Mode" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Caricamento in attesa: Modalità Desktop" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "충전 대기 중: 데스크탑 모드" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Ładowanie wstrzymane: Tryb stacjonarny" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Carregando em espera: Modo Desktop" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Зарядка приостановлена: настольный режим" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Şarj beklemede: Masaüstü Modu" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Заряджання в Режимі очікування: Режим робочого столу" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "暂停充电:桌面模式" + } + } + } + }, + "Check for Updates…": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Check for Updates…" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Zkontrolovat aktualizace…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suche nach Updates…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for Updates…" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Check for Updates…" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Buscar actualizaciones…" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifier les mises à jour…" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Check for Updates…" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Controlla aggiornamenti…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 확인 중…" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Sprawdź aktualizacje…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Verificar atualizações…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверить обновления…" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Güncellemeleri Kontrol et…" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Перевірка наявності оновлень…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "检查更新…" + } + } + } + }, + "Chip color": { + "comment": "A label for the color of the chip in the lock screen reminder widget.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chipfarbe" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Chip color" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur de la puce" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвет плашки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "芯片颜色" + } + } + } + }, + "Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF.": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie eine benutzerdefinierte Sounddatei, die abgespielt wird, wenn der Timer abgeschlossen ist. Unterstützte Formate: MP3, M4A, WAV, AIFF." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elija un archivo de sonido personalizado que se reproducirá cuando se complete el temporizador. Formatos compatibles: MP3, M4A, WAV, AIFF." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Valitse mukautettu äänitiedosto, joka toistetaan ajastimen päätyttyä. Tuetut tiedostomuodot: MP3, M4A, WAV, AIFF." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez un son personnalisé lorsque le minuteur . Formats supportés : MP3, M4A, WAV, AIFF." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "टाइमर पूरा होने पर बजने वाली कस्टम ध्वनि फ़ाइल चुनें। समर्थित प्रारूप: MP3, M4A, WAV, AIFF।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "타이머가 완료될 때 재생될 사용자 지정 사운드 파일을 선택하세요. 지원되는 형식: MP3, M4A, WAV, AIFF." + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите звуковой файл для окончания таймера. Поддерживаются форматы: MP3, M4A, WAV, AIFF." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Välj en anpassad ljudfil som ska spelas upp när timern är slut. Format som stöds: MP3, M4A, WAV, AIFF." + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Zamanlayıcı tamamlandığında çalacak özel bir ses dosyası seçin. Desteklenen formatlar: MP3, M4A, WAV, AIFF." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择一个自定义声音文件,该文件将在计时器结束时播放。支持的格式:MP3、M4A、WAV、AIFF。" + } + } + } + }, + "Choose a Music Source": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Choose a Music Source" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Vyberte zdroj hudby" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wähle eine Musikquelle" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a Music Source" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Choose a Music Source" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Elige una Fuente de Música" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisir une source de musique" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Choose a Music Source" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Scegli una fonte musicale" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "음악 소스 선택" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wybierz źródło muzyki" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Escolha fonte da música" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите источник музыки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Müzik kaynağı seç" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Оберіть джерело музики" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "选择音乐来源" + } + } + } + }, + "Choose AI model": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "KI-Modell auswählen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Choose AI model" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisir le modèle d’IA" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбрать модель ИИ" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择 AI 模型" + } + } + } + }, + "Choose animation to display when Atoll is idle. Tap to select, hold to delete custom animations.": { + "comment": "A footer label under the section that lists their idle animations.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vyberte animaci, která se zobrazí, když je Atoll nečinný. Klepnutím vyberte, podržením smažte vlastní animace." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie die Animation aus, die angezeigt werden soll, wenn Atoll im Leerlauf ist. Tippen Sie zum Auswählen und halten Sie zum Löschen benutzerdefinierter Animationen gedrückt." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Choose animation to display when Atoll is idle. Tap to select, hold to delete custom animations." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elige la animación para que se muestre cuando Atoll esté inactivo. Toque para seleccionar, mantenga presionado para eliminar animaciones personalizadas." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Valitse animaatio, joka näytetään, kun Atoll on lepotilassa. Napauta valitaksesi, pidä painettuna poistaaksesi mukautetut animaatiot." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisir une animation à afficher lorsque la Atoll est inactive. Touchez pour sélectionner, maintenez pour supprimer." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "जब एटोल निष्क्रिय हो तब दिखाने के लिए एनिमेशन चुनें। कस्टम एनिमेशन चुनने के लिए टैप करें, हटाने के लिए दबाकर रखें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Velg animasjon som skal vises når Atoll er inaktiv. Trykk for å velge, hold nede for å slette tilpassede animasjoner." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите анимацию для режима ожидания. Нажмите для выбора, удерживайте для удаления." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Välj animation som ska visas när Atoll är inaktiv. Tryck för att välja, håll ned för att radera anpassade animationer." + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择在Atoll闲置时显示的动画效果。轻点选择,长按删除自定义动画。" + } + } + } + }, + "Choose File": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datei auswählen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Choose File" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisir un fichier" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбрать файл" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择文件" + } + } + } + }, + "Choose which system controls should display custom OSD windows.": { + "comment": "A footer description for the \"Controls\" section of the Custom OSD settings view.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vyberte, které ovládací prvky systému mají zobrazovat vlastní okna OSD." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie aus, welche Systemsteuerelemente benutzerdefinierte OSD-Fenster anzeigen sollen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Choose which system controls should display custom OSD windows." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elija qué controles del sistema deben mostrar ventanas OSD personalizadas." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Valitse, mitkä järjestelmän ohjaimet näyttävät mukautetut OSD-ikkunat." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez les contrôles système qui doivent afficher des fenêtres OSD personnalisées." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите элементы управления для показа всплывающих окон OSD." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hangi sistem denetimlerinin özel OSD pencerelerini göstermesi gerektiğini seçin." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择哪些系统控件应显示自定义OSD窗口。" + } + } + } + }, + "Choose which system controls should display HUD notifications.": { + "comment": "A footer for the \"Controls\" section in the HUD settings, explaining that users can choose which system controls should display HUD notifications.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vyberte, které ovládací prvky systému mají zobrazovat oznámení HUD." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie aus, welche Systemsteuerungen HUD-Benachrichtigungen anzeigen sollen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Choose which system controls should display HUD notifications." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elija qué controles del sistema deben mostrar notificaciones de HUD." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez les commandes du système qui doivent afficher les notifications HUD." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Velg hvilke systemkontroller som skal vise HUD-varsler." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите элементы управления для показа уведомлений HUD." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择哪些系统控件应显示HUD通知。" + } + } + } + }, + "Choose your preferred AI model and configuration": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie Ihr bevorzugtes KI-Modell und Ihre bevorzugte Konfiguration" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Choose your preferred AI model and configuration" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Valitse haluamasi tekoälymalli ja kokoonpano" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez votre modèle d’IA préféré et sa configuration" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "अपना पसंदीदा एआई मॉडल और कॉन्फ़िगरेशन चुनें" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите предпочитаемую модель ИИ и её параметры" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择您偏好的 AI 模型和配置" + } + } + } + }, + "Choose Your Profile": { + "comment": "A title displayed above the list of profile selection options.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie Ihr Profil" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Choose Your Profile" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez votre profil" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите свой профиль" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择您的配置文件" + } + } + } + }, + "Circle": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Circle" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Kruh" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kreis" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Circle" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Circle" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Círculo" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cercle" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Circle" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Cerchio" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "원형의" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Okręg" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Círculo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Круговой" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Circle" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Коло" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "圆形" + } + } + } + }, + "Clear": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Freifläche" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clear" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除" + } + } + } + }, + "Clear All Files": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Dateien Löschen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clear All Files" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer tous les fichiers" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить все файлы" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除所有文件" + } + } + } + }, + "Clear all files removes all attached files and audio recordings. This action is permanent.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vymazat všechny soubory odstraní všechny připojené soubory a zvukové nahrávky. Tato akce je nevratná." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Dateien löschen entfernt alle angehängten Dateien und Audioaufnahmen. Diese Aktion ist unwiderruflich." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clear all files removes all attached files and audio recordings. This action is permanent." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar todos los archivos elimina todos los archivos adjuntos y las grabaciones de audio. Esta acción es permanente." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer tous les fichiers supprime tous les fichiers et enregistrements joints. Cette action est définitive." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистка удалит все прикрепленные файлы и аудиозаписи. Это действие необратимо." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除所有文件将删除所有附加文件和音频录音。此操作不可撤销。" + } + } + } + }, + "Clear Clipboard History": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwischenablage-Verlauf löschen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clear Clipboard History" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer l’Historique du Presse-papiers" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить историю буфера обмена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除剪贴板历史" + } + } + } + }, + "Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vymazání historie schránky odstraní poslední kopie. Vymazání připnutých položek odstraní vaše oblíbené položky. Obě akce jsou trvalé." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Durch „Zwischenablage löschen“ werden die letzten Kopien entfernt. Durch „Angeheftete Elemente löschen“ werden Ihre Favoriten entfernt. Beide Aktionen sind dauerhaft." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar historial de portapapeles elimina copias recientes. Los elementos anclados Borrados eliminan tus favoritos. Ambas acciones son permanentes." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Leikepöydän historian tyhjentäminen poistaa viimeisimmät kopiot. Kiinnitettyjen kohteiden tyhjentäminen poistaa suosikkisi. Molemmat toimet ovat pysyviä." + } + }, + "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." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클립보드 기록 지우기는 최근 복사 내용을 삭제합니다. 고정 항목 지우기는 즐겨찾기를 삭제합니다. 두 작업 모두 영구적입니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tøm utklippstavlehistorikken fjerner de siste kopiene. Tøm festede elementer fjerner favorittene dine. Begge handlingene er permanente." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистка истории или закрепленных элементов необратима." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pano geçmişini temizle, son kopyaları kaldırır. Sabitlenmiş öğeleri temizle favorilerinizi kaldırır. Her iki eylem de kalıcıdır." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除剪贴板历史记录将删除最近的复制内容。清除固定项目将删除您的收藏项。这两项操作均为永久性操作。" + } + } + } + }, + "Clear Color History": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klarer Farbverlauf" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clear Color History" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer l’Historique de Couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить историю цветов" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除颜色历史" + } + } + } + }, + "Clear color history removes all picked colors. Start color picking begins screen color capture mode.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vymazání historie schránky odstraní poslední kopie. Vymazání připnutých položek odstraní vaše oblíbené položky. Obě akce jsou trvalé." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Durch das Löschen des Farbverlaufs werden alle ausgewählten Farben entfernt. Durch das Starten der Farbauswahl wird der Bildschirmfarbenerfassungsmodus gestartet." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clear color history removes all picked colors. Start color picking begins screen color capture mode." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El historial de colores claro elimina todos los colores elegidos. Iniciar la selección de color comienza el modo de captura de color de la pantalla." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Tyhjennä värihistoria poistaa kaikki valitut värit. Aloita värin valinta käynnistää näytön värin kaappausmoodin." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer l’historique de couleurs supprime toutes les couleurs prélevées." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "색상 기록 지우기는 선택한 모든 색상을 제거합니다. 색상 선택 시작은 화면 색상 캡처 모드를 시작합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tøm fargehistorikk fjerner alle valgte farger. Start fargevalg starter skjermens fargefangstmodus." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистка удаляет все цвета. Запуск выбора включает режим захвата цвета." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除颜色历史记录将删除所有选取的颜色。开始颜色拾取将启动屏幕颜色捕获模式。" + } + } + } + }, + "Clear Data": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Daten Löschen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clear Data" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer les données" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить данные" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除数据" + } + } + } + }, + "Clear Pinned Items": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Angeheftete Elemente löschen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clear Pinned Items" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer les Éléments épinglés" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить закрепленные элементы" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除固定项" + } + } + } + }, + "Clear search": { + "comment": "An accessibility label for the button that clears the search field in the sidebar.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suche bereinigen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clear search" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer la recherche" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить поиск" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除搜索" + } + } + } + }, + "Click 'Pick Color' or use Cmd+Shift+P to start picking colors": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Klikněte na „Vybrat barvu“ nebo použijte klávesovou zkratku Cmd+Shift+P a začněte vybírat barvy." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klicken Sie auf \"Farbe auswählen\" oder verwenden Sie Cmd+Shift+P, um mit der Auswahl von Farben zu beginnen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Click 'Pick Color' or use Cmd+Shift+P to start picking colors" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cliquez sur \"Pick Color\" ou pressez simultanément Cmd+Maj+P pour prélever des couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Click 'Pick Color' or use Cmd+Shift+P to start picking цв." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击「取色」或使用 Cmd+Shift+P 开始取色" + } + } + } + }, + "Click for details": { + "comment": "A hint text displayed below the graph in the Stats Card, indicating that tapping it will reveal more details.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klicken Sie für Details" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Click for details" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cliquez pour en savoir plus" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите для подробностей" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击查看详情" + } + } + } + }, + "Clipboard": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwischenablage" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clipboard" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Presse-papiers" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Буфер обмена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "剪贴板" + } + } + } + }, + "Clipboard feature is disabled": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Zwischenablagefunktion ist deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clipboard feature is disabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La fonctionnalité de presse-papiers est désactivée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Функция буфера обмена отключена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "剪贴板功能已禁用" + } + } + } + }, + "Clipboard History": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwischenablage-Verlauf" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clipboard History" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Historique du presse-papiers" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "История буфера обмена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "剪贴板历史" + } + } + } + }, + "Clipboard History:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwischenablage-Verlauf:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clipboard History:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Historique du presse-papiers:" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "История буфера обмена:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "剪贴板历史:" + } + } + } + }, + "Clipboard Manager": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwischenablagemanager" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Clipboard Manager" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gestionnaire de presse-papiers" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Менеджер буфера обмена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "剪贴板管理器" + } + } + } + }, + "Close": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "قُفْل\n" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Zavřít" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Cerrar" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Close" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Chiudi" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "닫기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zamknij" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Fechar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Close" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Закрити" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "关闭" + } + } + } + }, + "Close assistant": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Assistent in der Nähe" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Close assistant" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer l’assistant" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть помощника" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭助手" + } + } + } + }, + "close gesture": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließen-Geste" + } + }, + "ru": { + "stringUnit": { + "value": "жест закрытия", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭手势" + } + } + } + }, + "Collect the latest crash report to share with the developer when reporting lock screen or overlay issues.": { + "comment": "A footer for the \"Diagnostics\" section of the lock screen settings.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Při hlášení problémů s uzamčenou obrazovkou nebo překryvem shromážděte nejnovější zprávu o selhání, kterou můžete sdílet s vývojářem." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sammeln Sie den neuesten Absturzbericht, um es mit dem Entwickler zu teilen, wenn Sie Probleme mit dem Sperrbildschirm oder der Überlagerung melden." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Collect the latest crash report to share with the developer when reporting lock screen or overlay issues." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recueillez le dernier rapport de crash à partager avec le développeur lorsque vous signalez les problèmes d'écran de verrouillage ou de superposition." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잠금 화면 또는 오버레이 문제 발생 시 개발자와 공유할 최신 충돌 보고서를 수집하십시오." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Hent den nyeste krasjrapporten for å dele den med utvikleren når du rapporterer problemer med låseskjermen eller overlegget." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Соберите последний отчет о сбое для разработчика." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Зберіть останній звіт про збій, щоб поділитися ним із розробником під час повідомлення про проблеми із екраном блокування або накладенням." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收集最新崩溃报告以便向开发者报告锁屏或覆盖层问题时分享。" + } + } + } + }, + "Color Details": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbdetails" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Color Details" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Détails des couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Детали цвета" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "颜色详情" + } + } + } + }, + "Color Formats": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbformate" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Color Formats" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Formats de couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Форматы цвета" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "颜色格式" + } + } + } + }, + "Color History": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbgeschichte" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Color History" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Historique des couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "История цветов" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "颜色历史" + } + } + } + }, + "Color options control the icon and progress bar appearance. Auto adapts to system theme.": { + "comment": "A description under the \"Appearance\" section of the Custom OSD settings, explaining that the color options control the icon and progress bar appearance, and that \"Auto\" automatically adapts to the system theme.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Možnosti barev ovládají vzhled ikon a ukazatele průběhu. Automaticky se přizpůsobí motivu systému." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farboptionen steuern das Erscheinungsbild des Symbols und des Fortschrittsbalkens. Automatische Anpassung an das Systemthema." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Color options control the icon and progress bar appearance. Auto adapts to system theme." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les options de couleur contrôlent l'icône et l'apparence de la barre de progression. S'adapte automatiquement au thème du système." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "रंग विकल्प आइकन और प्रगति पट्टी की उपस्थिति को नियंत्रित करते हैं। स्वचालित रूप से सिस्टम थीम के अनुसार अनुकूलित हो जाता है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "색상 옵션은 아이콘과 진행률 표시줄의 모양을 제어합니다. 자동으로 시스템 테마에 맞춰집니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fargevalg styrer utseendet på ikonet og fremdriftslinjen. Auto tilpasser seg systemets tema." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки цвета влияют на иконку и индикатор. Автоматически адаптируется под тему." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "வண்ண விருப்பங்கள் ஐகான் மற்றும் முன்னேற்றப் பட்டையின் தோற்றத்தைக் கட்டுப்படுத்துகின்றன. தானாகவே கணினி கருப்பொருளுக்கு ஏற்ப மாற்றியமைத்துக் கொள்ளும்." + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "颜色选项控制图标和进度条外观。自动适应系统主题。" + } + } + } + }, + "Color Picked!": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbe ausgewählt!" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Color Picked!" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur prélevée!" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвет выбран!" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已取色!" + } + } + } + }, + "Color Picker": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbwähler" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Color Picker" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pipette de couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пипетка цветов" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取色器" + } + } + } + }, + "Color Picker feature is disabled": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Farbauswahlfunktion ist deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Color Picker feature is disabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La fonctionnalité de pipette est désactivée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Функция пипетки отключена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取色器功能已禁用" + } + } + } + }, + "Color Picker Panel:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbwählerpanel:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Color Picker Panel:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Panneau de la pipette de couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Панель пипетки:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取色器面板:" + } + } + } + }, + "Color-coded battery display": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbcodierte Batterieanzeige" + } + }, + "ru": { + "stringUnit": { + "value": "Цветное отображение батареи", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "彩色电池显示" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Запаўненні з каляровай кадзіроўкай і плаўныя градыенты недаступныя ў рэжыме «Секцыяніраваны». Пераключыцеся ў рэжым «Іерархічны» або «Градыент», каб наладзіць гэтыя опцыі." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Barevně odlišené výplně a plynulé přechody nejsou v segmentovaném režimu k dispozici. Chcete-li tyto možnosti upravit, přepněte do hierarchického nebo přechodového režimu." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbcodierte Füllungen und glatte Farbverläufe sind im Segmentierten Modus nicht verfügbar. Wechseln Sie zu Hierarchisch oder Gradient, um diese Optionen anzupassen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Color-coded fills and smooth gradients are unavailable in Segmented mode. Switch to Hierarchical or Gradient to adjust these options." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los rellenos codificados por colores y los gradientes suaves no están disponibles en el modo segmentado. Cambie a Jerárquico o Gradiente para ajustar estas opciones." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Värikoodatut täytöt ja pehmeät liukuvärit eivät ole käytettävissä Segmentoidussa tilassa. Vaihda Hierarkkiseen tai Liukuväriin, jos haluat säätää näitä asetuksia." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les remplissages à code couleur et les dégradés lisses ne sont pas disponibles en mode segmenté. Passez à Hiérarchique ou Gradient pour ajuster ces options." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "सेगमेंटेड मोड में रंग-कोडित भराई और चिकनी ढालें उपलब्ध नहीं हैं। इन विकल्पों को समायोजित करने के लिए पदानुक्रमित या ढाल मोड में स्विच करें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "분할 모드에서는 색상 코드 채우기와 부드러운 그라데이션을 사용할 수 없습니다. 이러한 옵션을 조정하려면 계층적 또는 그라데이션 모드로 전환하십시오." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fargekodede fyllinger og jevne overganger er ikke tilgjengelige i segmentert modus. Bytt til hierarkisk eller overgang for å justere disse alternativene." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цветная заливка и градиенты недоступны в режиме Segmented." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Färgkodade fyllningar och mjuka övergångar är inte tillgängliga i segmenterat läge. Växla till hierarkiskt eller gradient för att justera dessa alternativ." + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Renk kodlu dolgular ve pürüzsüz gradyanlar Bölümlenmiş modda kullanılamaz. Bu seçenekleri ayarlamak için Hiyerarşik veya Gradyan'a geçin." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分段模式下不支持彩色填充和平滑渐变。切换到层级或渐变模式来调整这些选项。" + } + } + } + }, + "Color-Coded Progress Bars": { + "comment": "A section header that lists the options for color-coded progress bars.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbcodierte Fortschrittsbalken" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Color-Coded Progress Bars" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Barres de progression avec code couleur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "रंग-कोडित प्रगति बार" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цветные индикаторы" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "彩色进度条" + } + } + } + }, + "ColorPicker Disabled": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "ColorPicker deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "ColorPicker Disabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La pipette de couleur est désactivée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пипетка отключена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取色器已禁用" + } + } + } + }, + "Coming soon": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Již brzy" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Demnächst" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Coming soon" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Coming soon" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Próximamente" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bientôt disponible" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Coming soon" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "In arrivo" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "곧 출시" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Już wkrótce" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Em breve" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скоро" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Çok yakında geliyor" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Незабаром" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "即将上线" + } + } + } + }, + "Configuration": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfiguration" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Configuration" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Configuration" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Конфигурация" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配置" + } + } + } + }, + "Configure countdown style and lock screen widgets in the Calendar tab.": { + "comment": "A footer for the \"Enable reminder live activity\" toggle in the Live Activities settings.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Наладзьце стыль адліку часу і віджэты экрана блакіроўкі на ўкладцы «Каляндар»." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Nastavte styl odpočítávání a widgety na uzamčené obrazovce na kartě Kalendář." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfigurieren Sie den Countdown-Stil und die Widgets zum Sperrbildschirm auf der Registerkarte \"Kalender\"." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Configure countdown style and lock screen widgets in the Calendar tab." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Configure el estilo de cuenta regresiva y los widgets de la pantalla de bloqueo en la pestaña Calendario." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Configurez le style de compte à rebours et les widgets de l'écran de verrouillage dans l'onglet Calendrier." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "कैलेंडर टैब में काउंटडाउन शैली और लॉक स्क्रीन विजेट्स को कॉन्फ़िगर करें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Konfigurer nedtellingsstil og låseskjermwidgets i fanen Kalender." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройте стиль обратного отсчета и виджеты на вкладке Calendar." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "காலண்டர் தாவலில் கவுண்ட்டவுன் பாணி மற்றும் பூட்டுத் திரை விட்ஜெட்களை உள்ளமைக்கவும்." + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在日历标签页中配置倒计时样式和锁屏小组件。" + } + } + } + }, + "Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Наладзьце выгляд таймера ў закрытым вырезе. Прагрэс можа адлюстроўвацца ў выглядзе кольца вакол іконкі або гарызантальных палос." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Nakonfigurujte vzhled časovače uvnitř uzavřeného zářezu. Průběh lze zobrazit jako prstenec kolem ikony nebo jako vodorovné pruhy." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfigurieren Sie, wie der Timer in der geschlossenen Kerbe aussieht. Der Fortschritt kann als Ring um das Symbol oder als horizontale Balken angezeigt werden." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Configura cómo se ve el temporizador dentro de la muesca cerrada. El progreso se puede representar como un anillo alrededor del icono o como barras horizontales." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Määritä, miltä ajastin näyttää suljetussa lovessa. Edistyminen voidaan näyttää kuvakkeen ympärillä olevana renkaana tai vaakasuorina palkkeina." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Configurez l'apparence de la minuterie à l'intérieur de l'encoche fermée. La progression peut être rendue comme un anneau autour de l'icône ou comme des barres horizontales." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "बंद नॉच के अंदर टाइमर कैसा दिखे, इसे कॉन्फ़िगर करें। प्रगति आइकन के चारों ओर एक छल्ला या क्षैतिज बार के रूप में प्रदर्शित हो सकती है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Konfigurer hvordan timeren ser ut inne i det lukkede hakket. Fremdriften kan vises som en ring rundt ikonet eller som horisontale streker." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройте внешний вид таймера в закрытом вырезе." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Konfigurera hur timern ser ut inuti det stängda spåret. Förloppet kan visas som en ring runt ikonen eller som horisontella staplar." + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配置计时器在关闭的刘海中的外观。进度可以显示为图标周围的圆环或水平条。" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Voreinstellungen in den Einstellungen konfigurieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Configure presets in Settings" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Määritä esiasetukset Asetukset-kohdassa" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Configurer les préréglages dans Paramètres" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настроить пресеты в Настройках" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在设置中配置预设" + } + } + } + }, + "Connected": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbunden" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Connected" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connecté" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключено" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已连接" + } + } + } + }, + "Container": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Behälter" + } + }, + "ru": { + "stringUnit": { + "value": "Контейнер", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "容器" + } + } + } + }, + "Continue": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Continue" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Pokračovat" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fortfahren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Continue" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Continuar" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Continuer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Continue" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Continua" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "계속" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Kontynuuj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Continue" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Продолжить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Continue" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Далі" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "继续" + } + } + } + }, + "Control timer availability, live activity behaviour, and whether the app mirrors timers started from the macOS Clock app.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحكم في توفر المؤقت وسلوك النشاط المباشر وما إذا كان التطبيق يعكس المؤقتات التي بدأت من تطبيق macOS Clock." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Кантралюйце даступнасць таймераў, паводзіны актыўнасці ў рэальным часе і тое, ці адлюстроўвае дадатак таймеры, запушчаныя з дадатка «Гадзіннік» macOS." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Ovládejte dostupnost časovače, chování živé aktivity a to, zda aplikace zrcadlí časovače spuštěné z aplikace Hodiny v systému macOS." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kontrollieren Sie die Timer-Verfügbarkeit, das Live-AktivitätsVerhalten und die Frage, ob die App Timer widerspiegelt, die von der macOS Clock-App gestartet wurden." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Control timer availability, live activity behaviour, and whether the app mirrors timers started from the macOS Clock app." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Controle la disponibilidad del temporizador, el comportamiento de la actividad en vivo y si la aplicación refleja los temporizadores iniciados desde la aplicación Reloj de macOS." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Hallitse ajastimen saatavuutta, reaaliaikaista toimintaa ja sitä, peilaako sovellus macOS-kellosovelluksesta käynnistettyjä ajastimia." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contrôlez la disponibilité de la minuterie, le comportement de l'activité en direct et si l'application reflète les minuteries lancées à partir de l'application macOS Clock." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "टाइमर की उपलब्धता, लाइव गतिविधि का व्यवहार, और यह कि क्या ऐप macOS क्लॉक ऐप से शुरू किए गए टाइमर को दर्शाता है।" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Az időzítő elérhetőségének, az élő tevékenység viselkedésének és annak ellenőrzése, hogy az alkalmazás tükrözi-e a macOS Óra alkalmazásból elindított időzítőket." + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "타이머 사용 가능 여부, 실시간 활동 동작, 그리고 앱이 macOS 시계 앱에서 시작한 타이머를 동기화하는지 여부를 제어합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kontroller tilgjengeligheten til tidtakeren, liveaktivitetsatferd og om appen speiler tidtakere som er startet fra macOS Clock-appen." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управляет доступностью таймера, Live Activities и тем, дублирует ли приложение таймеры из системного приложения Часы." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Kontrollera tillgängligheten för timern, liveaktivitetsbeteendet och om appen speglar timers som startats från macOS-klockappen." + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "கண்ட்ரோல் மையத்தில் டைமர்கள் கிடைப்பது, நேரலைச் செயல்பாட்டின் நடத்தை, மற்றும் macOS கடிகாரச் செயலியிலிருந்து தொடங்கப்பட்ட டைமர்களைச் செயலியானது பிரதிபலிக்கிறதா இல்லையா என்பதைக் கட்டுப்படுத்துங்கள்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Zamanlayıcı kullanılabilirliğini, canlı etkinlik davranışını ve uygulamanın macOS Clock uygulamasından başlatılan zamanlayıcıları yansıtıp yansıtmadığını kontrol edin." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "控制计时器可用性、实时活动行为,以及应用是否同步 macOS 时钟应用启动的计时器。" + } + } + } + }, + "Controls": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kontrollen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Controls" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contrôles" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управление" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "控制" + } + } + } + }, + "Controls how often system metrics refresh while monitoring is active.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتحكم في عدد مرات تحديث مقاييس النظام أثناء نشاط المراقبة." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Кантралюе, як часта абнаўляюцца сістэмныя метрыкі падчас актыўнага маніторынгу." + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steuert, wie oft Systemmetriken aktualisiert werden, während die Überwachung aktiv ist." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Controls how often system metrics refresh while monitoring is active." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Controla la frecuencia con la que las métricas del sistema se actualizan mientras el monitoreo está activo." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contrôle la fréquence à laquelle les métriques du système se rafraîchissent pendant que la surveillance est active." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "नियंत्रित करता है कि मॉनिटरिंग सक्रिय रहने के दौरान सिस्टम मेट्रिक्स कितनी बार रिफ्रेश होते हैं।" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Szabályozza, hogy a rendszerfigyelés aktív állapotában milyen gyakran frissüljenek a rendszermutatók." + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kontrollerer hvor ofte systemmetrikken oppdateres mens overvåkingen er aktiv." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управляет частотой обновления системных метрик при активном мониторинге." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İzleme etkinken sistem ölçümlerinin ne sıklıkta yenilendiğini kontrol eder." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "控制监控激活时系统指标的刷新频率。" + } + } + } + }, + "Controls the optional timer widget that floats above the media panel. Blur adds a frosted finish behind the compact view.": { + "comment": "A footer for the \"Timer Widget\" section in the Lock Screen Settings view.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتحكم في أداة المؤقت الاختيارية التي تظهر فوق لوحة الوسائط. يضيف Blur لمسة نهائية شفافة خلف العرض المضغوط." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Кіруе дадатковым таймер-віджэтам, які лунае над медыяпанэллю. Затуманьванне дадае матавае пакрыццё за кампактным праглядам." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Ovládá volitelný widget časovače, který se vznáší nad panelem médií. Rozostření přidává za kompaktním zobrazením matný povrch." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steuert das optionale Timer-Widget, das über dem Medienfenster schwebt. Die Unschärfe fügt hinter der kompakten Ansicht ein mattiertes Finish hinzu." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Controls the optional timer widget that floats above the media panel. Blur adds a frosted finish behind the compact view." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Controla el widget del temporizador opcional que flota sobre el panel multimedia. El desenfoque añade un acabado helado detrás de la vista compacta." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Ohjaa valinnaista ajastinwidgetiä, joka kelluu mediapaneelin yläpuolella. Blur lisää himmeän pinnan kompaktin näkymän taakse." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contrôle le widget de minuterie optionnel qui flotte au-dessus du panneau multimédia. Le flou ajoute une finition givrée derrière la vue compacte." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "मीडिया पैनल के ऊपर तैरने वाले वैकल्पिक टाइमर विजेट को नियंत्रित करता है। ब्लर कॉम्पैक्ट व्यू के पीछे एक फ्रॉस्टेड फिनिश जोड़ता है।" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "A médiapanel felett lebegő opcionális időzítő widgetet vezérli. A Blur homályos háttérrel egészíti ki a kompakt nézetet." + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미디어 패널 위에 떠 있는 선택적 타이머 위젯을 제어합니다. 흐림 효과는 컴팩트 뷰 뒤에 서리 낀 마감을 추가합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kontrollerer den valgfrie timer-widgeten som flyter over mediepanelet. Blur legger til en frostet finish bak kompaktvisningen." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управляет дополнительным виджетом таймера над медиапанелью. Размытие добавляет матовый эффект." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Styr den valfria timerwidgeten som flyter ovanför mediepanelen. Blur lägger till en frostad yta bakom kompaktvyn." + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "மீடியா பேனலுக்கு மேலே மிதக்கும் விருப்பத்தேர்வு டைமர் விட்ஜெட்டைக் கட்டுப்படுத்துகிறது. மங்கல், சுருங்கிய காட்சிக்குப் பின்னால் ஒரு பனிமூட்டமான தோற்றத்தைச் சேர்க்கிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Medya panelinin üzerinde yüzen isteğe bağlı zamanlayıcı widget'ını kontrol eder. Bulanıklık, kompakt görünümün arkasına buzlu bir yüzey ekler." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "控制浮动在媒体面板上方的可选计时器小组件。模糊效果为紧凑视图添加磨砂效果。" + } + } + } + }, + "Controls whether Dynamic Island mirrors lock/unlock events with its own live activity and audible chimes.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتحكم في ما إذا كان Dynamic Island يعكس أحداث القفل/الفتح بنشاطه المباشر وأصواته المسموعة." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Кантралюе, ці Дынамічны востраў адлюстроўвае падзеі блакіроўкі/разблакіроўкі з дапамогай уласнай ажыўленай актыўнасці і гучных сігналаў." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Ovládá, zda Dynamic Island zrcadlí události zamykání/odemykání vlastní živou aktivitou a zvukovými signály." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steuert, ob Dynamic Island Sperr-/Entsperrereignisse mit eigener Live-Aktivität und hörbaren Glockenspielen spiegelt." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Controls whether Dynamic Island mirrors lock/unlock events with its own live activity and audible chimes." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Controla si Dynamic Island refleja eventos de bloqueo/desbloqueo con su propia actividad en vivo y campanadas audibles." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Ohjaa, peilaako Dynamic Island lukitus-/lukituksen avaustapahtumat omalla reaaliaikaisella toiminnallaan ja äänimerkeillä." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contrôle si Dynamic Island reflète les événements de verrouillage/déverrouillage avec sa propre activité en direct et des carillons audibles." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "यह नियंत्रित करता है कि डायनामिक आइलैंड अपनी लाइव गतिविधि और श्रव्य घंटियों के साथ लॉक/अनलॉक घटनाओं को प्रतिबिंबित करे या नहीं।" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Beállítja, hogy a Dynamic Island tükrözze-e a zárolási/feloldási eseményeket saját élő tevékenységével és hangjelzésekkel." + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다이내믹 아일랜드가 잠금/잠금 해제 이벤트를 자체 라이브 액티비티 및 청각적 알림음으로 동기화하는지 여부를 제어합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kontrollerer om Dynamic Island speiler låse-/låse opp-hendelser med sin egen liveaktivitet og hørbare signaler." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управляет тем, дублирует ли Dynamic Island системные события блокировки/разблокировки своими Live Activity и звуками." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Styr om Dynamic Island speglar låsnings-/upplåsningshändelser med sin egen liveaktivitet och hörbara signaler." + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "டைனமிக் ஐலேண்ட், அதன் சொந்த நேரடிச் செயல்பாடு மற்றும் கேட்கக்கூடிய ஒலிக் குறிப்புகளுடன் பூட்டு/திறக்கும் நிகழ்வுகளைப் பிரதிபலிக்கிறதா இல்லையா என்பதைக் கட்டுப்படுத்துகிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dynamic Island'ın kendi canlı etkinliği ve sesli çanları ile kilitleme/kilit açma olaylarını yansıtıp yansıtmadığını kontrol eder." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "控制灵动岛是否通过自身的实时活动和提示音来响应锁屏/解锁事件。" + } + } + } + }, + "Copy event link": { + "comment": "A tooltip for a button that copies the link of a reminder event to the clipboard.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Event-Link kopieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Copy event link" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier le lien vers l'événement" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Копировать ссылку на событие" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制事件链接" + } + } + } + }, + "Copy Latest Crash Report": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuester Absturzbericht kopieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Copy Latest Crash Report" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier le dernier rapport de crash" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скопировать последний отчет об ошибке" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制最新崩溃报告" + } + } + } + }, + "Copy something to get started": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kopieren Sie etwas, um loszulegen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Copy something to get started" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copiez quelque-chose pour commencer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скопируйте что-нибудь для начала" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Kopiera något för att komma igång" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制内容即可开始" + } + } + } + }, + "Copy something to start": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kopieren Sie etwas, um zu beginnen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Copy something to start" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier quelque-chose pour commencer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скопируйте что-нибудь для начала" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制内容即可开始" + } + } + } + }, + "Core %lld": { + "comment": "A label showing the ID of a CPU core. The argument is the ID of the CPU core.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kern %lld" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Core %lld" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cœur %lld" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ядро %lld" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "核心 %lld" + } + } + } + }, + "Corner radius scaling": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Eckenradius-Skalierung" + } + }, + "ru": { + "stringUnit": { + "value": "Масштабирование радиуса углов", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圆角缩放" + } + } + } + }, + "Countdown style": { + "comment": "A label for the style of countdown to use when reminders are live.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Countdown-Stil" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Countdown style" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estilo de cuenta atrás" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Style compte à rebours" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стиль обратного отсчета" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "倒计时样式" + } + } + } + }, + "CPU Usage": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "CPU-Nutzung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "CPU Usage" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation CPU" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использование CPU" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "CPU 使用率" + } + } + } + }, + "Current custom timer:": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktueller benutzerdefinierter Timer:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Current custom timer:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Minuteur personnalisé actuel :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Текущий таймер:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前自定义计时器:" + } + } + } + }, + "Current default:": { + "comment": "A label displayed alongside the currently set default timer duration.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktueller Standard:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Current default:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Valeur par défaut actuelle :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбран по умолчанию:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前默认:" + } + } + } + }, + "Current History": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelle Geschichte" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Current History" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Historique actuel :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Текущая история" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前历史" + } + } + } + }, + "Current Items": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktuelle Artikel" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Current Items" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Éléments actuels" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Текущие элементы" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前项目" + } + } + } + }, + "Currently active": { + "comment": "A text indicating whether the camera is \"Currently active\" or \"Not active\".", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Derzeit aktiv" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Currently active" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В данный момент активен" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前活跃" + } + } + } + }, + "Custom": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Angepasst" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Custom" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Personnalisé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пользовательский" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义" + } + } + } + }, + "Custom color": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Farbe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom color" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur personnalisée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Свой цвет" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "தனிப்பயன் நிறம்" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义颜色" + } + } + } + }, + "Custom height": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Custom height" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Vlastní výška" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Höhe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom height" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Custom height" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Altura personalizada" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hauteur personnalisée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Custom height" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Altezza personalizzata" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "사용자 정의 높이" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Niestandardowa wysokość" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Customizar altura" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Своя высота" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Custom height" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Користувацька висота" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "自定义高度" + } + } + } + }, + "Custom music live activity animation": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Custom music live activity animation" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Vlastní animace živé aktivity hudby" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Animation für Musik-Live-Aktivitäten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom music live activity animation" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Custom music live activity animation" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Animación de actividad de música en vivo personalizada" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Animation d'activité musicale personnalisée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Custom music live activity animation" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Animazione attività musicale personalizzata" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "사용자 정의 음악 재생 애니메이션" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Niestandardowa animacja aktywności muzyki" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Customizar animação da música" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Своя анимация музыки в Live Activity" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "தனிப்பயன் இசை நேரடி செயல்பாட்டு அனிமேஷன்" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Custom music live activity animation" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Власна анімація музики в реальному часі" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "自定义音乐实时动画" + } + } + } + }, + "Custom notch size - %.0f": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Custom notch size - %.0f" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Vlastní velikost notche - %.0f" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Notchgröße - %.0f" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom notch size - %.0f" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Custom notch size - %.0f" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Tamaño de notch personalizado - %.0f" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Taille de l'encoche personnalisée - %.0f" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Custom notch size - %.0f" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Dimensione notch personalizzata - %.0f" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "사용자 정의 노치 사이즈 - %.0f" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Niestandardowy rozmiar notcha - %.0f" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Descrição" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Свой размер выреза — %.0f" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Custom notch size - %.0f" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Користувацький розмір брові - %.0f" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "自定义Notch大小 - %.0f" + } + } + } + }, + "Custom OSD": { + "comment": "The title of the settings screen for customizing the OSD.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte OSD" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Custom OSD" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "OSD personnalisé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Собственный OSD" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义 OSD" + } + } + } + }, + "Custom OSD feature requires macOS 15 or later.": { + "comment": "A description of the Custom OSD feature, visible only on macOS 15 or later.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Функцыя карыстальніцкага OSD патрабуе macOS 15 або больш позняй версіі." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Funkce přizpůsobeného OSD vyžaduje macOS 15 nebo novější." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die benutzerdefinierte OSD-Funktion erfordert macOS 15 oder neuer." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Custom OSD feature requires macOS 15 or later." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La función OSD personalizada requiere macOS 15 o posterior." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La fonctionnalité OSD personnalisée nécessite macOS 15 ou une version ultérieure." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 OSD 기능은 macOS 15 이상이 필요합니다." + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Функция Собственный OSD требует macOS 15 и новее." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "தனிப்பயன் OSD அம்சத்திற்கு macOS 15 அல்லது அதற்குப் பிந்தைய பதிப்பு தேவை." + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义 OSD 功能需要 macOS 15 或更高版本。" + } + } + } + }, + "Custom OSD is an experimental alpha feature and may contain bugs or unexpected behavior.\n\nThis feature requires macOS 15 or later and is still under active development. It's recommended to keep it disabled unless you want to help test it.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "OSD المخصص هو ميزة تجريبية في مرحلة ألفا وقد تحتوي على أخطاء أو سلوك غير متوقع.\n\nتتطلب هذه الميزة نظام macOS 15 أو أحدث وهي لا تزال قيد التطوير. يوصى بإبقائها معطلة ما لم تكن ترغب في المساعدة في اختبارها." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Наладжвальны OSD — гэта эксперыментальная альфа-функцыя, якая можа ўтрымліваць памылкі або дэманстраваць нечаканае паводзіны.\n\nГэтая функцыя патрабуе macOS 15 або больш позняй версіі і ўсё яшчэ знаходзіцца ў актыўнай распрацоўцы. Рэкамендуецца пакінуць яе адключанай, калі вы не хочаце дапамагчы з тэсціраваннем." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vlastní OSD je experimentální funkce v alfa verzi a může obsahovat chyby nebo neočekávané chování.\n\nTato funkce vyžaduje macOS 15 nebo novější a je stále ve vývoji. Doporučujeme ji ponechat vypnutou, pokud nechcete pomáhat s jejím testováním." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte OSD ist eine experimentelle Alpha-Funktion und kann Fehler oder unerwartetes Verhalten enthalten. Diese Funktion erfordert macOS 15 oder höher und befindet sich noch in der aktiven Entwicklung. Es wird empfohlen, es deaktiviert zu lassen, es sei denn, Sie möchten beim Testen helfen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Custom OSD is an experimental alpha feature and may contain bugs or unexpected behavior.\n\nThis feature requires macOS 15 or later and is still under active development. It's recommended to keep it disabled unless you want to help test it." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El OSD personalizado es una característica alfa experimental y puede contener errores o comportamientos inesperados. Esta función requiere macOS 15 o posterior y todavía está en desarrollo activo. Se recomienda mantenerlo desactivado a menos que quieras ayudar a probarlo." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Mukautettu OSD on kokeellinen alfa-ominaisuus, joka saattaa sisältää virheitä tai toimia odottamattomasti.\n\nTämä ominaisuus vaatii macOS 15:n tai uudemman version ja on vielä aktiivisen kehityksen alla. On suositeltavaa pitää se pois käytöstä, ellet halua auttaa sen testaamisessa." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'OSD personnalisé est une fonctionnalité alpha expérimentale et peut contenir des bogues ou un comportement inattendu. Cette fonctionnalité nécessite macOS 15 ou une version ultérieure et est toujours en cours de développement actif. Il est recommandé de le garder désactivé à moins que vous ne vouliez aider à le tester." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "कस्टम OSD एक प्रयोगात्मक अल्फा फ़ीचर है और इसमें बग या अप्रत्याशित व्यवहार हो सकते हैं।\n\nइस फ़ीचर के लिए macOS 15 या बाद के संस्करण की आवश्यकता है और यह अभी भी सक्रिय विकास के अधीन है। जब तक आप इसका परीक्षण करने में मदद नहीं करना चाहते, तब तक इसे अक्षम रखना अनुशंसित है।" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Az egyéni OSD egy kísérleti alfa funkció, amely hibákat vagy váratlan viselkedést tartalmazhat.\n\nEz a funkció macOS 15 vagy újabb verziót igényel, és még aktív fejlesztés alatt áll. Javasoljuk, hogy hagyja kikapcsolva, hacsak nem szeretne segíteni a tesztelésében." + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 OSD는 실험적인 알파 기능으로 버그나 예상치 못한 동작이 발생할 수 있습니다.\n\n이 기능은 macOS 15 이상이 필요하며 아직 개발 중입니다. 테스트에 참여하지 않는 한 비활성화 상태로 유지하는 것이 좋습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilpasset OSD er en eksperimentell alfa-funksjon og kan inneholde feil eller uventet oppførsel.\n\nDenne funksjonen krever macOS 15 eller nyere og er fortsatt under aktiv utvikling. Det anbefales å holde den deaktivert med mindre du ønsker å hjelpe til med å teste den." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Custom OSD — экспериментальная функция, возможны ошибки.\n\nТребуется macOS 15 или новее. Рекомендуется не включать, если вы не участвуете в тестировании." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "தனிப்பயன் OSD என்பது ஒரு சோதனை ஆல்ஃபா அம்சமாகும், மேலும் இதில் பிழைகள் அல்லது எதிர்பாராத நடத்தைகள் இருக்கலாம்.\n\nஇந்த அம்சத்திற்கு macOS 15 அல்லது அதற்குப் பிந்தைய பதிப்பு தேவைப்படுகிறது, மேலும் இது இன்னும் தீவிர மேம்பாட்டில் உள்ளது. இதைச் சோதிக்க நீங்கள் உதவ விரும்பினால் தவிர, இதை முடக்கியே வைத்திருக்கப் பரிந்துரைக்கப்படுகிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Özel OSD deneysel bir alfa özelliğidir ve hatalar veya beklenmedik davranışlar içerebilir. Bu özellik macOS 15 veya daha yenisini gerektirir ve hala etkin bir geliştirme aşamasındadır. Test etmeye yardımcı olmak istemediğiniz sürece devre dışı bırakmanız önerilir." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义 OSD 是一个实验性测试功能,可能包含错误或意外行为。\n\n此功能需要 macOS 15 或更高版本,仍在积极开发中。除非您想帮助测试,否则建议保持禁用。" + } + } + } + }, + "Custom OSD is disabled because HUDs are enabled. Disable HUDs in the HUDs tab to use Custom OSD.": { + "comment": "A message displayed below the toggle to inform the user that they cannot enable Custom OSD if HUDs are enabled.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم تعطيل OSD المخصص لأن HUDs ممكّن. قم بتعطيل HUDs في علامة التبويب HUDs لاستخدام OSD المخصص." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Наладжвальны OSD адключаны, таму што ўключаны HUD-ы. Адключыце HUD-ы на ўкладцы «HUD-ы», каб выкарыстоўваць наладжвальны OSD." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vlastní OSD je zakázáno, protože jsou povoleny HUD. Chcete-li použít vlastní OSD, zakážte HUD na kartě HUD." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefiniertes OSD ist deaktiviert, da HUDs aktiviert sind. Deaktivieren Sie HUDs auf der Registerkarte HUDs, um benutzerdefiniertes OSD zu verwenden." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Custom OSD is disabled because HUDs are enabled. Disable HUDs in the HUDs tab to use Custom OSD." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El OSD personalizado está deshabilitado porque los HUD están habilitados. Deshable HUDs en la pestaña HUDs para usar OSD personalizado." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Mukautettu OSD on pois käytöstä, koska HUD-näytöt ovat käytössä. Poista HUD-näytöt käytöstä HUD-välilehdessä, jotta voit käyttää mukautettua OSD-näyttöä." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'OSD personnalisé est désactivé car les HUD sont activés. Désactiver les HUD dans l'onglet HUDs pour utiliser l'OSD personnalisé." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "कस्टम OSD अक्षम है क्योंकि HUD सक्षम हैं। कस्टम OSD का उपयोग करने के लिए HUD टैब में HUD को अक्षम करें।" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Az egyéni OSD le van tiltva, mert a HUD-ok engedélyezve vannak. A HUD-ok lapon tiltsa le a HUD-okat, hogy használhassa az egyéni OSD-t." + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "HUD가 활성화되어 있어 사용자 지정 OSD가 비활성화되었습니다. 사용자 지정 OSD를 사용하려면 HUD 탭에서 HUD를 비활성화하십시오." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tilpasset OSD er deaktivert fordi HUD-er er aktivert. Deaktiver HUD-er i HUD-fanen for å bruke tilpasset OSD." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Собственный OSD отключен, так как включены системные HUD. Отключите HUD в настройках, чтобы использовать эту функцию." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "HUD-கள் இயக்கப்பட்டிருப்பதால், தனிப்பயன் OSD முடக்கப்பட்டுள்ளது. தனிப்பயன் OSD-ஐப் பயன்படுத்த, HUD-கள் தாவலில் HUD-களை முடக்கவும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "HUD'ler etkinleştirildiği için Özel OSD devre dışı bırakıldı. Özel OSD'yi kullanmak için HUD'ler sekmesindeki HUD'leri devre dışı bırakın." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义 OSD 已禁用,因为 HUD 已启用。请在 HUD 标签页中禁用 HUD 以使用自定义 OSD。" + } + } + } + }, + "Custom Timer": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierter Timer" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Custom Timer" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Minuteur personnalisé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Свой таймер" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义计时器" + } + } + } + }, + "Custom Timer Duration": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Timer Dauer" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Custom Timer Duration" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Durée du minuteur personnalisée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Длительность пользовательского таймера" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义计时器时长" + } + } + } + }, + "Custom vizualizers (Lottie)": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Custom vizualizers (Lottie)" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Vlastní vizualizátory (Lottie)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eigene Audiovisualisierung (via Lottie)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom vizualizers (Lottie)" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Custom visualisers (Lottie)" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Visualizadores personalizados (Lottie)" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Visualiseurs personnalisés (Lottie)" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Custom vizualizers (Lottie)" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Vizualizers personalizzati (Lottie)" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "사용자 지정 시각화 도구(Lottie)" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Niestandardowe wizualizatory (Lottie)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Customizar visualizadores (Lottie)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Свои визуализаторы (Lottie)" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Custom vizualizers (Lottie)" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Користувацькі візуалізатори (Лотті)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "自定义可视化器(Lottie)" + } + } + } + }, + "Custom: %@": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Angepasst: %@" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Custom: %@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Personnalisé: %@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Свой: %@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义:%@" + } + } + } + }, + "Customize Animation": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Animation anpassen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Customize Animation" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Personnaliser l’animation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настроить анимацию" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义动画" + } + } + } + }, + "Customize in Settings": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Customize in Settings" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Přizpůsobit v nastavení" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In den Einstellungen anpassen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Customize in Settings" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Customise in Settings" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Personalizar en ajustes" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Personnaliser dans les paramètres" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Customize in Settings" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Personalizza nelle impostazioni" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "설정에서 커스텀하기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Dostosuj w ustawieniach" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Customizar em Configurações" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настроить в окне конфигурации" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Customize in Settings" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Змінити в Налаштуваннях" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "在设置中自定义" + } + } + } + }, + "Default": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Default" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Výchozí" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standard" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Default" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Default" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Por defecto" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Par défaut" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Default" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Predefinito" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "기본" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Domyślny" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Padrão" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "По умолчанию" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Default" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Типово" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "默认" + } + } + } + }, + "Default Custom Timer": { + "comment": "A label displayed above the section that lets users configure a custom timer.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardmäßiger benutzerdefinierter Timer" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Default Custom Timer" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Minuterie personnalisée par défaut" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Польз. таймер по умолчанию" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认自定义计时器" + } + } + } + }, + "Default: dynamic.m4a": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standard: dynamic.m4a" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Default: dynamic.m4a" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Par défaut : dynamique.m4a" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "По умолчанию: dynamic.m4a" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "默认:dynamic.m4a" + } + } + } + }, + "Delete": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Löschen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Delete" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除" + } + } + } + }, + "Delete Animation": { + "comment": "The title of an alert that appears when trying to delete a custom animation.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lösche Animation" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Delete Animation" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer l’Animation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить анимацию" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除动画" + } + } + } + }, + "Delete Color": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbe löschen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Delete Color" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer la Couleur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить цвет" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除颜色" + } + } + } + }, + "Description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Description" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Popis" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beschreibung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Description" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Description" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Descripción" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Description" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Description" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Descrizione" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "설명" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Opis" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Descrição" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Описание" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Description" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Опис" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "描述" + } + } + } + }, + "Detail View": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Detailansicht" + } + }, + "ru": { + "stringUnit": { + "value": "Детальный просмотр", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "详细视图" + } + } + } + }, + "Detection Status": { + "comment": "The header for a section that displays whether the screen recording detection feature is currently active or inactive.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erkennungsstatus" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Detection Status" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Statut de la détéction" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статус обнаружения" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测状态" + } + } + } + }, + "Diagnostics": { + "comment": "A section header in the Lock Screen Settings view, describing diagnostics-related functionality.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diagnose" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Diagnostics" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diagnostics" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Диагностика" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "诊断" + } + } + } + }, + "Disable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Disable" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Vypnout" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deaktivieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Disable" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Deshabilitar" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactiver" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Disable" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Disabilita" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "비활성화" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wyłącz" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Disable" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отключить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Disable" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Вимкнути" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "禁用" + } + } + } + }, + "Disabled": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Disabled" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Vypnuto" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Disabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Disabled" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Deshabilitado" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactivé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Disabled" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Disabilitato" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "비활성화됨" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wyłączone" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Disabled" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отключено" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Disabled" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Вимкнено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "已禁用" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنتقل الألوان بشكل متقطع بين الأخضر (0-60٪) والأصفر (60-85٪) والأحمر (85-100٪)." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Дыскрэтныя пераходы імгненна пераключаюцца паміж зялёным (0–60%), жоўтым (60–85%) і чырвоным (85–100%)." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Diskrétní přechody mezi zelenou (0–60 %), žlutou (60–85 %) a červenou (85–100 %) barvou." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diskrete Übergänge zwischen Grün (0–60%), Gelb (60–85%) und Rot (85–100%)." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%)." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las transiciones discretas se ajustan entre el verde (0-60%), el amarillo (60-85%) y el rojo (85-100%)." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les transitions discrètes s'enclenchent entre le vert (0-60 %), le jaune (60 à 85 %) et le rouge (85 à 100 %)." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Diskrete overganger skifter mellom grønt (0–60 %), gult (60–85 %) og rødt (85–100 %)." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дискретные переходы: Зеленый (0–60%), Желтый (60–85%) и Красный (85–100%)." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "பச்சை (0–60%), மஞ்சள் (60–85%), மற்றும் சிவப்பு (85–100%) ஆகியவற்றுக்கு இடையில் தனித்தனி மாற்றங்கள் திடீரென நிகழ்கின்றன." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayrık geçişler Yeşil (%0–60), Sarı (%60–85) ve Kırmızı (%85–100) arasında geçiş yapar." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Дискретні переходи між зеленим (0–60%), жовтим (60–85%) і червоним (85–100%) кольорами." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "离散过渡在绿色(0-60%)、黄色(60-85%)和红色(85-100%)之间切换。" + } + } + } + }, + "Disk Read": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Festplattenlesen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Disk Read" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture du disque" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чтение с диска" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "磁盘读取" + } + } + } + }, + "Disk Write": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Festplattenschreiben" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Disk Write" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écriture du disque" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запись на диск" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "磁盘写入" + } + } + } + }, + "Display custom macOS-style OSD windows at the bottom center of the screen when adjusting volume, brightness, or keyboard backlight.": { + "comment": "A description of the functionality of the Custom OSD feature.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اعرض نوافذ OSD مخصصة على غرار macOS في الجزء السفلي الأوسط من الشاشة عند ضبط مستوى الصوت أو السطوع أو الإضاءة الخلفية للوحة المفاتيح." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Падчас рэгулявання гучнасці, яркасці або падсветкі клавіятуры адлюстроўваць на ніжнім цэнтры экрана карыстальніцкія OSD-вокны ў стылі macOS." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Při nastavování hlasitosti, jasu nebo podsvícení klávesnice zobrazte v dolní střední části obrazovky vlastní okna OSD ve stylu macOS." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigen Sie benutzerdefinierte OSD-Fenster im macOS-Stil in der unteren Mitte des Bildschirms an, wenn Sie die Lautstärke, Helligkeit oder Tastaturhintergrundbeleuchtung anpassen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Display custom macOS-style OSD windows at the bottom center of the screen when adjusting volume, brightness, or keyboard backlight." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Muestra ventanas OSD personalizadas de estilo macOS en la parte inferior central de la pantalla al ajustar el volumen, el brillo o la retroiluminación del teclado." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Näytä mukautetut macOS-tyyliset OSD-ikkunat näytön alareunassa keskellä, kun säädät äänenvoimakkuutta, kirkkautta tai näppäimistön taustavaloa." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affichez des fenêtres OSD personnalisées de style macOS en bas au centre de l'écran lors du réglage du volume, de la luminosité ou du rétroéclairage du clavier." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "वॉल्यूम, ब्राइटनेस या कीबोर्ड बैकलाइट समायोजित करते समय स्क्रीन के निचले केंद्र में कस्टम macOS-शैली की OSD विंडो दिखाएँ।" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "A hangerő, a fényerő vagy a billentyűzet háttérvilágításának beállításakor a képernyő alsó közepén egyedi, macOS stílusú OSD ablakok jelennek meg." + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis tilpassede OSD-vinduer i macOS-stil nederst i midten av skjermen når du justerer volum, lysstyrke eller bakgrunnsbelysning på tastaturet." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображать собственные OSD-окна в стиле macOS внизу экрана при изменении громкости, яркости или подсветки клавиатуры." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "ஒலி, பிரகாசம் அல்லது விசைப்பலகை பின்னொளியைச் சரிசெய்யும்போது, திரையின் கீழ்ப்பகுதி மையத்தில் தனிப்பயன் macOS பாணி OSD சாளரங்களைக் காட்டவும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ses düzeyini, parlaklığı veya klavye arka aydınlatmasını ayarlarken ekranın alt ortasında özel macOS tarzı OSD pencereleri görüntüleyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відображення спеціальних вікон OSD у стилі macOS у нижній центральній частині екрана під час регулювання гучності, яскравості або підсвічування клавіатури." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "调节音量、亮度或键盘背光时在屏幕底部居中显示自定义 macOS 风格的 OSD 窗口。" + } + } + } + }, + "Display Mode": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigemodus" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Display Mode" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mode d’affichage" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим отображения" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示模式" + } + } + } + }, + "Display Options": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigeoptionen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Display Options" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Options d’affichage" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Параметры отображения" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示选项" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعرض إشعار HUD عند توصيل أجهزة الصوت التي تعمل بتقنية Bluetooth (سماعات الرأس، AirPods، مكبرات الصوت)، مع عرض اسم الجهاز ومستوى البطارية." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Паказвае апавяшчэнне на HUD пры падключэнні Bluetooth-аўдыяпрылад (навушнікаў, AirPods, калонкі), якое адлюстроўвае назву прылады і ўзровень зарадкі батарэі." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Zobrazí oznámení HUD, když se připojí audio zařízení Bluetooth (sluchátka, AirPods, reproduktory), a zobrazí název zařízení a úroveň baterie." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt eine HUD-Benachrichtigung an, wenn Bluetooth-Audiogeräte (Kopfhörer, AirPods, Lautsprecher) eine Verbindung herstellen, die den Gerätenamen und den Batteriestand anzeigt." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Muestra una notificación de HUD cuando los dispositivos de audio Bluetooth (auriculares, AirPods, altavoces) se conectan, mostrando el nombre del dispositivo y el nivel de batería." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Näyttää HUD-ilmoituksen, kun Bluetooth-äänilaitteet (kuulokkeet, AirPods, kaiuttimet) muodostavat yhteyden, ja näyttää laitteen nimen ja akun varaustason." + } + }, + "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." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "जब ब्लूटूथ ऑडियो डिवाइस (हेडफ़ोन, एयरपॉड्स, स्पीकर्स) कनेक्ट होते हैं, तो डिवाइस का नाम और बैटरी स्तर दिखाने वाला एक HUD नोटिफिकेशन प्रदर्शित करता है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Viser en HUD-varsling når Bluetooth-lydenheter (hodetelefoner, AirPods, høyttalere) kobles til, med enhetsnavn og batterinivå." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывает HUD-уведомление при подключении Bluetooth-аудиоустройств (наушников, AirPods, колонок), отображая имя и заряд." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "புளூடூத் ஆடியோ சாதனங்கள் (ஹெட்ஃபோன்கள், ஏர்பாட்ஸ், ஸ்பீக்கர்கள்) இணைக்கப்படும்போது, சாதனத்தின் பெயர் மற்றும் பேட்டரி அளவைக் காட்டும் ஒரு HUD அறிவிப்பைக் காண்பிக்கிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth ses aygıtları (kulaklık, AirPods, hoparlörler) bağlandığında aygıt adını ve pil seviyesini gösteren bir HUD bildirimi görüntüler." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відображає сповіщення HUD під час підключення аудіопристроїв Bluetooth (навушників, AirPods, динаміків) із зазначенням назви пристрою та рівня заряду акумулятора." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蓝牙音频设备(耳机、AirPods、扬声器)连接时显示 HUD 通知,包含设备名称和电池电量。" + } + } + } + }, + "Displays play/pause and skip buttons beside the notch while music is active. Disabled by default.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعرض أزرار التشغيل/الإيقاف المؤقت والتخطي بجانب الشق أثناء تشغيل الموسيقى. معطل بشكل افتراضي." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Падчас прайгравання музыкі паказвае кнопкі прайгравання/паўзы і перамоту побач з выемкай. Па змаўчанні адключана." + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt Wiedergabe-/Pause- und Überspringen-Tasten neben der Note an, während die Musik aktiv ist. Standardmäßig deaktiviert." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Displays play/pause and skip buttons beside the notch while music is active. Disabled by default." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Muestra los botones de reproducción/pausa y omitir junto a la muesca mientras la música está activa. Desactivado por defecto." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affiche les boutons de lecture/pause et de saut à côté de l'encoche lorsque la musique est active. Désactivé par défaut." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "जब संगीत चल रहा हो, तो नॉच के पास प्ले/पॉज़ और स्किप बटन दिखाई देते हैं। डिफ़ॉल्ट रूप से अक्षम।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображает кнопки \"Воспроизведение/Пауза\" и \"Вперед\" рядом с вырезом при активной музыке. Отключено по умолчанию." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Müzik etkinken çentiğin yanında oynat/duraklat ve atla düğmelerini görüntüler. Varsayılan olarak devre dışı." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "音乐播放时在刘海旁边显示播放/暂停和跳过按钮。默认禁用。" + } + } + } + }, + "Do Not Disturb": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht stören" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Do Not Disturb" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ne pas déranger" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не беспокоить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "勿扰模式" + } + } + } + }, + "Download": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Download" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Stáhnout" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Herunterladen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Descargar" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Téléchargements" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Download" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Scarica" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "다운로드" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Pobierz" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Download" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "İndir" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Завантажити" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "下载" + } + } + } + }, + "Download icon style": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbolstil herunterladen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download icon style" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Télécharger le style d’icône" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стиль иконки загрузки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载图标样式" + } + } + } + }, + "Download indicator style": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Indikatorstil herunterladen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download indicator style" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Télécharger le style de l’indicateur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стиль индикатора загрузки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gösterge stilini indirin" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载指示器样式" + } + } + } + }, + "Download indicators": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Indikatoren herunterladen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Download indicators" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Télécharger les indicateurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Индикаторы загрузки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载指示器" + } + } + } + }, + "downloads": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Downloads" + } + }, + "ru": { + "stringUnit": { + "value": "загрузки", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载" + } + } + } + }, + "Downloads": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Downloads" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Stahování" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Downloads" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloads" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Downloads" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Descargas" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Téléchargements" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Downloads" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Scaricati" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "다운로드" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Pobrane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Downloads" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "İndirilenler" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Завантаження" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "下载" + } + } + } + }, + "Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Changes apply instantly while the widgets are visible.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسحب المعاينات لضبط الموضع الرأسي. القيم الموجبة ترفع اللوحة؛ القيم السالبة تخفضها. يتم تطبيق التغييرات على الفور أثناء ظهور الأدوات." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Перацягніце папярэднія прагляды, каб адрэгуляваць вертыкальнае размяшчэнне. Пазітыўныя значэнні падымаюць панэль, адмоўныя — апускаюць. Змены прымяняюцца імгненна, пакуль віджэты бачныя." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Přetažením náhledů upravte vertikální umístění. Kladné hodnoty panel zvednou, záporné hodnoty jej sníží. Změny se projeví okamžitě, pokud jsou widgety viditelné." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ziehen Sie die Vorschauen, um die vertikale Platzierung anzupassen. Positive Werte heben das Panel an; negative Werte senken es. Änderungen werden sofort angewendet, solange die Widgets sichtbar sind." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Changes apply instantly while the widgets are visible." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Arrastre las vistas previas para ajustar la ubicación vertical. Los valores positivos elevan el panel; los valores negativos lo bajan. Los cambios se aplican al instante mientras los widgets están visibles." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Faites glisser les aperçus pour ajuster le placement vertical. Les valeurs positives soulèvent le panneau ; les valeurs négatives l'abaissent. Les modifications s'appliquent instantanément tant que les widgets sont visibles." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "ऊर्ध्वाधर स्थिति समायोजित करने के लिए पूर्वावलोकन को खींचें। सकारात्मक मान पैनल को ऊपर उठाते हैं; नकारात्मक मान इसे नीचे लाते हैं। विजेट्स दिखाई देने के दौरान परिवर्तन तुरंत लागू होते हैं।" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Húzza az előnézeteket a függőleges elhelyezés beállításához. A pozitív értékek felemelik a panelt, a negatív értékek lejjebb tolják. A változások azonnal érvénybe lépnek, amíg a widgetek láthatók." + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перетащите превью для настройки вертикального положения. Положительные значения поднимают панель, отрицательные — опускают. Изменения применяются мгновенно." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "செங்குத்து நிலைப்படுத்துதலைச் சரிசெய்ய முன்னோட்டங்களை இழுக்கவும். நேர்மறை மதிப்புகள் பேனலை உயர்த்தும்; எதிர்மறை மதிப்புகள் அதைக் கீழே இறக்கும். விட்ஜெட்டுகள் தெரியும் போது மாற்றங்கள் உடனடியாகப் பொருந்தும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dikey yerleşimi ayarlamak için önizlemeleri sürükleyin. Pozitif değerler paneli kaldırır; negatif değerler onu düşürür. Değişiklikler, widget'lar görünürken anında uygulanır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перетягніть попередній перегляд, щоб відрегулювати вертикальне розміщення. Позитивні значення піднімають панель, а негативні — опускають її. Зміни застосовуються миттєво, поки віджети видимі." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拖动预览以调整垂直位置。正值上移面板,负值下移。更改在小组件可见时立即生效。" + } + } + } + }, + "Drop files here": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Drop files here" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Přetáhněte soubory sem" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dateien hier ablegen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Drop files here" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Drop files here" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Soltar archivos aquí" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déposer les fichiers ici" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Drop files here" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Trascina i file qui" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "여기로 파일을 드래그하세요" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Upuść pliki tutaj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Drop files here" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Бросьте файлы сюда" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Doslayarı buraya bırak" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Перетягніть файл сюди" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "拖放文件到这里" + } + } + } + }, + "dynamic.island": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "dynamic.island" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "dynamic.island" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "dynamic.island" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "灵动岛" + } + } + }, + "shouldTranslate": false + }, + "Each graph can be individually enabled or disabled. Network activity shows download/upload speeds, and disk I/O shows read/write speeds.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يمكن تمكين أو تعطيل كل رسم بياني على حدة. يُظهر نشاط الشبكة سرعات التنزيل/التحميل، ويُظهر إدخال/إخراج القرص سرعات القراءة/الكتابة." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Кожны графік можна ўключыць або выключыць асобна. Актыўнасць сеткі паказвае хуткасці спампоўвання/загрузкі, а дыскавыя I/O — хуткасці чытання/запісу." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Každý graf lze individuálně zapnout nebo vypnout. Síťová aktivita zobrazuje rychlosti stahování/nahrávání a disk I/O zobrazuje rychlosti čtení/zápisu." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Jede Grafik kann einzeln aktiviert oder deaktiviert werden. Die Netzwerkaktivität zeigt Download-/Upload-Geschwindigkeiten an, und die Festplatten-E/A zeigt Lese-/Schreibgeschwindigkeiten." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Each graph can be individually enabled or disabled. Network activity shows download/upload speeds, and disk I/O shows read/write speeds." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cada gráfico se puede habilitar o desactivar individualmente. La actividad de la red muestra velocidades de descarga/carga, y la E/S del disco muestra velocidades de lectura/escritura." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "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." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "प्रत्येक ग्राफ़ को व्यक्तिगत रूप से सक्षम या अक्षम किया जा सकता है। नेटवर्क गतिविधि डाउनलोड/अपलोड गति दिखाती है, और डिस्क I/O रीड/राइट गति दिखाती है।" + } + }, + "hu": { + "stringUnit": { + "state": "translated", + "value": "Minden grafikon külön-külön engedélyezhető vagy letiltható. A hálózati aktivitás a letöltési/feltöltési sebességet, a lemez I/O pedig az olvasási/írási sebességet mutatja." + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Каждый график можно включить или выключить отдельно. Сетевая активность показывает скорость интернета, а ввода-вывода — скорость диска." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "ஒவ்வொரு வரைபடத்தையும் தனித்தனியாக இயக்கலாம் அல்லது முடக்கலாம். நெட்வொர்க் செயல்பாடு பதிவிறக்கம்/பதிவேற்ற வேகங்களைக் காட்டுகிறது, மேலும் வட்டு I/O வாசிப்பு/எழுதுதல் வேகங்களைக் காட்டுகிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Her grafik ayrı ayrı etkinleştirilebilir veya devre dışı bırakılabilir. Ağ etkinliği indirme/yükleme hızlarını gösterir ve disk G/Ç okuma/yazma hızlarını gösterir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Кожен графік можна окремо ввімкнути або вимкнути. Мережева активність показує швидкість завантаження/вивантаження, а дисковий ввід-вивід показує швидкість читання/запису." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每个图表可以单独启用或禁用。网络活动显示下载/上传速度,磁盘 I/O 显示读写速度。" + } + } + } + }, + "EAQI": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "EAQI" + } + }, + "ru": { + "stringUnit": { + "value": "ЕИВ (EAQI)", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "欧洲空气质量指数" + } + } + } + }, + "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Лёгка капіруйце, захоўвайце і кіруйце сваім найбольш выкарыстоўваным кантэнтам. Абнавіцеся зараз, каб атрымаць прасунутыя функцыі, такія як захоўванне некалькіх элементаў і хуткі доступ!" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Snadno kopírujte, ukládejte a spravujte svůj nejpoužívanejší obsah. Upgradujte nyní pro pokročilé funkce, jako je vícenásobné ulužiště a rychlý přístup!" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kopieren, speichern und verwalten Sie ganz einfach Ihre am häufigsten verwendeten Inhalte. Aktualisieren Sie jetzt, um erweiterte Funktionen wie die Speicherung mehrerer Elemente und schnellen Zugriff zu erhalten!" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Copia, almacena y gestiona fácilmente tu contenido más usado. ¡Actualiza ahora para funciones avanzadas como almacenamiento de múltiples elementos y acceso rápido!" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "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 !" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "अपने सबसे अधिक उपयोग किए जाने वाले कंटेंट को आसानी से कॉपी करें, स्टोर करें और प्रबंधित करें। मल्टी-आइटम स्टोरेज और त्वरित पहुँच जैसी उन्नत सुविधाओं के लिए अभी अपग्रेड करें!" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Copia facilmente, memorizza e gestisci i tuoi contenuti più usati. Aggiorna ora per funzioni avanzate come storage multi-item e accesso rapido!" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "자주 사용하는 파일들을 쉽게 복사, 저장, 관리하세요. 업그레이드 해서 다중 저장 기능과 빠른 접근 기능을 활성화하세요!" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Łatwo kopiuj, przechowuj i zarządzaj najczęściej używaną treścią. Uaktualnij teraz, aby uzyskać zaawansowane funkcje, takie jak przechowywanie wielu produktów i szybki dostęp!" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Легко копируйте, храните и управляйте наиболее часто используемым контентом. Обновитесь сейчас, чтобы получить доступ к расширенным функциям, таким как хранение нескольких элементов и быстрый доступ!" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "உங்கள் அதிகம் பயன்படுத்தப்படும் உள்ளடக்கத்தை எளிதாக நகலெடுத்து, சேமித்து, நிர்வகிக்கவும். பல-பொருள் சேமிப்பு மற்றும் விரைவான அணுகல் போன்ற மேம்பட்ட அம்சங்களுக்கு இப்போதே மேம்படுத்துங்கள்!" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "En çok kullanılan içeriğinizi kolayca kopyalayın, saklayın ve yönetin. Çok öğeli depolama ve hızlı erişim gibi gelişmiş özellikler için şimdi yükseltin!" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Легко копіюйте, зберігайте та керуйте найчастіше використовуваним контентом. Оновіть програму зараз, щоб отримати доступ до розширених функцій, таких як зберігання декількох елементів та швидкий доступ!" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "轻松复制、存储和管理您最常用的内容。升级后可解锁高级功能,如多项目存储和快速访问!" + } + } + } + }, + "Edit Animation": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Animation bearbeiten" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Edit Animation" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier l’Animation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изменить анимацию" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Animasyonu Düzenle" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑动画" + } + } + } + }, + "Edit layout": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Edit layout" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Upravit rozložení" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Layout bearbeiten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit layout" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Edit layout" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Editar diseño" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier la mise en page" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Edit layout" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Modifica layout" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "레이아웃 수정" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Edytuj układ" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Edit layout" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изменить макет" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yerleşimi düzenle" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Редагувати розташування" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "编辑布局" + } + } + } + }, + "Email address": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "E-Mail-Adresse" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Email address" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Adresse e-mail" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Электронная почта" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电子邮件地址" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Уключыце «Паказваць крутую анімацыю твару пры бездзейнасці», каб наладзіць анімацыі." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Povolit „Zobrazit animace cool tváře při nečinnosti“ pro přizpůsobení animací" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren Sie \"Coole Gesichtsanimation während Inaktivität anzeigen\", um Animationen anzupassen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable \"Show cool face animation while inactivity\" to customize animations" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Habilitar \"Mostrar animación facial genial durante la inactividad\" para personalizar las animaciones" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer \"Afficher l'animation du visage cool pendant l'inactivité\" pour personnaliser les animations" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "एनिमेशन को अनुकूलित करने के लिए \"अक्रियता के दौरान कूल फेस एनिमेशन दिखाएँ\" सक्षम करें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aktiver «Vis kul ansiktsanimasjon ved inaktivitet» for å tilpasse animasjoner" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включите \"Отображать лицо при бездействии\", чтобы настроить анимации." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "அசைவூட்டல்களைத் தனிப்பயனாக்க, \"செயலற்ற நிலையில் குளிர் முக அசைவூட்டலைக் காட்டு\" என்பதை இயக்கவும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Animasyonları özelleştirmek için \"Hareketsizlik sırasında havalı yüz animasyonunu göster\"i etkinleştirin" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用「空闲时显示有趣的表情动画」以自定义动画" + } + } + } + }, + "Enable and style the media controls that appear above the system clock when the screen is locked.": { + "comment": "A description of the media panel settings in the lock screen settings view.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Уключыце і наладзьце элементы кіравання мультымедыя, якія з'яўляюцца над сістэмным гадзіннікам пры заблакіраваным экране." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Povolit a upravit ovládací prvky médií, které se zobrazují nad systémovými hodinami, když je obrazovka uzamčena." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren und stylen Sie die Mediensteuerelemente, die über der Systemuhr erscheinen, wenn der Bildschirm gesperrt ist." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable and style the media controls that appear above the system clock when the screen is locked." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Habilita y diseña los controles multimedia que aparecen sobre el reloj del sistema cuando la pantalla está bloqueada." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activez et stylisez les contrôles multimédias qui apparaissent au-dessus de l'horloge système lorsque l'écran est verrouillé." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "स्क्रीन लॉक होने पर सिस्टम क्लॉक के ऊपर दिखने वाले मीडिया नियंत्रणों को सक्षम करें और उनका स्टाइल बदलें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aktiver og tilpass mediekontrollene som vises over systemklokken når skjermen er låst." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включение и настройка плера над системными часами при заблокированном экране." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "திரை பூட்டப்பட்டிருக்கும்போது, சிஸ்டம் கடிகாரத்திற்கு மேலே தோன்றும் மீடியா கட்டுப்பாடுகளை இயக்கவும் மற்றும் வடிவமைக்கவும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ekran kilitliyken sistem saatinin üzerinde görünen medya denetimlerini etkinleştirin ve şekillendirin." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用并设置屏幕锁定时系统时钟上方显示的媒体控制。" + } + } + } + }, + "Enable Anyway": { + "comment": "A button that enables the Custom OSD feature despite the warning.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Trotzdem aktivieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable Anyway" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer quand même" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Всё равно включить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仍然启用" + } + } + } + }, + "Enable blur effect behind album art": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Unschärfeeffekt hinter Albumcover aktivieren" + } + }, + "ru": { + "stringUnit": { + "value": "Размытие фона обложки", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用专辑封面后的模糊效果" + } + } + } + }, + "Enable Built-in System HUD": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Уключыць убудаваны сістэмны HUD" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Integriertes System-HUD aktivieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable Built-in System HUD" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer le système d’ATH intégré" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить системный HUD" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用内置系统 HUD" + } + } + } + }, + "Enable built-in system monitoring to replace macOS HUD notifications with Atoll displays.": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Уключыце ўбудаваны маніторынг сістэмы, каб замяніць апавяшчэнні HUD macOS на дысплеі Atoll." + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren Sie die integrierte Systemüberwachung, um macOS HUD-Benachrichtigungen durch Atoll-Displays zu ersetzen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable built-in system monitoring to replace macOS HUD notifications with Atoll displays." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Habilite la supervisión del sistema integrado para reemplazar las notificaciones de macOS HUD con pantallas Atoll." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activez le système de suivi intégré pour remplacer les notifications macOS avec celles de Atoll." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "macOS HUD सूचनाओं को Atoll डिस्प्ले से बदलने के लिए अंतर्निहित सिस्टम निगरानी सक्षम करें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заменяет стандартные HUD-уведомления macOS (яркость/громкость) красивыми элементами Atoll." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "macOS HUD அறிவிப்புகளுக்குப் பதிலாக Atoll காட்சிகளைப் பயன்படுத்த, உள்ளமைக்கப்பட்ட சிஸ்டம் கண்காணிப்பை இயக்கவும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "macOS HUD bildirimlerini Atoll ekranlarıyla değiştirmek için yerleşik sistem izlemeyi etkinleştirin." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用内置系统监控,用 Atoll 显示替代 macOS HUD 通知。" + } + } + } + }, + "Enable Camera Detection": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kameraerkennung aktivieren" + } + }, + "ru": { + "stringUnit": { + "value": "Включить обнаружение камеры", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用相机检测" + } + } + } + }, + "Enable colored spectrograms": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Enable colored spectrograms" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Enable colored spectrograms" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbe für Audiovisualisierung aktivieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable colored spectrograms" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Enable coloured spectrograms" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Enable colored spectrograms" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer les spectrogrammes colorés" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Enable colored spectrograms" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Enable colored spectrograms" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "Enable colored spectrograms" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Enable colored spectrograms" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Enable colored spectrograms" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цветные спектрограммы" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Renkli spektrogramları etkinleştir" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Enable colored spectrograms" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "启用彩色谱图" + } + } + } + }, + "Enable ColorPicker in Settings → ColorPicker": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "ColorPicker in den Einstellungen aktivieren → ColorPicker" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable ColorPicker in Settings → ColorPicker" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Habilitar ColorPicker en Configuración → ColorPicker" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activez la pipette de couleur dans Réglages → Pipette de couleur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включите Пипетку: Настройки → Пипетка" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在设置 → 取色器中启用取色器" + } + } + } + }, + "Enable Custom OSD": { + "comment": "A toggle that enables or disables the custom OSD feature.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefiniertes OSD aktivieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable Custom OSD" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer l'OSD personnalisé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить Собственный OSD" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用自定义 OSD" + } + } + } + }, + "Enable dynamic mirror": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dynamische Spiegelung aktivieren" + } + }, + "ru": { + "stringUnit": { + "value": "Динамическое зеркало", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用动态镜像" + } + } + } + }, + "Enable gestures": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesten aktivieren" + } + }, + "ru": { + "stringUnit": { + "value": "Включить жесты", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用手势" + } + } + } + }, + "Enable global keyboard shortcuts above to customize your shortcuts.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Уключыце сусветныя спалучэнні клавіш вышэй, каб наладзіць свае спалучэнні." + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren Sie die globalen Tastenkombinationen oben, um Ihre Verknüpfungen anzupassen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable global keyboard shortcuts above to customize your shortcuts." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activez les raccourcis clavier globaux ci-dessus pour personnaliser vos raccourcis." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включите глобальные сочетания клавиш выше, чтобы настроить их." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kısayollarınızı özelleştirmek için yukarıdaki genel klavye kısayollarını etkinleştirin." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用上方的全局键盘快捷键以自定义您的快捷键。" + } + } + } + }, + "Enable graph visibility in Settings → Stats to view performance data.": { + "comment": "A message displayed when no graphs are enabled in the Stats tab.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Chcete-li zobrazit údaje o výkonu, povolte viditelnost grafu v Nastavení → Statistiky." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren Sie die Diagrammsichtbarkeit unter Einstellungen → Statistiken, um Leistungsdaten anzuzeigen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable graph visibility in Settings → Stats to view performance data." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activez la visibilité du graphique dans Paramètres → Statistiques pour afficher les données de performance." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aktiver grafvisibilitet i Innstillinger → Statistikk for å se ytelsesdata." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включите отображение графиков в Settings → Stats." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在设置 → 统计中启用图表可见性以查看性能数据。" + } + } + } + }, + "Enable haptics": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Enable haptics" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Enable haptics" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Haptisches Feedback aktivieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable haptics" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Enable haptics" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Enable haptics" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer l'haptique" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Enable haptics" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Enable haptics" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "햅틱 켜기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Enable haptics" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Enable haptics" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить тактильные ощущения" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Enable haptics" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Увімкнути тактильний відгук" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "启用触觉" + } + } + } + }, + "Enable HUD replacement": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Enable HUD replacement" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Povolit nahrazení HUD" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "HUD Ersatz aktivieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable HUD replacement" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Enable HUD replacement" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Habilitar reemplazo HUD" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer le mode de remplacement ATH" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Enable HUD replacement" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Abilita sostituzione HUD" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "HUD 대체 활성화" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Włącz zastępowanie HUD" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Enable HUD replacement" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заменять системный HUD" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Enable HUD replacement" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Увімкнути заміну HUD" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "启用 HUD 替换" + } + } + } + }, + "Enable HUDs": { + "comment": "A toggle that enables or disables the use of HUDs.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "HUDs aktivieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable HUDs" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer les HUD" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить системные HUD" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用 HUD" + } + } + } + }, + "Enable lyrics": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Songtexte aktivieren" + } + }, + "ru": { + "stringUnit": { + "value": "Включить тексты песен", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用歌词" + } + } + } + }, + "Enable media ": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Medien aktivieren" + } + }, + "ru": { + "stringUnit": { + "value": "Включить медиа ", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用媒体 " + } + } + } + }, + "Enable media panel blur": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Medienbereich unscharf machen" + } + }, + "ru": { + "stringUnit": { + "value": "Включить размытие медиа-панели", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用媒体面板模糊" + } + } + } + }, + "Enable Microphone Detection": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mikrofonerkennung aktivieren" + } + }, + "ru": { + "stringUnit": { + "value": "Отслеживать использование микрофона", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用麦克风检测" + } + } + } + }, + "Enable music live activity": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Enable music live activity" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Povolist živou aktivitu hudby" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Musik-Live-Aktivität aktivieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable music live activity" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Enable music live activity" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Habilitar actividad de música en vivo" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer l'activité musicale en direct" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Enable music live activity" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Abilita attività musicale live" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "음악 실시간 현황 켜기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Włącz aktywność muzyki na żywo" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Enable music live activity" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить музыкальную Live Activity" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Enable music live activity" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Enable music live activity" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "启用音乐实时动态" + } + } + } + }, + "Enable reminder live activity": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erinnerung für Live-Aktivität aktivieren" + } + }, + "ru": { + "stringUnit": { + "value": "Включить Live Activity напоминаний", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用提醒实时活动" + } + } + } + }, + "Enable screen color picking functionality. Use Cmd+Shift+P to quickly access the color picker.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Уключыце функцыю выбару колеру з экрана. Націсніце Cmd+Shift+P, каб хутка адкрыць выбарнік колеру." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Povolte funkci výběru barvy obrazovky. Pomocí klávesové zkratky Cmd+Shift+P můžete rychle otevřít nástroj pro výběr barvy." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren Sie die Bildschirmfarbauswahlfunktion. Verwenden Sie Cmd+Shift+P, um schnell auf die Farbauswahl zuzugreifen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable screen color picking functionality. Use Cmd+Shift+P to quickly access the color picker." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Habilite la funcionalidad de selección de color de pantalla. Usa Cmd+Shift+P para acceder rápidamente al selector de color." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer la fonctionnalité de sélection des couleurs de l'écran. Utilisez Cmd+Shift+P pour accéder rapidement au sélecteur de couleurs." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включите пипетку цветов. Cmd+Shift+P для быстрого доступа." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ekran rengi toplama işlevini etkinleştirin. Renk seçiciye hızlı bir şekilde erişmek için Cmd+Shift+P tuşlarını kullanın." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用屏幕取色功能。使用 Cmd+Shift+P 快速访问取色器。" + } + } + } + }, + "Enable Screen Recording Detection": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildschirmaufzeichnung erkennen" + } + }, + "ru": { + "stringUnit": { + "value": "Отслеживать запись экрана", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用屏幕录制检测" + } + } + } + }, + "Enable sneak peek": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek aktivieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable sneak peek" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer Sneak Peek" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить быстрый просмотр" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用快速预览" + } + } + } + }, + "Enable stats monitoring in Settings to view system performance data.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Уключыце маніторынг статыстыкі ў наладах, каб праглядаць даныя аб прадукцыйнасці сістэмы." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Povolte sledování statistik v nastaveních, abyste mohli zobrazit údaje o výkonu systému." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren Sie die Statistiküberwachung in den Einstellungen, um Systemleistungsdaten anzuzeigen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable stats monitoring in Settings to view system performance data." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Habilite la supervisión de estadísticas en Configuración para ver los datos de rendimiento del sistema." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activez la surveillance des statistiques dans les paramètres pour afficher les données relatives aux performances du système." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включите мониторинг системы в настройках." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sistem performans verilerini görüntülemek için Ayarlar'da istatistik izlemeyi etkinleştirin." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在设置中启用统计监控以查看系统性能数据。" + } + } + } + }, + "Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators.": { + "comment": "A footer for the \"Weather Widget\" section in the Lock Screen Settings view, describing its purpose.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Уключыце капсулу надвор'я і наладзьце яе макет, пастаўшчыка, адзінкі вымярэння і дадатковыя індыкатары батарэі/AQI." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Aktivujte kapsli počasí a nakonfigurujte její rozložení, poskytovatele, jednotky a volitelné indikátory baterie/AQI." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren Sie die Wetterkapsel und konfigurieren Sie ihr Layout, Anbieter, Einheiten und optionale Batterie-/AQI-Anzeigen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Habilite la cápsula meteorológica y configure su diseño, proveedor, unidades e indicadores opcionales de batería/AQI." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activez la capsule météo et configurez sa disposition, son fournisseur, ses unités et ses indicateurs de batterie/AQI en option." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "वेदर कैप्सूल को सक्षम करें और इसके लेआउट, प्रदाता, इकाइयों तथा वैकल्पिक बैटरी/एकी संकेतकों को कॉन्फ़िगर करें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aktiver værkapselen og konfigurer utformingen, leverandøren, enhetene og valgfrie batteri-/AQI-indikatorer." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить капсулу погоды и настроить её." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "வானிலை கேப்ஸுலை இயக்கவும், அதன் தளவமைப்பு, வழங்குநர், அலகுகள் மற்றும் விருப்பத்தேர்வான பேட்டரி/AQI குறிகாட்டிகளை உள்ளமைக்கவும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hava kapsülünü etkinleştirin ve düzenini, sağlayıcısını, birimlerini ve isteğe bağlı pil/AQI göstergelerini yapılandırın." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用天气胶囊并配置其布局、数据源、单位和可选的电池/AQI 指示器。" + } + } + } + }, + "Enable Thinking Mode": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Denkmodus aktivieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable Thinking Mode" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer le mode réflexion" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим обдумывания" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用思考模式" + } + } + } + }, + "Enable timer blur": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Timer-Unschärfe aktivieren" + } + }, + "ru": { + "stringUnit": { + "value": "Включить размытие таймера", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用计时器模糊" + } + } + } + }, + "Enable timer feature in Settings to use this tab": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren Sie die Timer-Funktion in den Einstellungen, um diese Registerkarte zu verwenden" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable timer feature in Settings to use this tab" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer la fonction de minuterie dans les paramètres pour utiliser cet onglet" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включите функцию таймера в настройках" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu sekmeyi kullanmak için Ayarlar'da zamanlayıcı özelliğini etkinleştirin" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在设置中启用计时器功能以使用此标签页" + } + } + } + }, + "Enable timer live activity": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Timer-Live-Aktivität aktivieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enable timer live activity" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer l'activité en direct du minuteur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить Live Activity таймера" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用计时器实时活动" + } + } + } + }, + "Enable window shadow": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fensterschatten aktivieren" + } + }, + "ru": { + "stringUnit": { + "value": "Включить тень окна", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用窗口阴影" + } + } + } + }, + "Engine Utilization": { + "comment": "A label describing the utilization of the GPU's engine components.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Engine-Auslastung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Engine Utilization" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использование движка" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "引擎利用率" + } + } + } + }, + "Enhance your experience with HUDs": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Enhance your experience with HUDs" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Vylepšete své zkušenosti s HUDy" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbessere deine Erfahrung mit HUDs" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enhance your experience with HUDs" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Enhance your experience with HUDs" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Mejora tu experiencia con HUDs" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Améliorez votre expérience avec les HUD" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Enhance your experience with HUDs" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Migliora la tua esperienza con gli HUD" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "HUD로 경험을 향상 하세요" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Ulepsz swoje doświadczenia z HUD" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Enhance your experience with HUDs" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Улучшите свой опыт с HUD" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "HUD'lerle deneyiminizi geliştirin" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Enhance your experience with HUDs" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "用 HUD 提升您的体验" + } + } + } + }, + "Enjoy your free time!": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Enjoy your free time!" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Užijte si svůj volný čas!" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Genieße deine Freizeit!" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enjoy your free time!" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Enjoy your free time!" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "¡Disfruta de tu tiempo libre!" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Profitez de votre temps libre !" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Enjoy your free time!" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Goditi il tuo tempo libero!" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "즐거운 시간 되세요!" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Ciesz się swoim wolnym czasem!" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Enjoy your free time!" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наслаждайтесь свободным временем!" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Boş zamanınızın tadını çıkarın!" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Насолоджуйтесь своїм вільним часом!" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "享受您的闲暇时光!" + } + } + } + }, + "Enter your Gemini API Key": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie Ihren Gemini-API-Schlüssel ein" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Enter your Gemini API Key" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Entrez votre clé Gemini API" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Введите Gemini API Key" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入您的 Gemini API 密钥" + } + } + } + }, + "Error": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Error" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Chyba" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Error" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Error" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Errore" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "에러" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Błąd" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Error" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Hata" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Помилка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "错误" + } + } + } + }, + "Event List": { + "comment": "A section header in the calendar settings view, describing the list of calendar events.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Veranstaltungsliste" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Event List" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Liste des événements" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Список событий" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "事件列表" + } + } + } + }, + "Exclude apps": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Apps ausschließen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Exclude apps" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exclure des applications" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Исключить приложения" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "排除应用" + } + } + } + }, + "Exit": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Exit" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Ukončit" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlassen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Exit" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Exit" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Salir" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Quitter" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Exit" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Esci" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "종료" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wyjdź" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Exit" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выйти" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Çıkış yap" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Вихід" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "退出" + } + } + } + }, + "Expand Notch with Animation": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erweitern Sie die Notch mit Animation" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Expand Notch with Animation" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Développer l'encoche avec l'animation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Расширять вырез с анимацией" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Animasyonla Çentiği Genişlet" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "展开刘海时带动画" + } + } + } + }, + "Expanded notch width": { + "comment": "A label describing the expanded notch width setting.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erweiterte Kerbweite" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Expanded notch width" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Largeur d'encoche élargie" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Увеличенная ширина выреза" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "展开的刘海宽度" + } + } + } + }, + "Extend the notch span so the clipboard, colour picker, and other trailing icons remain visible on scaled displays (e.g. More Space).": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Vergrößern Sie den Abstand zwischen den Symbolen, damit die Zwischenablage, die Farbauswahl und andere nachfolgende Symbole auf skalierten Anzeigen (z. B. „Mehr Platz“) sichtbar bleiben." + } + }, + "ru": { + "stringUnit": { + "value": "Расширьте вырез, чтобы буфер обмена, пипетка и другие иконки оставались видимыми на масштабированных дисплеях.", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扩展刘海范围,使剪贴板、取色器和其他尾部图标在缩放显示(如更多空间)上保持可见。" + } + } + } + }, + "Extensions": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Extensions" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Rozšíření" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erweiterungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Extensions" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Extensions" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Extensiones" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Extensions" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Extensions" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Estensioni" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "확장기능들" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Rozszerzenia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Extensions" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Расширения" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Genişletmeler" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Розширення" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "扩展" + } + } + } + }, + "External": { + "comment": "A label indicating an external storage device.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Extern" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "External" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Externe" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внешний" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "外置" + } + } + } + }, + "Failed to read crash reports:": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler beim Lesen der Absturzberichte:" + } + }, + "ru": { + "stringUnit": { + "value": "Не удалось прочитать отчеты о сбоях:", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读取崩溃报告失败:" + } + } + } + }, + "Fill Notch": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Notch ausfüllen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Fill Notch" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Remplir l'encoche" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заполнить вырез" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "填充刘海" + } + } + } + }, + "Finish": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Finish" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Dokončit" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beenden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Finish" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Finish" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Finalizar" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Terminer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Finish" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Fine" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "완료" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zakończ" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Finish" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Завершить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Finish" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Завершити" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "完成" + } + } + } + }, + "Fit to Notch": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Angepasst an die Notch" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Fit to Notch" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajusté à l'encoche" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вписать в вырез" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "适应刘海" + } + } + } + }, + "Focus Status": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حالة التركيز" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fokusstatus" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Focus Status" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "État de mise au point" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статус Фокуса" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "专注状态" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kostenlos · %@" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Free · %@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Libre · %@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Своб. · %@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可用 · %@" + } + } + } + }, + "Fully charged": { + "comment": "Charging fallback label when already charged", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مشحون بالكامل" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Voll aufgeladen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Fully charged" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Complètement chargé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Полностью заряжена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已充满" + } + } + } + }, + "Gemini API Key": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gemini API-Schlüssel" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Gemini API Key" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clé Gemini API" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ключ Gemini API" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Gemini API 密钥" + } + } + } + }, + "General": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "General" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Obecné" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Allgemein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "General" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "General" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "General" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Général" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "General" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Generali" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "일반" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Ogólne" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "General" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Основные" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "General" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "General" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "通用" + } + } + } + }, + "Gesture control": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Gesture control" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Kontrola gest" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gestensteuerung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Gesture control" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Gesture control" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Control de gestos" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contrôle gestuel" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Gesture control" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Controllo gesti" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "제스처 컨트롤" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Ustawienia gestów" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Gesture control" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управление жестами" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Gesture control" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Керування жестами" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "手势控制" + } + } + } + }, + "Gesture sensitivity": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Gesture sensitivity" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Citlivost gesta" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesten Empfindlichkeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Gesture sensitivity" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Gesture sensitivity" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Sensibilidad del gesto" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sensibilité de la gestuelle" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Gesture sensitivity" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Sensibilità gesti" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "제스처 민감도" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Czułość gestów" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Gesture sensitivity" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чувствительность жестов" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Gesture sensitivity" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Чутливість жестів" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "手势灵敏度" + } + } + } + }, + "Get started": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Get started" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Začněte" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erste Schritte" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Get started" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Get started" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Comenzar" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commencer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Get started" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Inizia" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "시작하기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Rozpocznij" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Get started" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Начать" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Get started" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Get started" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "开始使用" + } + } + } + }, + "Get your free API key from Google AI Studio": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "احصل على مفتاح واجهة برمجة التطبيقات المجاني من Google AI Studio" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Holen Sie sich Ihren kostenlosen API-Schlüssel von Google AI Studio" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Get your free API key from Google AI Studio" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Obtenez votre clé API gratuitement depuis Google AI Studio" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ottieni la tua chiave API gratuita da Google AI Studio" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Получите бесплатный API ключ в Google AI Studio" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从 Google AI Studio 获取免费 API 密钥" + } + } + } + }, + "GitHub": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "GitHub" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "GitHub" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "en-GB": { + "stringUnit": { + "state": "needs_review", + "value": "GitHub" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "GitHub" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "GitHub" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "GitHub" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "GitHub" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "GitHub" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "GitHub" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "GitHub" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "GitHub" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + } + } + }, + "Got it!": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Got it!" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Mám to!" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verstanden!" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Got it!" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Got it!" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "¡Entendido!" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Compris !" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Got it!" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Capito!" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "알겠습니다!" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Rozumiem!" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Got it!" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Понятно!" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Got it!" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Got it!" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "明白了!" + } + } + } + }, + "GPU Usage": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "استخدام وحدة معالجة الرسومات" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "GPU Nutzung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "GPU Usage" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation GPU" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использование GPU" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "GPU 使用率" + } + } + } + }, + "Gradient": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Gradient" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Přechod" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbverlauf" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Gradient" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Gradient" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Gradiente" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dégradé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Gradient" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Sfumatura" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "그라디언트" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Gradient" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Gradient" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Градиент" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Gradient" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Градієнт" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "渐变" + } + } + } + }, + "Grant Accessibility permission in System Settings to control macOS HUD replacements from here.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "منح إذن إمكانية الوصول في إعدادات النظام للتحكم في استبدال macOS HUD من هنا." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Дазвольце доступ у наладах сістэмы, каб адсюль кіраваць заменамі HUD у macOS." + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gewähren Sie die Berechtigung zur Bedienungshilfen in den Systemeinstellungen, um den macOS HUD-Austausch von hier aus zu steuern." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Grant Accessibility permission in System Settings to control macOS HUD replacements from here." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Concede permiso de accesibilidad en Ajustes del sistema para controlar los reemplazos de macOS HUD desde aquí." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accorder l'autorisation d'accessibilité dans les paramètres système pour contrôler les remplacements de macOS HUD à partir d'ici." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "सिस्टम सेटिंग्स में एक्सेसिबिलिटी की अनुमति दें ताकि यहां से macOS HUD प्रतिस्थापनों को नियंत्रित किया जा सके।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Concedi l'autorizzazione all'accessibilità in Impostazioni di sistema per controllare le sostituzioni di macOS HUD da qui." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi tilgangstillatelse i systeminnstillingene for å kontrollere macOS HUD-erstatninger herfra." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дайте разрешение на Универсальный доступ для управления заменой macOS HUD." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Ge åtkomstbehörighet i systeminställningarna för att styra macOS HUD-ersättningar härifrån." + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "macOS HUD değişimlerini buradan kontrol etmek için Sistem Ayarları'nda Erişilebilirlik izni verin." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在系统设置中授予辅助功能权限以从此处控制 macOS HUD 替代。" + } + } + } + }, + "Grant Accessibility permission in System Settings to customize the on-screen display replacements here.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Дазвольце доступ у наладах сістэмы, каб тут наладзіць замены на экране." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "V nastavení systému udělejte oprávnění pro přístupnost, abyste mohli přizpůsobit náhrady zobrazení na obrazovce." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gewähren Sie hier die Berechtigung zur Bedienungshilfen in den Systemeinstellungen, um die Display-Ersetzungen auf dem Bildschirm anzupassen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Grant Accessibility permission in System Settings to customize the on-screen display replacements here." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Concede permiso de accesibilidad en Configuración del sistema para personalizar los reemplazos de la pantalla aquí." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accorder l'autorisation d'accessibilité dans les paramètres système pour personnaliser les remplacements d'affichage à l'écran ici." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "यहाँ ऑन-स्क्रीन डिस्प्ले प्रतिस्थापनों को अनुकूलित करने के लिए सिस्टम सेटिंग्स में पहुँच क्षमता अनुमति दें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Concedi l'autorizzazione di accessibilità in Impostazioni di sistema per personalizzare le sostituzioni dello schermo qui." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gi tilgangstillatelse i systeminnstillingene for å tilpasse erstatningene for skjermvisningen her." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дайте разрешение на Универсальный доступ для настройки экранных уведомлений (OSD)." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ekrandaki ekran değiştirmelerini burada özelleştirmek için Sistem Ayarları'nda Erişilebilirlik izni verin." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在系统设置中授予辅助功能权限以自定义屏幕显示替代。" + } + } + } + }, + "Graph Visibility": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Graphensichtbarkeit" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Graph Visibility" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Visibilité du graphique" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображение графиков" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图表可见性" + } + } + } + }, + "Height:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Höhe:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Height:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hauteur :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Высота:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高度:" + } + } + } + }, + "Hide": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbergen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Hide" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cacher" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрыть" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏" + } + } + } + }, + "Hide all-day events": { + "comment": "A toggle to hide all-day events in the calendar.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ganztägige Veranstaltungen ausblenden" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Hide all-day events" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer les événements d'une journée entière" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрыть события на весь день" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏全天事件" + } + } + } + }, + "Hide completed reminders": { + "comment": "A toggle that lets the user hide completed reminders", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abgeschlossene Erinnerungen ausblenden" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Hide completed reminders" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer les rappels terminés" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрыть завершенные напоминания" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏已完成的提醒" + } + } + } + }, + "Hide DynamicIsland Options": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "DynamicIsland-Optionen ausblenden" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Hide DynamicIsland Options" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cacher les options de DynamicIsland" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрыть настройки Dynamic Island" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏灵动岛选项" + } + } + } + }, + "Hide from Notch": { + "comment": "A button label that hides the Clock timer from the Notch app.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vor Notch verstecken" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Hide from Notch" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se cacher de Notch" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрыть из выреза" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从刘海中隐藏" + } + } + } + }, + "Hide only when NowPlaying app is in fullscreen": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Hide only when NowPlaying app is in fullscreen" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Skrýt pouze když je Právě hrající aplikace v režimu celé obrazovky" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur ausblenden, wenn die Läuft gerade App im Vollbildmodus ist" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide only when NowPlaying app is in fullscreen" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Hide only when NowPlaying app is in full-screen" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Hide only when NowPlaying app is in fullscreen" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer seulement quand lorsque l'app \"À l’écoute\" est en plein écran" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Hide only when NowPlaying app is in fullscreen" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Nascondi solo quando l'app NowPlaying è a schermo intero" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "현재 재생 중인 앱이 전체 화면일 때만 숨기기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Ukryj tylko wtedy, gdy aplikacja NowPlaying jest w trybie pełnoekranowym" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Hide only when NowPlaying app is in fullscreen" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрывать только если приложение NowPlaying в полноэкранном режиме" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Hide only when NowPlaying app is in fullscreen" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Hide only when NowPlaying app is in fullscreen" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "仅在“当前播放”全屏时隐藏" + } + } + } + }, + "Hierarchical": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Hierarchical" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Hierarchické" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hierarchisch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hierarchical" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Hierarchical" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Hierarchical" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hiérarchique" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Hierarchical" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Gerarchico" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "계층적" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Hierarchiczny" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Hierarchical" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Иерархический" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Hierarchical" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Hierarchical" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "分级" + } + } + } + }, + "High": { + "comment": "High battery level label", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "High" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Vysoká" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hoch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "High" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "High" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Alto" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Haut" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "High" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Alta" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "높음" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wysoki" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "High" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Высокий" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "High" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Високий" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "高" + } + } + } + }, + "High-frequency updates without a timeout can increase battery usage.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Časté aktualizace bez časového limitu mohou zvýšit spotřebu baterie." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hochfrequenz-Updates ohne Time-Out können den Batterieverbrauch erhöhen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "High-frequency updates without a timeout can increase battery usage." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las actualizaciones de alta frecuencia sin tiempo de espera pueden aumentar el uso de la batería." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les mises à jour à haute fréquence sans délai d'attente peuvent augmenter l'utilisation de la batterie." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Gli aggiornamenti ad alta frequenza senza un timeout possono aumentare il consumo della batteria." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Частые обновления без таймаута могут увеличить расход батареи." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "டைம்அவுட் இல்லாமல் உயர்-அடர்த்தி புதுப்பிப்புகள் பேட்டரி பயன்பாட்டை அதிகரிக்கக்கூடும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Zaman aşımı olmadan yüksek frekanslı güncellemeler pil kullanımını artırabilir." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无超时的高频更新可能会增加电池消耗。" + } + } + } + }, + "History Size": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlaufsgröße" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "History Size" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Taille de l’historique" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Размер истории" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "历史大小" + } + } + } + }, + "Horizontal:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Horizontal:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Horizontal:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Horizontal :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "По горизонтали:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "水平:" + } + } + } + }, + "https://example.com/animation.json": { + "comment": "A placeholder text for a Lottie JSON URL input field.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "https://example.com/animation.json" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "https://exemple.com/animation.json" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "https://example.com/animation.json" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://example.com/animation.json" + } + } + }, + "shouldTranslate": false + }, + "https://github.com/th-ch/youtube-music": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "en-GB": { + "stringUnit": { + "state": "needs_review", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "https://github.com/th-ch/youtube-music" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "https://github.com/th-ch/youtube-music" + } + } + } + }, + "HUD style": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "HUD style" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Styl HUD" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "HUD Stil" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "HUD style" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "HUD style" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estilo HUD" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Style de l’ATH" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "HUD style" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Stile HUD" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "HUD 스타일" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Styl HUD" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "HUD style" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стиль HUD" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "HUD style" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "HUD style" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "HUD 样式" + } + } + } + }, + "HUDs": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "HUDs" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "HUDy" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "HUDs" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "HUDs" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "HUDs" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "HUDs" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "ATH" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "HUDs" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "HUDs" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "HUD들" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Interfejsy HUD" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "HUDs" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "HUD" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "HUDs" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "HUDs" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "HUD" + } + } + } + }, + "HUDs are disabled because Custom OSD is enabled. Disable Custom OSD in the Custom OSD tab to use HUDs.": { + "comment": "A message displayed below the toggle to disable HUDs, explaining that it's disabled because Custom OSD is enabled.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "HUD-ы адключаны, таму што ўключаны карыстальніцкі OSD. Адключыце карыстальніцкі OSD на ўкладцы «Карыстальніцкі OSD», каб выкарыстоўваць HUD-ы." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "HUD jsou deaktivovány, protože je aktivováno vlastní OSD. Chcete-li používat HUD, deaktivujte vlastní OSD na kartě Vlastní OSD." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "HUDs sind deaktiviert, da benutzerdefiniertes OSD aktiviert ist. Deaktivieren Sie Benutzerdefiniertes OSD auf der Registerkarte Benutzerdefiniertes OSD, um HUDs zu verwenden." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "HUDs are disabled because Custom OSD is enabled. Disable Custom OSD in the Custom OSD tab to use HUDs." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los HUD están deshabilitados porque Custom OSD está habilitado. Deshable Custom OSD en la pestaña Custom OSD para usar HUDs." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les HUD sont désactivés car l'OSD personnalisé est activé. Désactiver l'OSD personnalisé dans l'onglet OSD personnalisé pour utiliser les HUD." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "कस्टम OSD सक्षम होने के कारण HUD अक्षम हैं। HUD का उपयोग करने के लिए कस्टम OSD टैब में कस्टम OSD को अक्षम करें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Gli HUD sono disabilitati perché l'OSD personalizzato è abilitato. Disabilita OSD personalizzato nella scheda OSD personalizzato per utilizzare gli HUD." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "HUD-er er deaktivert fordi tilpasset OSD er aktivert. Deaktiver tilpasset OSD i fanen Tilpasset OSD for å bruke HUD-er." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "HUD отключены, так как включен Пользовательский OSD. Отключите его для использования HUD." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "HUD:er är inaktiverade eftersom anpassad OSD är aktiverad. Inaktivera anpassad OSD på fliken Anpassad OSD för att använda HUD:er." + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "தனிப்பயன் OSD இயக்கப்பட்டிருப்பதால் HUD-கள் முடக்கப்பட்டுள்ளன. HUD-களைப் பயன்படுத்த, தனிப்பயன் OSD தாவலில் தனிப்பயன் OSD-ஐ முடக்கவும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Özel OSD etkinleştirildiği için HUD'ler devre dışı bırakılmıştır. HUD'leri kullanmak için Özel OSD sekmesinde Özel OSD'yi devre dışı bırakın." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "由于已启用自定义 OSD,HUD 已禁用。请在自定义 OSD 标签页中禁用以使用 HUD。" + } + } + } + }, + "Icon & Progress Color": { + "comment": "A picker that lets users choose the color scheme for the icon and progress bar in the OSD.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbol & Fortschrittsfarbe" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Icon & Progress Color" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Icône et couleur de progression" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아이콘 및 진행률 색상" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвет иконки и индикатора" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图标和进度颜色" + } + } + } + }, + "Idle": { + "comment": "A label displayed alongside the status of a GPU device.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Durchschnitt" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Idle" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En veille" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "空闲" + } + } + } + }, + "Idle Animation Style": { + "comment": "The header label for the section that lets users choose the style of idle animation.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Leerlauf-Animationsstil" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Idle Animation Style" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estilo de animación inactiva" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Style de l’animation de veille" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아이들 애니메이션 스타일" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стиль анимации ожидания" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "空闲动画样式" + } + } + } + }, + "Import": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Importieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Import" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Importer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Импорт" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入" + } + } + } + }, + "Import Error": { + "comment": "The title of an alert that appears when there is an error importing a Lottie animation.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Importfehler" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Import Error" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur d’importation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка импорта" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入错误" + } + } + } + }, + "Import File...": { + "comment": "A menu option to import a Lottie animation from a file.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datei importieren..." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Import File..." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Importer un fichier" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Импортировать файл..." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导入文件..." + } + } + } + }, + "In progress": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "In progress" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Probíhá" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In Bearbeitung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "In progress" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "In progress" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "En curso" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En cours" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "In progress" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "In corso" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "진행 중" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "W toku" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "In progress" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В процессе" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "In progress" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "В процесі" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "进行中" + } + } + } + }, + "Inactive": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inaktiv" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Inactive" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inactif" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неактивно" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不活跃" + } + } + } + }, + "Inline": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Inline" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Inline" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inline" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Inline" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Inline" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Inline" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En ligne" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Inline" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "In linea" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "인라인" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wbudowany" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Inline" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Встроенный" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Inline" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Inline" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "内嵌" + } + } + } + }, + "Inline snapshot preview": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inline-Schnappschussvorschau" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Inline snapshot preview" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aperçu instantané en ligne" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인라인 스냅샷 미리보기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Встроенный предпросмотр снимка" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内联快照预览" + } + } + } + }, + "Install updates automatically": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Updates automatisch installieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Install updates automatically" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalar actualizaciones automáticamente" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installer les mises à jour automatiquement" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트를 자동으로 설치합니다" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Устанавливать обновления автоматически" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "புதுப்பிப்புகளைத் தானாக நிறுவவும்" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动安装更新" + } + } + } + }, + "Installed extensions": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Installed extensions" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Nainstalovaná rozšíření" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Installierte Erweiterungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Installed extensions" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Installed extensions" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Installed extensions" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Extensions installées" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Installed extensions" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Estensioni installate" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "설치된 확장기능들" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zainstalowane rozszerzenia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Installed extensions" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Установленные расширения" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Installed extensions" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Встановлені розширення" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "已安装扩展" + } + } + } + }, + "IPv4 · %@": { + "comment": "A label displaying an IP address for a network interface. The text inside the parentheses is the IP address of the interface.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "IPv4 · %@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "IPv4 · %@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "IPv4 · %@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "IPv4 · %@" + } + } + }, + "shouldTranslate": false + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "IPv6 · %@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "IPv6 · %@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "IPv6 · %@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "IPv6 · %@" + } + } + }, + "shouldTranslate": false + }, + "Key": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schlüssel" + } + }, + "ru": { + "stringUnit": { + "value": "Ключ", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "密钥" + } + } + } + }, + "Key 1": { + "extractionState": "manual", + "localizations": { + "ru": { + "stringUnit": { + "value": "Клавиша 1", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "密钥 1" + } + } + } + }, + "Keyboard Backlight HUD": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tastatur Hintergrundbeleuchtung HUD" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Keyboard Backlight HUD" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Teclado Retroiluminación HUD" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "ATH de l’éclairage du clavier" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "HUD подсветки клавиатуры" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "键盘背光 HUD" + } + } + } + }, + "Keyboard Backlight OSD": { + "comment": "A label displayed above the toggle for the keyboard backlight OSD.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tastatur Hintergrundbeleuchtung OSD" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Keyboard Backlight OSD" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "OSD de retroiluminación del teclado" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rétroéclairage du clavier OSD" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "OSD подсветки клавиатуры" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "键盘背光 OSD" + } + } + } + }, + "Keyboard shortcuts are disabled": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tastenkombinationen sind deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Keyboard shortcuts are disabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les raccourcis claviers sont désactivés" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сочетания клавиш отключены" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "键盘快捷键已禁用" + } + } + } + }, + "Last Updated": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Letztes Update" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Last Updated" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dernière mise à jour" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Последнее обновление" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上次更新" + } + } + } + }, + "Later": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Later" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Později" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Später" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Later" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Later" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Later" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Plus tard" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Later" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Più tardi" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "나중에" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Później" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Later" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Позже" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Later" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Пізніше" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "稍后再说" + } + } + } + }, + "Launch at login": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Anmelden starten" + } + }, + "ru": { + "stringUnit": { + "value": "Запускать при входе", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "登录时启动" + } + } + } + }, + "Layout": { + "comment": "A label for the segment control that lets the user choose the layout of the weather widget.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Layout" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Layout" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mise en page" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Макет" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "布局" + } + } + } + }, + "License key": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lizenzschlüssel" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "License key" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clé de license" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Лицензионный ключ" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "许可证密钥" + } + } + } + }, + "LinkedIn": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "LinkedIn" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "LinkedIn" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "LinkedIn" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "LinkedIn" + } + } + }, + "shouldTranslate": false + }, + "Liquid Glass requires macOS 26 or later.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass erfordert macOS 26 oder neuer." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Liquid Glass requires macOS 26 or later." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass nécessite macOS 26 ou plus récent." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass требуется macOS 26 или новее." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "液态玻璃需要 macOS 26 或更高版本。" + } + } + } + }, + "Listens for Focus session changes via distributed notifications": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يستمع إلى تغييرات جلسة التركيز عبر الإشعارات الموزعة" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Слухае аб змены ў сесіі Focus праз размеркаваныя апавяшчэнні" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hört sich über verteilte Benachrichtigungen auf Änderungen der Fokussitzung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Listens for Focus session changes via distributed notifications" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écoute les changements de session Focus via des notifications distribuées" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ascolta le modifiche della sessione di messa a fuoco tramite notifiche distribuite" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отслеживает изменения сеансов Фокуса через распределенные уведомления" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "பகிர்ந்த அறிவிப்புகள் வழியாக கவனம் அமர்வு மாற்றங்களைக் கேட்கிறது" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通过分布式通知监听专注模式会话变化" + } + } + } + }, + "LIVE": { + "comment": "A label indicating that the music is live.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "LIVE" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "LIVE" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "EN DIRECT" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "LIVE" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "直播" + } + } + } + }, + "Live Activities": { + "comment": "The title of the \"Live Activities\" screen.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Live-Aktivitäten" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Live Activities" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activités en direct" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Live Activities" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实时活动" + } + } + } + }, + "Live Activity & Feedback": { + "comment": "The header for the \"Live Activity & Feedback\" section in the lock screen settings.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Live-Aktivität & Feedback" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Live Activity & Feedback" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activité en direct et commentaires" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Live-aktivitet og tilbakemelding" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Live Activity и Отклик" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实时活动与反馈" + } + } + } + }, + "Live Monitoring": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Live-Überwachung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Live Monitoring" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suivi en directe" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Мониторинг в реальном времени" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实时监控" + } + } + } + }, + "Live Performance Data": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Live-Performance-Daten" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Live Performance Data" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Données de performance en directe" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Данные производительности" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实时性能数据" + } + } + } + }, + "Live Preview": { + "comment": "A section header that indicates the live preview of custom OSD settings.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Live-Vorschau" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Live Preview" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aperçu en direct" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предпросмотр в реальном времени" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实时预览" + } + } + } + }, + "Live stream indicator": { + "comment": "A visual indicator that appears when a music stream is live.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Live-Stream-Anzeige" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Live stream indicator" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Indicateur de diffusion en direct" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Индикатор прямого эфира" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "直播指示器" + } + } + } + }, + "Load Average": { + "comment": "A label displayed above the load average values.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lastdurchschnitt" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Load Average" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Moyenne de la charge" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Средняя нагрузка" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "负载平均值" + } + } + } + }, + "Lock Screen": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sperrbildschirm" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Lock Screen" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écran verrouillé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Экран блокировки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "锁屏" + } + } + } + }, + "Lock Screen Integration": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Integration des Sperrbildschirms" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Lock Screen Integration" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Intégration de l'écran de verrouillage" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Интеграция с экраном блокировки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "锁屏集成" + } + } + } + }, + "Lock screen panel preview": { + "comment": "A description of the content displayed in the lock screen panel.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "معاينة لوحة شاشة القفل" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Náhled panelu uzamčené obrazovky" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschau des Sperrbildschirms" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Lock screen panel preview" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aperçu du panneau de verrouillage de l'écran" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предпросмотр панели экрана блокировки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "锁屏面板预览" + } + } + } + }, + "Lock Screen Positioning": { + "comment": "The header text for the \"Lock Screen Positioning\" section in the settings view.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Positionierung des Sperrbildschirms" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Lock Screen Positioning" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Positionnement de l'écran de verrouillage" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Låseskjermposisjonering" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Позиционирование на экране блокировки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "锁屏定位" + } + } + } + }, + "Lock Screen Reminder Widget": { + "comment": "A section header for the lock screen reminder widget settings.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erinnerungs-Widget für den Sperrbildschirm" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Lock Screen Reminder Widget" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Lukitusnäytön muistutuswidget" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Widget de rappel sur l'écran verrouillé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Виджет напоминаний экрана блокировки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "锁屏提醒小组件" + } + } + } + }, + "lockScreen": { + "extractionState": "manual", + "shouldTranslate": false, + "localizations": { + "ru": { + "stringUnit": { + "value": "экран блокировки", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "锁屏" + } + } + } + }, + "Loop Mode": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schleifenmodus" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Loop Mode" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mode marche " + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим зацикливания" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "循环模式" + } + } + } + }, + "Loop Mode:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schleifenmodus:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Loop Mode:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mode marche :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим зацикливания:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "循环模式:" + } + } + } + }, + "Lottie JSON URL": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Lottie JSON URL" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Lottie JSON URL" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lottie JSON URL" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Lottie JSON URL" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Lottie JSON URL" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Lottie JSON URL" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lien URL Lottie JSON" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Lottie JSON URL" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "URL Lottie JSON" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "Lottie JSON URL" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Lottie JSON URL" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Lottie JSON URL" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "URL Lottie JSON" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Lottie JSON URL" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Lottie JSON URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "Lottie JSON 链接" + } + } + } + }, + "Low": { + "comment": "Low battery level label", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Low" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Nízká" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Niedrig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Low" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Low" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Bajo" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bas" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Low" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Bassa" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "낮음" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Niski" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Low" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Низкий" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Low" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Low" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "低" + } + } + } + }, + "Low Power Mode": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Low Power Mode" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Režim nízké spotřeby" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Energiesparmodus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Low Power Mode" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Low Power Mode" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Modo de bajo consumo" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mode économie d'énergie" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Low Power Mode" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Modalità Bassa Potenza" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "저전력 모드" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Tryb niskiego zużycia energii" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Low Power Mode" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим энергосбережения" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Düşük Güç Modu" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Режим економії енергії" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "低电量模式" + } + } + } + }, + "Mac battery at %d percent": { + "comment": "Mac battery gauge accessibility label", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mac Batterie bei %d Prozent" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Mac battery at %d percent" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Batterie Mac à %d pour cent" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заряд батареи Mac: %d%%" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Mac 电池电量 %d%%" + } + } + } + }, + "macOS 15 or later required": { + "comment": "A message displayed when the Custom OSD feature is unavailable on this version of macOS.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vyžaduje macOS 15 nebo novější" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "macOS 15 oder neuer erforderlich" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "macOS 15 or later required" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "macOS 15 ou version ultérieure requise" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуется macOS 15 или новее" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要 macOS 15 或更高版本" + } + } + } + }, + "Made with ❤️ by Ebullioscopic": { + "comment": "A footer text that credits the developer of the app.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "صنع مع ❤️ بواسطة Ebullioscopic" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Зроблена з любоўю ад Ebullioscopic" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vyrobeno s láskou ❤️ společností Ebullioscopic" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hergestellt mit ❤️ von Ebullioscopic" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Made with ❤️ by Ebullioscopic" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fabriqué avec ❤️ par Ebullioscopic" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Realizzato con ❤️ da Ebullioscopic" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сделано с ❤️ Ebullioscopic" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Tillverkad med ❤️ av Ebullioscopic" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "எபுல்லியோஸ்கோப்பிக்-ஆல் ❤️-உடன் உருவாக்கப்பட்டது" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "由 Ebullioscopic 用 ❤️ 制作" + } + } + } + }, + "Manage indicator preferences from Settings → Privacy. No manual microphone toggle is required.": { + "comment": "A description of how to manage the microphone privacy indicator preferences.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدارة تفضيلات المؤشر من الإعدادات → الخصوصية. لا يلزم تبديل الميكروفون اليدوي." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Кіруйце наладамі індыкатара ў Наладах → Канфідэнцыяльнасць. Не патрабуецца ручное ўключэнне/выключэнне мікрафона." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Nastavení indikátoru spravujte v části Nastavení → Soukromí. Ruční přepínání mikrofonu není nutné." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwalten Sie die Anzeigeeinstellungen unter Einstellungen → Datenschutz. Es ist kein manueller Mikrofonschalter erforderlich." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Manage indicator preferences from Settings → Privacy. No manual microphone toggle is required." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérer les préférences des indicateurs à partir de Paramètres → Confidentialité. Aucune bascule manuelle du microphone n'est requise." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "सेटिंग्स → प्राइवेसी से संकेतक प्राथमिकताएँ प्रबंधित करें। मैन्युअल माइक्रोफ़ोन टॉगल की आवश्यकता नहीं है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Gestisci le preferenze degli indicatori da Impostazioni → Privacy. Non è richiesta alcuna attivazione manuale del microfono." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정 → 개인정보 보호에서 표시기 기본값을 관리하세요. 수동 마이크 전환은 필요하지 않습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Administrer indikatorinnstillinger fra Innstillinger → Personvern. Det er ikke nødvendig å slå mikrofonen av og på manuelt." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управляйте индикаторами в Настройки → Конфиденциальность. Ручной переключатель микрофона не требуется." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Hantera indikatorinställningar från Inställningar → Sekretess. Ingen manuell mikrofonväxling krävs." + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "அமைப்புகள் → தனியுரிமை என்பதிலிருந்து குறிகாட்டி விருப்பங்களை நிர்வகிக்கவும். கைமுறை மைக்ரோஃபோன் சுவிட்ச் தேவையில்லை." + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在设置 → 隐私中管理指示器偏好。无需手动切换麦克风。" + } + } + } + }, + "Manage the Clock timer from the Clock app.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Spravujte časovač hodin z aplikace Hodiny." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwalten Sie den Clock-Timer in der Clock-App." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Manage the Clock timer from the Clock app." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérez le minuteur de l'horloge à partir de l'application Horloge." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Gestisci il timer dell'orologio dall'app dell'orologio." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시계 앱에서 시계 타이머를 관리하세요." + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управляйте таймером из приложения Часы." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从时钟应用管理时钟计时器。" + } + } + } + }, + "Mark as complete": { + "comment": "An accessibility label for a button that marks an item as complete.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Markiert als Vollständig" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Mark as complete" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Marquer comme terminé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отметить как выполненное" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标记为完成" + } + } + } + }, + "Mark as incomplete": { + "comment": "An accessibility label for a button that marks an item as incomplete.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Markiert als Unvollständig " + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Mark as incomplete" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Marquer comme incomplet" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отметить как невыполненное" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标记为未完成" + } + } + } + }, + "Match menubar height": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Match menubar height" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Match menubar height" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Höhe der Menüleiste" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Match menubar height" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Match menubar height" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Match menubar height" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Correspondre à la hauteur de la barre de menu" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Match menubar height" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Adatta altezza menubar" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "메뉴바 높이에 맞추기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Dopasuj do wysokości paska menu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Match menubar height" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Соответствовать высоте строки меню" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Menubar yüksekliğini eşle" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Match menubar height" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "匹配菜单栏高度" + } + } + } + }, + "Match real notch size": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Match real notch size" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Match real notch size" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Größe der echten Notch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Match real notch size" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Match real notch size" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Match real notch size" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Correspondre à la vraie hauteur de l'encoche" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Match real notch size" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Adatta altezza reale menubar" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "실제 노치 크기에 맞추기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Dopasuj do rzeczywistego rozmiaru notcha" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Match real notch size" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Соответствовать реальному размеру выреза" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Gerçek çentik boyutunu eşle" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Match real notch size" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "匹配Notch真实大小" + } + } + } + }, + "Material": { + "comment": "A picker that lets the user select the material of the lock screen glass.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Material" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Material" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Materiaali" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Matériau" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Материал" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "材质" + } + } + } + }, + "Material Options:": { + "comment": "A label describing the material options for the custom OSD.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Materialoptionen:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Material Options:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Options de matériaux :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Параметры материала:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "材质选项:" + } + } + } + }, + "Max Capacity: %lld%%": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Max Capacity: %lld%%" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Max Capacity: %lld%%" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Max. Kapazität: %lld%%" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Max Capacity: %lld%%" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Max Capacity: %lld%%" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Max Capacity: %lld%%" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Capacité maximale : %lld%%" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Max Capacity: %lld%%" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Capacità Massima: %lld%%" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "최대 용량: %lld%%" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Maksymalna pojemność: %lld%%" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Max Capacity: %lld%%" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Макс. емкость: %lld%%" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "அதிகபட்ச கொள்ளளவு: %lld%%" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Max Capacity: %lld%%" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Max Capacity: %lld%%" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "最大容量:%lld%%" + } + } + } + }, + "Media": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Media" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Media" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Medium" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Media" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Media" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Media" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Média" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Media" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Media" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "미디어" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Multimedia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Media" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Медиа" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Medya" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Media" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "媒体" + } + } + } + }, + "Media change with horizontal gestures": { + "comment": "A toggle that lets the user enable or disable the feature of changing the currently playing media using horizontal gestures.", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Media change with horizontal gestures" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Media change with horizontal gestures" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Medium Änderung mit Horizontalen Gesten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Media change with horizontal gestures" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Media change with horizontal gestures" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Media change with horizontal gestures" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Changement de média avec les gestuelles horizontales" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Media change with horizontal gestures" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Cambia media con gesti orizzontali" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "가로 방향 제스처로 미디어 변경" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Medieendring med horisontale bevegelser" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zmiana multimediów z poziomymi gestami" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Media change with horizontal gestures" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Горизонтальные жесты для управления медиа" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Media change with horizontal gestures" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Media change with horizontal gestures" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "横向手势改变媒体" + } + } + } + }, + "Media controls": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Media controls" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Media controls" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Medium Steuerung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Media controls" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Media controls" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Media controls" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contrôles des médias" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Media controls" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Controlli multimediali" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "미디어 컨트롤" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Ustawienia multimediów" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Media controls" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Элементы управления медиа" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Media controls" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Media controls" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "媒体控制" + } + } + } + }, + "Media inactivity timeout": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Media inactivity timeout" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Časový limit nečinnosti médií" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Medium Inaktivität Zeitüberschreitung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Media inactivity timeout" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Media inactivity timeout" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Media inactivity timeout" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Délai d'inactivité du média" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Media inactivity timeout" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Timeout inattività media" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "미디어 비활성화 시간 초과" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Limit czasu braku aktywności multimediów" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Media inactivity timeout" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Таймаут неактивности медиа" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Media inactivity timeout" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Media inactivity timeout" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "媒体非活动超时" + } + } + } + }, + "Media Live Activity": { + "comment": "A section header that lists the media live activity settings.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Medien-Live-Aktivität" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Media Live Activity" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activité en direct dans les médias" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Медиа Live Activity" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "媒体实时活动" + } + } + } + }, + "Media output": { + "comment": "Accessibility label describing the control that opens the media output selector.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Medienausgabe" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Media output" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Production médiatique" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вывод аудио" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "媒体输出" + } + } + } + }, + "Media Panel": { + "comment": "A section header in the lock screen settings view.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Medienpanel" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Media Panel" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Panel média" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Медиа-панель" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Медіа-панель" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "媒体面板" + } + } + } + }, + "Media playback live activity": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Media playback live activity" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Živá aktivita přehrávání médií" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Medium Wiedergabe live Aktivität" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Media playback live activity" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Media playback live activity" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Media playback live activity" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activité de lecture de média en direct" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Media playback live activity" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Riproduzione media attività live" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "미디어 재생 실시간 활동" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Medieavspilling live-aktivitet" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Odtwarzanie multimediów na żywo" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Atividade ao vivo da reprodução de mídia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно Live Activity для медиа" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "ஊடக ஒளிபரப்பு நேரடிச் செயல்பாடு" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Media playback live activity" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відтворення медіа в режимі реального часу" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "媒体播放实时动态" + } + } + } + }, + "Media Source": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مصدر وسائل الإعلام" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Zdroj médií" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Medium Quelle" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Media Source" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Media Source" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Media Source" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Source du Média" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Media Source" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Sorgente multimediale" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "미디어 소스" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Źródło multimediów" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Media Source" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Источник медиа" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Medya kaynağı" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Media Source" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "媒体来源" + } + } + } + }, + "Medium": { + "comment": "Medium battery level label", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Medium" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Střední" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mittel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Medium" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Medium" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Mediano" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Moyen" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Medium" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Media" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "중간" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Średni" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Medium" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Mediuм" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Medium" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Medium" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "中" + } + } + } + }, + "Memory Usage": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Speichernutzung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Memory Usage" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Memory Usage" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation de la mémoire" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использование памяти" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内存使用情况" + } + } + } + }, + "Menubar icon": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Menubar icon" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Menubar icon" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Icon in Menüleiste anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Menubar icon" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Menubar icon" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Menubar icon" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Icône de la barre de menu" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Menubar icon" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Menubar icon" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "메뉴바 아이콘" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Menubar icon" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Menubar icon" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Иконка в строке меню" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Menubar icon" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Menubar icon" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "菜单栏图标" + } + } + } + }, + "Mic %@": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Mic %@" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Mic %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mikrofon %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mic %@" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Mic %@" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Mic %@" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Microphone %@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Mic %@" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Mic %@" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "마이크 %@" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Mikrofon %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Mic %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Микрофон %@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Mic %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Маленький %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "麦克风 %@" + } + } + } + }, + "Mic muted": { + "comment": "A description of the state when the microphone is muted.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mikrofon stummgeschaltet" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Mic muted" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Micro coupé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Микрофон выключен" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "麦克风已静音" + } + } + } + }, + "Mic unmuted": { + "comment": "A description of the state when the microphone is unmuted.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mikrofon stummgeschaltet" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Mic unmuted" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Microphone activé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Микрофон включен" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "麦克风已取消静音" + } + } + } + }, + "Microphone": { + "comment": "A label displayed next to the microphone icon in the privacy indicator detail view.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mikrofon" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Microphone" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Microphone" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Микрофон" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "麦克风" + } + } + } + }, + "Microphone Active": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mikrofon Aktiv" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Microphone Active" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Microphone actif" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Микрофон активен" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "麦克风已激活" + } + } + } + }, + "Microphone is being used by an application": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Mikrofon wird von einer Anwendung verwendet." + } + }, + "ru": { + "stringUnit": { + "value": "Микрофон используется приложением", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "麦克风正被某个应用使用" + } + } + } + }, + "Microphone privacy indicator runs automatically whenever apps access audio.": { + "comment": "A description of the microphone privacy indicator feature.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعمل مؤشر خصوصية الميكروفون تلقائيا كلما وصلت التطبيقات إلى الصوت." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Індзікатар прыватнасці мікрафона ўключаецца аўтаматычна кожны раз, калі праграмы атрымліваюць доступ да гуку." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Indikátor soukromí mikrofonu se spouští automaticky vždy, když aplikace přistupují k zvuku." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Datenschutzanzeige des Mikrofons wird automatisch angezeigt, wenn Apps auf Audio zugreifen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Microphone privacy indicator runs automatically whenever apps access audio." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Microphone privacy indicator runs automatically whenever apps access audio." + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'indicateur de confidentialité du microphone fonctionne automatiquement chaque fois que les applications accèdent à l'audio." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "जब भी ऐप्स ऑडियो का उपयोग करती हैं, माइक्रोफ़ोन गोपनीयता संकेतक स्वचालित रूप से चालू हो जाता है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L'indicatore di privacy del microfono funziona automaticamente ogni volta che le app accedono all'audio." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마이크 프라이버시 표시기는 앱이 오디오에 접근할 때마다 자동으로 실행됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Mikrofonens personvernindikator kjører automatisk når apper får tilgang til lyd." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Индикатор конфиденциальности микрофона включается автоматически, когда приложения используют аудио." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "செயலிகள் ஒலியை அணுகும் போதெல்லாம் மைக்ரோஃபோன் தனியுரிமை சுட்டிக்காட்டி தானாகவே இயங்கும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Mikrofon gizlilik göstergesi, uygulamalar sese her eriştiğinde otomatik olarak çalışır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Індикатор конфіденційності мікрофона працює автоматично, коли програми отримують доступ до аудіо." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "麦克风隐私指示器在应用访问音频时自动运行。" + } + } + } + }, + "Microphone Status": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " Mikrofon Status" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Microphone Status" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Statut du microphone" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статус микрофона" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "麦克风状态" + } + } + } + }, + "min": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Minute" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "min" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "min" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "мин" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分钟" + } + } + } + }, + "Minimalistic mode focuses on media controls and system HUDs, hiding all extra features for a clean, focused experience. Automatically enables simpler animations.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يركز الوضع البسيط على عناصر التحكم في الوسائط ونظام HUDs، وإخفاء جميع الميزات الإضافية لتجربة نظيفة ومركزة. يتيح تلقائيا رسوما متحركة أبسط." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Мінімалістычны рэжым сканцэнтраваны на кіраванні мультымедыя і сістэмнымі HUD-амі, хаваючы ўсе лішнія функцыі для чыстага, сканцэнтраванага досведу. Аўтаматычна ўключае больш простыя анімацыі." + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der minimalistische Modus konzentriert sich auf Mediensteuerungen und System-HUDs und verbirgt alle zusätzlichen Funktionen für ein sauberes, fokussiertes Erlebnis. Ermöglicht automatisch einfachere Animationen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Minimalistic mode focuses on media controls and system HUDs, hiding all extra features for a clean, focused experience. Automatically enables simpler animations." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Minimalistic mode focuses on media controls and system HUDs, hiding all extra features for a clean, focused experience. Automatically enables simpler animations." + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le mode minimaliste se concentre sur les commandes multimédias et les HUD du système, cachant toutes les fonctionnalités supplémentaires pour une expérience propre et ciblée. Active automatiquement des animations plus simples." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "मिनिमलिस्टिक मोड मीडिया नियंत्रणों और सिस्टम HUDs पर केंद्रित होता है, एक स्वच्छ और केंद्रित अनुभव के लिए सभी अतिरिक्त सुविधाओं को छिपा देता है। यह स्वचालित रूप से सरल एनिमेशन सक्षम करता है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La modalità minimalista si concentra sui controlli multimediali e sugli HUD di sistema, nascondendo tutte le funzionalità extra per un'esperienza pulita e mirata. Abilita automaticamente animazioni più semplici." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미니멀 모드는 미디어 컨트롤과 시스템 HUD에 집중하며, 깔끔하고 집중된 경험을 위해 모든 추가 기능을 숨깁니다. 자동으로 더 단순한 애니메이션을 활성화합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Minimalistisk modus fokuserer på mediekontroller og system-HUD-er, og skjuler alle ekstrafunksjoner for en ren og fokusert opplevelse. Aktiverer automatisk enklere animasjoner." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Минималистичный режим фокусируется на медиа и системных HUD, скрывая все лишние функции. Автоматически включает простые анимации." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Minimalistiskt läge fokuserar på mediekontroller och system-HUD:ar och döljer alla extrafunktioner för en ren och fokuserad upplevelse. Aktiverar automatiskt enklare animationer." + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "குறைந்தபட்ச பயன்முறை, ஊடகக் கட்டுப்பாடுகள் மற்றும் அமைப்பு HUD-களில் கவனம் செலுத்தி, ஒரு நேர்த்தியான, ஒருமுகப்படுத்தப்பட்ட அனுபவத்திற்காக அனைத்து கூடுதல் அம்சங்களையும் மறைக்கிறது. இது தானாகவே எளிமையான அனிமேஷன்களைச் செயல்படுத்துகிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Minimalist mod, temiz, odaklanmış bir deneyim için tüm ekstra özellikleri gizleyerek medya kontrollerine ve sistem HUD'lerine odaklanır. Daha basit animasyonları otomatik olarak etkinleştirir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Мінімалістичний режим зосереджується на елементах керування медіа та системних HUD, приховуючи всі додаткові функції для чистого, зосередженого досвіду. Автоматично вмикає простіші анімації." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "简约模式专注于媒体控制和系统 HUD,隐藏所有额外功能以提供简洁、专注的体验。自动启用更简单的动画。" + } + } + } + }, + "Minimum hover duration": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Minimum hover duration" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Minimum hover duration" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Minimale Schwebedauer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Minimum hover duration" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Minimum hover duration" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Minimum hover duration" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Durée minimale du survol" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Minimum hover duration" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Durata minima di hover" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최소 호버 지속 시간" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Minimalny czas najechania kursorem" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Minimum hover duration" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Минимальное время наведения" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Minimum asılı kalma süresi" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Minimum hover duration" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "悬停最短时长" + } + } + } + }, + "Minutes": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Minuten" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Minutes" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Minutes" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Минуты" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分钟" + } + } + } + }, + "Mirror": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Mirror" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Mirror" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Spiegel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mirror" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Mirror" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Mirror" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Miroir" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Mirror" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Specchio" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "거울" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Lustro" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Mirror" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Зеркало" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Ayna" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Mirror" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "镜子" + } + } + } + }, + "Mirror shape": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Mirror shape" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Mirror shape" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Spiegelform" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mirror shape" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Mirror shape" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Mirror shape" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Forme du miroir" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Mirror shape" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Forma dello specchio" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "거울 모양" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Kształt lustra" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Mirror shape" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Зеркалить форму" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Ayna şekli" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Mirror shape" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "镜子大小" + } + } + } + }, + "Mirror System Timer": { + "extractionState": "manual", + "localizations": { + "ru": { + "stringUnit": { + "value": "Повторять системный таймер", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "同步系统计时器" + } + } + } + }, + "Mirrors the toggle found under Lock Screen settings so timer-specific workflows can enable or disable the widget without switching tabs.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعكس التبديل الموجود ضمن إعدادات قفل الشاشة حتى تتمكن سير العمل الخاص بالموقت من تمكين القطعة أو تعطيلها دون تبديل علامات التبويب." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Дубліруе пераключальнік, размешчаны ў наладах экрана блакіроўкі, каб можна было ўключаць або выключаць віджэт таймера, не пераключаючы ўкладкі." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Zrcadlí přepínač, který se nachází v nastavení uzamčení obrazovky, takže pracovní postupy specifické pro časovač mohou widget povolit nebo zakázat bez přepínání karet." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Spiegelt den Schalter unter den Einstellungen des Sperrbildschirms wider, sodass timerspezifische Workflows das Widget aktivieren oder deaktivieren können, ohne die Registerkarten zu wechseln." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Mirrors the toggle found under Lock Screen settings so timer-specific workflows can enable or disable the widget without switching tabs." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Mirrors the toggle found under Lock Screen settings so timer-specific workflows can enable or disable the widget without switching tabs." + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reflète la bascule trouvée sous les paramètres de l'écran de verrouillage afin que les flux de travail spécifiques à la minuterie puissent activer ou désactiver le widget sans changer d'onglet." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "लॉक स्क्रीन सेटिंग्स में पाए जाने वाले टॉगल को दर्शाता है, ताकि टाइमर-विशिष्ट वर्कफ़्लो टैब बदले बिना विजेट को सक्षम या अक्षम कर सकें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Specchia l'interruttore che si trova nelle impostazioni della schermata di blocco in modo che i flussi di lavoro specifici del timer possano abilitare o disabilitare il widget senza cambiare scheda." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잠금 화면 설정에서 찾을 수 있는 토글을 반영하여, 타이머 관련 작업 흐름에서 탭 전환 없이 위젯을 활성화하거나 비활성화할 수 있습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Speiler bryteren som finnes under innstillingene for låseskjerm, slik at tidsbestemte arbeidsflyter kan aktivere eller deaktivere widgeten uten å bytte faner." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дублирует переключатель настроек экрана блокировки для управления виджетом из этой вкладки." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Speglar växeln som finns under inställningarna för låsskärmen så att timerspecifika arbetsflöden kan aktivera eller inaktivera widgeten utan att byta flik." + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kilit Ekranı ayarları altında bulunan geçişi yansıtır, böylece zamanlayıcıya özel iş akışları sekmeleri değiştirmeden widget'ı etkinleştirebilir veya devre dışı bırakabilir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відображає перемикач, розташований у налаштуваннях екрану блокування, щоб робочі процеси, пов'язані з таймером, могли вмикати або вимикати віджет без перемикання вкладок." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "镜像锁屏设置中的开关,以便计时器工作流可以无需切换标签页即可启用或禁用小组件。" + } + } + } + }, + "Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "راقب تغييرات الحافظة واحتفظ بسجل النسخ الحديثة. استخدم Cmd+Shift+V للوصول بسرعة إلى سجل الحافظة." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Сачыце за зменамі ў буферы абмену і захоўвайце гісторыю апошніх капіяванняў. Выкарыстоўвайце Cmd+Shift+V для хуткага доступу да гісторыі буфера абмену." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Sledujte změny ve schránce a uchovávejte historii posledních kopií. Pomocí klávesové zkratky Cmd+Shift+V můžete rychle přistupovat k historii schránky." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Überwachen Sie die Änderungen an der Zwischenablage und bewahren Sie einen Verlauf der letzten Kopien auf. Verwenden Sie Cmd+Shift+V, um schnell auf den Zwischenablageverlauf zuzugreifen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Supervisa los cambios en el portapapeles y guarda un historial de las copias recientes. Utiliza Cmd+Mayús+V para acceder rápidamente al historial del portapapeles." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Surveillez les modifications apportées au presse-papiers et conservez un historique des copies récentes. Utilisez Cmd+Shift+V pour accéder rapidement à l'historique du presse-papiers." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "क्लिपबोर्ड में होने वाले परिवर्तनों की निगरानी करें और हाल की कॉपीज़ का इतिहास रखें। क्लिपबोर्ड इतिहास तक तुरंत पहुँचने के लिए Cmd+Shift+V का उपयोग करें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Monitora le modifiche agli appunti e conserva una cronologia delle copie recenti. Usa Cmd+Shift+V per accedere rapidamente alla cronologia degli appunti." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클립보드 변경 사항을 모니터링하고 최근 복사 내역을 기록합니다. Cmd+Shift+V를 사용하여 클립보드 기록에 빠르게 접근하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Overvåk endringer i utklippstavlen og lag en historikk over nylige kopier. Bruk Cmd+Shift+V for å få rask tilgang til utklippstavlens historikk." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отслеживайте изменения буфера обмена и ведите историю копирования. Нажмите Cmd+Shift+V для быстрого доступа." + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Övervaka ändringar i urklipp och spara en historik över senaste kopior. Använd Cmd+Shift+V för att snabbt komma åt urklippets historik." + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pano değişikliklerini izleyin ve son kopyaların geçmişini saklayın. Pano geçmişine hızlı bir şekilde erişmek için Cmd+Shift+V tuşlarını kullanın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відстежуйте зміни в буфері обміну та зберігайте історію останніх копій. Використовуйте Cmd+Shift+V для швидкого доступу до історії буфера обміну." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "监控剪贴板变化并保留最近复制的历史记录。使用 Cmd+Shift+V 快速访问剪贴板历史。" + } + } + } + }, + "Monitoring Behavior": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verhalten überwachen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Monitoring Behavior" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Monitoring Behavior" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим мониторинга" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "监控行为" + } + } + } + }, + "Monitoring Status": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Überwachungsstatus" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Monitoring Status" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Statut du suivi" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статус мониторинга" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "监控状态" + } + } + } + }, + "Monitoring Stopped": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Überwachung gestoppt" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Monitoring Stopped" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le suivi a pris fin" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Мониторинг остановлен" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "监控已停止" + } + } + } + }, + "More": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "More" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "More" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mehr" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "More" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "More" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Más" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Plus" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "More" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Altro" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "더" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Więcej" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "More" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Больше" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "More" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "More" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "更多" + } + } + } + }, + "Move Down": { + "comment": "A button label that says \"Move Down\".", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach Unten Bewegen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Move Down" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Descendre" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить вниз" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下移" + } + } + } + }, + "Move Up": { + "comment": "A button label that says \"Move Up\".", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach Oben bewegen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Move Up" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Monter" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить вверх" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上移" + } + } + } + }, + "Music Source": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Music Source" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Music Source" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Musik Quelle" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Music Source" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Music Source" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Fuente de música" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Source de la musique" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Music Source" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Sorgente Musica" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "음악 소스" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Źródło muzyki" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Music Source" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Источник музыки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Müzik kaynağı" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Music Source" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "音乐源" + } + } + } + }, + "muted": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "muted" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "muted" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Stumm" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "muted" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "muted" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "silenciado" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "muet" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "muted" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "mutato" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "음소거됨" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wyciszony" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "muted" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "без звука" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "susturuldu" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "muted" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "已静音" + } + } + } + }, + "My Animation": { + "comment": "A default name for a new animation added via a URL.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Meine Animation" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "My Animation" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mon Animation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Моя анимация" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "我的动画" + } + } + } + }, + "Name": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Name" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Name" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Nombre" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Name" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Nome" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "이름" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Nazwa" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Name" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Имя" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "İsim" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Name" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "名称" + } + } + } + }, + "Navigation": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Navigation" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Navigation" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Navigation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Навигация" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导航" + } + } + } + }, + "Need Restart": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Need Restart" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Need Restart" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Braucht einen Neustart" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Need Restart" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Need Restart" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Need Restart" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrage nécessaire" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Need Restart" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Necessario Riavvio" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "재시작 필요" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Potrzebne ponowne uruchomienie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Need Restart" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуется перезапуск" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Need Restart" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Need Restart" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "需要重启" + } + } + } + }, + "Network Download": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerk-Download" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Network Download" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Téléchargement réseau" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скачивание" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络下载" + } + } + } + }, + "Network Upload": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerk-Upload" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Network Upload" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoi réseau" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络上传" + } + } + } + }, + "Never hide": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Never hide" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Never hide" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Niemals verstecken" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Never hide" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Never hide" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Never hide" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ne jamais masquer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Never hide" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Non nascondere mai" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "숨기지 않기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Nigdy nie ukrywaj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Never hide" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Никогда не скрывать" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Asla gizleme" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Never hide" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "永不隐藏" + } + } + } + }, + "No active interfaces detected yet.": { + "comment": "A message displayed when there are no active network interfaces to display.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Zatím nebyla detekována žádná aktivní rozhraní." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine aktiven Schnittstellen erkannt." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No active interfaces detected yet." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Aktiivisia rajapintoja ei vielä havaittu." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune interface détectée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen aktive grensesnitt oppdaget ennå." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Активных интерфейсов пока не обнаружено." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未检测到活跃网络接口。" + } + } + } + }, + "No audio outputs available": { + "comment": "Displayed when no audio output devices can be listed in the media output selector.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Audioausgänge verfügbar" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No audio outputs available" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune sortie audio disponible" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen lydutganger tilgjengelig" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет аудиовыходов" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无可用音频输出" + } + } + } + }, + "No clipboard history": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Zwischenablageverlauf" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No clipboard history" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’historique du presse-papiers est vide" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen utklippstavlehistorikk" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет истории буфера обмена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无剪贴板历史" + } + } + } + }, + "No Colors Found": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Farben gefunden" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No Colors Found" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune couleur n’a été trouvé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвета не найдены" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到颜色" + } + } + } + }, + "No colors match '%@'": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Farbübereinstimmung '%@'" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No colors match '%@'" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune couleur ne correspond à \"%@\"" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет цветов, соответствующих «%@»" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有颜色匹配 '%@'" + } + } + } + }, + "No Colors Yet": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch Keine Farben" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No Colors Yet" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pas encore de couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цветов еще нет" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无颜色" + } + } + } + }, + "No custom animation available": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "No custom animation available" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "No custom animation available" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Benutzerdefinierten Animationen verfügbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No custom animation available" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "No custom animation available" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "No custom animation available" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune animation personnalisée disponible" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "No custom animation available" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Nessuna animazione personalizzata disponibile" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "사용자 정의 애니메이션 없음" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Brak dostępnych niestandardowych animacji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "No custom animation available" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет пользовательской анимации" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "No custom animation available" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "No custom animation available" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "没有可用的自定义动画" + } + } + } + }, + "No custom visualizer": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "No custom visualizer" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "No custom visualizer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine benutzerdefinierte Audiovisualisierung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No custom visualizer" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "No custom visualiser" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "No custom visualizer" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun visualiseur personnalisé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "No custom visualizer" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Nessun visualizzatore personalizzato" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "사용자 지정 시각화 도구 없음" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Brak niestandardowych wizualizerów" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "No custom visualizer" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет пользовательского визуализатора" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "No custom visualizer" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "No custom visualizer" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "没有自定义可视化器" + } + } + } + }, + "No events": { + "comment": "A description displayed when there are no events in the calendar.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Event" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No events" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun événement" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет событий" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无事件" + } + } + } + }, + "No events today": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "No events today" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "No events today" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Ereignisse heute" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No events today" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "No events today" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "No events today" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun événement aujourd'hui" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "No events today" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Non ci sono eventi" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "오늘 일정 없음" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Brak wydarzeń na dzisiaj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "No events today" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сегодня событий нет" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "No events today" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "No events today" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "今天无日程" + } + } + } + }, + "No excluded apps": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine ausgeschlossenen Apps" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No excluded apps" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune application exclue" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет исключенных приложений" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无排除的应用" + } + } + } + }, + "No excludes": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Ausschlüsse" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No excludes" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune exclusion" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет исключений" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无排除项" + } + } + } + }, + "No extension installed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "No extension installed" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "No extension installed" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Erweiterungen installiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No extension installed" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "No extension installed" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "No extension installed" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune extension installée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "No extension installed" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Nessuna estensione installata" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "설치된 확장기능 없음" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Brak zainstalowanych rozszerzeń" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "No extension installed" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Расширения не установлены" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "No extension installed" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "No extension installed" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "未安装扩展" + } + } + } + }, + "No favorites": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Favoriten" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No favorites" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun favoris" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет избранного" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无收藏" + } + } + } + }, + "No Graphs Enabled": { + "comment": "A label displayed when no graphs are enabled in the Stats tab.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Grafiken aktiviert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No Graphs Enabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun graphique activé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет включенных графиков" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未启用图表" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم تكوين أي إعدادات مسبقة. أضف إعدادا مسبقا لجعله يظهر في النافذة المنبثقة المؤقت." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Ніякіх предустановак не наладжана. Дадайце предустаноўку, каб яна з'явілася ў сплывальным акне таймера." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Nejsou nakonfigurovány žádné předvolby. Přidejte předvolbu, aby se zobrazila v rozbalovací nabídce časovače." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Voreinstellungen konfiguriert. Fügen Sie eine Voreinstellung hinzu, damit sie im Timer-Popover angezeigt wird." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No presets configured. Add a preset to make it appear in the timer popover." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Esiasetuksia ei ole määritetty. Lisää esiasetus, jotta se näkyy ajastimen ponnahdusikkunassa." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun préréglage configuré. Ajoutez un préréglage pour le faire apparaître dans la fenêtre contextuelle de la minuterie." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "कोई प्रीसेट कॉन्फ़िगर नहीं किया गया है। टाइमर पॉपओवर में दिखाने के लिए एक प्रीसेट जोड़ें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessun preset configurato. Aggiungi un preset per farlo apparire nel popover del timer." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen forhåndsinnstillinger konfigurert. Legg til en forhåndsinnstilling for å få den til å vises i timer-popup-vinduet." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет настроенных пресетов. Добавьте пресет для его появления в окне таймера." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yapılandırılmış ön ayar yok. Zamanlayıcı açılır penceresinde görünmesi için bir ön ayar ekleyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не налаштовано жодних пресетів. Додайте пресет, щоб він з'явився у спливаючому вікні таймера." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未配置预设。添加预设使其出现在计时器弹出窗口中。" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Працэсных даных пакуль няма. Затрымайце статыстыку адкрытай на хвілінку." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Zatím nejsou k dispozici žádná procesní data. Nechte statistiky chvíli otevřené." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Prozessdaten. Halten Sie die Statistiken für einen Moment offen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No process data yet. Keep stats open for a moment." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Prosessitietoja ei vielä ole. Pidä tilastot hetken auki." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pas encore de données de traitement. Gardez les statistiques ouvertes pendant un moment." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessun dato di processo ancora. Tieni le statistiche aperte per un momento." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen forhåndsinnstillinger konfigurert. Legg til en forhåndsinnstilling for å få den til å vises i timer-popup-vinduet." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Данных о процессах пока нет. Оставьте меню открытым ненадолго." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Поки що немає даних про процес. Залиште статистику відкритою на деякий час." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚无进程数据。请保持统计打开片刻。" + } + } + } + }, + "No results": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Ergebnis" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No results" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun résultats" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет результатов" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无结果" + } + } + } + }, + "No results found": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Ergebnisse gefunden" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No results found" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun résultats" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Результаты не найдены" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到结果" + } + } + } + }, + "No storage devices detected yet.": { + "comment": "A message indicating that no storage devices have been detected yet.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Zatím nebyla detekována žádná úložná zařízení." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Speichergeräte erkannt." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "No storage devices detected yet." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Tallennuslaitteita ei vielä havaittu." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun périphérique de stockage détecté pour le moment." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen lagringsenheter oppdaget ennå." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Устройства хранения пока не обнаружены." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未检测到存储设备。" + } + } + } + }, + "Non-notch display height": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Non-notch display height" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Non-notch display height" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Displayhöhe ohne Noch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Non-notch display height" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Non-notch display height" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Non-notch display height" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hauteur de l'affichage hors-Notch" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Non-notch display height" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Altezza display senza-notch" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "노치 확장 전 표시 높이" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wysokość wyświetlacza bez notcha" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Non-notch display height" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Высота дисплея без выреза" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Non-notch display height" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Non-notch display height" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "非Notch显示高度" + } + } + } + }, + "Not active": { + "comment": "A description of whether the camera is currently active.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht Aktiv" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Not active" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non active" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неактивно" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不活跃" + } + } + } + }, + "Not Now": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Not Now" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Not Now" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht jetzt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Not Now" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Not Now" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Ahora no" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pas pour l'instant" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Not Now" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Non Ora" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "나중에" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Nie teraz" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Not Now" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не сейчас" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Şimdi Değil" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Not Now" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "不是现在" + } + } + } + }, + "Not Set": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht gespeichert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Not Set" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non défini" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не задано" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未设置" + } + } + } + }, + "Notch behavior": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Notch behavior" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Notch behavior" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verhalten der Notch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Notch behavior" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Notch behavior" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Comportamiento de Notch" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Comportement du Notch" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Notch behavior" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Comportamento notch" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "노치 동작" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zachowanie notcha" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Notch behavior" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поведение выреза" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Notch behavior" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Notch behavior" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "Notch行为" + } + } + } + }, + "Notch display height": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Notch display height" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Notch display height" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigehöhe der Notch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Notch display height" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Notch display height" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Notch display height" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hauteur d'affichage du Notch" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Notch display height" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Altezza display con notch" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "노치 확장 후 표시 높이" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wysokość wyświetlania notcha" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Notch display height" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Высота дисплея с вырезом" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Notch display height" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Notch display height" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "Notch显示高度" + } + } + } + }, + "Notch Height": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Notch Height" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Notch Height" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Größe der Notch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Notch Height" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Notch Height" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Notch Height" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hauteur du Notch" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Notch Height" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Altezza Notch" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "노치 높이" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wysokość notcha" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Notch Height" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Высота выреза" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Çentik yüksekliği" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Notch Height" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "Notch高度" + } + } + } + }, + "Notch Width": { + "comment": "A section header for adjusting the expanded notch width.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Notch Breite" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Notch Width" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Notchin leveys" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Largeur de l'encoche" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ширина выреза" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刘海宽度" + } + } + } + }, + "Notify before": { + "comment": "A label describing the slider that allows the user to set a reminder lead time.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorher benachrichtigen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Notify before" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Prévenir avant" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомлять за" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "提前通知" + } + } + } + }, + "OK": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "OK" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "OK" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Ok" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ok" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "OK" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "OK" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "네" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "OK" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "OK" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ОК" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "OK" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "OK" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "好的" + } + } + } + }, + "One or more files failed to load": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "One or more files failed to load" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "One or more files failed to load" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eine oder mehrere Dateien konnten nicht geladen werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "One or more files failed to load" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "One or more files failed to load" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "One or more files failed to load" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de charger un ou plusieurs fichiers" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "One or more files failed to load" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Impossibile caricare uno o più file" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "한 개 이상의 파일 로드 실패" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Nie udało się załadować jednego lub więcej plików" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "One or more files failed to load" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Один или несколько файлов не удалось загрузить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "One or more files failed to load" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "One or more files failed to load" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "一个或多个文件加载失败" + } + } + } + }, + "Only app icon": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur App-Symbol" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Only app icon" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Icône de l'application uniquement" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Только иконка приложения" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅应用图标" + } + } + } + }, + "Only download icon": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Download-Symbol" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Only download icon" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Icône de téléchargement uniquement" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Только иконка скачивания" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅下载图标" + } + } + } + }, + "Opacity:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deckkraft:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Opacity:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Opacité :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Непрозрачность:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "透明度:" + } + } + } + }, + "Open Google AI Studio": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffnen Sie Google AI Studio" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Open Google AI Studio" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir Google AI Studio" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "गूगल एआई स्टूडियो खोलें" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть Google AI Studio" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开 Google AI Studio" + } + } + } + }, + "Open in Calendar": { + "comment": "A button label that opens a calendar with the event details.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Im Kalender öffnen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Open in Calendar" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir dans le calendrier" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть в приложении Календарь" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在日历中打开" + } + } + } + }, + "Open Model Settings": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Modelleinstellungen öffnen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Open Model Settings" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les paramètres du modèle" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "ओपन मॉडल सेटिंग्स" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть настройки модели" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开模型设置" + } + } + } + }, + "Open noch on hover": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Darüberfahren mit der Maus öffnen" + } + }, + "ru": { + "stringUnit": { + "value": "Открывать вырез при наведении", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "悬停时打开刘海" + } + } + } + }, + "Open Settings": { + "comment": "A button that opens system settings when pressed.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen öffnen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Open Settings" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les paramètres" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть Настройки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开设置" + } + } + } + }, + "Open System Settings": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Open System Settings" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les paramètres système" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "सिस्टम सेटिंग्स खोलें" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть Системные Настройки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开系统设置" + } + } + } + }, + "Opens the AI assistant panel for file analysis and conversation. Default is Cmd+Shift+A. Only works when screen assistant feature is enabled.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يفتح لوحة مساعد الذكاء الاصطناعي لتحليل الملفات والمحادثة. الافتراضي هو Cmd+Shift+A. يعمل فقط عند تمكين ميزة مساعد الشاشة." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Адкрывае панэль дапаможніка AI для аналізу і пераўтварэння файлаў. Па змаўчанні — Cmd+Shift+A. Працуе толькі калі ўключана функцыя экраннага дапаможніка." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Otevře panel AI asistenta pro analýzu souborů a konverzaci. Výchozí klávesová zkratka je Cmd+Shift+A. Funguje pouze při zapnuté funkci asistenta obrazovky." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffnet das KI-Assistenten-Panel für Dateianalyse und Konversation. Standard ist Cmd+Shift+A. Funktioniert nur, wenn die Funktion des Bildschirmassistenten aktiviert ist." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Opens the AI assistant panel for file analysis and conversation. Default is Cmd+Shift+A. Only works when screen assistant feature is enabled." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvre le panneau d'assistant d'IA pour l'analyse et la conversation de fichiers. La valeur par défaut est Cmd+Shift+A. Ne fonctionne que lorsque la fonction d'assistant d'écran est activée." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "फ़ाइल विश्लेषण और बातचीत के लिए AI सहायक पैनल खोलता है। डिफ़ॉल्ट Cmd+Shift+A है। केवल तभी काम करता है जब स्क्रीन सहायक सुविधा सक्षम हो।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apre il pannello dell'assistente AI per l'analisi dei file e la conversazione. L'impostazione predefinita è Cmd+Shift+A. Funziona solo quando la funzione di assistente dello schermo è abilitata." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpner AI-assistentpanelet for filanalyse og samtale. Standard er Cmd+Shift+A. Fungerer bare når skjermassistentfunksjonen er aktivert." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открывает панель ИИ-помощника. По умолчанию Cmd+Shift+A." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dosya analizi ve konuşma için AI asistan panelini açar. Varsayılan Cmd+Shift+A'dır. Yalnızca ekran yardımcısı özelliği etkinleştirildiğinde çalışır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відкриває панель AI-асистента для аналізу файлів і спілкування. За замовчуванням — Cmd+Shift+A. Працює тільки при увімкненій функції екранного асистента." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开 AI 助手面板,进行文件分析与对话。默认为 Cmd+Shift+A。仅在启用屏幕助手功能时有效。" + } + } + } + }, + "Opens the clipboard history panel. Default is Cmd+Shift+V (similar to Windows+V on PC). Only works when clipboard feature is enabled.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يفتح لوحة سجل الحافظة. الافتراضي هو Cmd+Shift+V (على غرار Windows+V على جهاز الكمبيوتر). يعمل فقط عند تمكين ميزة الحافظة." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Адкрывае панэль гісторыі буфера абмену. Па змаўчанні — Cmd+Shift+V (падобна да Windows+V на ПК). Працуе толькі калі функцыя буфера абмену ўключана." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Otevře panel historie schránky. Výchozí klávesová zkratka je Cmd+Shift+V (podobně jako Windows+V na PC). Funguje pouze v případě, že je funkce schránky povolena." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffnet das Bedienfeld für den Clip-Verlauf. Standard ist Cmd+Shift+V (ähnlich wie Windows+V auf dem PC). Funktioniert nur, wenn die Zwischenablagefunktion aktiviert ist." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Opens the clipboard history panel. Default is Cmd+Shift+V (similar to Windows+V on PC). Only works when clipboard feature is enabled." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvre le panneau d'historique du presse-papiers. La valeur par défaut est Cmd+Shift+V (similaire à Windows+V sur PC). Ne fonctionne que lorsque la fonction presse-papiers est activée." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "क्लिपबोर्ड इतिहास पैनल खोलता है। डिफ़ॉल्ट Cmd+Shift+V है (PC पर Windows+V के समान)। केवल तभी काम करता है जब क्लिपबोर्ड सुविधा सक्षम हो।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apre il pannello della cronologia degli appunti. L'impostazione predefinita è Cmd+Shift+V (simile a Windows+V su PC). Funziona solo quando la funzione degli appunti è abilitata." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpner utklippstavlehistorikkpanelet. Standard er Cmd+Shift+V (tilsvarende Windows+V på PC). Fungerer bare når utklippstavlefunksjonen er aktivert." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открывает панель буфера обмена. По умолчанию Cmd+Shift+V." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pano geçmişi panelini açar. Varsayılan Cmd+Shift+V'dir (PC'deki Windows+V'ye benzer). Yalnızca pano özelliği etkinleştirildiğinde çalışır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відкриває панель історії буфера обміну. За замовчуванням — Cmd+Shift+V (аналогічно Windows+V на ПК). Працює тільки тоді, коли увімкнено функцію буфера обміну." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开剪贴板历史记录面板。默认为 Cmd+Shift+V(类似于 PC 上的 Windows+V)。仅在启用剪贴板功能时有效。" + } + } + } + }, + "Opens the color picker panel for screen color capture. Default is Cmd+Shift+P. Only works when color picker feature is enabled.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يفتح لوحة منتقي الألوان لالتقاط ألوان الشاشة. الافتراضي هو Cmd+Shift+P. يعمل فقط عند تمكين ميزة منتقي الألوان." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Адкрывае панэль выбару колеру для захопу колеру экрана. Па змаўчанні — Cmd+Shift+P. Працуе толькі калі функцыя выбару колеру ўключана." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Otevře panel pro výběr barvy pro zachycení barvy obrazovky. Výchozí klávesová zkratka je Cmd+Shift+P. Funguje pouze v případě, že je funkce výběru barvy povolena." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffnet das Farbauswahlfeld für die Bildschirmfarbaufnahme. Standard ist Cmd+Shift+P. Funktioniert nur, wenn die Farbauswahlfunktion aktiviert ist." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Opens the color picker panel for screen color capture. Default is Cmd+Shift+P. Only works when color picker feature is enabled." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvre le panneau de sélection de couleurs pour la capture des couleurs de l'écran. La valeur par défaut est Cmd+Shift+P. Ne fonctionne que lorsque la fonction de sélection de couleurs est activée." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "स्क्रीन रंग कैप्चर के लिए रंग चयनकर्ता पैनल खोलता है। डिफ़ॉल्ट Cmd+Shift+P है। केवल तभी काम करता है जब रंग चयनकर्ता सुविधा सक्षम हो।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apre il pannello del selettore colore per l'acquisizione del colore dello schermo. L'impostazione predefinita è Cmd+Shift+P. Funziona solo quando la funzione di selezione del colore è abilitata." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpner fargevelgerpanelet for skjermfargeopptak. Standard er Cmd+Shift+P. Fungerer bare når fargevelgerfunksjonen er aktivert." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открывает панель пипетки цветов. По умолчанию Cmd+Shift+P." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ekran rengi yakalama için renk seçici panelini açar. Varsayılan Cmd+Shift+P'dir. Yalnızca renk seçici özelliği etkinleştirildiğinde çalışır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відкриває панель вибору кольорів для захоплення кольорів екрана. За замовчуванням — Cmd+Shift+P. Працює тільки тоді, коли увімкнено функцію вибору кольорів." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开取色器面板进行屏幕颜色捕获。默认为 Cmd+Shift+P。仅在启用取色器功能时有效。" + } + } + } + }, + "Output devices": { + "comment": "Section title for the list of available audio output devices in the media output selector.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgabegeräte" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Output devices" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Périphériques de sortie" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Utdataenheter" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выходные устройства" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çıkış cihazları" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输出设备" + } + } + } + }, + "Output volume": { + "comment": "Label describing the system volume control inside the media output selector.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lautstärke" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Output volume" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Volume de production" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Громкость звука" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输出音量" + } + } + } + }, + "Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يظهر وضع اللوحة الحافظة في نافذة عائمة بالقرب من الشق. يظهر الوضع المنبثق الحافظة كقامة منسدلة متصلة بزر الحافظة." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Панэльны рэжым паказвае буфер абмену ў плаваючым акне каля выемкі. Выскочны рэжым паказвае буфер абмену ў выглядзе выпадальнага спіса, прывязанага да кнопкі буфера абмену." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Režim panelu zobrazuje schránku v plovoucím okně poblíž zářezu. Režim vyskakovacího okna zobrazuje schránku jako rozevírací nabídku připojenou k tlačítku schránky." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Bedienfeldmodus zeigt die Zwischenablage in einem schwebenden Fenster in der Nähe der Kerbe an. Der Popover-Modus zeigt die Zwischenablage als Dropdown-Liste an, die an der Zwischenablage-Schaltfläche angehängt ist." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le mode panneau affiche le presse-papiers dans une fenêtre flottante près de l'encoche. Le mode popover affiche le presse-papiers comme une liste déroulante attachée au bouton du presse-papiers." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "पैनल मोड क्लिपबोर्ड को नॉच के पास एक फ्लोटिंग विंडो में दिखाता है। पॉपओवर मोड क्लिपबोर्ड को क्लिपबोर्ड बटन से जुड़े एक ड्रॉपडाउन के रूप में दिखाता है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La modalità pannello mostra gli appunti in una finestra mobile vicino alla tacca. La modalità popover mostra gli appunti come un menu a discesa collegato al pulsante degli appunti." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Panellmodus viser utklippstavlen i et flytende vindu nær hakket. Popover-modus viser utklippstavlen som en rullegardinmeny knyttet til utklippstavleknappen." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим \"Панель\" показывает окно рядом с вырезом. Режим \"Всплывающее окно\" показывает выпадающее меню возле кнопки." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Panel modu, çentiğin yanındaki yüzen bir pencerede panoyu gösterir. Açılır pencere modu, pano düğmesine bağlı bir açılır menü olarak panoyu gösterir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "У режимі панелі буфер обміну відображається у спливаючому вікні біля вирізу. У режимі спливаючого вікна буфер обміну відображається у вигляді випадаючого меню, прикріпленого до кнопки буфера обміну." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "面板模式会在刘海附近的浮动窗口中显示剪贴板。弹出模式会将剪贴板显示为附加在剪贴板按钮上的下拉菜单。" + } + } + } + }, + "Panel mode shows color picker in a floating window. Popover mode shows color picker as a dropdown attached to the color picker button.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يظهر وضع اللوحة منتقي الألوان في نافذة عائمة. يعرض وضع Popover منتقي الألوان كقائمة منسدلة مرفقة بزر منتقي الألوان." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Панэльны рэжым паказвае выбарнік колеру ў плаваючым акне. Выскаквальны рэжым паказвае выбарнік колеру ў выглядзе выпадальнага спіса, прывязанага да кнопкі выбару колеру." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Režim panelu zobrazuje výběr barev v plovoucím okně. Režim vyskakovacího okna zobrazuje výběr barev jako rozevírací nabídku připojenou k tlačítku výběru barev." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Bedienfeldmodus zeigt die Farbauswahl in einem schwebenden Fenster an. Der Popover-Modus zeigt die Farbauswahl als Dropdown-Liste an, die an der Farbauswahlschaltfläche befestigt ist." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Panel mode shows color picker in a floating window. Popover mode shows color picker as a dropdown attached to the color picker button." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le mode panneau affiche le sélecteur de couleurs dans une fenêtre flottante. Le mode popover affiche le sélecteur de couleurs en tant que liste déroulante attachée au bouton du sélecteur de couleurs." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "पैनल मोड रंग चयनकर्ता को एक फ्लोटिंग विंडो में दिखाता है। पॉपओवर मोड रंग चयनकर्ता बटन से जुड़ा एक ड्रॉपडाउन के रूप में रंग चयनकर्ता दिखाता है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La modalità pannello mostra il selettore di colori in una finestra mobile. La modalità popover mostra il selettore di colori come un menu a discesa collegato al pulsante del selettore colori." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Panellmodus viser fargevelgeren i et flytende vindu. Popover-modus viser fargevelgeren som en rullegardinmeny knyttet til fargevelgerknappen." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим \"Панель\" показывает пипетку в отдельном окне. Всплывающее окно – возле кнопки." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Panel modu, yüzen bir pencerede renk seçiciyi gösterir. Açılır pencere modu, renk seçici düğmesine bağlı bir açılır menü olarak renk seçiciyi gösterir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "У режимі панелі колірний пікер відображається у спливаючому вікні. У режимі спливаючого вікна колірний пікер відображається у вигляді випадаючого меню, прикріпленого до кнопки колірного пікера." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "面板模式会在浮动窗口中显示取色器。弹出模式会将取色器显示为附加在取色器按钮上的下拉菜单。" + } + } + } + }, + "Panel mode shows the assistant in a floating window near the notch. Popover mode shows the assistant as a dropdown attached to the AI button.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يظهر وضع اللوحة المساعد في نافذة عائمة بالقرب من الشق. يظهر الوضع المنبثق المساعد كقامة منسدلة مرفقة بزر الذكاء الاصطناعي." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Панэльны рэжым паказвае асістэнта ў плаваючым акне каля выемкі. Выскоўкавы рэжым паказвае асістэнта ў выглядзе выпадальнага меню, прывязанага да кнопкі ШІ." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Režim panelu zobrazuje asistenta v plovoucím okně poblíž výřezu. Režim vyskakovacího okna zobrazuje asistenta jako rozevírací nabídku připojenou k tlačítku AI." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Bedienfeldmodus zeigt den Assistenten in einem schwebenden Fenster in der Nähe der Kerbe an. Der Popover-Modus zeigt den Assistenten als Dropdown-Menü an, das an der KI-Taste befestigt ist." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Panel mode shows the assistant in a floating window near the notch. Popover mode shows the assistant as a dropdown attached to the AI button." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le mode panneau affiche l'assistant dans une fenêtre flottante près de l'encoche. Le mode popover montre l'assistant comme une liste déroulante attachée au bouton d'IA." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "पैनल मोड में असिस्टेंट नॉच के पास एक फ्लोटिंग विंडो में दिखाई देता है। पॉपओवर मोड में असिस्टेंट AI बटन से जुड़ा एक ड्रॉपडाउन के रूप में दिखाई देता है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La modalità pannello mostra l'assistente in una finestra mobile vicino alla tacca. La modalità popover mostra l'assistente come un menu a discesa collegato al pulsante AI." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Panellmodus viser assistenten i et flytende vindu nær hakket. Popover-modus viser assistenten som en rullegardinmeny knyttet til AI-knappen." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Инструмент ИИ отображается в отдельном окне или контекстном меню кнопки." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Panel modu, asistanı çentiğin yakınındaki yüzen bir pencerede gösterir. Açılır pencere modu, asistanı AI düğmesine bağlı bir açılır menü olarak gösterir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "У режимі панелі помічник відображається у спливаючому вікні біля вирізу. У режимі спливаючого вікна помічник відображається у вигляді випадаючого меню, прикріпленого до кнопки AI." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "面板模式会在刘海附近的浮动窗口中显示助手。弹出模式会将助手显示为附加在 AI 按钮上的下拉菜单。" + } + } + } + }, + "Pause": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pause " + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Pause" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pause" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пауза" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂停" + } + } + } + }, + "Paused": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pausiert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Paused" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En pause" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пауза" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已暂停" + } + } + } + }, + "Percentage": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Prozentsatz" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Percentage" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pourcentage" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проценты" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "百分比" + } + } + } + }, + "Permissions": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Berechtigungen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Permissions" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autorisations" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешения" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "权限" + } + } + } + }, + "Pick Color": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbe wählen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Pick Color" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez une couleur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбрать цвет" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取色" + } + } + } + }, + "Picked %@": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgewählt %@" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Picked %@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sélectionné %@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбран %@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已选取 %@" + } + } + } + }, + "Picked Color": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gewählte Farbe" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Picked Color" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur choisie" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбранный цвет" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拾取的颜色" + } + } + } + }, + "Picking Status": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kommissionierungsstatus" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Picking Status" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Statut de sélection" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статус захвата" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拾取状态" + } + } + } + }, + "Pin items from history to save them here": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Elemente aus dem Verlauf anheften, um sie hier zu speichern" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Pin items from history to save them here" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fija los elementos del historial para guardarlos aquí" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Épinglez les éléments de l'historique pour les enregistrer ici" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрепите элементы из истории, чтобы сохранить их здесь" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从历史记录中固定项目以保存在此处" + } + } + } + }, + "Pin items to add favorites": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Elemente anheften, um Favoriten hinzuzufügen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Pin items to add favorites" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Épingler des éléments pour ajouter des favoris" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрепите элементы для добавления в избранное" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "固定项目以添加收藏" + } + } + } + }, + "Pin items to add them to favorites": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Elemente anheften, um sie zu Favoriten hinzuzufügen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Pin items to add them to favorites" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Épinglez des éléments pour les ajouter à vos favoris" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрепите элементы для добавления в избранное" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "固定项目以将它们添加到收藏夹" + } + } + } + }, + "Pinned Items": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Angeheftete" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Pinned Items" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Éléments épinglés" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрепленные элементы" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已固定项目" + } + } + } + }, + "Please configure your API key for the selected AI provider in model settings.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يرجى تكوين مفتاح واجهة برمجة التطبيقات الخاص بك لمزود الذكاء الاصطناعي المحدد في إعدادات النموذج." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Калі ласка, наладзьце ваш API-ключ для выбранага пастаўшчыка ШІ ў наладах мадэлі." + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bitte konfigurieren Sie Ihren API-Schlüssel für den ausgewählten KI-Anbieter in den Modelleinstellungen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Please configure your API key for the selected AI provider in model settings." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Por favor, configure su clave API para el proveedor de IA seleccionado en la configuración del modelo." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Veuillez configurer votre clé API pour le fournisseur d'IA sélectionné dans les paramètres du modèle." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Konfigurer API-nøkkelen din for den valgte AI-leverandøren i modellinnstillingene." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройте ваш API ключ для выбранного провайдера ИИ в настройках." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Будь ласка, налаштуйте свій API-ключ для вибраного постачальника штучного інтелекту в налаштуваннях моделі." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请在模型设置中为所选的 AI 提供商配置您的 API 密钥。" + } + } + } + }, + "Plugged In": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Plugged In" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Plugged In" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Angeschlossen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugged In" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Plugged In" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Plugged In" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Branché" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Plugged In" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Collegato" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "충전 중" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Podłączony" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Plugged In" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключено" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Plugged In" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Plugged In" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "已插入" + } + } + } + }, + "plus": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Plus" + } + }, + "ru": { + "stringUnit": { + "value": "плюс", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加" + } + } + } + }, + "Popover mode shows clipboard as a dropdown attached to the clipboard button. Panel mode shows clipboard in a floating window near the notch.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يظهر الوضع المنبثق الحافظة كقامة منسدلة متصلة بزر الحافظة. يظهر وضع اللوحة الحافظة في نافذة عائمة بالقرب من الشق." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Popover mode shows clipboard as a dropdown attached to the clipboard button. Panel mode shows clipboard in a floating window near the notch." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Režim vyskakovacího okna zobrazuje schránku jako rozevírací nabídku připojenou k tlačítku schránky. Režim panelu zobrazuje schránku v plovoucím okně poblíž zářezu." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Popover-Modus zeigt die Zwischenablage als Dropdown-Liste an, die an der Zwischenablage-Schaltfläche angehängt ist. Der Bedienfeldmodus zeigt die Zwischenablage in einem schwebenden Fenster in der Nähe der Kerbe an." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Popover mode shows clipboard as a dropdown attached to the clipboard button. Panel mode shows clipboard in a floating window near the notch." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Popover mode shows clipboard as a dropdown attached to the clipboard button. Panel mode shows clipboard in a floating window near the notch." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le mode popover affiche le presse-papiers comme une liste déroulante attachée au bouton du presse-papiers. Le mode panneau affiche le presse-papiers dans une fenêtre flottante près de l'encoche." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "पॉपओवर मोड क्लिपबोर्ड को क्लिपबोर्ड बटन से जुड़े ड्रॉपडाउन के रूप में दिखाता है। पैनल मोड क्लिपबोर्ड को नॉच के पास एक फ्लोटिंग विंडो में दिखाता है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Popover-modus viser utklippstavlen som en rullegardinmeny knyttet til utklippstavleknappen. Panelmodus viser utklippstavlen i et flytende vindu nær hakket." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поведение отображения буфера обмена." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "பாப்ஓவர் பயன்முறை, கிளிப் போர்டை கிளிப் போர்டு பொத்தானுடன் இணைக்கப்பட்ட ஒரு கீழ்விரிவாகக் காட்டுகிறது. பேனல் பயன்முறை, கிளிப் போர்டை நாட்சுக்கு அருகிலுள்ள ஒரு மிதக்கும் சாளரத்தில் காட்டுகிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Açılır pencere modu, pano düğmesine bağlı bir açılır menü olarak panoyu gösterir. Panel modu, çentiğin yanındaki yüzen bir pencerede panoyu gösterir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "У режимі спливаючого вікна буфер обміну відображається у вигляді випадаючого меню, прикріпленого до кнопки буфера обміну. У режимі панелі буфер обміну відображається у спливаючому вікні біля виїмки." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "弹出模式会将剪贴板显示为附加在剪贴板按钮上的下拉菜单。面板模式会在刘海附近的浮动窗口中显示剪贴板。" + } + } + } + }, + "Popover mode shows color picker as a dropdown attached to the color picker button. Panel mode shows color picker in a floating window.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعرض وضع Popover منتقي الألوان كقائمة منسدلة مرفقة بزر منتقي الألوان. يظهر وضع اللوحة منتقي الألوان في نافذة عائمة." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "У рэжыме пап-овер каляровы выбарнік паказваецца ў выглядзе выпадальнага спіса, прывязанага да кнопкі каляровага выбарніка. У панэльным рэжыме каляровы выбарнік паказваецца ў плаваючым акне." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Režim vyskakovacího okna zobrazuje výběr barev jako rozevírací nabídku připojenou k tlačítku výběru barev. Režim panelu zobrazuje výběr barev v plovoucím okně." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Popover-Modus zeigt die Farbauswahl als Dropdown-Liste an, die an der Farbauswahlschaltfläche befestigt ist. Der Bedienfeldmodus zeigt die Farbauswahl in einem schwebenden Fenster an." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Popover mode shows color picker as a dropdown attached to the color picker button. Panel mode shows color picker in a floating window." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El modo emergente muestra el selector de color como un menú desplegable adjunto al botón del selector de color. El modo Panel muestra el selector de color en una ventana flotante." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le mode popover affiche le sélecteur de couleurs comme une liste déroulante attachée au bouton du sélecteur de couleurs. Le mode panneau affiche le sélecteur de couleurs dans une fenêtre flottante." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "पॉपओवर मोड रंग चयनकर्ता को रंग चयनकर्ता बटन से जुड़े ड्रॉपडाउन के रूप में दिखाता है। पैनल मोड रंग चयनकर्ता को एक फ्लोटिंग विंडो में दिखाता है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Popover-modus viser fargevelgeren som en rullegardinmeny knyttet til fargevelgerknappen. Panelmodus viser fargevelgeren i et flytende vindu." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поведение отображения пипетки." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "பாப்ஓவர் பயன்முறை, வண்ணத் தேர்வியை அதன் பொத்தானுடன் இணைக்கப்பட்ட கீழ்விரி பட்டியலாகக் காட்டுகிறது. பேனல் பயன்முறை, வண்ணத் தேர்வியை ஒரு மிதக்கும் சாளரத்தில் காட்டுகிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Açılır pencere modu, renk seçici düğmesine bağlı bir açılır menü olarak renk seçiciyi gösterir. Panel modu, yüzen bir pencerede renk seçiciyi gösterir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "У режимі спливаючого вікна помічник відображається у вигляді випадаючого меню, прикріпленого до кнопки AI. У режимі панелі помічник відображається у спливаючому вікні біля вирізу." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "弹出模式会将取色器显示为附加在取色器按钮上的下拉菜单。面板模式会在浮动窗口中显示取色器。" + } + } + } + }, + "Popover mode shows the assistant as a dropdown attached to the AI button. Panel mode shows the assistant in a floating window near the notch.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يظهر الوضع المنبثق المساعد كقامة منسدلة مرفقة بزر الذكاء الاصطناعي. يظهر وضع اللوحة المساعد في نافذة عائمة بالقرب من الشق." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Рэжым «Popover» паказвае асістэнта ў выглядзе выпадальнага меню, прывязанага да кнопкі ШІ. Рэжым «Панэль» паказвае асістэнта ў плаваючым акне побач з выемкай." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Režim Popover zobrazuje asistenta jako rozbalovací nabídku připojenou k tlačítku AI. Režim Panel zobrazuje asistenta v plovoucím okně poblíž výřezu." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Popover-Modus zeigt den Assistenten als Dropdown-Menü an, das an der KI-Taste befestigt ist. Der Bedienfeldmodus zeigt den Assistenten in einem schwebenden Fenster in der Nähe der Kerbe an." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Popover mode shows the assistant as a dropdown attached to the AI button. Panel mode shows the assistant in a floating window near the notch." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El modo emergente muestra el asistente como un menú desplegable adjunto al botón AI. El modo panel muestra al asistente en una ventana flotante cerca de la muesca." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le mode pop-over montre l'assistant comme une liste déroulante attachée au bouton d'IA. Le mode panneau affiche l'assistant dans une fenêtre flottante près de l'encoche." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "पॉपओवर मोड में असिस्टेंट AI बटन से जुड़ा एक ड्रॉपडाउन के रूप में दिखाई देता है। पैनल मोड में असिस्टेंट नॉच के पास एक फ्लोटिंग विंडो में दिखाई देता है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Popover-modus viser assistenten som en rullegardinmeny knyttet til AI-knappen. Panelmodus viser assistenten i et flytende vindu nær hakket." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поведение отображения ИИ помощника." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "பாப்ஓவர் பயன்முறை, உதவியாளரை AI பொத்தானுடன் இணைக்கப்பட்ட ஒரு கீழ்விரிவாகக் காட்டுகிறது. பேனல் பயன்முறை, உதவியாளரை நாட்சுக்கு அருகிலுள்ள ஒரு மிதக்கும் சாளரத்தில் காட்டுகிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Açılır pencere modu, asistanı AI düğmesine bağlı bir açılır menü olarak gösterir. Panel modu, asistanı çentiğin yakınındaki yüzen bir pencerede gösterir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "У режимі спливаючого вікна помічник відображається у вигляді випадаючого меню, прикріпленого до кнопки AI. У режимі панелі помічник відображається у спливаючому вікні біля вирізу." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "弹出模式会将助手显示为附加在 AI 按钮上的下拉菜单。面板模式会在刘海附近的浮动窗口中显示助手。" + } + } + } + }, + "Position": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Position " + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Position" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Position" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Позиция" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "位置" + } + } + } + }, + "Preset name": { + "comment": "A label for the name of a timer preset.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Voreingestellter Name" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Preset name" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom prédéfini" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Имя пресета" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预设名称" + } + } + } + }, + "Presets": { + "comment": "A header label for the list of timer presets.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Voreinstellungen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Presets" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préréglages" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пресеты" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ön ayarlar" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预设" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تظهر الإعدادات المسبقة داخل النافذة المنبثقة المؤقتة مع الاسم والمدة ولون اللكنة الذي تم تكوينه. أعد ترتيبها لتغيير ترتيب العرض." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Прэсеты з'яўляюцца ў сплывальным акне таймера з наладжанай назвай, працягласцю і акцэнтным колерам. Змяніце іх парадак, каб змяніць парадак адлюстравання." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Předvolby se zobrazují v rozbalovacím okně časovače s nakonfigurovaným názvem, trváním a barvou zvýraznění. Změňte jejich pořadí, abyste změnili pořadí zobrazení." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Voreinstellungen werden im Timer-Popover mit dem konfigurierten Namen, der Dauer und der Akzentfarbe angezeigt. Ordnen Sie sie neu an, um die Anzeigereihenfolge zu ändern." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los ajustes preestablecidos aparecen dentro de la ventana emergente del temporizador con el nombre, la duración y el color de acento configurados. Reordenarlos para cambiar el orden de visualización." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les préréglages apparaissent à l'intérieur de la fenêtre contextuelle de la minuterie avec le nom, la durée et la couleur d'accentuation configurés. Réordonnez-les pour modifier l'ordre d'affichage." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "प्रीसेट्स टाइमर पॉपओवर में कॉन्फ़िगर किए गए नाम, अवधि और हाइलाइट रंग के साथ दिखाई देते हैं। प्रदर्शन क्रम बदलने के लिए उन्हें पुनः क्रमबद्ध करें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "I preset venno visualizzati all'interno del popover del timer con il nome configurato, la durata e il colore di accento. Riordinali per cambiare l'ordine di visualizzazione." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Forhåndsinnstillinger vises i timer-popoveren med det konfigurerte navnet, varigheten og aksentfargen. Omorganiser dem for å endre visningsrekkefølgen." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пресеты отображаются в окне таймера. Меняйте их порядок для изменения отображения." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "மீடியா பேனலுக்கு மேலே மிதக்கும் விருப்பத்தேர்வு டைமர் விட்ஜெட்டைக் கட்டுப்படுத்துகிறது. மங்கல், சுருங்கிய காட்சிக்குப் பின்னால் ஒரு பனிமூட்டமான தோற்றத்தைச் சேர்க்கிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ön ayarlar, yapılandırılmış ad, süre ve vurgu rengiyle birlikte zamanlayıcı açılır penceresinin içinde görünür. Görüntüleme sırasını değiştirmek için bunları yeniden sıralayın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Пресети відображаються у спливаючому вікні таймера із налаштованою назвою, тривалістю та кольором акценту. Змініть їх порядок, щоб змінити порядок відображення." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预设将显示在计时器弹出窗口中,包括配置的名称、持续时间和重音颜色。重新排序以更改显示顺序。" + } + } + } + }, + "Pressure": { + "comment": "A label describing the memory pressure status.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Druck" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Pressure" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pression" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Давление" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "气压" + } + } + } + }, + "Preview": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschau" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Preview" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aperçu" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предпросмотр" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预览" + } + } + } + }, + "Preview Zoom:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschau-Zoom:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Preview Zoom:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aperçu Zoom :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Масштаб предпросмотра:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预览缩放:" + } + } + } + }, + "Privacy Indicators": { + "comment": "A heading for the section that lists privacy indicators.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datenschutzindikatoren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Privacy Indicators" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Indicateurs de confidentialité" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Индикаторы конфиденциальности" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐私指示器" + } + } + } + }, + "Privacy Policy": { + "comment": "A link to the privacy policy.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datenschutzpolitik" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Privacy Policy" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Politique de confidentialité" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Политика конфиденциальности" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐私政策" + } + } + } + }, + "Progress bar": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fortschrittsbalken" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Progress bar" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Barre de progression" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Индикатор" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "进度条" + } + } + } + }, + "Progress style": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fortschrittstyle" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Progress style" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Style de progression" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стиль индикатора" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "进度样式" + } + } + } + }, + "Progressbar style": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Progressbar style" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Progressbar style" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladebalken Stil" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Progressbar style" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Progress bar style" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Progressbar style" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Style de la barre de progression" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Progressbar style" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Stile barra di avanzamento" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "진행 바 스타일" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Styl paska postępu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Progressbar style" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стиль индикатора" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Progressbar style" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Progressbar style" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "进度条样式" + } + } + } + }, + "Quick Actions": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schnell-Aktionen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Quick Actions" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actions rapides" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Быстрые действия" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快捷操作" + } + } + } + }, + "Quick Presets": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schnelle Voreinstellungen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Quick Presets" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préréglages rapides" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Быстрые пресеты" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快捷预设" + } + } + } + }, + "Quick Start": { + "comment": "A label displayed above the quick start options in the timer tab.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schnellstart" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Quick Start" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Démarrage rapide" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Быстрый старт" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快速开始" + } + } + } + }, + "Quit": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Quit" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Quit" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlassen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Quit" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Quit" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Quitter" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Quit" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Chiudi" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "종료" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wyjdź" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Quit" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Завершить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Çık" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Quit" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "退出" + } + } + } + }, + "Quit app": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Quit app" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Quit app" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App verlassen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit app" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Quit app" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Cerrar app" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Quitter l'application" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Quit app" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Chiudi app" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "앱 종료" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wyjdź z aplikacji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Quit app" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть приложение" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Uygulamadan Çık" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Quit app" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "退出应用" + } + } + } + }, + "Quit boring.notch": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beende boring.notch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit boring.notch" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Quitter boring.notch" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть boring.notch" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出 boring.notch" + } + } + } + }, + "Read": { + "comment": "A heading displayed for the user's disk read speed.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lesen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Read" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чтение" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读取" + } + } + } + }, + "Ready": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereit" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Ready" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Prêt" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Готово" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "就绪" + } + } + } + }, + "Reasoning Mode": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Argumentationsmodus" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Reasoning Mode" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mode de raisonnemen" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим размышления" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "推理模式" + } + } + } + }, + "Recent Colors": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neueste Farben" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Recent Colors" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleurs récentes" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недавние цвета" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最近颜色" + } + } + } + }, + "Recording": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufnahme" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Recording" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrement" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запись" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "录制中" + } + } + } + }, + "Recording Detected": { + "comment": "A text description of a red dot displayed in the Atoll header when screen recording is active.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufnahme erkannt" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Recording Detected" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrement détécté" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обнаружена запись" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测到录制" + } + } + } + }, + "Recording Status": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufnahmestatus" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Recording Status" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Statut de l’enregistrement" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статус записи" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "录制状态" + } + } + } + }, + "Release name": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Release name" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Release name" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Veröffentlichungsname" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Release name" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Release name" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Release name" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom de la version" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Release name" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Nome del rilascio" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "릴리즈 이름" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Nazwa wydania" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Release name" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Название релиза" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Release name" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Release name" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "版本名称" + } + } + } + }, + "Remember last tab": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Remember last tab" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Remember last tab" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Merke den letzten Tab" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remember last tab" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Remember last tab" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Remember last tab" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se souvenir du dernier onglet" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Remember last tab" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Ricorda l'ultima scheda" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "마지막 탭 기억하기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zapamiętaj ostatnią kartę" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Remember last tab" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запоминать последнюю вкладку" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Remember last tab" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Remember last tab" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "记住上一个标签页" + } + } + } + }, + "Reminder Live Activity": { + "comment": "A section header that lists the options related to live activity notifications for reminders.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erinnerung an Live-Aktivitäten" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Reminder Live Activity" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rappel Activité en direct" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Live Activity напоминаний" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "提醒实时活动" + } + } + } + }, + "Reminders": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erinnerung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Reminders" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rappels" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Напоминания" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "提醒事项" + } + } + } + }, + "Replaces macOS system HUD with Dynamic Island displays for volume, brightness, and keyboard backlight.": { + "comment": "A footer description of the HUDs feature.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يستبدل نظام macOS HUD بشاشات Dynamic Island لمستوى الصوت والسطوع والإضاءة الخلفية للوحة المفاتيح." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Замяняе сістэмны HUD macOS на дынамічныя востравы для рэгулявання гучнасці, яркасці і падсветкі клавіятуры." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Nahrazuje systémové HUD macOS displeji Dynamic Island pro ovládání hlasitosti, jasu a podsvícení klávesnice." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ersetzt das macOS-System-HUD durch Dynamic Island-Displays für Lautstärke, Helligkeit und Tastatur-Hintergrundbeleuchtung." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Replaces macOS system HUD with Dynamic Island displays for volume, brightness, and keyboard backlight." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reemplaza el HUD del sistema macOS con pantallas Dynamic Island para el volumen, el brillo y la retroiluminación del teclado." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Remplace le HUD du système macOS par des écrans Dynamic Island pour le volume, la luminosité et le rétroéclairage du clavier." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sostituisce l'HUD del sistema macOS con i display Dynamic Island per volume, luminosità e retroilluminazione della tastiera." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Erstatt macOS-systemets HUD med Dynamic Island-skjermer for volum, lysstyrke og tastaturbelysning." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zastępuje system HUD macOS wyświetlaczami Dynamic Island służącymi do regulacji głośności, jasności i podświetlenia klawiatury." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заменяет системный HUD macOS на элементы Dynamic Island." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "ஒலி அளவு, பிரகாசம் மற்றும் விசைப்பலகை பின்னொளி ஆகியவற்றிற்கான macOS சிஸ்டம் HUD-ஐ டைனமிக் ஐலேண்ட் காட்சிகளுடன் மாற்றுகிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ses seviyesi, parlaklık ve klavye arka aydınlatması için macOS sistem HUD'sini Dynamic Island ekranlarıyla değiştirir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Замінює системний HUD macOS на дисплеї Dynamic Island для регулювання гучності, яскравості та підсвічування клавіатури." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用灵动岛显示替换 macOS 系统 HUD 的音量、亮度和键盘背光。" + } + } + } + }, + "Request Access": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zugriff anfordern" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Request Access" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Demander l’accès" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запросить доступ" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请求访问" + } + } + } + }, + "Reset": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu setzen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Reset" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinitialiser" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбросить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + } + } + }, + "Reset All": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alles zurücksetzen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Reset All" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout réinitialiser" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбросить все" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部重置" + } + } + } + }, + "Reset Music": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Musik zurücksetzen" + } + }, + "ru": { + "stringUnit": { + "value": "Сбросить музыку", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置音乐" + } + } + } + }, + "Reset Timer": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Timer zurücksetzen" + } + }, + "ru": { + "stringUnit": { + "value": "Сбросить таймер", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置计时器" + } + } + } + }, + "Reset to Default": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf Standard zurücksetzen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Reset to Default" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinitialiser aux paramètres d’origine " + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "По умолчанию" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复默认" + } + } + } + }, + "Reset Weather": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wetter zurücksetzen" + } + }, + "ru": { + "stringUnit": { + "value": "Сбросить погоду", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置天气" + } + } + } + }, + "Reset Width": { + "comment": "A button to reset the expanded notch width to its default value.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Breite zurücksetzen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Reset Width" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinitialiser la largeur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбросить ширину" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置宽度" + } + } + } + }, + "Restart": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Restart" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Restart" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neustarten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Restart" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Restart" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Restart" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Riavvia" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "재시작" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Uruchom ponownie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Restart" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yeniden başlat" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Restart" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "重启" + } + } + } + }, + "Restart Atoll": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Atoll Neu Starten" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Restart Atoll" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Relancer Atoll" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить Atoll" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启 Atoll" + } + } + } + }, + "Restore": { + "comment": "The text for a button that restores default settings.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wiederherstellen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Restore" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Restaurer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复" + } + } + } + }, + "Restore default timer presets?": { + "comment": "A confirmation dialog title asking if the user wants to restore the default timer presets.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standard-Timer-Voreinstellungen wiederherstellen?" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Restore default timer presets?" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Restaurar los ajustes preestablecidos predeterminados del temporizador?" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Restaurer les préréglages de minuterie par défaut ?" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gjenopprette standardinnstillinger for tidtakeren?" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановить пресеты таймера по умолчанию?" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复默认计时器预设?" + } + } + } + }, + "Restore Defaults": { + "comment": "A button that, when tapped, presents a confirmation dialog to reset all timer settings to their default values.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardeinstellungen wiederherstellen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Restore Defaults" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Restaurer les paramètres par défaut" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gjenopprett standardinnstillinger" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановить настройки по умолчанию" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复默认" + } + } + } + }, + "Resume": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zusammenfassung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Resume" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reprendre" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Возобновить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "继续" + } + } + } + }, + "Rotation:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Drehen:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Rotation:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rotation:" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вращение:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "旋转:" + } + } + } + }, + "Running": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Running" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Running" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Läuft" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Running" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Running" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Running" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En cours d'exécution" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Running" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "In esecuzione" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "실행 중" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Uruchomiony" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Running" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выполняется" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Koşma" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Running" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "运行中" + } + } + } + }, + "Sampling can continue while the notch is closed when the timeout is disabled.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يمكن أن تستمر أخذ العينات أثناء إغلاق الشق عند تعطيل المهلة." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Збор проб можа працягвацца, пакуль заглушка закрытая, калі таймаўт адключаны." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vzorkování může pokračovat i při uzavřeném zářezu, pokud je časový limit deaktivován." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Probenahme kann fortgesetzt werden, während die Kerbe geschlossen ist, wenn das Timeout deaktiviert ist." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Sampling can continue while the notch is closed when the timeout is disabled." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El muestreo puede continuar mientras la muesca está cerrada cuando el tiempo de espera está deshabilitado." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'échantillonnage peut continuer tant que l'encoche est fermée lorsque le délai d'attente est désactivé." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il campionamento può continuare mentre la tacca è chiusa quando il timeout è disabilitato." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis på GitHub: th-ch/youtube-music" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pobieranie próbek może być kontynuowane przy zamkniętym wycięciu, gdy limit czasu jest wyłączony." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сэмплинг может продолжаться при закрытом вырезе, если отключен таймаут." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "டைம்அவுட் முடக்கப்பட்டிருக்கும்போது, நோச் மூடப்பட்டிருக்கும்போதும் சாம்பிளிங் தொடரலாம்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Zaman aşımı devre dışı bırakıldığında çentik kapalıyken örneklemeye devam edebilir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відбір проб може продовжуватися, коли зазор закритий, якщо тайм-аут відключений." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用超时后,采样可以在刘海关闭时继续。" + } + } + } + }, + "Save": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Speichern" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Save" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохранить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + } + } + }, + "Save Changes": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Speichere Änderungen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Save Changes" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrer les changements" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохранить изменения" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存更改" + } + } + } + }, + "Save Configuration": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfiguration speichern" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Save Configuration" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrer la configuration" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохранить конфигурацию" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存配置" + } + } + } + }, + "Scale Down": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Herunterskalieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Scale Down" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réduire l’échelle" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уменьшить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩小" + } + } + } + }, + "Scale Up 2x": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "2x skalieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Scale Up 2x" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Doubler l’échelle" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Увеличить 2x" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "放大 2x" + } + } + } + }, + "Scale:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maßstab:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Scale:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échelle :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Масштаб:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩放:" + } + } + } + }, + "Screen Assistant": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildschirmassistent" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Screen Assistant" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Assistant d'écran" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Экранный помощник" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ekran Asistanı" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕助手" + } + } + } + }, + "Screen Assistant feature is disabled": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Funktion des Bildschirmassistenten ist deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Screen Assistant feature is disabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La fonction Assistant écran est désactivée." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Funkcja Screen Assistant jest wyłączona." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Функция экранного помощника отключена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕助手功能已禁用" + } + } + } + }, + "Screen Assistant:": { + "comment": "Name for the keyboard shortcut that opens the AI assistant panel for file analysis and conversation.", + "isCommentAutoGenerated": true, + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildschirmassistent:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Screen Assistant:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Assistant d’écran :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Экранный помощник:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕助手:" + } + } + } + }, + "Screen Padding: %@px": { + "extractionState": "manual", + "localizations": { + "ru": { + "stringUnit": { + "value": "Отступы экрана: %@ px", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕边距:%@像素" + } + } + } + }, + "Screen Recording": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildschirmaufnahme" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Screen Recording" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrement d'écran" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запись экрана" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕录制" + } + } + } + }, + "Screenshot options": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Screenshot-Optionen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Screenshot options" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Options de capture d’écran" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Параметры снимка экрана" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "截图选项" + } + } + } + }, + "Screenshot Type": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Screenshot-Typ" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Screenshot Type" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Format de la capture d’écran" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тип снимка" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "截图类型" + } + } + } + }, + "Scroll device name in HUD": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gerätename in HUD scrollen" + } + }, + "ru": { + "stringUnit": { + "value": "Прокручивать имя устройства в HUD", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 HUD 中滚动显示设备名称" + } + } + } + }, + "Search clipboard...": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwischenablage durchsuchen..." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Search clipboard..." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher dans le presse-papiers…" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Искать в буфере обмена..." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索剪贴板..." + } + } + } + }, + "Search colors...": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farben suchen..." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Search colors..." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher des couleurs…" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Искать цвета..." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索颜色..." + } + } + } + }, + "Search Settings": { + "comment": "A placeholder text for a search bar in the settings view.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sucheinstellungen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Search Settings" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Hakuasetukset" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Paramètres de recherche" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поиск настроек" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索设置" + } + } + } + }, + "Search...": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Suche..." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Search..." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher…" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поиск..." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索..." + } + } + } + }, + "seconds": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sekunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "seconds" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "secondes" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "секунды" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Saniyeler" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "秒" + } + } + } + }, + "Seconds": { + "extractionState": "manual", + "generatesSymbol": false, + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sekunden" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Seconds" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Secondes" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Секунды" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Saniyeler" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "秒" + } + } + } + }, + "Segmented": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Segmentiert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Segmented" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Segmenté" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сегментированный" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分段" + } + } + } + }, + "Select a color to view details": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie eine Farbe, um Details anzuzeigen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Select a color to view details" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sélectionnez une couleur pour afficher les détails" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите цвет для просмотра деталей" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择颜色查看详情" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حدد صوتا مخصصا لتشغيله عند انتهاء المؤقت. تتضمن التنسيقات المدعومة MP3 وM4A وWAV وAIFF." + } + }, + "be": { + "stringUnit": { + "state": "translated", + "value": "Выберыце ўласны гук, які будзе прайгравацца па заканчэнні таймера. Падтрымліваюцца фарматы MP3, M4A, WAV і AIFF." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vyberte vlastní zvuk, který se přehraje po uplynutí časovače. Podporované formáty zahrnují MP3, M4A, WAV a AIFF." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie einen benutzerdefinierten Sound aus, der abgespielt werden soll, wenn ein Timer endet. Unterstützte Formate umfassen MP3, M4A, WAV und AIFF." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Zamanlayıcı sona erdiğinde çalmak için özel bir ses seçin. Desteklenen formatlar arasında MP3, M4A, WAV ve AIFF bulunur." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sélectionnez un son personnalisé à lire lorsqu'une minuterie se termine. Les formats pris en charge comprennent MP3, M4A, WAV et AIFF." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Velg en tilpasset lyd som skal spilles av når timeren slutter. Støttede formater inkluderer MP3, M4A, WAV og AIFF." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wybierz niestandardowy dźwięk, który ma być odtwarzany po upływie czasu. Obsługiwane formaty to MP3, M4A, WAV i AIFF." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите звук для окончания таймера. Поддерживаются MP3, M4A, WAV и AIFF." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "டைமர் முடிவடையும் போது ஒலிக்க ஒரு தனிப்பயன் ஒலியைத் தேர்ந்தெடுக்கவும். ஆதரிக்கப்படும் வடிவங்களில் MP3, M4A, WAV மற்றும் AIFF ஆகியவை அடங்கும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Zamanlayıcı sona erdiğinde çalmak için özel bir ses seçin. Desteklenen formatlar arasında MP3, M4A, WAV ve AIFF bulunur." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Піктограма MacBook доступна тільки в круглому макеті." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择计时器结束时播放的自定义声音。支持格式包括 MP3、M4A、WAV 和 AIFF。" + } + } + } + }, + "Select Calendars": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender auswählen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Select Calendars" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sélectionner des calendriers" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбрать календари" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择日历" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر ملفا شخصيا واحدا أو أكثر لتخصيص تجربتك" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vyberte jeden nebo více profilů a přizpůsobte si své prostředí." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie ein oder mehrere Profile aus, um Ihre Erfahrung anzupassen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Select one or more profiles to customize your experience" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Seleccione uno o más perfiles para personalizar su experiencia" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sélectionnez un ou plusieurs profils pour personnaliser votre expérience" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Velg ett eller flere profiler for å tilpasse opplevelsen din" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите один или несколько профилей для настройки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Deneyiminizi özelleştirmek için bir veya daha fazla profil seçin" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Виберіть один або кілька профілів, щоб налаштувати свій досвід" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择一个或多个配置文件以自定义您的体验" + } + } + } + }, + "Select the music source you want to use. You can change this later in the app settings.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حدد مصدر الموسيقى الذي تريد استخدامه. يمكنك تغيير هذا لاحقا في إعدادات التطبيق." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Vyberte zdroj hudby, který chcete použít. Toto nastavení můžete později změnit v nastavení aplikace." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wähle die Musik Quelle aus, die du benutzen willst. Du kannst dies später in den Einstellungen ändern." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Select the music source you want to use. You can change this later in the app settings." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Select the music source you want to use. You can change this later in the app settings." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Kullanmak istediğiniz müzik kaynağını seçin. Bunu daha sonra uygulama ayarlarından değiştirebilirsiniz." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "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." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "તમે ઉપયોગ કરવા માંગતા સંગીત સ્ત્રોત પસંદ કરો. તમે તેને પછી એપની સેટિંગ્સમાં બદલી શકો છો." + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Select the music source you want to use. You can change this later in the app settings." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Selezionare la sorgente musicale che si desidera utilizzare. È possibile modificare questa impostazione in seguito nelle impostazioni dell'app." + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "음악 소스 선택하세요. 나중에 앱 설정에서 변경 가능합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Velg musikkilden du vil bruke. Du kan endre dette senere i appinnstillingene." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wybierz źródło muzyki, którego chcesz użyć. Możesz to później zmienić w ustawieniach aplikacji." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Select the music source you want to use. You can change this later in the app settings." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите источник музыки, который хотите использовать. Вы можете изменить его позже в настройках приложения." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "நீங்கள் பயன்படுத்த விரும்பும் இசை மூலத்தைத் தேர்ந்தெடுக்கவும். இதை நீங்கள் பின்னர் செயலி அமைப்புகளில் மாற்றலாம்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kullanmak istediğiniz müzik kaynağını seçin. Bunu daha sonra uygulama ayarlarından değiştirebilirsiniz." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Виберіть власний звук, який буде відтворюватися після закінчення таймера. Підтримувані формати: MP3, M4A, WAV та AIFF." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "选择您想要使用的音乐源。您可以稍后在应用设置中更改此项。" + } + } + } + }, + "selected": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "selected" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "selected" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "ausgewählt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "selected" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "selected" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "selected" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "sélectionné" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "selected" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "selezionato" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "선택된" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "zaznaczone" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "selected" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "выбрано" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "selected" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "selected" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "已选择" + } + } + } + }, + "Selected": { + "comment": "A label indicating that an animation is currently selected.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgewählt" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Selected" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sélectionnée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбрано" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已选择" + } + } + } + }, + "Selected animation": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Selected animation" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Selected animation" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgewählte Animation" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Selected animation" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Selected animation" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Selected animation" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Animation sélectionnée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Selected animation" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Animazione selezionata" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "선택된 애니메이션" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wybrana animacja" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Selected animation" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбранная анимация" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Selected animation" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Selected animation" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "所选动画" + } + } + } + }, + "Sensitivity": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Empfindlichkeit" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Sensitivity" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sensibilité" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чувствительность" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "灵敏度" + } + } + } + }, + "Session · ↓ %@ · ↑ %@": { + "comment": "A row displaying the total downloaded and uploaded bytes for the network session.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Relace · ↓ %1$@ · ↑ %2$@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sitzung · ↓ %1$@ · ↑ %2$@" + } + }, + "en": { + "stringUnit": { + "state": "new", + "value": "Session · ↓ %1$@ · ↑ %2$@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Istunto · ↓ %1$@ · ↑ %2$@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Session · ↓ %1$@ · ↑ %2$@" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "સત્ર · ↓ %1$@ · ↑ %2$@" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Økt · ↓ %1$@ · ↑ %2$@" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сеанс · ↓ %1$@ · ↑ %2$@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "会话 · ↓ %@ · ↑ %@" + } + } + } + }, + "Set": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Speichern" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Set" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Définir" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Установить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置" + } + } + } + }, + "Settings": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Settings" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Settings" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Settings" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Settings" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Paramètres" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Settings" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Impostazioni" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "설정" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Ustawienia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Settings" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Ayarlar" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Settings" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "设置" + } + } + } + }, + "Settings icon in notch": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungs-Symbol in der Kerbe" + } + }, + "ru": { + "stringUnit": { + "value": "Значок настроек в вырезе", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刘海中的设置图标" + } + } + } + }, + "Settings…": { + "comment": "A command that shows the settings window.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen.." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Settings…" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Paramètres…" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки…" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayarlar..." + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置…" + } + } + } + }, + "Shelf": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Shelf" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Shelf" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ablage" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Shelf" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Shelf" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Shelf" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tiroir" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Shelf" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Scaffale" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "서랍" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Półka" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Shelf" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Полка" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Shelf" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Shelf" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "暂存器" + } + } + } + }, + "Shortcuts": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Shortcuts" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Shortcuts" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tastenkombinationen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Shortcuts" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Shortcuts" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Shortcuts" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Raccourcis" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Shortcuts" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Scorciatoie" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "바로가기들" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Skróty klawiszowe" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Shortcuts" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сочетания клавиш" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Kestirmeler" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Shortcuts" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "快捷方式" + } + } + } + }, + "Show AQI widget": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "AQI-Widget anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать виджет AQI", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示空气质量小组件" + } + } + } + }, + "Show battery indicator": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Show battery indicator" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Show battery indicator" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batterie-Symbol anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show battery indicator" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Show battery indicator" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Show battery indicator" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher l'indicateur de batterie" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Show battery indicator" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Show battery indicator" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "배터리 상태 표시하기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Show battery indicator" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Show battery indicator" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать индикатор батареи" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pil göstergesini göster" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Show battery indicator" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "显示电量" + } + } + } + }, + "Show battery percentage text in HUD": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Batterieprozentsatz-Text in HUD anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать процент в HUD", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 HUD 中显示电池百分比文本" + } + } + } + }, + "Show Bluetooth battery": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth-Akku anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать заряд Bluetooth", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示蓝牙电池" + } + } + } + }, + "show Bluetooth device connections": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth-Geräteverbindungen anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "показывать подключения Bluetooth", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示蓝牙设备连接" + } + } + } + }, + "Show calender": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать календарь", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示日历" + } + } + } + }, + "Show calender events inside the notch": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalenderereignisse in der Aussparung anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать события внутри выреза", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在刘海中显示日历事件" + } + } + } + }, + "Show charging indicator": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladeanzeige anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show charging indicator" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher l’indicateur de charge" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать индикатор зарядки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şarj göstergesini göster" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示充电指示器" + } + } + } + }, + "Show charging percentage": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladezustand anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать процент зарядки", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示充电百分比" + } + } + } + }, + "Show charging status": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladezustand anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать статус зарядки", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示充电状态" + } + } + } + }, + "Show Color History": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbverlauf anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Show Color History" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher l’historique de couleur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать историю цветов" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Renk Geçmişini Göster" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示颜色历史" + } + } + } + }, + "Show Color Picker Panel": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbauswahlfeld anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Show Color Picker Panel" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher le panneau de sélection de couleurs" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать панель пипетки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示取色器面板" + } + } + } + }, + "Show cool face animation while inactivity": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Show cool face animation while inactivity" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Zobrazit cool animaci obličeje při nečinnosti" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Coole Gesichtsanimationen zeigen, während sie inaktiv sind" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show cool face animation while inactivity" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Show cool face animation while inactivity" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Show cool face animation while inactivity" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher une animation de visage cool en cas d'inactivité" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Show cool face animation while inactivity" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Show cool face animation while inactivity" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "Show cool face animation while inactivity" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis kul ansiktsanimasjon når du er inaktiv" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Show cool face animation while inactivity" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Show cool face animation while inactivity" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать забавную анимацию при неактивности" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hareketsizken havalı yüz animasyonu göster" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Show cool face animation while inactivity" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "不活动时显示动画" + } + } + } + }, + "Show countdown": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Countdown anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Show countdown" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher le compte à rebours" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать обратный отсчет" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示倒计时" + } + } + } + }, + "Show download progress": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download-Fortschritt anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show download progress" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher la vitesse de téléchargement" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать прогресс загрузки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示下载进度" + } + } + } + }, + "Show floating pause/stop controls": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار عناصر التحكم العائمة في الإيقاف المؤقت/الإيقاف" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schwebende Pause/Stopp-Steuerelemente anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Show floating pause/stop controls" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es-419": { + "stringUnit": { + "state": "translated", + "value": "Mostrar controles flotantes de pausa/detener" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les commandes de pause/arrêt flottantes" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать плавающие элементы паузы/остановки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示浮动暂停/停止控制" + } + } + } + }, + "Show Focus Indicator": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fokusanzeige anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать индикатор Фокуса", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示专注指示器" + } + } + } + }, + "Show Focus Label": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fokus-Beschriftung anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать метку Фокуса", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示专注标签" + } + } + } + }, + "Show full event titles": { + "comment": "A toggle that shows or hides the full event titles in the calendar view", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vollständige Veranstaltungstitel anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Show full event titles" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les titres complets des événements" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать полные названия событий" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示完整事件标题" + } + } + } + }, + "Show location label": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Standortbezeichnung anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать локацию", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示位置标签" + } + } + } + }, + "Show lock screen media panel": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Medienleiste auf Sperrbildschirm anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать медиа-панель на экране блокировки", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示锁屏媒体面板" + } + } + } + }, + "Show lock screen timer": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Timer für Sperrbildschirm anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать таймер экрана блокировки", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示锁屏计时器" + } + } + } + }, + "Show lock screen weather": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wetter auf Sperrbildschirm anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать погоду экрана блокировки", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示锁屏天气" + } + } + } + }, + "Show media output control in other layouts": { + "comment": "A toggle that lets the user enable or disable the media output control in layouts other than the home screen.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار التحكم في إخراج الوسائط في تخطيطات أخرى" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Medienausgabesteuerung in anderen Layouts anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Show media output control in other layouts" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher le contrôle de sortie multimédia dans d'autres mises en page" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать переключатель вывода аудио в других макетах" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在其他布局中显示媒体输出控制" + } + } + } + }, + "Show on a specific display": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض على شاشة محددة" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Show on a specific display" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf einem bestimmten Display anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show on a specific display" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Show on a specific display" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Show on a specific display" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher sur un écran spécifique" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Show on a specific display" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Mostra su un display specifico" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "특정 디스플레이에 표시" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Pokaż na określonym wyświetlaczu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Show on a specific display" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать на конкретном дисплее" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Show on a specific display" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Show on a specific display" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "在指定显示器上显示" + } + } + } + }, + "Show on all displays": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Show on all displays" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Show on all displays" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf allen Displays anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show on all displays" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Show on all displays" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Show on all displays" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher sur tous les écrans" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Show on all displays" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Mostra su tutti gli schermi" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "모든 디스플레이에 표시" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Pokaż na wszystkich wyświetlaczach" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Show on all displays" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать на всех дисплеях" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Show on all displays" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Show on all displays" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "在所有显示器上显示" + } + } + } + }, + "Show panel border": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Rahmen anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать рамки", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示面板边框" + } + } + } + }, + "Show progress": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fortschritt anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Show progress" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Montrer la progression" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать прогресс" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示进度" + } + } + } + }, + "Show Recording Indicator": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufzeichnungsanzeige anzeigen" + } + }, + "ru": { + "stringUnit": { + "value": "Показывать индикатор записи", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示录制指示器" + } + } + } + }, + "Show shuffle and repeat buttons": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Show shuffle and repeat buttons" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Show shuffle and repeat buttons" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeige Zufallswiedergabe und Wiederholung Tasten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show shuffle and repeat buttons" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Show shuffle and repeat buttons" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Show shuffle and repeat buttons" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les boutons \"aléatoire\" et \"répéter\"" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Show shuffle and repeat buttons" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Mostra pulsanti casuale e ripeti" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "랜덤과 반복 버튼 표시" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Pokaż przyciski \"pomieszaj\" i \"powtórz\"" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Show shuffle and repeat buttons" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать кнопки перемешивания/повтора" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Show shuffle and repeat buttons" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Show shuffle and repeat buttons" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "显示随机播放和重复按钮" + } + } + } + }, + "Show timer name": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Timer-Name anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Show timer name" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher le nom de la minuterie" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать название таймера" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示计时器名称" + } + } + } + }, + "Shows green camera icon and yellow microphone icon when in use. Uses event-driven CoreAudio and CoreMediaIO APIs.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعرض أيقونة الكاميرا الخضراء وأيقونة الميكروفون الصفراء عند الاستخدام. يستخدم واجهات برمجة تطبيقات CoreAudio وCoreMediaIO القائمة على الأحداث." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Při použití se zobrazí zelená ikona kamery a žlutá ikona mikrofonu. Používá událostmi řízené rozhraní API CoreAudio a CoreMediaIO." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt bei Verwendung das grüne Kamerasymbol und das gelbe Mikrofonsymbol an. Verwendet ereignisgesteuerte CoreAudio- und CoreMediaIO-APIs." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Shows green camera icon and yellow microphone icon when in use. Uses event-driven CoreAudio and CoreMediaIO APIs." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affiche l'icône de la caméra verte et l'icône jaune du microphone lorsqu'il est utilisé. Utilise les API CoreAudio et CoreMediaIO pilotées par les événements." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "ઉપયોગમાં હોય ત્યારે લીલું કેમેરા ચિહ્ન અને પીળું માઇક્રોફોન ચિહ્ન બતાવે છે. ઇવેન્ટ-ચલિત CoreAudio અને CoreMediaIO API નો ઉપયોગ કરે છે." + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra l'icona della fotocamera verde e l'icona gialla del microfono quando è in uso. Utilizza le API CoreAudio e CoreMediaIO basate sugli eventi." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Viser et grønt kameraikon og et gult mikrofonikon når det er i bruk. Bruker hendelsesdrevne CoreAudio- og CoreMediaIO-API-er." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывает зеленую иконку камеры и желтую иконку микрофона во время их использования." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "பயன்பாட்டில் இருக்கும்போது பச்சை கேமரா சின்னத்தையும் மஞ்சள் மைக்ரோஃபோன் சின்னத்தையும் காட்டுகிறது. நிகழ்வு-இயக்கப்படும் கோர்ஆடியோ மற்றும் கோர்மீடியாஐஓ ஏபிஐ-களைப் பயன்படுத்துகிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kullanımdayken yeşil kamera simgesi ve sarı mikrofon simgesi gösterir. Olay odaklı CoreAudio ve CoreMediaIO API'lerini kullanır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Під час використання відображає зелену піктограму камери та жовту піктограму мікрофона. Використовує API CoreAudio та CoreMediaIO, що керуються подіями." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用时显示绿色相机图标和黄色麦克风图标。使用事件驱动的 CoreAudio 和 CoreMediaIO API。" + } + } + } + }, + "Shows the model's reasoning process before the final answer": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعرض عملية التفكير في النموذج قبل الإجابة النهائية" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Zobrazuje proces uvažování modelu před konečnou odpovědí." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt den Argumentationsprozess des Modells vor der endgültigen Antwort" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Shows the model's reasoning process before the final answer" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher le processus de réflexion du modèle avant la réponse." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "અંતિમ જવાબ પહેલાં મોડેલની તર્ક પ્રક્રિયા બતાવે છે" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Viser modellens resonnement før det endelige svaret" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывает рассуждения модели перед выдачей ответа" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "இறுதி விடையைத் தருவதற்கு முன்பு, மாதிரியின் பகுத்தறிவு செயல்முறையைக் காட்டுகிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Nihai cevaptan önce modelin akıl yürütme sürecini gösterir" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Показує процес міркування моделі перед остаточним відповіддю" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在最终答案之前显示模型的推理过程" + } + } + } + }, + "Shows the system Clock timer in the notch when available. Requires Accessibility permission to read the status item.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعرض مؤقت ساعة النظام في الشق عند توفره. يتطلب إذن إمكانية الوصول لقراءة عنصر الحالة." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Zobrazuje systémový časovač v zářezu, pokud je k dispozici. Vyžaduje oprávnění pro přístupnost, aby bylo možné číst stavovou položku." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt den Systemuhr-Timer in der Kerbe an, wenn verfügbar. Erfordert Zugriffsberechtigung für Bedienungshilfen, um das Statuselement zu lesen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Shows the system Clock timer in the notch when available. Requires Accessibility permission to read the status item." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affiche la minuterie d'horloge du système dans l'encoche lorsqu'elle est disponible. Nécessite l'autorisation d'accessibilité pour lire l'élément d'état." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "ઉપલબ્ધ હોય ત્યારે નોચમાં સિસ્ટમ ક્લોક ટાઈમર બતાવે છે. સ્થિતિ આઇટમ વાંચવા માટે ઍક્સેસિબિલિટી પરવાનગી જરૂરી છે." + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra il timer dell'orologio di sistema nella tacca quando disponibile. Richiede l'autorizzazione Accessibilità per leggere l'elemento di stato." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Viser systemklokkens timer i hakket når den er tilgjengelig. Krever tilgangstillatelse for å lese statuselementet." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображает системный таймер в вырезе. Требуется разрешение к функциям Универсального доступа." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "கிடைக்கும்போது, நாட்சில் அமைப்பு கடிகார டைமரைக் காட்டுகிறது. நிலைப் பொருளைப் படிக்க அணுகல்தன்மை அனுமதி தேவை." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sistem Saati zamanlayıcısını mevcut olduğunda çentikte gösterir. Durum öğesini okumak için Erişilebilirlik izni gerektirir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Показує системний таймер годинника у виїмці, якщо він доступний. Для читання елемента стану потрібен дозвіл на доступність." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在可用时在刘海中显示系统时钟计时器。需要辅助功能权限来读取状态项。" + } + } + } + }, + "Single Indicators": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einzelindikatoren" + } + }, + "ru": { + "stringUnit": { + "value": "Одиночные индикаторы", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "单个指示器" + } + } + } + }, + "Size & Scale": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Größe & Maßstab" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Size & Scale" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Taille et échelle" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Размер и масштаб" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "大小与缩放" + } + } + } + }, + "Skip behaviour": { + "comment": "A section header that describes the skip behaviour settings.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verhalten überspringen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Skip behaviour" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sauter le comportement" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поведение пропуска" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跳过行为" + } + } + } + }, + "Skip buttons": { + "comment": "A section header that lists the skip behaviour options.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schaltflächen überspringen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Skip buttons" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Skip buttons" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Boutons de saut" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скип-кнопки" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跳过按钮" + } + } + } + }, + "Slider color": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Slider color" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Slider color" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Slider Farbe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Slider color" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Slider colour" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Slider color" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur du curseur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Slider color" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Colore cursore" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "슬라이더 컬러" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Kolor suwaka" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Slider color" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвет ползунка" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Slider color" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Slider color" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "滑块颜色" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمزج التحولات السلسة بين الأخضر (0-60٪) والأصفر (60-85٪) والأحمر (85-100٪) من خلال التعبئة بأكملها." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Plynulé přechody mísí zelenou (0–60 %), žlutou (60–85 %) a červenou (85–100 %) barvu v celém výplni." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Glatte Übergänge mischen Grün (0–60%), Gelb (60–85%) und Rot (85–100%) durch die gesamte Füllung." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill." + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les transitions douces mélangent le vert (0 à 60 %), le jaune (60 à 85 %) et le rouge (85 à 100 %) tout au long du remplissage." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "સરળ પરિવર્તનો સમગ્ર ભરાવટમાં લીલું (0–60%), પીળું (60–85%), અને લાલ (85–100%) સંયોજિત કરે છે." + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le transizioni fluide mescolano verde (0-60%), giallo (60-85%) e rosso (85-100%) attraverso l'intero riempimento." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Jevne overganger blander grønn (0–60 %), gul (60–85 %) og rød (85–100 %) gjennom hele fyllingen." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Плавный переход от зеленого (0-60%) к желтому (60-85%) и красному (85-100%)." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "மென்மையான மாற்றங்கள், முழு நிரப்பிலும் பச்சை (0–60%), மஞ்சள் (60–85%), மற்றும் சிவப்பு (85–100%) ஆகியவற்றைக் கலக்கிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pürüzsüz geçişler Yeşil (%0–60), Sarı (%60–85) ve Kırmızıyı (%85–100) tüm dolgu boyunca harmanlar." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Плавний перехід між зеленим (0–60 %), жовтим (60–85 %) і червоним (85–100 %) кольорами по всій площі заповнення." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "平滑过渡在整个填充中混合绿色(0-60%)、黄色(60-85%)和红色(85-100%)。" + } + } + } + }, + "Sneak peek duration": { + "comment": "A label describing the duration of the \"sneak peek\" feature in the calendar settings.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dauer der Spitze" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak peek duration" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Sneak peek duration" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Durée de l'aperçu" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Длительность быстрого просмотра" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快速预览时长" + } + } + } + }, + "Sneak Peek shows the media title and artist under the notch for a few seconds.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak Peek shows the media title and artist under the notch for a few seconds." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek na několik sekund zobrazí název média a interpreta pod zářezem." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek zeigt den Medium-Titel und den Künstler unter der Notch für ein paar Sekunden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek shows the media title and artist under the notch for a few seconds." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek shows the media title and artist under the notch for a few seconds." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak Peek shows the media title and artist under the notch for a few seconds." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek montre l'artiste et le titre du média sous l'encoche pendant quelques secondes." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "સ્નીક પીક નોચની નીચે મીડિયા શીર્ષક અને કલાકારને થોડા સેકન્ડ માટે બતાવે છે." + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak Peek shows the media title and artist under the notch for a few seconds." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek mostra il titolo dei media e l'artista sotto la tacca per alcuni secondi." + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "훔쳐보기가 몇 초 동안 미디어 제목과 아티스트명을 노치 아래에 보여줍니다." + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Podgląd pokazuje tytuł multimediów i artystę pod notchem przez kilka sekund." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak Peek shows the media title and artist under the notch for a few seconds." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek показывает название медиафайла и исполнителя под выемкой в течение нескольких секунд." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek, medya başlığını ve sanatçıyı birkaç saniye boyunca çentiğin altında gösterir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek показує назву медіафайлу та виконавця під виїмкою протягом декількох секунд." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak Peek 在Notch下显示媒体标题和艺术家几秒钟。" + } + } + } + }, + "Sneak Peek Style": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak Peek Style" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak Peek Style" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek Stil" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek Style" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek Style" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak Peek Style" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Style du Sneak Peek" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak Peek Style" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Stile Sneak Peek" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "훔쳐보기 스타일" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Styl podglądu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak Peek Style" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стиль быстрого просмотра" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek Tarzı" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak Peek Style" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak Peek 样式" + } + } + } + }, + "Sneak peek style is locked to Standard in minimalistic mode": { + "comment": "A message displayed when the sneak peek style is locked to standard in minimalistic UI mode.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتم قفل نمط نظرة خاطفة على المعيار في وضع الحد الأدنى" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Styl náhledu je v minimalistickém režimu uzamčen na standardní." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Sneak-Peek-Stil ist im minimalistischen Modus auf Standard beschränkt" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Sneak peek style is locked to Standard in minimalistic mode" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Sneak peek style is locked to Standard in minimalistic mode" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le style Sneak peek est verrouillé à Standard en mode minimaliste" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "મિનિમલિસ્ટિક મોડમાં સ્નિક પીક શૈલી સ્ટાન્ડર્ડ પર લોક છે." + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sneak peek-stil er låst til Standard i minimalistisk modus" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стиль быстрого просмотра зафиксирован в минималистичном режиме" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sneak peek stili minimalist modda Standart'a kilitlendi" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "简约模式下快速预览样式锁定为标准" + } + } + } + }, + "Software updates": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Software updates" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Software updates" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Softwareupdates" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Software updates" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Software updates" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Software updates" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mises à jour logicielles" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Software updates" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Aggiornamenti software" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "소프트웨어 업데이트" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Aktualizacje oprogramowania" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Software updates" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновления ПО" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yazılım güncellemeleri" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Software updates" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "软件更新" + } + } + } + }, + "Solid colour": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einfarbig" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Solid colour" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur unie" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сплошной цвет" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "纯色" + } + } + } + }, + "Speed": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Speed" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Speed" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geschwindigkeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Speed" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Speed" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Speed" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vitesse" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Speed" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Velocità" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "속도" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Szybkość" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Speed" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скорость" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Speed" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Speed" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "速度" + } + } + } + }, + "Speed:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geschwindigkeit:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Speed:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vitesse:" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скорость:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "速度:" + } + } + } + }, + "Square": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Square" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Čtvercový" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Quadratisch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Square" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Square" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Square" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Carré" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Square" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Quadrato" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "사각형" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Kwadrat" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Square" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Квадрат" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Square" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Square" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "方形" + } + } + } + }, + "Start": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Start" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Start" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Démarrer" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Начать" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开始" + } + } + } + }, + "Start a conversation to see your chat history here": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ابدأ محادثة لرؤية سجل الدردشة الخاص بك هنا" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Zahajte konverzaci a podívejte se na historii chatů zde" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beginnen Sie ein Gespräch, um Ihren Chat-Verlauf hier zu sehen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Start a conversation to see your chat history here" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commencez une conversation pour voir votre historique" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Начните беседу" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sohbet geçmişinizi görmek için burada bir sohbet başlatın" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开始对话以在此查看聊天记录" + } + } + } + }, + "Start Color Picking": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بدء اختيار اللون" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbauswahl starten" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Start Color Picking" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commencer à " + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inizia a scegliere il colore" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Начать выбор цвета" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开始取色" + } + } + } + }, + "Start Custom Timer": { + "comment": "A button label that starts a custom timer.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierter Timer starten" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Start Custom Timer" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Démarrer le minuteur personnalisé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запустить настраиваемый таймер" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动自定义计时器" + } + } + } + }, + "Start Demo Timer:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Demo-Timer starten:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Start Demo Timer:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lancer le minuteur de démo" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запустить демо-таймер:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动演示计时器:" + } + } + } + }, + "Start Monitoring": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Starte Überwachung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Start Monitoring" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commencer le suivi" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Начать мониторинг" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开始监控" + } + } + } + }, + "Start picking colors to build your history": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ابدأ في اختيار الألوان لبناء تاريخك" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fangen Sie an, Farben auszuwählen, um Ihre Geschichte aufzubauen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Start picking colors to build your history" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commencez à choisir des couleurs pour construire votre histoire" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inizia a scegliere i colori per costruire la tua storia" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Начните выбирать цвета, чтобы создать историю" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开始取色以建立历史记录" + } + } + } + }, + "Start recording": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufnahme starten" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Start recording" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commencer l’enregistrement" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Начать запись" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开始录制" + } + } + } + }, + "Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يبدأ مؤقت العرض التوضيحي لمدة 5 دقائق لاختبار ميزة النشاط المباشر المؤقت. يعمل فقط عند تمكين ميزة المؤقت." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Spustí 5minutový demo časovač pro otestování funkce živé aktivity časovače. Funguje pouze v případě, že je funkce časovače povolena." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Startet einen 5-minütigen Demo-Timer, um die Live-Aktivitätsfunktion des Timers zu testen. Funktioniert nur, wenn die Timer-Funktion aktiviert ist." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inicia un temporizador de demostración de 5 minutos para probar la función de actividad en vivo del temporizador. Solo funciona cuando la función de temporizador está habilitada." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Démarre une minuterie de démonstration de 5 minutes pour tester la fonction d'activité en direct de la minuterie. Ne fonctionne que lorsque la fonction de minuterie est activée." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "ટાઈમર લાઇવ પ્રવૃત્તિ ફીચરની ચકાસણી માટે 5-મિનિટનું ડેમો ટાઈમર શરૂ કરે છે. ફક્ત ત્યારે જ કામ કરે છે જ્યારે ટાઈમર ફીચર સક્રિય હોય." + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Avvia un timer demo di 5 minuti per testare la funzione di attività live del timer. Funziona solo quando la funzione timer è abilitata." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Starter en 5-minutters demo-timer for å teste timerens live-aktivitetsfunksjon. Fungerer kun når timerfunksjonen er aktivert." + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запускает демо-таймер на 5 минут для тестирования." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "டைமர் நேரலைச் செயல்பாட்டு அம்சத்தைச் சோதிக்க, 5 நிமிட டெமோ டைமரைத் தொடங்குகிறது. டைமர் அம்சம் இயக்கப்பட்டிருக்கும்போது மட்டுமே இது செயல்படும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Zamanlayıcı canlı etkinlik özelliğini test etmek için 5 dakikalık bir demo zamanlayıcısı başlatır. Yalnızca zamanlayıcı özelliği etkinleştirildiğinde çalışır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Запускає 5-хвилинний демо-таймер для тестування функції таймера в режимі реального часу. Працює тільки тоді, коли функція таймера увімкнена." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动 5 分钟演示计时器以测试实时活动功能。仅在启用计时器功能时有效。" + } + } + } + }, + "Stats": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Statistiken" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Stats" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Statistiques" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статистика" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "统计" + } + } + } + }, + "Stats Disabled": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Statistiken deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Stats Disabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estadísticas desactivadas" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Statistiques désactivées" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Statistikk deaktivert" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статистика отключена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "统计已禁用" + } + } + } + }, + "Stats Feature Disabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Statistikfunktion deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Stats Feature Disabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La fonctionnalité de statistiques est désactivée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Функция статистики отключена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "统计功能已禁用" + } + } + } + }, + "Stats feature is disabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Statistikfunktion ist deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Stats feature is disabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La fonctionnalité de statistiques est désactivée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Statistikkfunksjonen er deaktivert" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Функция статистики отключена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "统计功能已禁用" + } + } + } + }, + "Stats panel is disabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Statistik-Panel ist deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Stats panel is disabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le panneau des statistiques est désactivé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Панель статистики отключена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "统计面板已禁用" + } + } + } + }, + "Stats Panel:": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Statistik-Panel:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Stats Panel:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Panneau des statistiques" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Панель статистики:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "统计面板:" + } + } + } + }, + "Stats update interval": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Statistik-Aktualisierungsintervall" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Stats update interval" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Intervalle de mise à jour des statistiques" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Интервал обновления статистики" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "统计更新间隔" + } + } + } + }, + "Status & Actions": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Status & Aktionen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Status & Actions" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actions et statut" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статус и действия" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "状态与操作" + } + } + } + }, + "Stop": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Stop" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Arrêter" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Остановить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + } + }, + "shouldTranslate": false + }, + "Stop Monitoring": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Überwachung stoppen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Stop Monitoring" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettre fin à le suivi" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Остановить мониторинг" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止监控" + } + } + } + }, + "Stop recording": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufnahme stoppen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Stop recording" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettre fin à l’enregisterement" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Остановить запись" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止录制" + } + } + } + }, + "Stopped": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Stopped" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Stopped" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gestoppt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stopped" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Stopped" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Stopped" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Arrêté" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Stopped" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Interrotto" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "중지 됨" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zatrzymano" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Stopped" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Остановлено" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Stopped" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Stopped" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "已停止" + } + } + } + }, + "Support Us": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Support Us" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Support Us" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unterstütze uns" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Support Us" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Support Us" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Support Us" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Soutenez-nous" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Support Us" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Supportaci" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "후원하기" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wesprzyj nas" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Support Us" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поддержите нас" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Bizi Destekle" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Support Us" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "支持我们" + } + } + } + }, + "Supports reasoning mode": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unterstützt den Argumentationsmodus" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Supports reasoning mode" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supporte la réfléxion" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поддерживает режим размышления" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "支持推理模式" + } + } + } + }, + "Swap": { + "comment": "A label for the section of the memory dashboard related to swap usage.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tauschen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Swap" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace d'échange" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Файл подкачки (Swap)" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "交换空间" + } + } + } + }, + "Swap is disabled on this system.": { + "comment": "A message indicating that swap memory is disabled on the user's system.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم تعطيل المبادلة على هذا النظام." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Swap je v tomto systému zakázán." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Swap ist auf diesem System deaktiviert." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Swap is disabled on this system." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Swap is disabled on this system." + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Swap on poistettu käytöstä tässä järjestelmässä." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’espace d’échange est désactivé sur ce système." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Swap отключен в системе." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Обмін вимкнено в цій системі." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此系统已禁用交换空间。" + } + } + } + }, + "System": { + "comment": "A label displayed above the system, load average, and uptime information.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "System" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Système" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Система" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统" + } + } + } + }, + "System features": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "System features" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "System features" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "System Funktionen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "System features" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "System features" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "System features" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fonctionnalités du système" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "System features" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Funzionalità di sistema" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "시스템 기능" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Funkcje systemowe" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "System features" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Системные функции" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Sistem özellikleri" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "System features" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "系统功能" + } + } + } + }, + "System Performance Monitor": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemleistungsmonitor" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "System Performance Monitor" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suivi des Performances Système" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Монитор производительности системы" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Монітор продуктивності системи" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统性能监视器" + } + } + } + }, + "Take area screenshot": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Screenshot des Bereichs machen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Take area screenshot" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Take area screenshot" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Prendre une capture d’écran de zone" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сделать снимок выделенной области" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "区域截图" + } + } + } + }, + "Temperature unit": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Temperatureinheit" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Temperature unit" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Temperature unit" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Unité de température" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Единицы температуры" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "温度单位" + } + } + } + }, + "The MacBook icon is available only in the circular layout.": { + "comment": "A message displayed below a toggle that lets the user choose whether to show a MacBook icon in the battery gauge of the weather widget. The message explains that the MacBook icon is only available when the weather widget is in a", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تتوفر أيقونة MacBook فقط في التخطيط الدائري." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Ikona MacBooku je k dispozici pouze v kruhovém rozložení." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das MacBook-Symbol ist nur im kreisförmigen Layout verfügbar." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "The MacBook icon is available only in the circular layout." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El icono del MacBook solo está disponible en el diseño circular." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'icône MacBook n'est disponible que dans la disposition circulaire." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L'icona del MacBook è disponibile solo nel layout circolare." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "MacBook-ikonet er kun tilgjengelig i det sirkulære oppsettet." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ikona MacBooka jest dostępna tylko w układzie okrągłym." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Иконка MacBook доступна только в круглом макете." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Піктограма MacBook доступна тільки в круглому макеті." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "MacBook 图标仅在圆形布局中可用。" + } + } + } + }, + "TheDynamicIsland": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "TheDynamicIsland" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "TheDynamicIsland" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "灵动岛" + } + } + }, + "shouldTranslate": false + }, + "These controls mirror the Lock Screen tab so you can tune the media overlay while focusing on playback settings.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعكس عناصر التحكم هذه علامة التبويب قفل الشاشة حتى تتمكن من ضبط تراكب الوسائط مع التركيز على إعدادات التشغيل." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Tyto ovládací prvky odpovídají kartě Zamknout obrazovku, takže můžete ladit překrytí médií a soustředit se přitom na nastavení přehrávání." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Steuerelemente spiegeln die Registerkarte \"Bildschirm sperren\" wider, so dass Sie das Medien-Overlay optimieren können, während Sie sich auf die Wiedergabeeinstellungen konzentrieren." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "These controls mirror the Lock Screen tab so you can tune the media overlay while focusing on playback settings." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "These controls mirror the Lock Screen tab so you can tune the media overlay while focusing on playback settings." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estos controles reflejan la pestaña de pantalla de bloqueo para que pueda ajustar la superposición de medios mientras se enfoca en la configuración de reproducción." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ces commandes reflètent l'onglet Écran de verrouillage afin que vous puissiez régler la superposition multimédia tout en vous concentrant sur les paramètres de lecture." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "આ નિયંત્રણો લોક સ્ક્રીન ટેબની પ્રતિબિંબ છે, જેથી તમે પ્લેબેક સેટિંગ્સ પર ધ્યાન કેન્દ્રિત કરતી વખતે મીડિયા ઓવરલેને સમાયોજિત કરી શકો." + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questi controlli rispecchiano la scheda Blocca schermo in modo da poter sintonizzare l'overlay multimediale mentre ti concentri sulle impostazioni di riproduzione." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Disse kontrollene speiler fanen Låseskjerm, slik at du kan justere medieoverlegget mens du fokuserer på avspillingsinnstillingene." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Te elementy sterujące odzwierciedlają zakładkę Ekran blokady, dzięki czemu można dostosować nakładkę multimedialną, skupiając się na ustawieniach odtwarzania." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Эти элементы управления дублируют настройки экрана блокировки для удобства." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "இந்தக் கட்டுப்பாடுகள் பூட்டுத் திரை தாவலைப் பிரதிபலிக்கின்றன, எனவே நீங்கள் பிளேபேக் அமைப்புகளில் கவனம் செலுத்தும் போது மீடியா ஓவர்லேவைச் சரிசெய்யலாம்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu kontroller Kilit Ekranı sekmesini yansıtır, böylece oynatma ayarlarına odaklanırken medya katmanını ayarlayabilirsiniz." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ці елементи керування відображають вкладку «Екран блокування», тому ви можете налаштувати накладення медіа, зосередившись на параметрах відтворення." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这些控制镜像了锁屏标签页,方便您在关注播放设置的同时调整媒体覆盖层。" + } + } + } + }, + "These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقع عناصر التحكم هذه بجانب الشق أثناء تشغيل المؤقت. إنهم يطلبون أن يظل اسم المؤقت مخفيا للتباعد." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Tyto ovládací prvky se nacházejí vedle zářezu, zatímco běží časovač. Vyžadují, aby název časovače zůstal skrytý z důvodu mezery." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Steuerelemente befinden sich neben der Kerbe, während ein Timer läuft. Sie erfordern, dass der Timer-Name für den Abstand ausgeblendet bleibt." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estos controles se encuentran al lado de la muesca mientras se ejecuta un temporizador. Requieren que el nombre del temporizador permanezca oculto para el espaciado." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ces commandes se trouvent à côté de l'encoche pendant qu'une minuterie fonctionne. Ils ont besoin que le nom de la minuterie reste caché pour l'espacement." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "આ નિયંત્રણો લોક સ્ક્રીન ટેબની પ્રતિબિંબ છે, જેથી તમે પ્લેબેક સેટિંગ્સ પર ધ્યાન કેન્દ્રિત કરતી વખતે મીડિયા ઓવરલેને સમાયોજિત કરી શકો." + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questi controlli si trovano accanto alla tacca mentre un timer è in atto. Richiedono il nome del timer per rimanere nascosto per la spaziatura." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Disse kontrollene ligger ved siden av hakket mens en tidtaker går. De krever at navnet på tidtakeren forblir skjult for å få plass." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Te elementy sterujące znajdują się obok wycięcia, podczas gdy działa licznik czasu. Wymagają one, aby nazwa licznika pozostała ukryta ze względu na odstępy." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Эти элементы находятся рядом с вырезом во время работы таймера." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "டைமர் இயங்கும் போது, இந்தக் கட்டுப்பாடுகள் நோச்சின் அருகே அமைந்துள்ளன. இடைவெளிக்கு வசதியாக, டைமரின் பெயர் மறைக்கப்பட்டிருக்க வேண்டும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu kontroller, bir zamanlayıcı çalışırken çentiğin yanında oturur. Zamanlayıcı adının boşluk için gizli kalmasını gerektirirler." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ці елементи управління розташовані поруч із виїмкою, поки працює таймер. Вони вимагають, щоб назва таймера залишалася прихованою для збереження пробілів." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "计时器运行时这些控制位于刘海旁边。为了间距需要隐藏计时器名称。" + } + } + } + }, + "These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تظهر هذه المؤشرات في الشق عندما تستخدم التطبيقات الكاميرا أو الميكروفون، مطابقة مؤشرات الخصوصية الأصلية لنظام التشغيل macOS." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Tyto indikátory se zobrazují v zářezu, když aplikace používají vaši kameru nebo mikrofon, a odpovídají nativním indikátorům soukromí systému macOS." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Anzeigen werden in der Notch angezeigt, wenn Apps Ihre Kamera oder Ihr Mikrofon verwenden, und stimmen mit den nativen Datenschutzindikatoren von macOS überein." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estos indicadores aparecen en la muesca cuando las aplicaciones están usando su cámara o micrófono, coincidiendo con los indicadores de privacidad nativos de macOS." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ces indicateurs apparaissent dans l'encoche lorsque les applications utilisent votre caméra ou votre microphone, correspondant aux indicateurs de confidentialité natifs de macOS." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "આ સમયગાળો ટાઇમર પોપઓવરમાં ઝડપી ઍક્સેસ માટે 'કસ્ટમ' વિકલ્પને સક્રિય કરે છે." + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questi indicatori appaiono nella tacca quando le app utilizzano la fotocamera o il microfono, corrispondenti agli indicatori di privacy nativi di macOS." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Disse indikatorene vises i hakket når apper bruker kameraet eller mikrofonen din, i samsvar med macOS' innebygde personvernindikatorer." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wskaźniki te pojawiają się w wycięciu, gdy aplikacje korzystają z aparatu lub mikrofonu, zgodnie z natywnymi wskaźnikami prywatności systemu macOS." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Эти индикаторы появляются в вырезе, когда приложения используют камеру или микрофон." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "ஆப்ஸ் உங்கள் கேமரா அல்லது மைக்ரோஃபோனைப் பயன்படுத்தும்போது, macOS-இன் உள்ளார்ந்த தனியுரிமைக் குறிகாட்டிகளுக்குப் பொருத்தமாக இந்தக் குறிகாட்டிகள் நாச்சில் தோன்றும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu göstergeler, uygulamalar kameranızı veya mikrofonunuzu kullanırken çentikte görünür ve macOS'un yerel gizlilik göstergeleriyle eşleşir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ці індикатори з'являються в зарізі, коли програми використовують вашу камеру або мікрофон, що відповідає вбудованим індикаторам конфіденційності macOS." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当应用使用您的相机或麦克风时,这些指示器会出现在刘海中,与 macOS 原生隐私指示器一致。" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعمل هذه المدة على تشغيل خيار \"مخصص\" داخل النافذة المنبثقة المؤقت للوصول السريع." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Tato doba aktivuje možnost „Vlastní“ v rozbalovací nabídce časovače pro rychlý přístup." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Dauer ermöglicht die Option \"Benutzerdefinierte\" im Timer-Popover für einen schnellen Zugriff." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "This duration powers the \"Custom\" option inside the timer popover for quick access." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "This duration powers the \"Custom\" option inside the timer popover for quick access." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta duración alimenta la opción \"Personalizado\" dentro de la ventana emergente del temporizador para un acceso rápido." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette durée alimente l'option \"Personnalisé\" à l'intérieur de la fenêtre contextuelle de la minuterie pour un accès rapide." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "જ્યારે ટાઈમર ચાલી રહ્યો હોય ત્યારે આ નિયંત્રણો નોચની બાજુમાં મુકાય છે. જગ્યા જાળવવા માટે તેમને ટાઈમરના નામને છુપાયેલું રાખવાની જરૂર છે." + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa durata alimenta l'opzione \"Personalizzato\" all'interno del popover del timer per un accesso rapido." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Denne varigheten aktiverer alternativet «Tilpasset» i timer-popoveren for rask tilgang." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ten czas działania zasila opcję „Niestandardowe” w wyskakującym okienku timera, zapewniając szybki dostęp." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Используется для быстрого доступа к кнопке «Особый»." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "இந்தக் காலம், விரைவாக அணுகுவதற்காக டைமர் பாப்ஓவரில் உள்ள \"தனிப்பயன்\" விருப்பத்தை இயக்குகிறது." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu süre, hızlı erişim için zamanlayıcı açılır penceresinin içindeki \"Özel\" seçeneğine güç sağlar." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ця тривалість активує опцію «Custom» (Налаштування) у спливаючому вікні таймера для швидкого доступу." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此时长用于计时器弹出窗口中的「自定义」选项,方便快速访问。" + } + } + } + }, + "This duration powers the \\\"Custom\\\" option inside the timer popover for quick access.": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Dauer unterstützt die Option \\\"Benutzerdefiniert\\\" im Timer-Popover für den schnellen Zugriff." + } + }, + "ru": { + "stringUnit": { + "value": "Используется для быстрого доступа к кнопке «Особый».", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此时长用于计时器弹出窗口中的「自定义」选项,方便快速访问。" + } + } + } + }, + "Time Range": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeitspanne" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Time Range" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Période de temps" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Временной диапазон" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "时间范围" + } + } + } + }, + "Time to Full Charge: %lld min": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Time to Full Charge: %lld min" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Time to Full Charge: %lld min" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeit bis zur vollen Ladung: %lld min" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Time to Full Charge: %lld min" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Time to Full Charge: %lld min" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Time to Full Charge: %lld min" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Temps avant la charge complète : %lld min" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Time to Full Charge: %lld min" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Tempo alla carica completa: %lld min" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "완충 까지 남은 시간: %lld 분" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Czas do pełnego naładowania: %lld min" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Time to Full Charge: %lld min" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Time to Full Charge: %lld мин" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Time to Full Charge: %lld min" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Time to Full Charge: %lld min" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "充电时间:%lld 分钟" + } + } + } + }, + "Timer": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Timer" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Timer" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Ajastin" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Minuteur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Таймер" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "计时器" + } + } + } + }, + "Timer Disabled": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Timer deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Timer Disabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Minuteur désactivé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Таймер отключен" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "计时器已禁用" + } + } + } + }, + "Timer Feature": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Timer-Funktion" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Timer Feature" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fonctionnalité du Minuteur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Функция таймера" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "计时器功能" + } + } + } + }, + "Timer feature is disabled": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Timer-Funktion ist deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Timer feature is disabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La fonctionnalité du minuteur est désactivée" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Функция таймера отключена" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "计时器功能已禁用" + } + } + } + }, + "Timer Presets": { + "comment": "A section header in the timer settings view, describing timer presets.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Timer-Voreinstellungen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Timer Presets" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préréglages de minuterie" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пресеты таймера" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "计时器预设" + } + } + } + }, + "Timer Sound": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Timer-Ton" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Timer Sound" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Son du Minuteur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Звук таймера" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "计时器声音" + } + } + } + }, + "Timer tint": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Timer-Tönung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Timer tint" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Timer dégel" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Оттенок таймера" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "计时器着色" + } + } + } + }, + "Timer Widget": { + "comment": "A section header for the timer widget settings.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Timer-Widget" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Timer Widget" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Widget Horloge" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Виджет таймера" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "计时器小组件" + } + } + } + }, + "Toggle Notch Open:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Notch Open:" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Notch Open:" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Notch öffnen umschalten:" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Notch Open:" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Toggle Notch Open:" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Notch Open:" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer/désactiver l’encoche :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Notch Open:" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Apri Notch:" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "노치 열기 토글:" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Włącz/wyłącz Notch:" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Notch Open:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть/закрыть вырез:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Notch Open:" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Notch Open:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "切换Notch:" + } + } + } + }, + "Toggle Sneak Peek:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Sneak Peek:" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Sneak Peek:" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sneak Peek umschalten:" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle Sneak Peek:" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Toggle Sneak Peek:" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Sneak Peek:" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer/désactiver Sneak Peek:" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Sneak Peek:" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Apri Sneak Peek:" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "훔쳐보기 토글:" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Włącz/wyłącz podgląd:" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Sneak Peek:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Быстрый просмотр:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Sneak Peek:" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle Sneak Peek:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "切换 Sneak Peek:" + } + } + } + }, + "Toggle the Dynamic Island open or closed from anywhere.": { + "comment": "A description of the \"Toggle Navigation\" keyboard shortcut.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قم بتبديل الجزيرة الديناميكية مفتوحة أو مغلقة من أي مكان." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Otevřete nebo zavřete Dynamic Island odkudkoli." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schalten Sie die dynamische Insel von überall aus öffnen oder schließen." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Toggle the Dynamic Island open or closed from anywhere." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Toggle the Dynamic Island open or closed from anywhere." + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activez l'île dynamique ouverte ou fermée de n'importe où." + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "કોઈપણ જગ્યાએથી ડાયનેમિક આઇલેન્ડને ખોલવા કે બંધ કરવા ટૉગલ કરો." + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "कहीं से भी डायनामिक आइलैंड को खोलें या बंद करें।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva l'apertura o la chiusura di Dynamic Island da qualsiasi luogo." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå Dynamic Island på eller av hvor som helst." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Włącz lub wyłącz Dynamic Island z dowolnego miejsca." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открывайте и закрывайте Dynamic Island из любого места." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вмикайте або вимикайте Dynamic Island з будь-якого місця." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从任何位置切换灵动岛的打开或关闭。" + } + } + } + }, + "Total · %@": { + "comment": "A label displaying the total storage capacity of a disk device. The argument is the total storage capacity of the disk device.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesamt · %@" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Total · %@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Yhteensä · %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Total · %@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Всего · %@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "总计 · %@" + } + } + } + }, + "Transform": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Umwandeln" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Transform" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Transform" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Transformez" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Transforм" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "变换" + } + } + } + }, + "Transform your notch truly yours": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حول شقك حقا لك" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwandeln Sie Ihre Notch wirklich zu Ihrer" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Transform your notch truly yours" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Transform your notch truly yours" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reprenez le contrôle sur votre encoche" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "अपने नॉच को सचमुच अपना बनाएं" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сделайте вырез по-настоящему вашим" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перетворіть свій виріз на справді свій" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将刘海变成真正属于你的空间" + } + } + } + }, + "Try adjusting your search terms": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Versuchen Sie, Ihre Suchbegriffe anzupassen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Try adjusting your search terms" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Essayez d’ajuster vos termes de recherche" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Попробуйте изменить поисковый запрос" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请尝试调整搜索词" + } + } + } + }, + "Try different search terms": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Probieren Sie verschiedene Suchbegriffe aus" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Try different search terms" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Essayez d’autres termes de recherche" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Prova diversi termini di ricerca" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Попробуйте другие условия поиска" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请尝试不同的搜索词" + } + } + } + }, + "Try pressing volume keys to test system HUD functionality": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Drücken Sie die Lautstärketasten, um die System-HUD-Funktionalität zu testen." + } + }, + "ru": { + "stringUnit": { + "value": "Нажмите клавиши громкости для проверки системного HUD", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尝试按音量键来测试系统 HUD 功能" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسحب بإصبعين لأعلى على الشق للإغلاق، واسحب بإصبعين لأسفل على الشق لفتحه عند تعطيل خيار **فتح الشق عند التحويم**" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Přejetím dvěma prsty nahoru po zářezu se okno zavře, přejetím dvěma prsty dolů po zářezu se okno otevře, pokud je možnost **Otevřít zářez při najetí** deaktivována." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwei-Finger Wischen nach oben zum Schließen der Notch, zwei-Finger wischen nach unten um die Notch zu öffnen, wenn die Option **Notch bei Mausberührung öffnen** deaktiviert ist" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Deslizar con dos dedos hacia arriba en la muesca para cerrar, deslizar dos dedos hacia abajo en la muesca para abrir cuando la opción **Abrir muesca en el desplazamiento** está desactivada" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "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" + } + }, + "gu": { + "stringUnit": { + "state": "translated", + "value": "જ્યારે **હોવર પર નોચ ખોલવાની** વિકલ્પ નિષ્ક્રિય હોય ત્યારે નોચ બંધ કરવા માટે નોચ પર બે-આંગળીથી ઉપર સ્વાઇપ કરો, નોચ ખોલવા માટે નોચ પર બે-આંગળીથી નીચે સ્વાઇપ કરો" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "जब **हॉवर पर नॉच खोलें** विकल्प अक्षम हो, तब नॉच बंद करने के लिए दो उंगलियों से नॉच पर ऊपर स्वाइप करें, नॉच खोलने के लिए दो उंगलियों से नॉच पर नीचे स्वाइप करें।" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Scorri due dita verso l'alto sulla notch per chiudere, scorri due dita verso il basso sulla tacca per aprirla quando l'opzione **Apri notch all'hover ** è disabilitata" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "노치에 가져다 대서 열기 옵션이 꺼져있을 때, 노치를 두 손가락을 위로 밀어서 닫고, 두 손가락을 아래로 내려서 열기" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sveip opp med to fingre på hakket for å lukke, sveip ned med to fingre på hakket for å åpne når alternativet **Åpne hakket ved å holde musepekeren over** er deaktivert." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Przesuń dwoma palcami w górę na notchu, aby zamknąć, przesuń dwoma palcami w dół na notchu, aby otworzyć, gdy opcja **Otwórz notch przy najechaniu palcem ** jest wyłączona" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Смахивание двумя пальцами вверх для закрытия, вниз для открытия, если отключено открытие при наведении." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "**'மேல்வைக்கும்போது திற' விருப்பம்** முடக்கப்பட்டுள்ளபோது, நாட்சை மூட இரண்டு விரல்களால் மேல்நோக்கித் தட்டவும், திறக்க இரண்டு விரல்களால் கீழ்நோக்கித் தட்டவும்." + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Проведіть двома пальцями вгору по виїмці, щоб закрити, проведіть двома пальцями вниз по виїмці, щоб відкрити, коли опція **Відкрити виїмку при наведенні курсору** вимкнена." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当**悬停时打开凹槽**选项关闭时,在凹槽处双指上滑关闭,双指下滑打开" + } + } + } + }, + "UI Mode": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzeroberflächenmodus" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "UI Mode" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mode UI" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим интерфейса" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "界面模式" + } + } + } + }, + "Uninstall": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Uninstall" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Uninstall" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deinstallieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Uninstall" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Uninstall" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Uninstall" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désinstaller" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Uninstall" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Disinstalla" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "설치제거" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Odinstaluj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Uninstall" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Uninstall" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Uninstall" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "卸载" + } + } + } + }, + "Unlock advanced features and improve your experience. Upgrade now for more customizations!": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schalte erweiterte Funktionen frei und verbessere deine Erfahrung. Upgrade jetzt für weitere Anpassungsmöglichkeiten!" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desbloquea funciones avanzadas y mejora tu experiencia. ¡Actualiza ahora para más personalizaciones!" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déverrouillez des fonctionnalités avancées et améliorez votre expérience. Mettez à niveau maintenant pour plus de personnalisations !" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Sblocca le funzionalità avanzate e migliora la tua esperienza. Aggiorna ora per ulteriori personalizzazioni!" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "고급 설정을 해제해 경험을 향상하세요. 더 많은 맞춤화 설정을 지금 바로 업그레이드해서 즐기세요!" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Lås opp avanserte funksjoner og forbedre opplevelsen din. Oppgrader nå for flere tilpasningsmuligheter!" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Odblokuj dodatkowe funkcje i ciesz się lepszymi możliwościami. Ulepsz teraz, aby móc bardziej dostosować ustawienia!" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разблокируйте расширенные функции и улучшите свой опыт. Обновитесь для дополнительных настроек!" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "மேம்பட்ட அம்சங்களைத் திறந்து உங்கள் அனுபவத்தை மேம்படுத்துங்கள். மேலும் தனிப்பயனாக்கல்களுக்கு இப்போதே மேம்படுத்துங்கள்!" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Unlock advanced features and improve your experience. Upgrade now for more customizations!" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Розблокуйте розширені функції та покращіть свій досвід. Оновіть зараз, щоб отримати більше можливостей для налаштування!" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "解锁高级功能并提升您的体验。现在升级以获取更多自定义功能!" + } + } + } + }, + "unmuted": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "unmuted" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "unmuted" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht stummgeschaltet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "unmuted" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "unmuted" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "unmuted" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "activé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "unmuted" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "smutato" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "음소거 해제됨" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wyciszenie wyłączone" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "unmuted" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "со звуком" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "unmuted" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "unmuted" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "已取消静音" + } + } + } + }, + "Update interval": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktualisierungsintervall" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Update interval" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Intervalle de mise à jour" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Интервал обновления" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新间隔" + } + } + } + }, + "Update Timer": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Timer aktualisieren" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Update Timer" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier le minuteur" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновить таймер" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新计时器" + } + } + } + }, + "Upgrade to Pro": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Upgrade to Pro" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Upgrade to Pro" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf Pro upgraden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade to Pro" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Upgrade to Pro" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Upgrade to Pro" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Passer à la version Pro" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Upgrade to Pro" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Passa a Pro" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "프로로 업그레이드" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Ulepsz do wersji Pro" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Upgrade to Pro" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейти на Pro" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Pro sürümüne Yükselt" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Upgrade to Pro" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "升级至 Pro" + } + } + } + }, + "Upload": { + "comment": "A label displayed above the user's upload statistics.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hochladen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Upload" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envois" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправка" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上传" + } + } + } + }, + "Usage": { + "comment": "A label displayed above the CPU usage statistics.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nutzung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Usage" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использование" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用情况" + } + } + } + }, + "Usage Breakdown": { + "comment": "A heading for the breakdown of CPU usage.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nutzungsaufschlüsselung" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Usage Breakdown" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Répartition de l’utilisation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Детализация использования" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用详情" + } + } + } + }, + "use a circular battery indicator": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwenden Sie eine kreisförmige Batterieanzeige" + } + }, + "ru": { + "stringUnit": { + "value": "использовать круглый индикатор батареи", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用圆形电池指示器" + } + } + } + }, + "Use clored gauges": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwenden Sie farbige Messgeräte" + } + }, + "ru": { + "stringUnit": { + "value": "Использовать цветные шкалы", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用彩色仪表" + } + } + } + }, + "Use MacBook icon when on battery": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwenden Sie das MacBook-Symbol, wenn Sie im Akkubetrieb arbeiten." + } + }, + "ru": { + "stringUnit": { + "value": "Использовать иконку MacBook при работе от батареи", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用电池时显示 MacBook 图标" + } + } + } + }, + "Use music visualizer spectrogram": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "استخدم مطياف متخيل الموسيقى" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Use music visualizer spectrogram" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Musik-Visualizer Spektrogramm verwenden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Use music visualizer spectrogram" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Use music visualiser spectrogram" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Use music visualizer spectrogram" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utiliser le spectrogramme du visualiseur de musique" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Use music visualizer spectrogram" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Usa spettrogramma per la visualizzatore musicale" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "음악 시각화 스펙트로그램 사용" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Użyj spektrogramu wizualizera muzyki" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Use music visualizer spectrogram" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Use music visualizer spectrograм" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Use music visualizer spectrogram" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Use music visualizer spectrogram" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "使用音乐可视化器谱图" + } + } + } + }, + "Use simpler close animation": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwenden Sie eine einfachere Schließanimation." + } + }, + "ru": { + "stringUnit": { + "value": "Использовать простую анимацию закрытия", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用更简单的关闭动画" + } + } + } + }, + "Use the Media tab to configure sneak peek, lyrics, and floating media controls.": { + "comment": "A footer for the \"Media Live Activity\" section, explaining how to configure the media controls.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "استخدم علامة التبويب \"الوسائط\" لتكوين نظرة خاطفة وكلمات الأغاني وعناصر تحكم الوسائط العائمة." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Na kartě Média můžete nakonfigurovat náhledy, texty písní a plovoucí ovládací prvky médií." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwenden Sie die Registerkarte \"Medien\", um Sneak Peek, Songtexte und schwebende Mediensteuerungen zu konfigurieren." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Use the Media tab to configure sneak peek, lyrics, and floating media controls." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Use the Media tab to configure sneak peek, lyrics, and floating media controls." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Utilice la pestaña Medios para configurar un adelanto, las letras y los controles de medios flotantes." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Määritä esikatselu, sanoitukset ja kelluvat mediatoiminnot Media-välilehdessä." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisez l'onglet Médias pour configurer les contrôles d'aperçu, de paroles et de médias flottants." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Usa la scheda Media per configurare l'anteprima, i testi e i controlli multimediali mobili." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bruk fanen Media til å konfigurere forhåndsvisning, sangtekster og flytende mediekontroller." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Użyj karty Media, aby skonfigurować podgląd, teksty piosenek i pływające elementy sterujące multimediami." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Используйте вкладку Media для настройки быстрого просмотра, текстов песен и др." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gizli bakış, şarkı sözleri ve kayan medya kontrollerini yapılandırmak için Medya sekmesini kullanın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Розблокуйте розширені функції та покращіть свій досвід. Оновіть зараз, щоб отримати більше можливостей для налаштування!" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用媒体标签页配置快速预览、歌词和浮动媒体控制。" + } + } + } + }, + "Used": { + "comment": "A label describing the percentage of memory that is currently in use.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendet" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Used" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisé" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Используется" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已使用" + } + } + } + }, + "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.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendet · %@" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Used · %@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisé · %@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Исп. · %@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Використано · %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已使用 · %@" + } + } + } + }, + "Uses event-driven private API for real-time screen recording detection": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يستخدم واجهة برمجة التطبيقات الخاصة القائمة على الأحداث للكشف عن تسجيل الشاشة في الوقت الفعلي" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Používá událostmi řízené soukromé API pro detekci nahrávání obrazovky v reálném čase." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendet ereignisgesteuerte private API für die Erkennung von Bildschirmaufnahmen in Echtzeit" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Uses event-driven private API for real-time screen recording detection" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Gerçek zamanlı ekran kaydı algılama için olay odaklı özel API kullanır" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Käyttää tapahtumapohjaista yksityistä sovellusliittymää reaaliaikaiseen näytön tallennuksen tunnistamiseen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilise une API privée basée sur les événements pour la détection d'enregistrement d'écran en temps réel" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Utilizza l'API privata basata sugli eventi per il rilevamento della registrazione dello schermo in tempo reale" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bruker hendelsesstyrt privat API for deteksjon av skjermopptak i sanntid" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wykorzystuje prywatny interfejs API sterowany zdarzeniami do wykrywania nagrywania ekranu w czasie rzeczywistym." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отслеживает запись экрана в реальном времени" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gerçek zamanlı ekran kaydı algılama için olay odaklı özel API kullanır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Використовує приватний API, керований подіями, для виявлення запису екрана в режимі реального часу" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用事件驱动的私有 API 进行实时屏幕录制检测" + } + } + } + }, + "Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当 Amazon Music 应用是活动媒体源时使用 macOS 正在播放。播放控制遵循系统正在播放目标。如果 Amazon Music 应用不支持远程拖拽,在时间轴上拖动可能无效。" + } + } + } + }, + "Utilization": { + "comment": "A label displayed alongside the utilization percentage in the GPU detail view.", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "استخدام" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Werkzeug" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Utilization" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "利用率" + } + } + } + }, + "Version": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Version" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Version" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Version" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Version" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Versione" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "버전" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wersja" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Version" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Версия" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Versiyon" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Version" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "版本" + } + } + } + }, + "Version info": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Version info" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Version info" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Versionsinfo" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Version info" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Version info" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Version info" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Informations de la version" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Version info" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Info versione" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "버전 설명" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Informacje o wersji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Version info" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Информация о версии" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Versiyon bilgisi" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Version info" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "版本信息" + } + } + } + }, + "Vertical:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vertikal:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Vertical:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vertical :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "По вертикали:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "垂直:" + } + } + } + }, + "View on GitHub: th-ch/youtube-music": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "View on GitHub: th-ch/youtube-music" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "View on GitHub: th-ch/youtube-music" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf GitHub anschauen: th-ch/youtube-music" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "View on GitHub: th-ch/youtube-music" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "View on GitHub: th-ch/youtube-music" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "View on GitHub: th-ch/youtube-music" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Katso GitHubissa: th-ch/youtube-music" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher sur GitHub: th-ch/youtube-music" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "View on GitHub: th-ch/youtube-music" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Visualizza su GitHub: th-ch/youtube-music" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "GitHub에서 보기: th-ch/youtube-music" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis på GitHub: th-ch/youtube-music" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zobacz na GitHub: th-ch/youtube-music" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "View on GitHub: th-ch/youtube-music" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Смотреть на GitHub: th-ch/youtube-music" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "View on GitHub: th-ch/youtube-music" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "View on GitHub: th-ch/youtube-music" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "在 GitHub 上查看:th-ch/youtube-music" + } + } + } + }, + "Volume": { + "comment": "A button that sets the preview to show a volume OSD.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lautstärke" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Volume" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Volume" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Громкость" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "音量" + } + } + } + }, + "Volume HUD": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حجم HUD" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lautstärke HUD" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Volume HUD" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "ATH du volume" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "HUD громкости" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "音量 HUD" + } + } + } + }, + "Volume OSD": { + "comment": "A toggle that enables or disables the volume OSD", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Volumen-OSD" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Volume OSD" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Volume OSD" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "OSD громкости" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "音量 OSD" + } + } + } + }, + "We need your camera for Mirror mode": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wir benötigen Ihre Kamera für den Spiegelmodus." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Tarvitsemme kamerasi peilikuva-tilaan" + } + }, + "ru": { + "stringUnit": { + "value": "Нам нужна ваша камера для режима зеркала", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "镜像模式需要使用您的相机" + } + } + } + }, + "Weather": { + "comment": "A label and its accompanying text displayed in a lock screen panel.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wetter" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Weather" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Sää" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Météo" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Погода" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "வானிலை" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "天气" + } + } + } + }, + "Weather data provider": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مزود بيانات الطقس" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anbieter von Wetterdaten" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Weather data provider" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hava durumu veri sağlayıcısı" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fournisseur de données météorologiques" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fornitore di dati meteorologici" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dostawca danych pogodowych" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Провайдер данных погоды" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "வானிலைத் தரவு வழங்குநர்" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "天气数据源" + } + } + } + }, + "Weather Widget": { + "comment": "A section header in the Lock Screen Settings view.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wetter-Widget" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Weather Widget" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Widget météo" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Widget meteo" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Widżet pogodowy" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Виджет погоды" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "天气小组件" + } + } + } + }, + "Weather: %@ %@": { + "comment": "Weather description and temperature", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wetter: %1$@ %2$@" + } + }, + "en": { + "stringUnit": { + "state": "new", + "value": "Weather: %1$@ %2$@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Météo : %1$@ %2$@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vær: %1$@ %2$@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pogoda: %1$@ %2$@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Погода: %1$@ %2$@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "வானிலை: %1$@ %2$@" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "天气:%1$@ %2$@" + } + } + } + }, + "Weather: %@ %@ in %@": { + "comment": "Weather description, temperature, and location", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الطقس: %1$@ %2$@ في %3$@" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wetter: %1$@ %2$@ in %3$@" + } + }, + "en": { + "stringUnit": { + "state": "new", + "value": "Weather: %1$@ %2$@ in %3$@" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Météo : %1$@ %2$@ à %3$@" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vær: %1$@ %2$@ i %3$@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pogoda: %1$@ %2$@ w %3$@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Погода: %1$@ %2$@ в %3$@" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "வானிலை: %1$@ %2$@ இல் %3$@" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "天气:%1$@ %2$@ 在 %3$@" + } + } + } + }, + "Welcome": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "Welcome" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "Welcome" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Willkommen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Welcome" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "Welcome" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Welcome" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bienvenue" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "Welcome" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Benvenuto" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "환영합니다" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Witamy" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Welcome" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добро пожаловать" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "நல்வரவு" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Hoş geldiniz" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ласкаво просимо" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "欢迎" + } + } + } + }, + "What's New": { + "localizations": { + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "What's New" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "What's New" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Was ist neu" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "What's New" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "What's New" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "What's New" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouveautés" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "What's New" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Novità" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "새로운 기능" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Co nowego" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "What's New" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Что нового" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "புதியது என்ன?" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Neler Yeni" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Що нового" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "更新内容" + } + } + } + }, + "When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn diese Option deaktiviert ist, sind alle Tastaturkürzel inaktiv. Sie können weiterhin die UI-Steuerelemente verwenden." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Kun tämä toiminto on pois käytöstä, kaikki pikanäppäimet ovat passiivisia. Voit kuitenkin edelleen käyttää käyttöliittymän ohjaimia." + } + }, + "ru": { + "stringUnit": { + "value": "При отключении все сочетания клавиш будут неактивны.", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用后,所有键盘快捷键将不可用。您仍然可以使用界面控制" + } + } + } + }, + "When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عند تعطيلها، ستكون جميع اختصارات لوحة المفاتيح غير نشطة. لا يزال بإمكانك استخدام عناصر التحكم في واجهة المستخدم." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Pokud je tato funkce vypnuta, všechny klávesové zkratky budou neaktivní. Ovládací prvky uživatelského rozhraní však můžete nadále používat." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn deaktiviert, sind alle Tastenkombinationen inaktiv. Sie können weiterhin die UI-Steuerelemente verwenden." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuando se deshabilita, todos los atajos de teclado estarán inactivos. Todavía puedes usar los controles de la interfaz de usuario." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lorsqu'ils sont désactivés, tous les raccourcis clavier seront inactifs. Vous pouvez toujours utiliser les commandes de l'interface utilisateur." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "जब अक्षम किया जाता है, तो सभी कीबोर्ड शॉर्टकट निष्क्रिय हो जाएंगे। आप अभी भी UI नियंत्रणों का उपयोग कर सकते हैं।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Quando disabilitate, tutte le scorciatoie da tastiera saranno inattive. Puoi ancora usare i controlli dell'interfaccia utente." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Når denne funksjonen er deaktivert, vil alle hurtigtaster være inaktive. Du kan fortsatt bruke kontrollene i brukergrensesnittet." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Po wyłączeniu wszystkie skróty klawiaturowe będą nieaktywne. Nadal można korzystać z elementów sterujących interfejsu użytkownika." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При отключении все сочетания клавиш будут неактивны." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "முடக்கப்பட்டிருக்கும்போது, அனைத்து விசைப்பலகை குறுக்குவழிகளும் செயலிழந்துவிடும். நீங்கள் இன்னும் பயனர் இடைமுகக் கட்டுப்பாடுகளைப் பயன்படுத்தலாம்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Devre dışı bırakıldığında, tüm klavye kısayolları devre dışı bırakılacaktır. Kullanıcı arayüzü kontrollerini kullanmaya devam edebilirsiniz." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Якщо ця функція вимкнена, всі комбінації клавіш будуть неактивними. Ви все одно зможете користуватися елементами керування інтерфейсу." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用后,所有键盘快捷键将不可用。您仍然可以使用界面控制。" + } + } + } + }, + "When enabled, stats monitoring stops a few seconds after the notch closes.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عند التمكين، تتوقف مراقبة الإحصائيات بعد بضع ثوان من إغلاق الشق." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Pokud je tato funkce zapnutá, monitorování statistik se zastaví několik sekund po uzavření zářezu." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn aktiviert, stoppt die Statistiküberwachung einige Sekunden nach Schließung der Notch." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "When enabled, stats monitoring stops a few seconds after the notch closes." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuando está habilitado, el monitoreo de estadísticas se detiene unos segundos después del cierre de la muesca." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lorsqu'il est activé, la surveillance des statistiques s'arrête quelques secondes après la fermeture de l'encoche." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "सक्रिय होने पर, स्टैट्स मॉनिटरिंग नॉच बंद होने के कुछ सेकंड बाद रुक जाती है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Quando abilitato, il monitoraggio delle statistiche si interrompe pochi secondi dopo la chiusura della tacca." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Når denne funksjonen er aktivert, stopper statistikkmonitoreringen noen sekunder etter at hakket lukkes." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Po włączeniu monitorowanie statystyk zatrzymuje się kilka sekund po zamknięciu wycięcia." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При включении мониторинг статистики останавливается через несколько секунд после закрытия выреза." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "இயக்கப்பட்டிருக்கும்போது, நோட்ச் மூடப்பட்ட சில வினாடிகளுக்குப் பிறகு புள்ளிவிவரக் கண்காணிப்பு நின்றுவிடும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Etkinleştirildiğinde, istatistik izleme çentik kapandıktan birkaç saniye sonra durur." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Коли ця функція увімкнена, моніторинг статистики зупиняється через кілька секунд після закриття вирізу." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用后,刘海关闭几秒后统计监控将停止。" + } + } + } + }, + "When enabled, the Atoll will expand horizontally to match the animation size": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عند التمكين، ستتوسع الجرة المرجانية أفقيا لتتناسب مع حجم الرسوم المتحركة" + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Pokud je tato funkce povolena, Atoll se rozbalí horizontálně tak, aby odpovídal velikosti animace." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn aktiviert, erweitert sich das Atoll horizontal, um der Animationsgröße zu entsprechen" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "When enabled, the Atoll will expand horizontally to match the animation size" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuando esté habilitado, el atolón se expandirá horizontalmente para que coincida con el tamaño de la animación" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lorsqu'il est activé, l'atoll se développe horizontalement pour correspondre à la taille de l'animation" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "सक्रिय होने पर, एटोल एनिमेशन के आकार से मेल खाने के लिए क्षैतिज रूप से फैल जाएगा।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Quando abilitato, l'atollo si espanderà orizzontalmente per corrispondere alla dimensione dell'animazione" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Når denne funksjonen er aktivert, vil Atoll utvides horisontalt for å tilpasse seg animasjonens størrelse." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Po włączeniu Atoll rozszerzy się poziomo, aby dopasować się do rozmiaru animacji." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При включении Atoll будет расширяться по горизонтали в соответствии с размером анимации." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "இயக்கப்பட்டிருக்கும்போது, அட்டோல் அனிமேஷனின் அளவிற்குப் பொருந்தும்படி கிடைமட்டமாக விரிவடையும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Etkinleştirildiğinde, Atoll animasyon boyutuna uyacak şekilde yatay olarak genişleyecektir" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用后,Atoll 将水平扩展以匹配动画大小" + } + } + } + }, + "When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عند التمكين، ستعرض علامة التبويب الإحصائيات الرسوم البيانية لأداء النظام في الوقت الفعلي. تتطلب هذه الميزة أذونات النظام وقد تستخدم بطارية إضافية." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Po zapnutí se na kartě Statistiky zobrazí grafy výkonu systému v reálném čase. Tato funkce vyžaduje systémová oprávnění a může zvýšit spotřebu baterie." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn aktiviert, zeigt die Registerkarte \"Statistik\" Echtzeit-Systemleistungsdiagramme an. Diese Funktion erfordert Systemberechtigungen und kann eine zusätzliche Batterie verwenden." + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuando esté habilitada, la pestaña Estadísticas mostrará gráficos de rendimiento del sistema en tiempo real. Esta función requiere permisos del sistema y puede usar batería adicional." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lorsqu'il est activé, l'onglet Statistiques affichera les graphiques de performance du système en temps réel. Cette fonctionnalité nécessite des autorisations système et peut utiliser une batterie supplémentaire." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "सक्रिय होने पर, स्टैट्स टैब वास्तविक समय में सिस्टम प्रदर्शन के ग्राफ़ दिखाएगा। इस सुविधा के लिए सिस्टम अनुमतियों की आवश्यकता होती है और यह अतिरिक्त बैटरी उपयोग कर सकता है।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Quando abilitato, la scheda Statistiche visualizzerà i grafici delle prestazioni del sistema in tempo reale. Questa funzione richiede autorizzazioni di sistema e può utilizzare una batteria aggiuntiva." + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Når denne funksjonen er aktivert, viser fanen «Statistikk» grafikk som viser systemytelsen i sanntid. Denne funksjonen krever systemtillatelser og kan bruke ekstra batteri." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Po włączeniu zakładka „Statystyki” wyświetla wykresy wydajności systemu w czasie rzeczywistym. Ta funkcja wymaga uprawnień systemowych i może powodować dodatkowe zużycie baterii." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При включении на вкладке Stats будут отображаться графики производительности в реальном времени." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "இதை இயக்கியதும், புள்ளிவிவரங்கள் தாவல் நிகழ்நேர கணினி செயல்திறன் வரைபடங்களைக் காண்பிக்கும். இந்த அம்சத்திற்கு கணினி அனுமதிகள் தேவை மற்றும் இது கூடுதல் பேட்டரியைப் பயன்படுத்தக்கூடும்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Etkinleştirildiğinde, İstatistikler sekmesi gerçek zamanlı sistem performans grafiklerini gösterecektir. Bu özellik sistem izinleri gerektirir ve ek pil kullanabilir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Використовуйте вкладку «Медіа», щоб налаштувати попередній перегляд, тексти пісень та плаваючі елементи керування медіа." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用后,统计标签页将显示实时系统性能图表。此功能需要系统权限,可能会消耗额外电量。" + } + } + } + }, + "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value.": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Breitenanpassungen gelten nur für das Standard-Notch-Layout. Deaktivieren Sie die minimalistische Benutzeroberfläche, um diesen Wert zu bearbeiten." + } + }, + "fi": { + "stringUnit": { + "state": "translated", + "value": "Leveysasetukset koskevat vain vakiomallista lovijärjestystä. Poista Minimalistinen käyttöliittymä käytöstä, jos haluat muokata tätä arvoa." + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "चौड़ाई समायोजन केवल मानक नॉच लेआउट पर लागू होते हैं। इस मान को संपादित करने के लिए मिनिमलिस्टिक यूआई को अक्षम करें।" + } + }, + "ru": { + "stringUnit": { + "value": "Настройки ширины применяются только к стандартному вырезу. Отключите Minimalistic UI.", + "state": "translated" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "宽度调整仅适用于标准刘海布局。禁用简约界面以编辑此值。" + } + } + } + }, + "Width:": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Breite:" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Width:" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Largueur :" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ширина:" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "宽度:" + } + } + } + }, + "With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مع تمكين الرسوم البيانية %lld، سيتم توسيع Dynamic Island أفقياً لاستيعاب جميع الرسوم البيانية في صف واحد." + } + }, + "cs": { + "stringUnit": { + "state": "translated", + "value": "Při zapnutých grafech %lld se Dynamic Island rozšíří horizontálně, aby se všechny grafy vešly do jednoho řádku." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn die %1$lldDiagramme aktiviert sind, wird die dynamische Insel horizontal erweitert, um alle Diagramme in einer einzigen Zeile aufzunehmen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lorsque les graphiques %lld sont activés, Dynamic Island s'étend horizontalement pour afficher tous les graphiques sur une seule ligne." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "%lld ग्राफ़ सक्षम होने पर, डायनामिक आइलैंड सभी ग्राफ़ को एक ही पंक्ति में समायोजित करने के लिए क्षैतिज रूप से फैल जाएगा।" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Når %lld-grafer er aktivert, utvides Dynamic Island horisontalt for å få plass til alle grafer i én rad." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Po włączeniu wykresów %lld Dynamic Island rozszerzy się w poziomie, aby pomieścić wszystkie wykresy w jednym rzędzie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При %lld включенных графиках Dynamic Island расширится по горизонтали." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "%lld வரைபடங்கள் இயக்கப்பட்ட நிலையில், அனைத்து வரைபடங்களையும் ஒரே வரிசையில் பொருத்துவதற்காக டைனமிக் ஐலேண்ட் கிடைமட்டமாக விரிவடையும்." + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用 %lld 个图表后,灵动岛将水平扩展以在单行中容纳所有图表。" + } + } + } + }, + "Write": { + "comment": "A label for the write section of the disk overview.", + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schreiben" + } + }, + "en": { + "stringUnit": { + "state": "needs_review", + "value": "Write" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écriture" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scrivere" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запись" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yazmak" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "写入" + } + } + } + }, + "You": { + "localizations": { + "ar": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "cs": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You" + } + }, + "en-GB": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "es": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vous" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tu" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pt-BR": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вы" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "uk": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你" + } + } + } + }, + "You can now enjoy the app. If you want to tweak things further, you can always visit the settings.": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يمكنك الآن الاستمتاع بالتطبيق. إذا كنت ترغب في تعديل الأمور أكثر، يمكنك دائما زيارة الإعدادات." + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du kannst die App nun genießen. Wenn du Dinge weiter anpassen möchtest, kannst du dies jederzeit in den Einstellungen tun." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ahora puedes disfrutar de la aplicación. Si quieres modificar aún más las cosas, siempre puedes visitar la configuración." + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vous pouvez maintenant utiliser l'application. Si vous voulez modifier plus de choses, vous pouvez toujours aller dans les réglages." + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Ora puoi goderti l'app. Se vuoi modificare ulteriormente le cose, puoi sempre visitare le impostazioni." + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "이제 앱을 즐기실 수 있습니다. 추가로 설정을 조정하고 싶으시면 언제든지 설정 메뉴를 방문하시면 됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nå kan du nyte appen. Hvis du vil justere innstillingene ytterligere, kan du alltid gå til innstillingene." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Możesz już cieszyć się aplikacją. Jeśli chcesz wprowadzić dodatkowe zmiany, zajrzyj do ustawień." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Теперь вы можете наслаждаться приложением. Настройки всегда доступны в меню." + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "translated", + "value": "இப்போது நீங்கள் செயலியை அனுபவிக்கலாம். நீங்கள் மேலும் மாற்றங்களைச் செய்ய விரும்பினால், எப்போது வேண்டுமானாலும் அமைப்புகளுக்குச் செல்லலாம்." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Etkinleştirildiğinde, Atoll animasyon boyutuna uyacak şekilde yatay olarak genişleyecektir" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Тепер ви можете користуватися додатком. Якщо ви хочете додатково налаштувати його, ви завжди можете перейти до налаштувань." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "如果您想进一步调整内容,您可以随时访问设置。" + } + } + } + }, + "You're All Set!": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "كل شيء جاهز!" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "You're All Set!" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du bist fertig!" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You're All Set!" + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "You're All Set!" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "¡Todo listo!" + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout est prêt !" + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "You're All Set!" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "È Tutto ok!" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "준비되었습니다!" + } + }, + "nb": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wszystko gotowe!" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "You're All Set!" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Все готово!" + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Tamamen Hazırsın!" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "You're All Set!" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "一切都准备好了!" + } + } + } + }, + "YouTube Music requires this third-party app to be installed: ": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب YouTube Music تثبيت تطبيق الطرف الثالث هذا:" + } + }, + "cs": { + "stringUnit": { + "state": "needs_review", + "value": "YouTube Music requires this third-party app to be installed: " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "YouTube Musik braucht diese Drittanbieter-App installiert: " + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "YouTube Music requires this third-party app to be installed: " + } + }, + "en-GB": { + "stringUnit": { + "state": "translated", + "value": "YouTube Music requires this third-party app to be installed: " + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "YouTube Music requires this third-party app to be installed: " + } + }, + "fi": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "YouTube Music requiert l'installation de cette application tierce : " + } + }, + "gu": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "hu": { + "stringUnit": { + "state": "needs_review", + "value": "YouTube Music requires this third-party app to be installed: " + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "YouTube Music richiede l'installazione di questa app di terze parti: " + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "유튜브 뮤직 서드파티-앱 설치를 필요로 합니다 " + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "YouTube Music krever at denne tredjepartsappen er installert:" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "YouTube Music wymaga zainstalowania tej aplikacji firm trzecich: " + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "YouTube Music requires this third-party app to be installed: " + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "YouTube Music требует установки этого стороннего приложения: " + } + }, + "sv": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "ta": { + "stringUnit": { + "state": "new", + "value": "" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "YouTube Music, bu üçüncü taraf uygulamanın yüklenmesini gerektirir:" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "YouTube Music requires this third-party app to be installed: " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "YouTube Music 需要安装此第三方应用:" + } + } + } + }, + "15 mins": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "15 分钟" + } + } + } + }, + "30 mins": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "30 分钟" + } + } + } + }, + "1 hour": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1 小时" + } + } + } + }, + "3 hours": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "3 小时" + } + } + } + }, + "6 hours": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "6 小时" + } + } + } + }, + "12 hours": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "12 小时" + } + } + } + }, + "Rest of the day": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "今天余下的时间" + } + } + } + }, + "All time": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所有时间" + } + } + } + }, + "Symbol": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "符号" + } + } + } + }, + "3D": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "3D" + } + } + } + }, + "Left": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "左对齐" + } + } + } + }, + "Right": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "右对齐" + } + } + } + }, + "Enable Minimalistic UI": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用极简 UI" + } + } + } + }, + "Hide Dynamic Island during screenshots & recordings": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "截屏和录屏时隐藏灵动岛" + } + } + } + }, + "Close gesture": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭手势" + } + } + } + }, + "Reverse swipe gestures": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "反向滑动" + } + } + } + }, + "Reverse scroll gestures": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "反向滚动" + } + } + } + }, + "Extend hover area": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扩展悬停区域" + } + } + } + }, + "External display style": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "外接显示器样式" + } + } + } + }, + "Hide until hovered": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "悬停时显示" + } + } + } + }, + "Enable Focus Detection": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用专注模式检测" + } + } + } + }, + "Accessibility permission lets Dynamic Island replace the native volume, brightness, and keyboard HUDs.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "辅助功能权限允许灵动岛替换原生的音量、亮度和键盘提示框。" + } + } + } + }, + "Full Disk Access for global mode": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全局模式需要完全磁盘访问权限" + } + } + } + }, + "Without Full Disk Access, Shelf can only read files from Documents and Downloads. Grant Full Disk Access to make Shelf work globally.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "如果没有完全磁盘访问权限,暂存器只能读取文档和下载目录的文件。授予完全磁盘访问权限以使暂存器能在全局范围工作。" + } + } + } + }, + "Request Full Disk Access": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请求完全磁盘访问权限" + } + } + } + }, + "Open Privacy & Security": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开隐私与安全性" + } + } + } + } + }, + "version": "1.1" +} \ No newline at end of file diff --git a/LottieAnimations/Dog waiting.json b/LottieAnimations/Dog waiting.json new file mode 100644 index 00000000..362e010e --- /dev/null +++ b/LottieAnimations/Dog waiting.json @@ -0,0 +1 @@ +{"v":"4.8.0","meta":{"g":"LottieFiles AE 3.0.2","a":"","k":"","d":"","tc":""},"fr":24,"ip":0,"op":192,"w":1920,"h":1920,"nm":"TUTORIAL","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"OJO 2 Outlines","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[432.311,183.126,0],"ix":2},"a":{"a":0,"k":[47.016,67.258,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-3.342,0.25],[-3.342,134.266],[94.908,134.266],[94.908,0.25]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.25,66.732],[0.25,66.732],[93.782,66.732],[93.782,66.732]],"c":true}]},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-3.342,0.25],[-3.342,134.266],[94.908,134.266],[94.908,0.25]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-2.402,9.041],[-3.202,0.227],[-1.892,-9.134],[6.674,1.652]],"o":[[0.91,-3.425],[9.323,-0.662],[2.225,10.734],[-5.601,-1.386]],"v":[[-19.351,-5.455],[-12.37,-11.419],[19.529,-2.781],[-2.716,10.43]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.999998922909,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[47.932,101.246],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[10.408,0],[3.403,-19.04],[-4.568,1.206],[-5.562,3.13],[-6.681,0.304],[-1.701,-0.02]],"o":[[-14.349,0],[4.322,1.53],[6.384,-1.686],[5.563,-3.131],[1.691,-0.077],[-5.568,-11.333]],"v":[[2.318,-17.323],[-27.488,15.793],[-13.355,16.036],[4.046,7.744],[22.379,1.393],[27.488,1.424]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.999998922909,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[43.493,26.481],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.57,-0.117],[8.455,-4.758],[9.703,-2.563],[6.569,2.325],[0,-5.756],[-25.828,0],[0,37.008],[5.354,10.898]],"o":[[-10.155,0.463],[-8.455,4.758],[-6.944,1.834],[-0.953,5.33],[0,37.008],[25.828,0],[0,-14.339],[-2.586,-0.031]],"v":[[30.493,-52.726],[2.627,-43.072],[-23.822,-30.468],[-45.305,-30.837],[-46.766,-14.165],[0,52.843],[46.766,-14.165],[38.259,-52.678]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[47.016,81.423],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-37.007],[25.828,0],[0,37.007],[-25.828,0]],"o":[[0,37.007],[-25.828,0],[0,-37.007],[25.828,0]],"v":[[46.766,0],[0,67.008],[-46.766,0],[0,-67.008]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[47.016,67.258],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":4,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"OJO 1 Outlines","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[245.247,181.73,0],"ix":2},"a":{"a":0,"k":[47.016,67.258,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-3.342,0.25],[-3.342,134.266],[94.908,134.266],[94.908,0.25]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.25,66.732],[0.25,66.732],[93.782,66.732],[93.782,66.732]],"c":true}]},{"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-3.342,0.25],[-3.342,134.266],[94.908,134.266],[94.908,0.25]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-2.402,9.041],[-3.202,0.227],[-1.893,-9.134],[6.674,1.652]],"o":[[0.91,-3.425],[9.323,-0.662],[2.224,10.734],[-5.601,-1.386]],"v":[[-19.351,-5.455],[-12.371,-11.419],[19.53,-2.781],[-2.717,10.43]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.999998922909,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[47.931,101.246],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[10.408,0],[3.403,-19.04],[-4.568,1.206],[-5.562,3.13],[-6.681,0.304],[-1.701,-0.02]],"o":[[-14.349,0],[4.322,1.53],[6.384,-1.686],[5.563,-3.131],[1.691,-0.077],[-5.568,-11.333]],"v":[[2.318,-17.323],[-27.488,15.793],[-13.355,16.036],[4.046,7.744],[22.379,1.393],[27.488,1.424]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.999998922909,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[43.493,26.481],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.57,-0.117],[8.455,-4.758],[9.703,-2.563],[6.569,2.325],[0,-5.756],[-25.828,0],[0,37.008],[5.354,10.898]],"o":[[-10.154,0.463],[-8.455,4.758],[-6.944,1.834],[-0.953,5.33],[0,37.008],[25.828,0],[0,-14.339],[-2.586,-0.031]],"v":[[30.493,-52.726],[2.627,-43.072],[-23.822,-30.468],[-45.305,-30.837],[-46.766,-14.165],[0,52.843],[46.766,-14.165],[38.259,-52.678]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[47.016,81.423],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-37.007],[25.828,0],[0,37.007],[-25.828,0]],"o":[[0,37.007],[-25.828,0],[0,-37.007],[25.828,0]],"v":[[46.766,0],[0,67.008],[-46.766,0],[0,-67.008]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[47.016,67.258],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":4,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"LENGUA Outlines","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[335.702,315.6,0],"ix":2},"a":{"a":0,"k":[40.75,8.75,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.25,-123.75],[0.25,-26.734],[85.25,-26.734],[85.25,-123.75]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.25,0.25],[0.25,97.266],[85.25,97.266],[85.25,0.25]],"c":true}]},{"t":20,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.25,0.25],[0.25,97.266],[85.25,97.266],[85.25,0.25]],"c":true}]}],"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.12,0],[0.17,0.681],[-0.083,1.357],[-0.815,-0.05],[0.05,-0.827],[-3.897,-15.591],[0.803,-0.201]],"o":[[-0.672,0],[-4.01,-16.04],[0.05,-0.826],[0.826,0.05],[-0.019,0.327],[0.201,0.803],[-0.122,0.03]],"v":[[1.904,26.023],[0.449,24.888],[-1.592,-24.567],[-0.004,-25.973],[1.402,-24.385],[3.36,24.16],[2.269,25.979]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[42.845,50.227],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-34.206,-0.6],[-3,22],[1.625,28.531]],"o":[[0,0],[33,1],[0,0],[-1.75,27.781]],"v":[[-42.5,-16.906],[1.5,48],[42.5,-16.141],[-0.5,-47.281]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.974512616326,0.59837957644,0.587590116613,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[42.75,49.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"CABEZA Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[927.255,854.617,0],"to":[0,1.667,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":8,"s":[927.255,864.617,0],"to":[0,0,0],"ti":[0,1.667,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":20,"s":[927.255,854.617,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":55,"s":[927.255,854.617,0],"to":[0,1.667,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":67,"s":[927.255,864.617,0],"to":[0,0,0],"ti":[0,1.667,0]},{"t":79,"s":[927.255,854.617,0]}],"ix":2},"a":{"a":0,"k":[346.457,255.717,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-13.398],[28.028,0],[0,13.398],[-28.028,0]],"o":[[0,13.398],[-28.028,0],[0,-13.398],[28.028,0]],"v":[[50.75,0],[0,24.259],[-50.75,0],[0,-24.259]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0.996747245041,0.498034787646,0.490713979684,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":12,"s":[0.996078431606,0.392156869173,0.384313732386,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":24,"s":[0.996747245041,0.498034787646,0.490713979684,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":36,"s":[0.996747245041,0.498034787646,0.490713979684,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":48,"s":[0.996078431606,0.392156869173,0.384313732386,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[0.996747245041,0.498034787646,0.490713979684,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":72,"s":[0.996747245041,0.498034787646,0.490713979684,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":84,"s":[0.996078431606,0.392156869173,0.384313732386,1]},{"t":96,"s":[0.996747245041,0.498034787646,0.490713979684,1]}],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[516.452,283.859],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-13.398],[28.028,0],[0,13.398],[-28.028,0]],"o":[[0,13.398],[-28.028,0],[0,-13.398],[28.028,0]],"v":[[50.75,0],[0,24.259],[-50.75,0],[0,-24.259]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0.996747245041,0.498034787646,0.490713979684,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":12,"s":[1,0.408680200577,0.399999976158,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":24,"s":[0.996747245041,0.498034787646,0.490713979684,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":36,"s":[0.996747245041,0.498034787646,0.490713979684,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":48,"s":[1,0.408680200577,0.399999976158,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[0.996747245041,0.498034787646,0.490713979684,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":72,"s":[0.996747245041,0.498034787646,0.490713979684,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":84,"s":[1,0.408680200577,0.399999976158,1]},{"t":96,"s":[0.996747245041,0.498034787646,0.490713979684,1]}],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[176.452,286.842],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.104,0],[0,1.104],[0,0],[-1.104,0],[0,-1.104],[0,0]],"o":[[-1.104,0],[0,0],[0,-1.104],[1.104,0],[0,0],[0,1.104]],"v":[[0,18.054],[-2,16.054],[-2,-16.054],[0,-18.054],[2,-16.054],[2,16.054]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[336.685,289.919],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[22.118,-5.25],[-15.954,-14.174]],"o":[[15.229,-14.174],[-25.381,-6.3]],"v":[[-26.287,7.087],[26.287,7.087]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.999998922909,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[337.332,228.399],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":4,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-15.32],[23.457,0],[0,15.32],[-23.456,0]],"o":[[0,15.32],[-23.456,0],[0,-15.32],[23.457,0]],"v":[[42.472,-5.764],[0.437,33.504],[-42.472,-5.764],[-0.001,-33.504]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[336.301,247.342],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":4,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.104,0],[0,18.97],[-0.02,0.398],[-1.115,-0.041],[0.056,-1.103],[0,-0.334],[-26.359,0],[0,-1.104]],"o":[[-28.564,0],[0,-0.403],[0.056,-1.103],[1.103,0.056],[-0.017,0.331],[0,16.764],[1.104,0],[0,1.104]],"v":[[24.902,18.771],[-26.902,-15.632],[-26.871,-16.834],[-24.772,-18.73],[-22.876,-16.631],[-22.902,-15.632],[24.902,14.771],[26.902,16.771]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[361.587,321.31],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":4,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[25.788,0],[0,1.104],[-1.104,0],[-6.495,19.719],[-1.053,-0.348],[0.346,-1.049]],"o":[[-1.104,0],[0,-1.104],[23.948,0],[0.346,-1.048],[1.049,0.345],[-7.069,21.466]],"v":[[-25.024,18.326],[-27.024,16.326],[-25.024,14.326],[22.879,-16.703],[25.405,-17.977],[26.678,-15.452]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[411.512,321.756],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 7","np":4,"cix":2,"bm":0,"ix":7,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.104,0],[9.045,22.33],[-1.024,0.415],[-0.414,-1.024],[-23.443,0],[0,-1.104]],"o":[[-25.232,0],[-0.415,-1.024],[1.024,-0.414],[8.374,20.672],[1.104,0],[0,1.104]],"v":[[25.036,18.335],[-26.621,-15.316],[-25.518,-17.921],[-22.914,-16.819],[25.036,14.335],[27.036,16.335]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[261.846,321.746],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 8","np":4,"cix":2,"bm":0,"ix":8,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[28.564,0],[0,1.104],[-1.104,0],[0,16.764],[-1.104,0],[0,-1.105]],"o":[[-1.104,0],[0,-1.104],[26.359,0],[0,-1.105],[1.105,0],[0,18.97]],"v":[[-24.901,18.201],[-26.901,16.201],[-24.901,14.201],[22.901,-16.201],[24.901,-18.201],[26.901,-16.201]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[311.784,321.88],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 9","np":4,"cix":2,"bm":0,"ix":9,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[{"i":[[0,0],[3.73,-28.208],[-178.098,0],[25.128,150.768],[8.275,24.375],[-0.222,11.869],[67.512,7.601],[0.03,-0.045],[0.881,0.133],[14.6,0.642],[2.57,0.085],[3.448,0],[25.001,-4.926],[0.519,0.767],[0.152,0.223],[37.584,-62.967],[-0.121,-0.351]],"o":[[-10.836,25.562],[-22.336,168.916],[178.098,0],[-4.404,-26.422],[-3.85,-11.343],[-96.177,-106.442],[-0.03,0.045],[-0.497,0.738],[-14.02,-2.127],[-2.579,-0.095],[-3.422,-0.086],[-26.926,0],[-0.909,0.179],[-0.151,-0.222],[-125.236,22.927],[0.12,0.354],[0,0]],"v":[[-307.155,-81.381],[-323.871,-0.001],[-1.396,255.467],[321.079,-0.001],[305.374,-76.798],[299.861,-111.869],[61.933,-252.008],[61.841,-251.872],[59.594,-250.887],[16.628,-255.055],[8.905,-255.322],[-1.396,-255.467],[-79.474,-247.919],[-81.82,-248.883],[-82.278,-249.55],[-309.76,-81.627],[-309.4,-80.566]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":12,"s":[{"i":[[0,0],[-0.086,-28.453],[-113.858,-2.085],[-12.334,163.383],[0,0],[-0.222,11.869],[67.512,7.601],[0.03,-0.045],[0.881,0.133],[14.6,0.642],[2.57,0.085],[3.448,0],[25.001,-4.926],[0.519,0.767],[0.152,0.223],[37.584,-62.967],[-0.121,-0.351]],"o":[[-10.836,25.562],[0.616,203.383],[124.135,2.273],[2.016,-26.711],[0,0],[-96.177,-106.442],[-0.03,0.045],[-0.497,0.738],[-14.02,-2.127],[-2.579,-0.095],[-3.422,-0.086],[-26.926,0],[-0.909,0.179],[-0.151,-0.222],[-125.236,22.927],[0.12,0.354],[0,0]],"v":[[-307.155,-81.381],[-315.871,17.999],[4.006,259.468],[311.079,21.999],[305.374,-76.798],[299.861,-111.869],[61.933,-252.008],[61.841,-251.872],[59.594,-250.887],[16.628,-255.055],[8.905,-255.322],[-1.396,-255.467],[-79.474,-247.919],[-81.82,-248.883],[-82.278,-249.55],[-309.76,-81.627],[-309.4,-80.565]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":24,"s":[{"i":[[0,0],[3.73,-28.208],[-178.098,0],[25.128,150.768],[8.275,24.375],[-0.222,11.869],[67.512,7.601],[0.03,-0.045],[0.881,0.133],[14.6,0.642],[2.57,0.085],[3.448,0],[25.001,-4.926],[0.519,0.767],[0.152,0.223],[37.584,-62.967],[-0.121,-0.351]],"o":[[-10.836,25.562],[-22.336,168.916],[178.098,0],[-4.404,-26.422],[-3.85,-11.343],[-96.177,-106.442],[-0.03,0.045],[-0.497,0.738],[-14.02,-2.127],[-2.579,-0.095],[-3.422,-0.086],[-26.926,0],[-0.909,0.179],[-0.151,-0.222],[-125.236,22.927],[0.12,0.354],[0,0]],"v":[[-307.155,-81.381],[-323.871,-0.001],[-1.396,255.467],[321.079,-0.001],[305.374,-76.798],[299.861,-111.869],[61.933,-252.008],[61.841,-251.872],[59.594,-250.887],[16.628,-255.055],[8.905,-255.322],[-1.396,-255.467],[-79.474,-247.919],[-81.82,-248.883],[-82.278,-249.55],[-309.76,-81.627],[-309.4,-80.566]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":48,"s":[{"i":[[0,0],[3.73,-28.208],[-178.098,0],[25.128,150.768],[8.275,24.375],[-0.222,11.869],[67.512,7.601],[0.03,-0.045],[0.881,0.133],[14.6,0.642],[2.57,0.085],[3.448,0],[25.001,-4.926],[0.519,0.767],[0.152,0.223],[37.584,-62.967],[-0.121,-0.351]],"o":[[-10.836,25.562],[-22.336,168.916],[178.098,0],[-4.404,-26.422],[-3.85,-11.343],[-96.177,-106.442],[-0.03,0.045],[-0.497,0.738],[-14.02,-2.127],[-2.579,-0.095],[-3.422,-0.086],[-26.926,0],[-0.909,0.179],[-0.151,-0.222],[-125.236,22.927],[0.12,0.354],[0,0]],"v":[[-307.155,-81.381],[-323.871,-0.001],[-1.396,255.467],[321.079,-0.001],[305.374,-76.798],[299.861,-111.869],[61.933,-252.008],[61.841,-251.872],[59.594,-250.887],[16.628,-255.055],[8.905,-255.322],[-1.396,-255.467],[-79.474,-247.919],[-81.82,-248.883],[-82.278,-249.55],[-309.76,-81.627],[-309.4,-80.566]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":60,"s":[{"i":[[0,0],[-0.086,-28.453],[-113.858,-2.085],[-12.334,163.383],[0,0],[-0.222,11.869],[67.512,7.601],[0.03,-0.045],[0.881,0.133],[14.6,0.642],[2.57,0.085],[3.448,0],[25.001,-4.926],[0.519,0.767],[0.152,0.223],[37.584,-62.967],[-0.121,-0.351]],"o":[[-10.836,25.562],[0.616,203.383],[124.135,2.273],[2.016,-26.711],[0,0],[-96.177,-106.442],[-0.03,0.045],[-0.497,0.738],[-14.02,-2.127],[-2.579,-0.095],[-3.422,-0.086],[-26.926,0],[-0.909,0.179],[-0.151,-0.222],[-125.236,22.927],[0.12,0.354],[0,0]],"v":[[-307.155,-81.381],[-315.871,17.999],[4.006,259.468],[311.079,21.999],[305.374,-76.798],[299.861,-111.869],[61.933,-252.008],[61.841,-251.872],[59.594,-250.887],[16.628,-255.055],[8.905,-255.322],[-1.396,-255.467],[-79.474,-247.919],[-81.82,-248.883],[-82.278,-249.55],[-309.76,-81.627],[-309.4,-80.565]],"c":true}]},{"t":72,"s":[{"i":[[0,0],[3.73,-28.208],[-178.098,0],[25.128,150.768],[8.275,24.375],[-0.222,11.869],[67.512,7.601],[0.03,-0.045],[0.881,0.133],[14.6,0.642],[2.57,0.085],[3.448,0],[25.001,-4.926],[0.519,0.767],[0.152,0.223],[37.584,-62.967],[-0.121,-0.351]],"o":[[-10.836,25.562],[-22.336,168.916],[178.098,0],[-4.404,-26.422],[-3.85,-11.343],[-96.177,-106.442],[-0.03,0.045],[-0.497,0.738],[-14.02,-2.127],[-2.579,-0.095],[-3.422,-0.086],[-26.926,0],[-0.909,0.179],[-0.151,-0.222],[-125.236,22.927],[0.12,0.354],[0,0]],"v":[[-307.155,-81.381],[-323.871,-0.001],[-1.396,255.467],[321.079,-0.001],[305.374,-76.798],[299.861,-111.869],[61.933,-252.008],[61.841,-251.872],[59.594,-250.887],[16.628,-255.055],[8.905,-255.322],[-1.396,-255.467],[-79.474,-247.919],[-81.82,-248.883],[-82.278,-249.55],[-309.76,-81.627],[-309.4,-80.566]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.953032250498,0.77241228889,0.35537591074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[346.457,255.718],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 10","np":4,"cix":2,"bm":0,"ix":10,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"OREJA D Outlines","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":72,"s":[-18]},{"t":84,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":60,"s":[157.845,92.937,0],"to":[-0.333,4,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":72,"s":[155.845,116.937,0],"to":[0,0,0],"ti":[-0.333,4,0]},{"t":84,"s":[157.845,92.937,0]}],"ix":2},"a":{"a":0,"k":[148.251,231.668,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-18.838,-53.032],[-9.798,7.01],[-7.739,-2.032],[-2.657,5.871],[-4.127,5.915],[-6.645,1.784],[-3.213,-2.704],[-0.886,2.237],[-7.375,4.642],[3.918,9.34],[41.819,9.015],[5.043,-5.118]],"o":[[-4.558,-11.152],[6.533,-4.673],[6.233,1.638],[2.974,-6.571],[3.937,-5.642],[4.184,-1.123],[1.841,1.55],[3.204,-8.083],[8.571,-5.396],[-11.844,-28.229],[-7.024,-1.514],[-42.735,43.368]],"v":[[-46.985,103.41],[-37.697,71.215],[-14.764,67.327],[0.625,60.216],[11.304,41.432],[26.761,28.416],[39.07,31.129],[44.596,29.834],[61.116,10.114],[69.047,-15.552],[-10.835,-101.896],[-30.23,-96.13]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.999998922909,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[105.894,152.946],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[17.505,38.009],[-72.348,26.268],[-51.162,-75.17],[123,-80]],"o":[[-30.482,-89.917],[54.509,-19.792],[0,0],[-92,63]],"v":[[-143.148,111.154],[-102.01,-185.371],[84.334,-56.77],[51.357,142.163]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.953032250498,0.77241228889,0.35537591074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[174.608,205.413],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"OREJA I Outlines","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":72,"s":[18]},{"t":84,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":60,"s":[531.738,51.268,0],"to":[4,6.667,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":72,"s":[555.738,91.268,0],"to":[0,0,0],"ti":[4,6.667,0]},{"t":84,"s":[531.738,51.268,0]}],"ix":2},"a":{"a":0,"k":[163.262,195.878,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[18.838,-53.032],[9.798,7.01],[7.739,-2.032],[2.657,5.871],[4.127,5.915],[6.645,1.784],[3.213,-2.704],[0.886,2.237],[7.375,4.642],[-3.918,9.34],[-41.819,9.015],[-5.043,-5.118]],"o":[[4.558,-11.152],[-6.533,-4.673],[-6.233,1.638],[-2.974,-6.571],[-3.937,-5.642],[-4.184,-1.123],[-1.841,1.55],[-3.204,-8.083],[-8.571,-5.396],[11.844,-28.229],[7.024,-1.514],[42.735,43.368]],"v":[[46.985,103.41],[37.697,71.215],[14.764,67.327],[-0.625,60.216],[-11.304,41.432],[-26.761,28.416],[-39.07,31.129],[-44.596,29.834],[-61.116,10.114],[-69.047,-15.552],[10.835,-101.896],[30.23,-96.13]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.999998922909,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[181.413,151.846],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-41.885,25.748],[-1.067,5.566],[62.125,22.557],[51.094,-75.772],[-104,-45]],"o":[[0.105,-5.615],[18.212,-94.961],[-54.927,-19.943],[0,0],[74,36]],"v":[[126.58,122.581],[128.323,105.782],[76.172,-147.89],[-111.347,-17.557],[-42.536,131.833]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.953032250498,0.77241228889,0.35537591074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[146.785,168.083],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"DIJE COLLAR Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.581],"y":[0]},"t":24,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":36,"s":[-12]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":52,"s":[11]},{"t":64,"s":[0]}],"ix":10},"p":{"a":0,"k":[935,1175.5,0],"ix":2},"a":{"a":0,"k":[34.25,-0.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-8.455,-2.114],[2.114,-1.057],[6.341,2.114],[0,0],[0,0],[3.171,-1.057],[-7.398,2.642],[-4.227,0.529]],"o":[[0,0],[0,0],[8.455,2.113],[7.926,3.699],[-4.227,-3.171],[0,0],[0,0],[-3.171,1.057],[-2.113,-3.171],[4.228,-0.528]],"v":[[-15.06,-4.756],[11.362,-4.228],[20.873,-11.097],[20.873,0],[18.759,11.097],[12.418,5.813],[-14.004,5.813],[-18.76,12.154],[-21.931,2.114],[-22.987,-7.927]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.999998922909,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[35.87,32.935],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-19.33],[19.33,0],[0,19.33],[-19.33,0]],"o":[[0,19.33],[-19.33,0],[0,-19.33],[19.33,0]],"v":[[35,0],[0,35],[-35,0],[0,-35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.965070717008,0.876030656403,0.707169476677,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[35.25,35.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"COLLAR Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[933.5,1142.25,0],"ix":2},"a":{"a":0,"k":[196.25,73.5,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-2.799,0],[0,0],[0,-2.799]],"o":[[0,0],[0,0],[0,-2.799],[0,0],[2.799,0],[0,0]],"v":[[7,37],[-7,37],[-7,-31.931],[-1.931,-37],[1.931,-37],[7,-31.931]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.965070717008,0.876030656403,0.707169476677,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[198.75,74.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-6.075],[6.075,0],[0,6.075],[-6.075,0]],"o":[[0,6.075],[-6.075,0],[0,-6.075],[6.075,0]],"v":[[11,0],[0,11],[-11,0],[0,-11]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.947371359432,0.409110753676,0.418479440726,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[358.75,43.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-6.075],[6.075,0],[0,6.075],[-6.075,0]],"o":[[0,6.075],[-6.075,0],[0,-6.075],[6.075,0]],"v":[[11,0],[0,11],[-11,0],[0,-11]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.947371359432,0.409110753676,0.418479440726,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[283.75,68.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-6.075],[6.075,0],[0,6.075],[-6.075,0]],"o":[[0,6.075],[-6.075,0],[0,-6.075],[6.075,0]],"v":[[11,0],[0,11],[-11,0],[0,-11]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.947371359432,0.409110753676,0.418479440726,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[190.75,76.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":4,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-6.075],[6.075,0],[0,6.075],[-6.075,0]],"o":[[0,6.075],[-6.075,0],[0,-6.075],[6.075,0]],"v":[[11,0],[0,11],[-11,0],[0,-11]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.947371359432,0.409110753676,0.418479440726,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[97.75,67.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":4,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-6.075],[6.075,0],[0,6.075],[-6.075,0]],"o":[[0,6.075],[-6.075,0],[0,-6.075],[6.075,0]],"v":[[11,0],[0,11],[-11,0],[0,-11]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.947371359432,0.409110753676,0.418479440726,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[27.75,44.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":4,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-152,80],[0,0],[135,47]],"o":[[65,29],[-1.5,-34.5],[0,0],[-14,36]],"v":[[-196,-2.75],[196,-6.75],[178.5,-73.25],[-176.5,-72.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996747245041,0.498034787646,0.490713979684,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[196.25,73.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 7","np":4,"cix":2,"bm":0,"ix":7,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"PATA I Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1051.615,1337.889,0],"ix":2},"a":{"a":0,"k":[106.387,231.139,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.062,0],[0.092,0.479],[-0.543,0.104],[-1.224,7.75],[10.631,5.849],[0,0],[0,0],[-0.496,-0.249],[0.249,-0.493],[0,0],[1.553,-9.835],[8.999,-1.729]],"o":[[-0.47,0],[-0.104,-0.542],[8.285,-1.592],[1.455,-9.217],[0,0],[0,0],[0.247,-0.494],[0.493,0.249],[0,0],[10.849,6.284],[-1.339,8.475],[-0.063,0.012]],"v":[[-13.78,47.906],[-14.761,47.094],[-13.967,45.924],[1.449,30.778],[-13.206,6.718],[-14.05,6.254],[12.829,-47.212],[14.172,-47.657],[14.616,-46.314],[-11.402,5.439],[3.424,31.09],[-13.591,47.888]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[130.048,367.013],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.01,0.546],[-0.552,0.01],[-2.508,8.944],[9.295,5.894],[-0.296,0.466],[-0.466,-0.295],[3.725,-13.275],[22.687,-0.409]],"o":[[-0.544,0],[-0.01,-0.552],[16.985,-0.306],[3.426,-12.213],[-0.467,-0.296],[0.296,-0.467],[10.032,6.36],[-1.934,6.891],[0,0]],"v":[[-17.21,26.621],[-18.21,25.639],[-17.228,24.621],[12.569,10.482],[-5.079,-24.636],[-5.389,-26.016],[-4.009,-26.325],[14.495,11.022],[-17.192,26.621]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[100.761,403.811],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[9.185,0],[8.338,6.823],[-0.064,2.762],[0,0],[-0.542,0],[0,0],[0.011,-0.552],[0,0],[-1.813,-1.484],[-27.098,24.185],[2.74,8.252],[22.965,3.009],[-0.073,0.548],[-0.553,-0.074],[-4.277,-12.88],[6.21,-6.04]],"o":[[-22.711,0.002],[-2.293,-1.877],[0,0],[0.011,-0.545],[0,0],[0.552,0.012],[0,0],[-0.05,2.146],[11.399,9.327],[5.598,-5.447],[-4.087,-12.307],[-0.548,-0.071],[0.071,-0.548],[23.819,3.12],[2.996,9.019],[-8.153,7.277]],"v":[[11.513,117.117],[-42.419,93.477],[-45.926,86.178],[-41.468,-128.831],[-40.469,-129.81],[-40.448,-129.81],[-39.468,-128.789],[-43.926,86.221],[-41.153,91.93],[36.646,105.625],[41.095,84.366],[-0.815,54.435],[-1.676,53.314],[-0.555,52.453],[42.993,83.736],[38.008,107.088]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[46.24,323.06],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[12.564,1.207],[11.168,2.414],[13.991,10.976],[-0.102,3.77],[0,0],[-10.089,1.367],[-17.204,2.575],[-11.358,-5.744],[-3.09,-1.08],[1.213,-2.883]],"o":[[-8.376,13.277],[-24.966,27.472],[-3.231,-2.535],[0,0],[9.926,2.43],[17.249,-2.338],[12.966,-1.941],[2.759,1.396],[-1.325,3.038],[27.92,15.691]],"v":[[43.25,12.385],[11.142,26.869],[-67.541,16.601],[-72.463,6.677],[-71.332,-35.123],[-41.309,-29.752],[3.235,-52.4],[39.622,-41.323],[48.845,-38.874],[44.646,-28.652]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.999998922909,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[74.063,401.375],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":4,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-54.124],[51.086,0],[0,54.124],[-51.086,0]],"o":[[0,54.124],[-51.086,0],[0,-54.124],[51.086,0]],"v":[[92.5,0],[0,98],[-92.5,0],[0,-98]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.953032250498,0.77241228889,0.35537591074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[101.271,98.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":4,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[27.92,-66.383],[12.564,1.207],[11.168,2.414],[0,0],[0,0],[-49.288,-3.141],[-0.938,-0.075],[-3,-33.712]],"o":[[27.92,15.691],[-8.376,13.276],[-30.712,33.795],[0,0],[1.113,-49.376],[0.921,0.058],[55,5],[16.752,103.799]],"v":[[11.436,132.244],[10.039,173.282],[-22.068,187.765],[-105.828,173.282],[-98.917,-133.138],[-6.711,-218.419],[-3.923,-218.218],[89.077,-146.932]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.953032250498,0.77241228889,0.35537591074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[106.695,240.469],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":4,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"PATA D Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[822.561,1334.195,0],"ix":2},"a":{"a":0,"k":[102.833,228.445,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-54.124],[51.086,0],[0,54.124],[-51.086,0]],"o":[[0,54.124],[-51.086,0],[0,-54.124],[51.086,0]],"v":[[92.5,0],[0,98],[-92.5,0],[0,-98]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.953032250498,0.77241228889,0.35537591074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[104.771,98.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.47,0],[0.063,0.012],[1.415,8.96],[-10.646,6.176],[0,0],[-0.5,0.234],[-0.234,-0.5],[0,0],[0,0],[-1.485,-9.404],[-8.369,-1.608],[0.104,-0.542]],"o":[[-0.062,0],[-9.208,-1.769],[-1.582,-10.02],[0,0],[-0.234,-0.501],[0.5,-0.23],[0,0],[0,0],[-10.415,5.73],[1.281,8.118],[0.543,0.104],[-0.092,0.479]],"v":[[14.603,51.216],[14.414,51.198],[-3.073,33.536],[11.385,7.764],[-15.453,-49.655],[-14.971,-50.985],[-13.643,-50.503],[13.984,8.604],[13.164,9.055],[-1.096,33.224],[14.789,49.234],[15.583,50.404]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[75.661,365.676],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.544,0],[0,0],[1.933,6.891],[-10.032,6.36],[-0.296,-0.467],[0.466,-0.296],[-3.427,-12.213],[-16.985,-0.306],[0.009,-0.552]],"o":[[0,0],[-22.687,-0.409],[-3.725,-13.276],[0.469,-0.296],[0.295,0.466],[-9.296,5.893],[2.508,8.944],[0.552,0.01],[-0.01,0.546]],"v":[[17.211,26.621],[17.191,26.621],[-14.494,11.022],[4.009,-26.325],[5.389,-26.016],[5.081,-24.636],[-12.569,10.482],[17.228,24.621],[18.211,25.639]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[104.907,404.811],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[22.501,-0.002],[8.113,7.241],[-2.995,9.019],[-23.819,3.12],[-0.072,-0.549],[0.548,-0.072],[4.087,-12.306],[-5.63,-5.476],[-8.736,-0.001],[-8.1,6.554],[0.059,2.495],[0,0],[-0.552,0.01],[-0.011,-0.552],[0,0],[2.622,-2.122]],"o":[[-9.163,0.001],[-6.241,-6.069],[4.277,-12.879],[0.543,-0.074],[0.072,0.547],[-22.964,3.008],[-2.74,8.252],[7.667,6.842],[21.833,0.002],[2.136,-1.729],[0,0],[-0.01,-0.553],[0.509,-0.023],[0,0],[0.073,3.116],[-8.28,6.699]],"v":[[-11.575,122.961],[-37.976,112.989],[-42.993,89.608],[0.556,58.325],[1.678,59.187],[0.816,60.308],[-41.095,90.238],[-36.613,111.526],[-11.538,120.963],[40.645,98.216],[43.915,91.57],[39.843,-121.919],[40.824,-122.938],[41.843,-121.958],[45.915,91.527],[41.903,99.771]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[159.428,318.189],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":4,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[3.09,-1.08],[-1.213,-2.883],[-12.564,1.207],[-11.168,2.414],[-13.991,10.976],[0.102,3.77],[0,0],[10.088,1.367],[17.204,2.575],[11.358,-5.744]],"o":[[1.325,3.039],[-27.92,15.691],[8.376,13.277],[24.966,27.472],[3.231,-2.535],[0,0],[-9.926,2.429],[-17.25,-2.338],[-12.966,-1.941],[-2.759,1.396]],"v":[[-48.844,-38.874],[-44.646,-28.652],[-43.25,12.385],[-11.142,26.869],[67.542,16.601],[72.464,6.677],[71.331,-35.122],[41.31,-29.752],[-3.234,-52.4],[-39.622,-41.323]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.999998922909,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[132.116,402.299],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":4,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-12.966,-1.941],[-17.25,-2.338],[-9.926,2.43],[0,0],[32.108,-15.691],[-29.178,-66.925],[-2.758,1.395]],"o":[[17.204,2.576],[10.088,1.367],[0,0],[0,0],[-16.024,99.291],[3.09,-1.08],[11.358,-5.745]],"v":[[27.032,136.041],[71.576,158.689],[101.599,153.319],[93.113,-160.056],[-85.575,-119.019],[-18.193,150.9],[-9.356,147.119]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.953032250498,0.77241228889,0.35537591074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[101.849,213.858],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":4,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"PATA TRASERA D Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[752.057,1396.144,0],"ix":2},"a":{"a":0,"k":[100.063,134.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.46,0],[0.071,0.015],[-0.117,0.54],[-5.436,3.146],[-24.015,-7.64],[0.168,-0.526],[0.527,0.169],[9.802,-5.671],[1.348,-6.215]],"o":[[-0.07,0],[-0.539,-0.118],[1.472,-6.788],[10.311,-5.966],[0.527,0.168],[-0.168,0.527],[-23.456,-7.464],[-4.928,2.851],[-0.101,0.469]],"v":[[-30.442,11.074],[-30.655,11.052],[-31.42,9.862],[-21.01,-5.108],[30.719,-2.583],[31.369,-1.327],[30.113,-0.677],[-20.008,-3.377],[-29.466,10.286]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[57.591,185.736],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.206,0],[0.196,0.266],[-4.612,6.926],[-20.947,-5.095],[0.13,-0.537],[0.537,0.132],[3.375,-5.069],[-3.112,-4.227],[0.445,-0.328]],"o":[[-0.307,0],[-4.083,-5.547],[3.666,-5.506],[0.536,0.131],[-0.131,0.537],[-19.761,-4.805],[-4.578,6.875],[0.328,0.445],[-0.178,0.131]],"v":[[-14.993,18.133],[-15.799,17.727],[-14.871,-4.151],[19.017,-13.038],[19.752,-11.83],[18.544,-11.095],[-13.206,-3.043],[-14.189,16.541],[-14.401,17.94]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[24.199,209.702],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[6.747,0],[2.034,10.55],[-5.148,5.689],[-25.636,-2.328],[0.049,-0.55],[0.558,0.062],[5.193,-5.739],[-1.481,-7.681],[-53.875,4.141],[-0.043,-0.55],[0.551,-0.042]],"o":[[-30.31,0],[-1.602,-8.313],[5.5,-6.076],[0.55,0.05],[-0.05,0.549],[-24.814,-2.261],[-4.716,5.212],[1.785,9.261],[0.557,-0.045],[0.042,0.55],[-9.121,0.701]],"v":[[8.7,25.364],[-31.777,8.063],[-26.279,-13.65],[16.791,-24.148],[17.697,-23.061],[16.61,-22.156],[-24.796,-12.308],[-29.813,7.684],[32.263,22.334],[33.337,23.255],[32.416,24.329]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[33.667,241.775],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.603,-6.301],[-12.215,0.754],[0,0],[0,14.484],[0,0],[0,0],[13.96,-43.451],[-27.92,-38.623],[11.168,-25.346],[-9.772,-15.691]],"o":[[2.672,10.502],[34.9,0],[0,0],[1.396,-14.483],[0,0],[-29.316,1.207],[-11.168,37.416],[-23.732,-3.621],[-15.356,0],[-7.329,8.147]],"v":[[-97.599,115.852],[-70.497,132.389],[74.686,125.147],[90.042,110.663],[99.814,-131.936],[25.827,-133.143],[-57.934,-76.416],[-27.221,47.901],[-74.686,64.799],[-90.041,93.766]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.898064048617,0.710316257851,0.324511988023,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[100.063,133.393],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":4,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"PATA TRASERA I Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[1124.868,1395.313,0],"ix":2},"a":{"a":0,"k":[104.95,134.286,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.07,0],[0.102,0.469],[4.179,2.519],[17.741,-4.948],[0.148,0.532],[-0.532,0.149],[-7.371,-4.445],[-1.13,-5.211],[0.54,-0.118]],"o":[[-0.461,0],[-1.551,-7.157],[-6.771,-4.084],[-0.534,0.151],[-0.148,-0.533],[18.555,-5.176],[6.011,3.625],[0.117,0.54],[-0.071,0.015]],"v":[[22.987,10.469],[22.01,9.681],[13.723,-4.311],[-22.703,-3.028],[-23.935,-3.722],[-23.241,-4.954],[14.756,-6.024],[23.966,9.257],[23.2,10.447]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[150.892,186.295],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.092,0],[0.125,0.439],[-0.532,0.151],[-2.192,1.169],[-0.26,0.072],[-0.148,-0.533],[0.497,-0.171],[2.181,-1.163],[0.26,-0.074]],"o":[[-0.435,0],[-0.152,-0.531],[0.289,-0.124],[4.79,-2.555],[0.533,-0.15],[0.143,0.512],[-0.402,0.173],[-4.754,2.535],[-0.091,0.026]],"v":[[-5.417,3.891],[-6.377,3.166],[-5.691,1.93],[-0.484,-0.817],[5.154,-3.741],[6.386,-3.046],[5.749,-1.833],[0.457,0.948],[-5.141,3.852]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[122.498,185.082],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.308,0],[0.179,0.131],[-0.327,0.445],[4.577,6.875],[19.763,-4.805],[0.13,0.536],[-0.536,0.131],[-3.666,-5.505],[4.082,-5.547]],"o":[[-0.206,0],[-0.444,-0.328],[3.113,-4.228],[-3.376,-5.07],[-0.536,0.132],[-0.131,-0.537],[20.946,-5.092],[4.611,6.927],[-0.197,0.266]],"v":[[14.994,18.132],[14.402,17.939],[14.189,16.54],[13.208,-3.045],[-18.544,-11.096],[-19.752,-11.833],[-19.016,-13.04],[14.872,-4.153],[15.8,17.726]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[176.828,209.658],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[32.661,0],[6.95,0.678],[-0.053,0.549],[-0.544,-0.045],[-2.553,13.237],[4.715,5.211],[24.817,-2.26],[0.051,0.55],[-0.55,0.05],[-5.499,-6.076],[1.603,-8.315]],"o":[[-5.459,0],[-0.55,-0.054],[0.053,-0.55],[34.311,3.344],[1.481,-7.682],[-5.195,-5.74],[-0.554,0.05],[-0.049,-0.55],[25.635,-2.331],[5.148,5.688],[-1.395,7.237]],"v":[[-11.832,26.165],[-30.401,25.182],[-31.3,24.091],[-30.208,23.191],[27.788,7.628],[22.771,-12.365],[-18.635,-22.213],[-19.722,-23.118],[-18.816,-24.205],[24.253,-13.708],[29.75,8.007]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[169.387,241.786],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":4,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-13.96,-43.45],[27.92,-38.623],[-11.168,-25.346],[9.772,-15.69],[19.544,1.207],[0,0],[0,14.483],[0,0]],"o":[[29.316,1.207],[11.168,37.416],[23.732,-3.621],[15.356,0],[19.544,21.726],[-34.9,0],[0,0],[-1.396,-14.484],[0,0]],"v":[[-30.712,-133.37],[53.048,-76.642],[22.336,47.675],[69.8,64.572],[85.156,93.539],[65.612,132.162],[-79.572,124.92],[-94.928,110.437],[-104.7,-132.162]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.898064048617,0.710316257851,0.324511988023,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[104.95,133.62],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":4,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"CUERPO Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[938.423,1260.725,0],"ix":2},"a":{"a":0,"k":[279.449,231.247,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[7.007,0],[0,0],[0,7.007],[0,0],[0,0],[0,0]],"o":[[0,0],[-7.007,0],[0,0],[0,0],[0,0],[0,7.007]],"v":[[4.812,50.5],[-4.812,50.5],[-17.5,37.812],[-17.5,-50.5],[17.5,-50.5],[17.5,37.812]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.953032250498,0.77241228889,0.35537591074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[276.526,229.022],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-115.879,0.255],[0,0],[51.652,133.973],[0,0]],"o":[[0,0],[-47.464,113.455],[0,0],[117.286,-0.257],[0,0],[0,0]],"v":[[-152.515,-229.172],[-231.735,0.212],[-95.982,230.742],[78.758,230.358],[227.547,5.04],[145.121,-230.997]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.898064048617,0.710316257851,0.324511988023,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[279.449,231.247],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"COLA Outlines 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.556],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":24,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":34,"s":[-15]},{"i":{"x":[0.583],"y":[1]},"o":{"x":[0.436],"y":[0]},"t":48,"s":[8]},{"i":{"x":[0.752],"y":[1]},"o":{"x":[0.231],"y":[0]},"t":58,"s":[-15]},{"t":72,"s":[0]}],"ix":10},"p":{"a":0,"k":[824.105,1267.506,0],"ix":2},"a":{"a":0,"k":[353.145,304.744,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ef":[{"ty":34,"nm":"Puppet","np":6,"mn":"ADBE FreePin3","ix":1,"en":1,"ef":[{"ty":7,"nm":"Puppet Engine","mn":"ADBE FreePin3 Puppet Engine","ix":1,"v":{"a":0,"k":2,"ix":1}},{"ty":0,"nm":"Mesh Rotation Refinement","mn":"ADBE FreePin3 Auto Rotate Pins","ix":2,"v":{"a":0,"k":20,"ix":2}},{"ty":7,"nm":"On Transparent","mn":"ADBE FreePin3 On Transparent","ix":3,"v":{"a":0,"k":0,"ix":3}},{"ty":24,"nm":"arap","np":3,"mn":"ADBE FreePin3 ARAP Group","ix":4,"en":1,"ef":[{"ty":6,"nm":"Auto-traced Shapes","mn":"ADBE FreePin3 Outlines","ix":1,"v":0},{"ty":1,"nm":"Mesh","np":2,"mn":"ADBE FreePin3 Mesh Group","ix":2,"en":1,"ef":[{"nm":"Mesh 1","np":8,"mn":"ADBE FreePin3 Mesh Atom","ix":1,"en":1,"ef":[{"ty":6,"nm":"Mesh","mn":"ADBE FreePin3 Mesh","ix":1,"v":0},{"ty":0,"nm":"Triangles","mn":"ADBE FreePin3 Mesh Tri Count","ix":2,"v":{"a":0,"k":50,"ix":2}},{"ty":0,"nm":"Density","mn":"ADBE FreePin3 Mesh Tri Density","ix":3,"v":{"a":0,"k":10,"ix":3}},{"ty":0,"nm":"Expansion","mn":"ADBE FreePin3 Mesh Expansion","ix":4,"v":{"a":0,"k":3,"ix":4}},{"nm":"Deform","np":1,"mn":"ADBE FreePin3 PosPins","ix":5,"en":1,"ef":[]},{"nm":"Overlap","np":1,"mn":"ADBE FreePin3 HghtPins","ix":6,"en":1,"ef":[]},{"nm":"Stiffness","np":1,"mn":"ADBE FreePin3 StarchPins","ix":7,"en":1,"ef":[]}]}]}]}]}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-40.869],[44.183,0],[0,40.869],[-44.183,0]],"o":[[0,40.869],[-44.183,0],[0,-40.869],[44.183,0]],"v":[[80,0],[0,74],[-80,0],[0,-74]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.953032250498,0.77241228889,0.35537591074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[338.04,295.239],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-10.358,4.933],[-16.324,0.245],[-7.756,4.03],[12.624,56.682],[1.622,-8.005],[-18.216,-48.453]],"o":[[14.02,-6.677],[9.183,-0.138],[-47.163,-42.443],[-1.814,-8.147],[-16.199,79.98],[6.049,-9.052]],"v":[[-15.113,74.73],[33.167,75.091],[58.81,68.307],[-25.953,-90.672],[-42.61,-91.341],[-35.454,99.346]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.999998922909,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[106.263,99.596],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":4,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-63.402,313.029],[-1.814,-8.146],[-153.093,-6.754]],"o":[[-95.427,-4.898],[1.622,-8.005],[22.263,99.96],[0,0]],"v":[[179.415,183.75],[-116.013,-175.745],[-99.356,-175.077],[136.636,41.492]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.953032250498,0.77241228889,0.35537591074,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[179.665,184],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"fFONDO Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[936,1495.416,0],"ix":2},"a":{"a":0,"k":[457,78.584,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-43.401],[252.394,0],[0,43.401],[-252.394,0]],"o":[[0,43.401],[-252.394,0],[0,-43.401],[252.394,0]],"v":[[457,0],[0,78.584],[-457,0],[0,-78.584]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.480012901157,0.480011106005,0.480013200348,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[457,78.584],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":2813.21321321321,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"PRECOMP TUTORIAL","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,960,0],"ix":2},"a":{"a":0,"k":[960,960,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1920,"h":1920,"ip":96,"op":192,"st":96,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"PRECOMP TUTORIAL","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,960,0],"ix":2},"a":{"a":0,"k":[960,960,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1920,"h":1920,"ip":0,"op":96,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/LottieAnimations/Lock.json b/LottieAnimations/Lock.json new file mode 100644 index 00000000..e36c23ff --- /dev/null +++ b/LottieAnimations/Lock.json @@ -0,0 +1 @@ +{"v":"5.1.18","fr":25,"ip":0,"op":18,"w":500,"h":500,"nm":"Unlock 2","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,310,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[11.5,0],[9.75,0],[0,-10.5],[0,-6.25],[-10.25,0],[-6.75,0],[0,11.25],[0,4]],"o":[[-4,0],[-11.25,0],[0,3.75],[0,10.75],[3.25,0],[11,0],[0,-3.5],[0,-10.5]],"v":[[22,-65],[-87.75,-65],[-105,-48.5],[-105,40],[-87.75,57],[22,57],[40,40],[40,-48.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":4,"s":[0.992156862745,0.192156862745,0.21568627451,1],"e":[0.015686275437,0.972549021244,0.443137258291,1]},{"t":16}],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[43,5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[134.286,134.286],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"n":"0p667_1_0p167_0p167","t":0,"s":[{"i":[[0,0],[0,13],[28.5,0],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-28.5,0],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-114.5],[-32.5,-153],[-78.5,-114.5],[-78.5,-64.814]],"c":false}],"e":[{"i":[[0,0],[0,13],[28.5,0],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-28.5,0],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.394],[-32.5,-167.894],[-78.5,-129.394],[-78.5,-79.707]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"n":"0p667_1_0p333_0","t":2,"s":[{"i":[[0,0],[0,13],[28.5,0],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-28.5,0],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.394],[-32.5,-167.894],[-78.5,-129.394],[-78.5,-79.707]],"c":false}],"e":[{"i":[[0,0],[0,13],[28.5,0],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-28.5,0],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.394],[-32.5,-167.894],[-78.5,-129.394],[-78.5,-79.707]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"n":"0p833_0p833_0p333_0","t":4,"s":[{"i":[[0,0],[0,13],[28.5,0],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-28.5,0],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.394],[-32.5,-167.894],[-78.5,-129.394],[-78.5,-79.707]],"c":false}],"e":[{"i":[[0,0],[0,13],[-0.08,0.154],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-0.08,-0.218],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.207],[13.856,-167.707],[13.84,-129.207],[14.166,-79.521]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"n":"0p667_1_0p167_0p167","t":10,"s":[{"i":[[0,0],[0,13],[-0.08,0.154],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[-0.08,-0.218],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.207],[13.856,-167.707],[13.84,-129.207],[14.166,-79.521]],"c":false}],"e":[{"i":[[0,0],[0,13],[-29.681,-0.032],[0,-13.5],[0,0]],"o":[[0,0],[0,-13],[28.032,0.03],[0,13.5],[0,0]],"v":[[14,-64.814],[14,-129.394],[60.213,-167.894],[106.181,-129.394],[106.181,-79.707]],"c":false}]},{"t":16}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":4,"s":[0.992156862745,0.192156862745,0.21568627451,1],"e":[0.015686275437,0.972549021244,0.443137258291,1]},{"t":16}],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":19,"ix":5},"lc":2,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[43,5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[134.286,134.286],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 2","np":3,"cix":2,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":625,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/LottieAnimations/Moody Dog.json b/LottieAnimations/Moody Dog.json new file mode 100644 index 00000000..b2e6909e --- /dev/null +++ b/LottieAnimations/Moody Dog.json @@ -0,0 +1 @@ +{"v":"5.7.13","fr":30,"ip":0,"op":80,"w":1080,"h":1080,"nm":"dog","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":50,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[560.5,848,0],"ix":2,"l":2},"a":{"a":0,"k":[20.5,272,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":0,"s":[90,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":5,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":10,"s":[90,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":15,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":20,"s":[90,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":25,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":30,"s":[90,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":35,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":40,"s":[90,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":45,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":50,"s":[90,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":55,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":60,"s":[90,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":65,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":70,"s":[90,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":75,"s":[100,100,100]},{"t":80,"s":[90,100,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-222,272],[263,272]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":18,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"right ear Outlines","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":2,"s":[5.6]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[-11]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[-1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[-14]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":22,"s":[5.6]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25,"s":[-11]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[-1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":35,"s":[-14]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":40,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":42,"s":[5.6]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":45,"s":[-11]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":50,"s":[-1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":55,"s":[-14]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":60,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":62,"s":[5.6]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":65,"s":[-11]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[-1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[-14]},{"t":80,"s":[0]}],"ix":10},"p":{"a":0,"k":[28.025,46.784,0],"ix":2,"l":2},"a":{"a":0,"k":[22.5,81.5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-1.25,-30.75],[1.25,30.75]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.951659737381,0.654027362898,0.315634723738,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[21.25,50.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"medal Outlines","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":35,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":40,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":45,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":50,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":55,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":60,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":65,"s":[-27]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[-27]},{"t":80,"s":[-2]}],"ix":10},"p":{"a":0,"k":[107.025,172.784,0],"ix":2,"l":2},"a":{"a":0,"k":[3.5,3.5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-6.627],[6.627,0],[0,6.627],[-6.627,0]],"o":[[0,6.627],[-6.627,0],[0,-6.627],[6.627,0]],"v":[[12,0],[0,12],[-12,0],[0,-12]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.502362539254,0.609483606675,0.33839934106,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[14.5,19.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-3.5,-6],[3.5,6]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":7,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[7,9.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"face Outlines","parent":9,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":2,"s":[-3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":7,"s":[3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[-3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":14,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":16,"s":[3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":22,"s":[-3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":32,"s":[-3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":34,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":36,"s":[3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":40,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":42,"s":[-3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":45,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":50,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":52,"s":[-3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":54,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":56,"s":[3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":60,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":62,"s":[-3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":65,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":67,"s":[3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":72,"s":[-3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":74,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":76,"s":[3]},{"t":80,"s":[0]}],"ix":10},"p":{"a":0,"k":[257.319,37.495,0],"ix":2,"l":2},"a":{"a":0,"k":[56.387,221.058,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-13.531],[13.531,0],[0,13.531],[-13.531,0]],"o":[[0,13.531],[-13.531,0],[0,-13.531],[13.531,0]],"v":[[24.5,0],[0,24.5],[-24.5,0],[0,-24.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[195.025,52.784],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-19,19]],"o":[[0,0],[0,0]],"v":[[-16,-10],[16,-9]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":9,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[93.525,53.284],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.574,0.068],[0,0],[-0.069,2.574],[0,0],[-2.574,-0.068],[0,0],[0.068,-2.574],[0,0]],"o":[[0,0],[-2.575,-0.067],[0,0],[0.067,-2.574],[0,0],[2.575,0.067],[0,0],[-0.067,2.574]],"v":[[52.707,15.394],[-53.443,12.604],[-57.981,7.821],[-57.49,-10.856],[-52.707,-15.394],[53.443,-12.604],[57.982,-7.821],[57.491,10.856]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.245484819599,0.200948393579,0.156365308575,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[58.3,167.809],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[29.011,0.57],[0,0],[-0.57,29.011],[0,0],[-29.011,-0.57],[0.57,-29.01],[0,0]],"o":[[0,0],[-29.011,-0.57],[0,0],[0.57,-29.011],[29.011,0.57],[0,0],[-0.571,29.011]],"v":[[-2.373,120.737],[-2.374,120.737],[-53.87,67.175],[-51.187,-69.242],[2.374,-120.738],[53.87,-67.176],[51.188,69.242]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.951659737381,0.654027362898,0.315634723738,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[58.46,121.558],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[22.729,1.763],[0,0],[0,0],[0,0],[0.531,-6.856],[0,0]],"o":[[0,0],[0,0],[0,0],[6.856,0.531],[0,0],[-1.763,22.73]],"v":[[30.502,55.336],[-79.287,46.819],[-71.227,-57.099],[67.305,-46.352],[78.756,-32.976],[74.85,17.373]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.951659737381,0.654027362898,0.315634723738,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[130.199,81.984],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":2,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"left ear Outlines","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[6]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[-1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[14]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25,"s":[6]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[-1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":35,"s":[14]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":40,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":45,"s":[6]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":50,"s":[-1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":55,"s":[14]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":60,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":65,"s":[6]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[-1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[14]},{"t":80,"s":[0]}],"ix":10},"p":{"a":0,"k":[62.025,43.784,0],"ix":2,"l":2},"a":{"a":0,"k":[22.5,81.5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-1.25,-30.75],[1.25,30.75]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.866351617551,0.549761903052,0.27486632104,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":40,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[21.25,50.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"tail Outlines","parent":9,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[20.456,20.721,0],"ix":2,"l":2},"a":{"a":0,"k":[33,73,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-10,-30],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":3,"s":[{"i":[[3.229,-15.169],[0,0]],"o":[[-3.45,16.208],[0,0]],"v":[[-0.05,-38.6],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":5,"s":[{"i":[[2.25,-25.75],[0,0]],"o":[[-2.25,25.75],[0,0]],"v":[[-11.75,-24.75],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":7,"s":[{"i":[[-14.941,-21.093],[0,0]],"o":[[16.75,23.647],[0,0]],"v":[[-25.75,-5.75],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":8,"s":[{"i":[[-12.337,-22.344],[0,0]],"o":[[19.167,22.257],[0,0]],"v":[[-24.417,-9.167],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":10,"s":[{"i":[[-7.13,-24.845],[0,0]],"o":[[8.25,28.75],[0,0]],"v":[[-6.75,-27.25],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":12,"s":[{"i":[[1.842,-25.782],[0,0]],"o":[[-2.5,35],[0,0]],"v":[[-1,-36.5],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":14,"s":[{"i":[[1.842,-25.782],[0,0]],"o":[[-2.5,35],[0,0]],"v":[[-11,-22.5],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":16,"s":[{"i":[[-14.596,-21.333],[0,0]],"o":[[19.5,28.5],[0,0]],"v":[[-25,-16],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[{"i":[[-20.108,-16.241],[0,0]],"o":[[26,21],[0,0]],"v":[[-23,-18],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":20,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-10,-30],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":23,"s":[{"i":[[3.229,-15.169],[0,0]],"o":[[-3.45,16.208],[0,0]],"v":[[-0.05,-38.6],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":25,"s":[{"i":[[2.25,-25.75],[0,0]],"o":[[-2.25,25.75],[0,0]],"v":[[-11.75,-24.75],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":27,"s":[{"i":[[-14.941,-21.093],[0,0]],"o":[[16.75,23.647],[0,0]],"v":[[-25.75,-5.75],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":28,"s":[{"i":[[-12.337,-22.344],[0,0]],"o":[[19.167,22.257],[0,0]],"v":[[-24.417,-9.167],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":30,"s":[{"i":[[-7.13,-24.845],[0,0]],"o":[[8.25,28.75],[0,0]],"v":[[-6.75,-27.25],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":32,"s":[{"i":[[1.842,-25.782],[0,0]],"o":[[-2.5,35],[0,0]],"v":[[-1,-36.5],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":34,"s":[{"i":[[1.842,-25.782],[0,0]],"o":[[-2.5,35],[0,0]],"v":[[-11,-22.5],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":36,"s":[{"i":[[-14.596,-21.333],[0,0]],"o":[[19.5,28.5],[0,0]],"v":[[-25,-16],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":38,"s":[{"i":[[-20.108,-16.241],[0,0]],"o":[[26,21],[0,0]],"v":[[-23,-18],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":40,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-10,-30],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":43,"s":[{"i":[[3.229,-15.169],[0,0]],"o":[[-3.45,16.208],[0,0]],"v":[[-0.05,-38.6],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":45,"s":[{"i":[[2.25,-25.75],[0,0]],"o":[[-2.25,25.75],[0,0]],"v":[[-11.75,-24.75],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":47,"s":[{"i":[[-14.941,-21.093],[0,0]],"o":[[16.75,23.647],[0,0]],"v":[[-25.75,-5.75],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":48,"s":[{"i":[[-12.337,-22.344],[0,0]],"o":[[19.167,22.257],[0,0]],"v":[[-24.417,-9.167],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":50,"s":[{"i":[[-7.13,-24.845],[0,0]],"o":[[8.25,28.75],[0,0]],"v":[[-6.75,-27.25],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":52,"s":[{"i":[[1.842,-25.782],[0,0]],"o":[[-2.5,35],[0,0]],"v":[[-1,-36.5],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":54,"s":[{"i":[[1.842,-25.782],[0,0]],"o":[[-2.5,35],[0,0]],"v":[[-11,-22.5],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":56,"s":[{"i":[[-14.596,-21.333],[0,0]],"o":[[19.5,28.5],[0,0]],"v":[[-25,-16],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[-20.108,-16.241],[0,0]],"o":[[26,21],[0,0]],"v":[[-23,-18],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":60,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-10,-30],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":63,"s":[{"i":[[3.229,-15.169],[0,0]],"o":[[-3.45,16.208],[0,0]],"v":[[-0.05,-38.6],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[2.25,-25.75],[0,0]],"o":[[-2.25,25.75],[0,0]],"v":[[-11.75,-24.75],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":67,"s":[{"i":[[-14.941,-21.093],[0,0]],"o":[[16.75,23.647],[0,0]],"v":[[-25.75,-5.75],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":68,"s":[{"i":[[-12.337,-22.344],[0,0]],"o":[[19.167,22.257],[0,0]],"v":[[-24.417,-9.167],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":70,"s":[{"i":[[-7.13,-24.845],[0,0]],"o":[[8.25,28.75],[0,0]],"v":[[-6.75,-27.25],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":72,"s":[{"i":[[1.842,-25.782],[0,0]],"o":[[-2.5,35],[0,0]],"v":[[-1,-36.5],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":74,"s":[{"i":[[1.842,-25.782],[0,0]],"o":[[-2.5,35],[0,0]],"v":[[-11,-22.5],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":76,"s":[{"i":[[-14.596,-21.333],[0,0]],"o":[[19.5,28.5],[0,0]],"v":[[-25,-16],[10,30]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":78,"s":[{"i":[[-20.108,-16.241],[0,0]],"o":[[26,21],[0,0]],"v":[[-23,-18],[10,30]],"c":false}]},{"t":80,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-10,-30],[10,30]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.951659737381,0.654027362898,0.315634723738,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":26,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[23,43],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"front right Outlines","parent":9,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[238.956,97.222,0],"ix":2,"l":2},"a":{"a":0,"k":[92.5,92.5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[0,0],[-16.374,2.1]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[30.5,44.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":5,"s":[{"i":[[0,0],[-4,-22]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-16.5,45.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":7,"s":[{"i":[[0,0],[24,-37]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-35,44.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":8,"s":[{"i":[[0,0],[43,-31]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-45.5,32.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[{"i":[[0,0],[43,-22.205]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-55,39.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":10,"s":[{"i":[[0,0],[53,7.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-75.5,39.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":12,"s":[{"i":[[0,0],[68.5,-12.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-45,36.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":14,"s":[{"i":[[0,0],[54.5,-17.25]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-14,28.2]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":16,"s":[{"i":[[0,0],[34,-29.75]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[3,30.2]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[{"i":[[0,0],[-29.5,-40.75]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[26.5,37.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":20,"s":[{"i":[[0,0],[-16.374,2.1]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[30.5,44.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":25,"s":[{"i":[[0,0],[-4,-22]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-16.5,45.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":27,"s":[{"i":[[0,0],[24,-37]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-35,44.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":28,"s":[{"i":[[0,0],[43,-31]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-45.5,32.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":29,"s":[{"i":[[0,0],[43,-22.205]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-55,39.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":30,"s":[{"i":[[0,0],[53,7.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-75.5,39.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":32,"s":[{"i":[[0,0],[68.5,-12.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-45,36.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":34,"s":[{"i":[[0,0],[54.5,-17.25]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-14,28.2]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":36,"s":[{"i":[[0,0],[34,-29.75]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[3,30.2]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":38,"s":[{"i":[[0,0],[-29.5,-40.75]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[26.5,37.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":40,"s":[{"i":[[0,0],[-16.374,2.1]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[30.5,44.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":45,"s":[{"i":[[0,0],[-4,-22]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-16.5,45.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":47,"s":[{"i":[[0,0],[24,-37]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-35,44.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":48,"s":[{"i":[[0,0],[43,-31]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-45.5,32.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":49,"s":[{"i":[[0,0],[43,-22.205]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-55,39.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":50,"s":[{"i":[[0,0],[53,7.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-75.5,39.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":52,"s":[{"i":[[0,0],[68.5,-12.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-45,36.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":54,"s":[{"i":[[0,0],[54.5,-17.25]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-14,28.2]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":56,"s":[{"i":[[0,0],[34,-29.75]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[3,30.2]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[0,0],[-29.5,-40.75]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[26.5,37.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":60,"s":[{"i":[[0,0],[-16.374,2.1]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[30.5,44.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[-4,-22]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-16.5,45.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":67,"s":[{"i":[[0,0],[24,-37]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-35,44.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":68,"s":[{"i":[[0,0],[43,-31]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-45.5,32.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":69,"s":[{"i":[[0,0],[43,-22.205]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-55,39.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":70,"s":[{"i":[[0,0],[53,7.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-75.5,39.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":72,"s":[{"i":[[0,0],[68.5,-12.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-45,36.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":74,"s":[{"i":[[0,0],[54.5,-17.25]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-14,28.2]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":76,"s":[{"i":[[0,0],[34,-29.75]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[3,30.2]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":78,"s":[{"i":[[0,0],[-29.5,-40.75]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[26.5,37.7]],"c":false}]},{"t":80,"s":[{"i":[[0,0],[-16.374,2.1]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[30.5,44.45]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.951659737381,0.654027362898,0.315634723738,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":37,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[123,139.05],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"back right Outlines","parent":9,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50.956,97.222,0],"ix":2,"l":2},"a":{"a":0,"k":[92.5,92.5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[0,0],[-16.374,2.1]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[30.5,44.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":5,"s":[{"i":[[0,0],[-1.25,-9.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-15.25,45.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":7,"s":[{"i":[[0,0],[25.75,-27.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-34.25,42.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":8,"s":[{"i":[[0,0],[37.75,-22]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-44.25,33.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[{"i":[[0,0],[42.75,-19]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-53.75,40.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":10,"s":[{"i":[[0,0],[56.25,7]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-73.75,39.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":12,"s":[{"i":[[0,0],[66.75,-8]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-43.75,34.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":14,"s":[{"i":[[0,0],[51.5,-18]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-11,26.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":16,"s":[{"i":[[0,0],[29,-34.25]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[7,29.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[{"i":[[0,0],[-25,-32.75]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[28,35.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":20,"s":[{"i":[[0,0],[-16.374,2.1]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[30.5,44.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":25,"s":[{"i":[[0,0],[-1.25,-9.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-15.25,45.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":27,"s":[{"i":[[0,0],[25.75,-27.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-34.25,42.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":28,"s":[{"i":[[0,0],[37.75,-22]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-44.25,33.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":29,"s":[{"i":[[0,0],[42.75,-19]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-53.75,40.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":30,"s":[{"i":[[0,0],[56.25,7]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-73.75,39.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":32,"s":[{"i":[[0,0],[66.75,-8]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-43.75,34.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":34,"s":[{"i":[[0,0],[51.5,-18]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-11,26.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":36,"s":[{"i":[[0,0],[29,-34.25]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[7,29.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":38,"s":[{"i":[[0,0],[-25,-32.75]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[28,35.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":40,"s":[{"i":[[0,0],[-16.374,2.1]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[30.5,44.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":45,"s":[{"i":[[0,0],[-1.25,-9.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-15.25,45.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":47,"s":[{"i":[[0,0],[25.75,-27.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-34.25,42.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":48,"s":[{"i":[[0,0],[37.75,-22]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-44.25,33.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":49,"s":[{"i":[[0,0],[42.75,-19]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-53.75,40.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":50,"s":[{"i":[[0,0],[56.25,7]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-73.75,39.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":52,"s":[{"i":[[0,0],[66.75,-8]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-43.75,34.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":54,"s":[{"i":[[0,0],[51.5,-18]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-11,26.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":56,"s":[{"i":[[0,0],[29,-34.25]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[7,29.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[0,0],[-25,-32.75]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[28,35.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":60,"s":[{"i":[[0,0],[-16.374,2.1]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[30.5,44.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[-1.25,-9.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-15.25,45.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":67,"s":[{"i":[[0,0],[25.75,-27.5]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-34.25,42.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":68,"s":[{"i":[[0,0],[37.75,-22]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-44.25,33.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":69,"s":[{"i":[[0,0],[42.75,-19]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-53.75,40.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":70,"s":[{"i":[[0,0],[56.25,7]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-73.75,39.45]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":72,"s":[{"i":[[0,0],[66.75,-8]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-43.75,34.95]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":74,"s":[{"i":[[0,0],[51.5,-18]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[-11,26.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":76,"s":[{"i":[[0,0],[29,-34.25]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[7,29.7]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":78,"s":[{"i":[[0,0],[-25,-32.75]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[28,35.7]],"c":false}]},{"t":80,"s":[{"i":[[0,0],[-16.374,2.1]],"o":[[0,0],[0,0]],"v":[[-30.5,-46.55],[30.5,44.45]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.951659737381,0.654027362898,0.315634723738,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":37,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[123,139.05],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"body Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[528.791,677.706,0],"to":[0,4.833,0],"ti":[0,-0.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":5,"s":[528.791,706.706,0],"to":[0,0.5,0],"ti":[0,0.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":10,"s":[528.791,680.706,0],"to":[0,-0.333,0],"ti":[0,-0.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":14,"s":[528.791,704.706,0],"to":[0,0.5,0],"ti":[0,4.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[528.791,683.706,0],"to":[0,-4.5,0],"ti":[0,-3.833,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":20,"s":[528.791,677.706,0],"to":[0,3.833,0],"ti":[0,-0.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":25,"s":[528.791,706.706,0],"to":[0,0.5,0],"ti":[0,0.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":30,"s":[528.791,680.706,0],"to":[0,-0.333,0],"ti":[0,-0.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":34,"s":[528.791,704.706,0],"to":[0,0.5,0],"ti":[0,4.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":38,"s":[528.791,683.706,0],"to":[0,-4.5,0],"ti":[0,-3.833,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":40,"s":[528.791,677.706,0],"to":[0,3.833,0],"ti":[0,-0.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":45,"s":[528.791,706.706,0],"to":[0,0.5,0],"ti":[0,0.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":50,"s":[528.791,680.706,0],"to":[0,-0.333,0],"ti":[0,-0.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":54,"s":[528.791,704.706,0],"to":[0,0.5,0],"ti":[0,4.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[528.791,683.706,0],"to":[0,-4.5,0],"ti":[0,-3.833,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":60,"s":[528.791,677.706,0],"to":[0,3.833,0],"ti":[0,-0.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[528.791,706.706,0],"to":[0,0.5,0],"ti":[0,0.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":70,"s":[528.791,680.706,0],"to":[0,-0.333,0],"ti":[0,-0.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":74,"s":[528.791,704.706,0],"to":[0,0.5,0],"ti":[0,4.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":78,"s":[528.791,683.706,0],"to":[0,-4.5,0],"ti":[0,1,0]},{"t":80,"s":[528.791,677.706,0]}],"ix":2,"l":2},"a":{"a":0,"k":[155.247,72.427,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[39.56,0.777],[0,0],[-0.778,39.56],[0,0],[-2.642,-0.052],[0,0],[0.059,-2.997],[0,0]],"o":[[0,0],[-39.56,-0.779],[0,0],[0.052,-2.643],[0,0],[2.997,0.06],[0,0],[-0.778,39.56]],"v":[[80.684,71.4],[-83.999,68.163],[-154.22,-4.875],[-152.99,-67.434],[-148.111,-72.125],[149.618,-66.272],[154.938,-60.738],[153.721,1.179]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.951659737381,0.654027362898,0.315634723738,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[155.247,72.427],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"front left Outlines","parent":9,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[245.965,91.221,0],"ix":2,"l":2},"a":{"a":0,"k":[147.509,92.5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[0,0],[57,7]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-28.5,43.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":5,"s":[{"i":[[0,0],[59,-8.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[30,28]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":7,"s":[{"i":[[0,0],[30.5,-33.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[53.5,29]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":8,"s":[{"i":[[0,0],[13.5,-39]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[63,23.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[{"i":[[0,0],[-22,-40.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[75.5,35.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":10,"s":[{"i":[[0,0],[-44,-28.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[79,40.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":12,"s":[{"i":[[0,0],[-39,-35]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[58,49]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":14,"s":[{"i":[[0,0],[-7.25,-39]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[34.75,47]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":16,"s":[{"i":[[0,0],[25.25,-29]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[15.75,45.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[{"i":[[0,0],[45.25,-15.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-5.25,41]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":20,"s":[{"i":[[0,0],[57,7]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-28.5,43.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":25,"s":[{"i":[[0,0],[59,-8.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[30,28]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":27,"s":[{"i":[[0,0],[30.5,-33.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[53.5,29]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":28,"s":[{"i":[[0,0],[13.5,-39]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[63,23.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":29,"s":[{"i":[[0,0],[-22,-40.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[75.5,35.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":30,"s":[{"i":[[0,0],[-44,-28.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[79,40.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":32,"s":[{"i":[[0,0],[-39,-35]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[58,49]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":34,"s":[{"i":[[0,0],[-7.25,-39]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[34.75,47]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":36,"s":[{"i":[[0,0],[25.25,-29]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[15.75,45.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":38,"s":[{"i":[[0,0],[45.25,-15.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-5.25,41]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":40,"s":[{"i":[[0,0],[57,7]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-28.5,43.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":45,"s":[{"i":[[0,0],[59,-8.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[30,28]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":47,"s":[{"i":[[0,0],[30.5,-33.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[53.5,29]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":48,"s":[{"i":[[0,0],[13.5,-39]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[63,23.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":49,"s":[{"i":[[0,0],[-22,-40.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[75.5,35.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":50,"s":[{"i":[[0,0],[-44,-28.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[79,40.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":52,"s":[{"i":[[0,0],[-39,-35]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[58,49]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":54,"s":[{"i":[[0,0],[-7.25,-39]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[34.75,47]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":56,"s":[{"i":[[0,0],[25.25,-29]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[15.75,45.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[0,0],[45.25,-15.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-5.25,41]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":60,"s":[{"i":[[0,0],[57,7]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-28.5,43.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[59,-8.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[30,28]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":67,"s":[{"i":[[0,0],[30.5,-33.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[53.5,29]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":68,"s":[{"i":[[0,0],[13.5,-39]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[63,23.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":69,"s":[{"i":[[0,0],[-22,-40.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[75.5,35.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":70,"s":[{"i":[[0,0],[-44,-28.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[79,40.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":72,"s":[{"i":[[0,0],[-39,-35]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[58,49]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":74,"s":[{"i":[[0,0],[-7.25,-39]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[34.75,47]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":76,"s":[{"i":[[0,0],[25.25,-29]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[15.75,45.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":78,"s":[{"i":[[0,0],[45.25,-15.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-5.25,41]],"c":false}]},{"t":80,"s":[{"i":[[0,0],[57,7]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-28.5,43.5]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.866351617551,0.549761903052,0.27486632104,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":37,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[121,143],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"back left Outlines","parent":9,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[60.965,91.221,0],"ix":2,"l":2},"a":{"a":0,"k":[147.509,92.5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[0,0],[57,7]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-28.5,43.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":5,"s":[{"i":[[0,0],[60.5,-22]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[28.5,33]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":7,"s":[{"i":[[0,0],[28.5,-53.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[53.5,27.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":8,"s":[{"i":[[0,0],[14,-57.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[61,24.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[{"i":[[0,0],[-30.5,-59.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[73.5,36]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":10,"s":[{"i":[[0,0],[-44.5,-29]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[77.5,42]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":12,"s":[{"i":[[0,0],[-39.5,-33.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[57.5,47.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":14,"s":[{"i":[[0,0],[-4.25,-36.75]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[32.75,47]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":16,"s":[{"i":[[0,0],[21.75,-22.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[13.75,45.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[{"i":[[0,0],[41.75,-14.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-6.25,42]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":20,"s":[{"i":[[0,0],[57,7]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-28.5,43.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":25,"s":[{"i":[[0,0],[60.5,-22]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[28.5,33]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":27,"s":[{"i":[[0,0],[28.5,-53.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[53.5,27.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":28,"s":[{"i":[[0,0],[14,-57.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[61,24.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":29,"s":[{"i":[[0,0],[-30.5,-59.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[73.5,36]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":30,"s":[{"i":[[0,0],[-44.5,-29]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[77.5,42]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":32,"s":[{"i":[[0,0],[-39.5,-33.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[57.5,47.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":34,"s":[{"i":[[0,0],[-4.25,-36.75]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[32.75,47]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":36,"s":[{"i":[[0,0],[21.75,-22.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[13.75,45.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":38,"s":[{"i":[[0,0],[41.75,-14.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-6.25,42]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":40,"s":[{"i":[[0,0],[57,7]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-28.5,43.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":45,"s":[{"i":[[0,0],[60.5,-22]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[28.5,33]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":47,"s":[{"i":[[0,0],[28.5,-53.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[53.5,27.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":48,"s":[{"i":[[0,0],[14,-57.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[61,24.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":49,"s":[{"i":[[0,0],[-30.5,-59.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[73.5,36]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":50,"s":[{"i":[[0,0],[-44.5,-29]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[77.5,42]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":52,"s":[{"i":[[0,0],[-39.5,-33.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[57.5,47.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":54,"s":[{"i":[[0,0],[-4.25,-36.75]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[32.75,47]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":56,"s":[{"i":[[0,0],[21.75,-22.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[13.75,45.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[0,0],[41.75,-14.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-6.25,42]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":60,"s":[{"i":[[0,0],[57,7]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-28.5,43.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[60.5,-22]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[28.5,33]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":67,"s":[{"i":[[0,0],[28.5,-53.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[53.5,27.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":68,"s":[{"i":[[0,0],[14,-57.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[61,24.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":69,"s":[{"i":[[0,0],[-30.5,-59.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[73.5,36]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":70,"s":[{"i":[[0,0],[-44.5,-29]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[77.5,42]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":72,"s":[{"i":[[0,0],[-39.5,-33.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[57.5,47.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":74,"s":[{"i":[[0,0],[-4.25,-36.75]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[32.75,47]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":76,"s":[{"i":[[0,0],[21.75,-22.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[13.75,45.5]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":78,"s":[{"i":[[0,0],[41.75,-14.5]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-6.25,42]],"c":false}]},{"t":80,"s":[{"i":[[0,0],[57,7]],"o":[[0,0],[0,0]],"v":[[26.5,-50.5],[-28.5,43.5]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.866351617551,0.549761903052,0.27486632104,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":37,"ix":5},"lc":2,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[121,143],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/LottieAnimations/Orange Cat Peeping.json b/LottieAnimations/Orange Cat Peeping.json new file mode 100644 index 00000000..4bc51f79 --- /dev/null +++ b/LottieAnimations/Orange Cat Peeping.json @@ -0,0 +1 @@ +{"nm":"appy_present","ddd":0,"h":2560,"w":1440,"meta":{"g":"@lottiefiles/toolkit-js 0.33.2"},"layers":[{"ty":3,"nm":"Nul 1","sr":1,"st":11,"op":386,"ip":11,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[183,183,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[578,3266,0],"t":11,"ti":[0,0,0],"to":[0,0,0]},{"s":[578,2556,0],"t":24}],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":0,"ix":11}},"ef":[],"ind":1},{"ty":4,"nm":"ojo der Silhouettes","sr":1,"st":0,"op":375,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[48.045,48.044,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[172.758,-68.586,0],"t":30,"ti":[0,0,0],"to":[0,0,0]},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[166.201,-82.794,0],"t":35,"ti":[0,0,0],"to":[0,0,0]},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[194.616,-83.887,0],"t":62,"ti":[0,0,0],"to":[0,0,0]},{"s":[172.758,-68.586,0],"t":67}],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Groupe 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Tracé 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,-26.397],[-26.396,0],[0,26.396],[26.396,0]],"o":[[0,26.396],[26.396,-0.001],[0,-26.396],[-26.396,0]],"v":[[-47.794,0],[-0.001,47.794],[47.794,-0.001],[-0.001,-47.794]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fond 1","c":{"a":0,"k":[0,0,0],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[48.045,48.045],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":2,"parent":1},{"ty":4,"nm":"ojo izq Silhouettes","sr":1,"st":0,"op":375,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[48.044,48.045,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[-26.17,-70.771,0],"t":30,"ti":[0,0,0],"to":[0,0,0]},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[-49.121,-84.978,0],"t":35,"ti":[0,0,0],"to":[0,0,0]},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[-22.891,-84.978,0],"t":62,"ti":[0,0,0],"to":[0,0,0]},{"s":[-26.17,-70.771,0],"t":67}],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Groupe 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Tracé 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,-26.397],[-26.396,0],[0,26.396],[26.396,0]],"o":[[0,26.396],[26.396,-0.001],[0,-26.396],[-26.397,0]],"v":[[-47.794,0],[-0.001,47.794],[47.794,-0.001],[0,-47.794]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fond 1","c":{"a":0,"k":[0,0,0],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[48.044,48.045],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":3,"parent":1},{"ty":4,"nm":"Calque 1 Silhouettes","sr":1,"st":0,"op":375,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[335.986,508.493,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[-0.076,109.585,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Groupe 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Tracé 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[-6.088,0],[0,-6.092],[-10.139,0],[0,10.136],[-0.003,0.101],[0,0.101],[0,0],[0,6.128],[-15.059,0],[0,-8.292],[9.56,-4.428],[0,0],[0.003,-0.1],[0,-0.101],[-10.139,0],[0,10.136],[-6.088,0],[0,-6.093],[22.296,0],[7.381,7.842],[11.591,0],[0,22.3]],"o":[[6.09,0],[0,10.137],[10.14,0],[0,-0.101],[-0.003,-0.1],[0,0],[-9.56,-4.429],[0,-8.291],[15.06,0],[0,6.128],[0,0],[0,0.101],[0.003,0.101],[0,10.136],[10.14,0],[0,-6.093],[6.089,0],[0,22.3],[-11.59,0.001],[-7.381,7.842],[-22.296,0],[0,-6.093]],"v":[[-58.824,-3.369],[-47.795,7.659],[-29.412,26.043],[-11.029,7.659],[-10.999,7.363],[-11.029,7.068],[-11.029,-13.26],[-27.266,-33.089],[0,-48.102],[27.268,-33.089],[11.03,-13.26],[11.03,7.068],[10.999,7.363],[11.03,7.659],[29.412,26.042],[47.794,7.659],[58.824,-3.37],[69.853,7.659],[29.412,48.101],[0,35.329],[-29.412,48.102],[-69.853,7.66]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fond 1","c":{"a":0,"k":[0.749,0.3922,0.149],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[407.717,410.813],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Groupe 2","ix":2,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Tracé 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,-37.008],[-37.009,0.001],[0,37.008],[37.008,0]],"o":[[0,37.008],[37.007,0],[0,-37.008],[-37.008,0]],"v":[[-67.008,0],[0,67.008],[67.008,-0.001],[0,-67.009]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fond 1","c":{"a":0,"k":[1,1,1],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[299.261,329.23],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Groupe 3","ix":3,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Tracé 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,-37.008],[-37.008,0.001],[0,37.008],[37.008,0]],"o":[[0,37.008],[37.008,0],[0,-37.008],[-37.008,0]],"v":[[-67.009,0],[0,67.008],[67.009,-0.001],[0,-67.009]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fond 1","c":{"a":0,"k":[1,1,1],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[516.174,329.229],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]},{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Groupe 4","ix":4,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Tracé 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[25.902,-23.233],[0,0],[0,0],[0,0],[0.05,-34.794],[0,0],[0.392,62.222],[31.775,0.045],[0.153,-0.002],[-0.203,-31.985],[-65.704,-8.654],[-2.494,-0.004],[-1.219,0.076],[0,0],[-22.658,50.5],[0,41.875],[0,0],[0,0],[0,0]],"o":[[0.05,-34.794],[0,0],[0,0],[0,0],[-25.836,-23.307],[0,0],[-19.955,-21.872],[-0.202,-31.831],[-0.155,0],[-31.984,0.202],[1.205,190.77],[2.539,0.336],[1.233,0.002],[0,0],[58.93,0.084],[13.454,-23.624],[0,0],[0,0],[0,0],[0,0]],"v":[[335.687,-454.416],[268.651,-484.394],[156.972,-384.219],[-1.722,-384.443],[-113.113,-484.937],[-180.235,-455.147],[-181.228,361.501],[-219.71,240.436],[-277.529,182.891],[-277.988,182.891],[-335.533,241.171],[-144.049,507.373],[-136.498,507.877],[-132.831,507.714],[181.211,508.159],[313.547,422.599],[334.642,325.355],[334.996,26.531],[334.974,26.531],[335,26.531]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fond 1","c":{"a":0,"k":[0.9373,0.5569,0.2706],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[335.986,508.494],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":4,"parent":1}],"v":"5.1.13","fr":25,"op":77,"ip":0,"assets":[]} \ No newline at end of file diff --git a/LottieAnimations/Reindeer.json b/LottieAnimations/Reindeer.json new file mode 100644 index 00000000..84576b67 --- /dev/null +++ b/LottieAnimations/Reindeer.json @@ -0,0 +1 @@ +{"v":"5.9.6","fr":29.9700012207031,"ip":0,"op":143.000005824509,"w":1080,"h":1080,"nm":"polar bear","ddd":0,"assets":[{"id":"comp_0","nm":"reindeer","fr":29.9700012207031,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[503.869,761.835,0],"to":[-46.583,-55.333,0],"ti":[46.583,55.333,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":15,"s":[224.369,429.835,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":22,"s":[224.369,429.835,0],"to":[99.667,-9.333,0],"ti":[-99.667,9.333,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":37,"s":[822.369,373.835,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[822.369,373.835,0],"to":[-43.667,-8.667,0],"ti":[43.667,8.667,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":57,"s":[560.369,321.835,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":69,"s":[560.369,321.835,0],"to":[-57.333,103.333,0],"ti":[57.333,-103.333,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":87,"s":[216.369,941.835,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":96,"s":[216.369,941.835,0],"to":[114,1.333,0],"ti":[-114,-1.333,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":108,"s":[900.369,949.835,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":119,"s":[900.369,949.835,0],"to":[-66.083,-31.333,0],"ti":[66.083,31.333,0]},{"t":137.000005580124,"s":[503.869,761.835,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-31.375,227.375,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":15,"s":[15,15,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":22,"s":[15,15,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":37,"s":[128,128,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":45,"s":[128,128,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":57,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":69,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":87,"s":[128,128,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":96,"s":[128,128,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":108,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":119,"s":[100,100,100]},{"t":137.000005580124,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[38.25,38.25],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.854901960784,0.662745098039,0.149019607843,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-31.375,227.375],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Layer 17 Outlines","parent":9,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":22,"s":[50.537,30.308,0],"to":[-1.775,0,0],"ti":[1.775,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":37,"s":[39.887,30.308,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[39.887,30.308,0],"to":[1.775,0,0],"ti":[-1.775,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":57,"s":[50.537,30.308,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":96,"s":[50.537,30.308,0],"to":[-1.775,0,0],"ti":[1.775,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":108,"s":[39.887,30.308,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":119,"s":[39.887,30.308,0],"to":[1.775,0,0],"ti":[-1.775,0,0]},{"t":137.000005580124,"s":[50.537,30.308,0]}],"ix":2,"l":2},"a":{"a":0,"k":[21.599,21.599,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":22,"s":[22.422,22.422,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":37,"s":[9.422,9.422,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":45,"s":[9.422,9.422,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":57,"s":[22.422,22.422,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":96,"s":[22.422,22.422,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":108,"s":[9.422,9.422,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":119,"s":[9.422,9.422,100]},{"t":137.000005580124,"s":[22.422,22.422,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.146,-11.71],[11.71,0.147],[-0.149,11.709],[-11.709,-0.143]],"o":[[-0.145,11.71],[-11.713,-0.146],[0.144,-11.711],[11.71,0.147]],"v":[[21.204,0.262],[-0.262,21.202],[-21.201,-0.264],[0.264,-21.206]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.956862804936,0.913725550034,0.84313731474,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[21.599,21.599],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Layer 2 Outlines","parent":9,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[-17.979,29.455,0],"to":[1.084,0,0],"ti":[-1.084,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":15,"s":[-11.477,29.455,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":22,"s":[-11.477,29.455,0],"to":[-1.084,0,0],"ti":[1.084,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":37,"s":[-17.979,29.455,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":69,"s":[-17.979,29.455,0],"to":[1.084,0,0],"ti":[-1.084,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":87,"s":[-11.477,29.455,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":96,"s":[-11.477,29.455,0],"to":[-1.084,0,0],"ti":[1.084,0,0]},{"t":108.00000439893,"s":[-17.979,29.455,0]}],"ix":2,"l":2},"a":{"a":0,"k":[21.599,21.6,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[22.422,22.422,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":15,"s":[13.422,13.422,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":22,"s":[13.422,13.422,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":37,"s":[22.422,22.422,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":69,"s":[22.422,22.422,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":87,"s":[13.422,13.422,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":96,"s":[13.422,13.422,100]},{"t":108.00000439893,"s":[22.422,22.422,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.145,-11.709],[11.71,0.147],[-0.146,11.71],[-11.708,-0.146]],"o":[[-0.147,11.712],[-11.711,-0.145],[0.145,-11.71],[11.712,0.143]],"v":[[21.204,0.261],[-0.263,21.202],[-21.203,-0.264],[0.263,-21.204]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.956862804936,0.913725550034,0.84313731474,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[21.599,21.6],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"nose Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[504.32,765.987,0],"to":[-14.167,-9.667,0],"ti":[14.167,9.667,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":15,"s":[419.32,707.987,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":22,"s":[419.32,707.987,0],"to":[33.5,-0.167,0],"ti":[-0.298,1.039,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":37,"s":[620.32,706.987,0],"to":[0.282,-0.984,0],"ti":[-0.298,1.039,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[620.32,706.987,0],"to":[0.282,-0.984,0],"ti":[-0.014,0.221,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":57,"s":[505.32,611.987,0],"to":[0.745,-11.429,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":61,"s":[505.32,594.987,0],"to":[0,0,0],"ti":[-0.014,0.221,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":65,"s":[505.32,611.987,0],"to":[0.745,-11.429,0],"ti":[0.007,0.222,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":69,"s":[505.32,611.987,0],"to":[-0.005,-0.179,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":87,"s":[410.32,770.487,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":96,"s":[410.32,770.487,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":108,"s":[602.32,769.487,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":119,"s":[602.32,769.487,0],"to":[0,0,0],"ti":[16.333,0.583,0]},{"t":137.000005580124,"s":[504.32,765.987,0]}],"ix":2,"l":2},"a":{"a":0,"k":[12.25,12.25,0],"ix":1,"l":2},"s":{"a":0,"k":[446,446,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-1.104],[1.104,0],[0,1.104],[-1.104,0]],"o":[[0,1.104],[-1.104,0],[0,-1.104],[1.104,0]],"v":[[2,0],[0,2],[-2,0],[0,-2]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[6.75,6.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-6.627],[6.627,0],[0,6.627],[-6.627,0]],"o":[[0,6.627],[-6.627,0],[0,-6.627],[6.627,0]],"v":[[12,0],[0,12],[-12,0],[0,-12]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.486274509804,0.113725490196,0.098039215686,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[12.25,12.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Layer 10 Outlines","parent":6,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[2.325,-9.361,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":57,"s":[2.325,-3.361,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":69,"s":[2.325,-3.361,0],"to":[0,0,0],"ti":[0,0,0]},{"t":87.0000035435826,"s":[2.325,-9.361,0]}],"ix":2,"l":2},"a":{"a":0,"k":[20.468,15.255,0],"ix":1,"l":2},"s":{"a":0,"k":[22.422,22.422,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.448,-2.43],[-10.37,-6.165],[-1.444,2.431],[10.371,6.165]],"o":[[-1.444,2.434],[10.367,6.164],[1.447,-2.431],[-10.368,-6.164]],"v":[[-18.774,-9.753],[1.855,-1.709],[18.771,12.574],[6.094,-8.84]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.117647066303,0.192156877705,0.258823529412,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[20.468,15.255],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"left eye Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[557.84,650.027,0],"to":[-9.917,-5.167,0],"ti":[9.917,5.167,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":15,"s":[498.34,619.027,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":22,"s":[498.34,619.027,0],"to":[25.958,0.083,0],"ti":[-1.885,1.517,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":37,"s":[654.09,619.527,0],"to":[1.136,-0.914,0],"ti":[-1.885,1.517,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[654.09,619.527,0],"to":[1.136,-0.914,0],"ti":[16.333,13.833,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":57,"s":[556.09,536.527,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":69,"s":[556.09,536.527,0],"to":[-12.208,23.292,0],"ti":[0.005,-1.626,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":87,"s":[482.84,676.277,0],"to":[-0.01,3.092,0],"ti":[0.005,-1.626,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":96,"s":[482.84,676.277,0],"to":[-0.01,3.092,0],"ti":[-0.224,0.167,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":108,"s":[633.34,680.277,0],"to":[0.11,-0.082,0],"ti":[-0.224,0.167,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":119,"s":[633.34,680.277,0],"to":[0.11,-0.082,0],"ti":[12.583,5.042,0]},{"t":137.000005580124,"s":[557.84,650.027,0]}],"ix":2,"l":2},"a":{"a":0,"k":[4.25,4.25,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":0,"s":[446,446,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":15,"s":[446,446,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":22,"s":[446,446,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":37,"s":[315,315,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":45,"s":[315,315,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":57,"s":[339,339,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":69,"s":[339,339,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":87,"s":[391,391,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":96,"s":[391,391,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":108,"s":[302,302,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":119,"s":[302,302,100]},{"t":137.000005580124,"s":[446,446,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-2.209],[2.209,0],[0,2.209],[-2.209,0]],"o":[[0,2.209],[-2.209,0],[0,-2.209],[2.209,0]],"v":[[4,0],[0,4],[-4,0],[0,-4]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[4.25,4.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Layer 9 Outlines","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[6.48,-9.4,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":57,"s":[6.48,-3.4,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":69,"s":[6.48,-3.4,0],"to":[0,0,0],"ti":[0,0,0]},{"t":87.0000035435826,"s":[6.48,-9.657,0]}],"ix":2,"l":2},"a":{"a":0,"k":[20.679,14.982,0],"ix":1,"l":2},"s":{"a":0,"k":[22.422,22.422,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.384,-2.468],[10.52,-5.906],[1.387,2.468],[-10.519,5.904]],"o":[[1.387,2.464],[-10.517,5.907],[-1.382,-2.465],[10.519,-5.904]],"v":[[19.043,-9.119],[-1.781,-1.594],[-19.048,12.263],[-5.841,-8.828]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.117647066303,0.192156877705,0.258823529412,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[20.679,14.982],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"right eye Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[446.34,650.027,0],"to":[-10.5,-4.917,0],"ti":[10.5,4.917,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":15,"s":[383.34,620.527,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":22,"s":[383.34,620.527,0],"to":[26.125,-0.125,0],"ti":[-0.598,4.399,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":37,"s":[540.09,619.777,0],"to":[0.342,-2.518,0],"ti":[-0.598,4.399,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[540.09,619.777,0],"to":[0.342,-2.518,0],"ti":[16.167,13.875,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":57,"s":[443.09,536.527,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":69,"s":[443.09,536.527,0],"to":[-12.667,23.5,0],"ti":[-0.104,-1.853,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":87,"s":[367.09,677.527,0],"to":[0.083,1.474,0],"ti":[-0.104,-1.853,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":96,"s":[367.09,677.527,0],"to":[0.083,1.474,0],"ti":[0.922,-0.115,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":108,"s":[517.59,680.027,0],"to":[-1.491,0.186,0],"ti":[0.922,-0.115,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":119,"s":[517.59,680.027,0],"to":[-1.491,0.186,0],"ti":[11.875,5,0]},{"t":137.000005580124,"s":[446.34,650.027,0]}],"ix":2,"l":2},"a":{"a":0,"k":[4.25,4.25,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[446,446,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":15,"s":[303,303,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":22,"s":[303,303,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":37,"s":[363,363,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":45,"s":[363,363,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":69,"s":[363,363,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":87,"s":[302,302,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":96,"s":[302,302,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":108,"s":[404,404,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":119,"s":[404,404,100]},{"t":137.000005580124,"s":[446,446,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-2.209],[2.209,0],[0,2.209],[-2.209,0]],"o":[[0,2.209],[-2.209,0],[0,-2.209],[2.209,0]],"v":[[4,0],[0,4],[-4,0],[0,-4]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[4.25,4.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Layer 1 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[502.09,790.517,0],"to":[-10.167,-4.167,0],"ti":[10.167,4.167,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":15,"s":[441.09,765.517,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":22,"s":[441.09,765.517,0],"to":[25.333,0.167,0],"ti":[-25.333,-0.167,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":37,"s":[593.09,766.517,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[593.09,766.517,0],"to":[-15.167,-14,0],"ti":[2.068,-0.5,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":57,"s":[502.09,682.517,0],"to":[-2.43,0.588,0],"ti":[2.068,-0.5,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":69,"s":[502.09,682.517,0],"to":[-2.43,0.588,0],"ti":[0.474,-0.772,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":87,"s":[425.09,823.517,0],"to":[-1.297,2.111,0],"ti":[0.474,-0.772,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":96,"s":[425.09,823.517,0],"to":[-1.297,2.111,0],"ti":[-1.677,0.601,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":108,"s":[576.09,825.517,0],"to":[1.123,-0.402,0],"ti":[-1.677,0.601,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":119,"s":[576.09,825.517,0],"to":[1.123,-0.402,0],"ti":[12.333,5.833,0]},{"t":137.000005580124,"s":[502.09,790.517,0]}],"ix":2,"l":2},"a":{"a":0,"k":[16.75,24.75,0],"ix":1,"l":2},"s":{"a":0,"k":[446,446,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[13.25,39.25],[20.25,39.25]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[9.112,0],[0,0],[0,9.112],[0,0],[-9.112,0],[0,-9.113],[0,0]],"o":[[0,0],[-9.112,0],[0,0],[0,-9.113],[9.112,0],[0,0],[0,9.112]],"v":[[0,24.5],[0,24.5],[-16.5,8],[-16.5,-8],[0,-24.5],[16.5,-8],[16.5,8]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.956862745098,0.913725490196,0.843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[16.75,24.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"Layer 8 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[499.86,788.287,0],"ix":2,"l":2},"a":{"a":0,"k":[58.75,66.75,0],"ix":1,"l":2},"s":{"a":0,"k":[371,446,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-30.869,0],[0,0],[0,-30.217]],"o":[[0,0],[0,0],[0,-30.869],[0,0],[30.218,0],[0,0]],"v":[[58.5,66.5],[-58.5,66.5],[-58.5,-10.607],[-2.607,-66.5],[3.786,-66.5],[58.5,-11.786]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.929411764706,0.549019607843,0.270588235294,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[58.75,66.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"Layer 7 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[381.653,545.928,0],"to":[6.667,3,0],"ti":[-6.667,-3,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":15,"s":[421.653,563.928,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":22,"s":[421.653,563.928,0],"to":[-6.667,-3,0],"ti":[6.667,3,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":37,"s":[381.653,545.928,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[381.653,545.928,0],"to":[6.667,3,0],"ti":[-6.667,-3,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":57,"s":[421.653,563.928,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":69,"s":[421.653,563.928,0],"to":[-3.667,2,0],"ti":[3.667,-2,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":87,"s":[399.653,575.928,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":96,"s":[399.653,575.928,0],"to":[0,-3,0],"ti":[0,3,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":108,"s":[399.653,557.928,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":119,"s":[399.653,557.928,0],"to":[-3,-2,0],"ti":[3,2,0]},{"t":137.000005580124,"s":[381.653,545.928,0]}],"ix":2,"l":2},"a":{"a":0,"k":[191.345,10.754,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[57.84,20.268],[-50.639,-22.545]],"o":[[-10.441,-10.032],[-45.944,-16.097],[50.633,22.543]],"v":[[95.548,-39.74],[-10.458,-36.045],[-44.908,29.598]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.929411824544,0.549019607843,0.270588235294,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[95.798,52.392],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"Layer 8 Outlines 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[630.903,553.108,0],"to":[1,0,0],"ti":[-1,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":15,"s":[636.903,553.108,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":22,"s":[636.903,553.108,0],"to":[-4.333,2,0],"ti":[4.333,-2,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":37,"s":[610.903,565.108,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[610.903,565.108,0],"to":[-4.667,3.333,0],"ti":[4.667,-3.333,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":57,"s":[582.903,585.108,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":69,"s":[582.903,585.108,0],"to":[8,-5.333,0],"ti":[-8,5.333,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":87,"s":[630.903,553.108,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":96,"s":[630.903,553.108,0],"to":[-5.333,4.667,0],"ti":[5.333,-4.667,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":108,"s":[598.903,581.108,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":119,"s":[598.903,581.108,0],"to":[5.333,-4.667,0],"ti":[-5.333,4.667,0]},{"t":137.000005580124,"s":[630.903,553.108,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0.25,8.257,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-58.326,18.822],[51.182,-21.277]],"o":[[10.687,-9.768],[46.326,-14.951],[-51.178,21.278]],"v":[[-94.935,-42.16],[10.945,-35.828],[43.753,30.649]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.929411824544,0.549019607843,0.270588235294,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[95.185,52.178],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"Layer 14 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[598.789,544.988,0],"to":[1.667,-1.333,0],"ti":[-1.667,1.333,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":15,"s":[608.789,536.988,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":22,"s":[608.789,536.988,0],"to":[-6.333,7.333,0],"ti":[6.333,-7.333,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":37,"s":[570.789,580.988,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[570.789,580.988,0],"to":[0.333,3.333,0],"ti":[-0.333,-3.333,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":57,"s":[572.789,600.988,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":69,"s":[572.789,600.988,0],"to":[4.333,-9.333,0],"ti":[-4.333,9.333,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":87,"s":[598.789,544.988,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":96,"s":[598.789,544.988,0],"to":[-3.333,9,0],"ti":[3.333,-9,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":108,"s":[578.789,598.988,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":119,"s":[578.789,598.988,0],"to":[3.333,-9,0],"ti":[-3.333,9,0]},{"t":137.000005580124,"s":[598.789,544.988,0]}],"ix":2,"l":2},"a":{"a":0,"k":[26.863,243.454,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-5.897,-6.836],[-9.818,8.474],[5.895,6.834],[9.821,-8.472]],"o":[[5.896,6.833],[9.818,-8.47],[-5.896,-6.837],[-9.818,8.471]],"v":[[-17.778,15.341],[10.679,12.377],[17.78,-15.337],[-10.679,-12.374]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.650980392157,0.363017302868,0.171041900036,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[390.293,95.516],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-34.878,-4.176],[-14.78,-15.153],[-26.61,33.351],[-43.44,-18.002],[-28.704,0.888],[-46.786,-49.231],[98.752,-16.238],[39.453,-62.836]],"o":[[0,0],[34.876,4.175],[14.779,15.154],[26.613,-33.352],[43.436,18.006],[28.701,-0.891],[46.792,49.233],[-98.746,16.236],[0,0]],"v":[[-233.613,118.369],[-179.925,14.253],[-148.023,57.062],[-70.384,33.079],[22.033,-75.549],[51.114,-6.575],[186.821,-85.973],[62.513,79.64],[-207.626,135.204]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.419607843137,0.207843137255,0.066666666667,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[233.863,135.454],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"Layer 1 Outlines 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[406.775,537.706,0],"to":[8.333,5.667,0],"ti":[-8.333,-5.667,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":15,"s":[456.775,571.706,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":22,"s":[456.775,571.706,0],"to":[-8.333,-5.667,0],"ti":[8.333,5.667,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":37,"s":[406.775,537.706,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[406.775,537.706,0],"to":[8.333,5.667,0],"ti":[-8.333,-5.667,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":57,"s":[456.775,571.706,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":69,"s":[456.775,571.706,0],"to":[-5,1.667,0],"ti":[5,-1.667,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":87,"s":[426.775,581.706,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":96,"s":[426.775,581.706,0],"to":[-0.333,-4.5,0],"ti":[0.333,4.5,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":108,"s":[424.775,554.706,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":119,"s":[424.775,554.706,0],"to":[-3,-2.833,0],"ti":[3,2.833,0]},{"t":137.000005580124,"s":[406.775,537.706,0]}],"ix":2,"l":2},"a":{"a":0,"k":[434.864,244.736,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[6.064,-6.687],[9.604,8.713],[-6.063,6.689],[-9.605,-8.713]],"o":[[-6.066,6.685],[-9.606,-8.714],[6.068,-6.685],[9.608,8.712]],"v":[[17.393,15.778],[-10.982,12.108],[-17.394,-15.778],[10.982,-12.107]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.650980392157,0.364705882353,0.172549019608,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[77.494,95.08],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[34.971,-3.307],[15.155,-14.781],[25.774,34.002],[43.872,-16.919],[28.671,1.604],[48,-48.054],[-98.317,-18.685],[-37.879,-63.798]],"o":[[0,0],[-34.969,3.308],[-15.151,14.78],[-25.775,-34.002],[-43.871,16.918],[-28.672,-1.604],[-48.002,48.054],[98.315,18.69],[0,0]],"v":[[231.614,123.305],[180.531,17.882],[147.574,59.887],[70.556,33.979],[-19.13,-76.912],[-49.917,-8.686],[-183.612,-91.433],[-63.459,77.219],[205.216,139.486]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.419607843137,0.207843137255,0.066666666667,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[231.864,139.737],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300.00001221925,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"reindeer","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[572,540,0],"ix":2,"l":2},"a":{"a":0,"k":[540,540,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":1080,"h":1080,"ip":0,"op":300.00001221925,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..0b9a27b2 --- /dev/null +++ b/NOTICE @@ -0,0 +1,7 @@ +Atoll (DynamicIsland) - NOTICE + +This product includes software derived from the boring.notch project, +modified and adapted for Atoll (DynamicIsland). The boring.notch project +is licensed under the GNU General Public License v3.0. + +See the LICENSE file for the full GPL v3 text. diff --git a/ReadMe.md b/ReadMe.md index 6fd8b063..51f5c7d7 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,225 +1,165 @@ -# DynamicIsland +

+ Atoll logo +

+

Atoll - DynamicIsland for macOS

+

+Ebullioscopic%2FAtoll | Trendshift +

+

+ + GitHub stars + + + GitHub forks + + + GitHub downloads + + + Discord server + +

- DynamicIsland Logo + + Sponsor Ebullioscopic + + + Download Atoll for macOS + + + Buy Me a Coffee for kryoscopic +

-> **DynamicIsland** enhances the notch on your MacBook M3 Pro with interactive, customizable widgets, transforming it into a dynamic control center for multitasking, notifications, and more. +

+ Join our Discord community +

-## Preview +Atoll turns the MacBook notch into a focused command surface for media, system insight, and quick utilities. It stays out of the way until needed, then expands with responsive, native SwiftUI animations. -![DynamicIsland Preview](https://github.com/Ebullioscopic/DynamicIsland/blob/main/dynamic-island-preview.jpeg) +

+ Atoll lock screen +

-## Demonstration -![DynamicIsland Demo](https://github.com/Ebullioscopic/DynamicIsland/blob/main/dynamic-island-demo.gif) -
-📑 Table of Contents -- [Features](#features) -- [Demo](#demo) -- [Getting Started](#getting-started) - - [Prerequisites](#prerequisites) - - [Installation](#installation) -- [Usage](#usage) - - [Basic Controls](#basic-controls) - - [Customization Options](#customization-options) -- [Configuration](#configuration) -- [Roadmap](#roadmap) -- [Troubleshooting](#troubleshooting) -- [Contributing](#contributing) -- [License](#license) -- [Acknowledgments](#acknowledgments) -- [Contributors](#contributors) -
+## Highlights +- Media controls for Apple Music, Spotify, and more with inline previews. +- Live Activities for media playback, Focus, screen recording, privacy indicators, downloads (beta), and battery/charging. +- Lock screen widgets for media, timers, charging, Bluetooth devices, and weather. +- Lightweight system insight for CPU, GPU, memory, network, and disk usage. +- Productivity tools including timers, clipboard history, color picker, and calendar previews. +- Customization for layouts, animations, hover behavior, and shortcut remapping. ---- +## Other Features +- Gesture controls for opening/closing the notch and media navigation. +- Parallax hover interactions with smooth transitions. +- Lock screen appearance and positioning controls for panels and widgets. -## 🌟 Features +

+ Atoll preview +

-- **Music Controls**: Play, pause, and skip tracks from the notch area. -- **Battery Status**: Real-time battery percentage and low-battery alerts. -- **Calendar Reminders**: Displays upcoming events directly in the notch. -- **Weather Updates**: Get current weather conditions at a glance. -- **Multitasking Tools**: Quickly switch between open applications. -- **Customizable Themes**: Light, dark, and custom color themes. -- **Gesture Support**: Use gestures for quick actions. +## Requirements +- macOS 14.0 or later (optimised for macOS 15+). +- MacBook with a notch (14/16‑inch MBP across Apple silicon generations). +- Xcode 15+ to build from source. +- Permissions as needed: Accessibility, Camera, Calendar, Screen Recording, Music. -## 🎥 Demo +## Installation +1) Download the latest DMG [here](https://github.com/Ebullioscopic/Atoll/releases/latest). +2) Open the DMG and drag Atoll into Applications. +3) Launch Atoll and grant the requested permissions. -Check out a quick demo of DynamicIsland in action: +## Quick Start +- Hover near the notch to expand; click to enter controls. +- Use tabs for Media, Stats, Timers, Clipboard, and more. +- Adjust layout, appearance, and shortcuts from Settings. +- Add files to Shelf from Terminal: `open -a Atoll /path/to/file`. -![DynamicIsland Demo](https://github.com/Ebullioscopic/DynamicIsland/raw/main/demo.gif) +## Settings +- Choose appearance, animation style, and per‑feature toggles. +- Remap global shortcuts and adjust hover behaviour. +- Enable lock screen widgets and select data sources. ---- +## Gesture Controls +- Two-finger swipe down to open the notch when hover-to-open is disabled; swipe up to close. +- Enable horizontal media gestures in **Settings → General → Gesture control** to turn the music pane into a trackpad for previous/next or ±10 second seeks. +- Pick the gesture skip behaviour (track vs ±10s) independently from the skip button configuration so swipes can scrub while buttons change tracks—or vice versa. +- Horizontal swipes trigger the same haptics and button animations you see in the notch, keeping visual feedback consistent with tap interactions. -## 🚀 Getting Started +## Troubleshooting (Basics) +- After granting Accessibility or Screen Recording, quit and relaunch the app. +- If metrics are empty, enable categories in Settings → Stats. +- Media not responding: verify player is active and Music permission is granted. -These instructions will get you a copy of **DynamicIsland** up and running on your local machine. +## License +Atoll is released under the GPL v3 License. Refer to [LICENSE](LICENSE) for the full terms. -### Prerequisites +## Acknowledgments -- **macOS Ventura 14.2** or later. -- **Xcode 15.0** or later. -- **SwiftUI** and **Objective-C** frameworks installed. -- **Make** installed for build automation. +Atoll builds upon the work of several open-source projects and draws inspiration from innovative macOS applications: -### Installation +- [**Boring.Notch**](https://github.com/TheBoredTeam/boring.notch) - foundational codebase that provided the initial media player integration, AirDrop surface implementation, file dock functionality, and calendar event display. Major architectural patterns and notch interaction models were adapted from this project. -1. **Clone the Repository** - ```bash - git clone https://github.com/Ebullioscopic/DynamicIsland.git - cd DynamicIsland - ``` +- [**Alcove**](https://tryalcove.com) - primary inspiration for the Minimalistic Mode interface design and the conceptual framework for lock screen widget integration that informed Atoll's compact layout strategy. -2. **Install Dependencies** - Make sure you have all required libraries and frameworks installed. Use Homebrew for some dependencies if needed: - ```bash - brew install make - ``` +- [**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. -3. **Build and Run the Project** - Open the project in Xcode, select your target device, and run: - ```bash - open DynamicIsland.xcodeproj - ``` +- [**Open Meteo**](https://open-meteo.com) - weather apis for the lock screen widgets -4. **Run the App in Xcode** - Build and run the app from Xcode by pressing `Cmd + R`: - - If prompted by macOS, allow permissions for **Privacy & Security**. +- [**SkyLightWindow**](https://github.com/Lakr233/SkyLightWindow) - window rendering for Lock Screen Widgets ---- +- [**rtaudio**](https://github.com/ZephyrCodesStuff/rtaudio) - Live music visualizer using C++ was adapted from this project -## 📖 Usage +- [**SwiftTerm**](https://github.com/migueldeicaza/SwiftTerm) - Terminal tab implementation in the standard mode was adapted from this project -Once **DynamicIsland** is installed and running, it will display widgets and controls in the MacBook's notch area. Here are some usage tips and customization options. +- [**DynamicNotch**](https://github.com/jackson-storm/DynamicNotch) - thanks DynamicNotch for letting us use their battery huds -### Basic Controls +- Wick - Thanks Nate for allowing us to replicate the iOS like Timer design for the Lock Screen Widget -- **Music Controls**: Hover over the notch and use on-screen buttons to control playback. -- **Battery Status**: A battery indicator will be displayed in the notch. Low battery alerts will appear automatically. -- **Weather Widget**: Displays current weather for your location (requires location access). -- **App Switcher**: Swipe gestures allow you to quickly switch between open applications. +## Contributors -### Customization Options + + + -To personalize your DynamicIsland experience: +## Star History -1. **Open Preferences**: Access the Preferences pane by long-pressing on the notch area. -2. **Themes**: Choose between **light**, **dark**, or **system adaptive** themes. -3. **Widget Settings**: Enable/disable specific widgets, adjust sizes, and set display preferences. -4. **Gestures**: Customize swipe, tap, and long-press actions for app switching and widget controls. +[![Star History Chart](https://api.star-history.com/svg?repos=Ebullioscopic/Atoll&type=timeline&legend=top-left)](https://www.star-history.com/#Ebullioscopic/Atoll&type=timeline&legend=top-left) ---- +## Updating Existing Clones +If you previously cloned DynamicIsland, update the remote to track the Atoll repository: -## ⚙️ Configuration - -Configuration settings can be found in the `config.json` file located in the root directory. - -### `config.json` Example - -```json -{ - "theme": "dark", - "batteryWidget": true, - "musicWidget": true, - "weatherWidget": true, - "gestureSettings": { - "tap": "toggleMusic", - "swipeLeft": "previousApp", - "swipeRight": "nextApp", - "longPress": "openSettings" - } -} +```bash +git remote set-url origin https://github.com/Ebullioscopic/Atoll.git ``` -- **`theme`**: Options are `"light"`, `"dark"`, or `"auto"`. -- **`batteryWidget`, `musicWidget`, `weatherWidget`**: Toggle visibility for each widget. -- **`gestureSettings`**: Assign actions to tap, swipe, and long-press gestures. - ---- - -## 🚧 Roadmap - -Planned updates for future releases: - -- [ ] **Advanced Calendar Integration**: Add support for weekly and monthly views. -- [ ] **Customizable Shortcuts**: Create custom app shortcuts. -- [ ] **Additional Widget Support**: Add widgets for finance, fitness, and more. -- [ ] **Voice Commands**: Enable Siri shortcuts for quick commands. -- [ ] **Animation Improvements**: Smooth animations for a more polished UI. - ---- - -## 🔧 Troubleshooting - -### Common Issues - -- **Permissions**: Go to **System Preferences > Security & Privacy** to manually enable permissions if the app fails to start. -- **Widgets Not Displaying**: Ensure your `config.json` file has valid settings, then restart the app. -- **Build Errors in Xcode**: Run `make clean` to reset the project, then re-build. - -### Useful Commands - -- **Clean Build Files**: - ```bash - make clean - ``` -- **Reset Configurations**: - ```bash - rm config.json && cp config.default.json config.json - ``` - ---- - -## 🤝 Contributing - -We welcome contributions from the community! Follow the steps below to contribute: - -1. **Fork the repository**. -2. **Clone Your Fork**: - ```bash - git clone https://github.com/yourusername/DynamicIsland.git - cd DynamicIsland - ``` -3. **Create a New Branch**: - ```bash - git checkout -b feature/YourFeatureName - ``` -4. **Make Your Changes** and **Commit**: - ```bash - git commit -m "Added new feature" - ``` -5. **Push to Your Fork**: - ```bash - git push origin feature/YourFeatureName - ``` -6. **Create a Pull Request**: Head to the original repository and open a pull request. - -For major changes, please open an issue first to discuss what you’d like to change. - ---- - -## 📜 License - -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +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 --- -## 🏆 Acknowledgments - -Special thanks to: -- **Apple Design Principles** for inspiration. -- **Boring Notch** project for initial ideas. - ---- +

+ iOS Development Centre exterior +
+ Backed by +
+ iOS Development Centre +
+ Powered by Apple and Infosys +
+ SRM Institute of Science and Technology, Chennai, India +

-## 👥 Contributors +

+ + Buy Me A Coffee + +

- - - - -

Ebullioscopic

Creator
\ No newline at end of file +

+ Your support helps fund teaching children software development. +

diff --git a/TRADEMARKS b/TRADEMARKS new file mode 100644 index 00000000..44d26dcd --- /dev/null +++ b/TRADEMARKS @@ -0,0 +1,9 @@ +Atoll (DynamicIsland) - Trademarks + +Atoll and DynamicIsland are trademarks of their respective owners. +DynamicIsland is not associated with, endorsed by, or affiliated with Apple +or Apple's "Dynamic Island" product. There is no intent to cause confusion +or infringement; the product is explicitly marketed as Atoll. +All other trademarks, service marks, and logos appearing in this project +are the property of their respective owners and used for identification +purposes only. diff --git a/Updates/appcast.xml b/Updates/appcast.xml new file mode 100644 index 00000000..1a5ce008 --- /dev/null +++ b/Updates/appcast.xml @@ -0,0 +1,66 @@ + + + + Atoll + + 2.2.0 + Sat, 30 May 2026 00:15:41 +0530 + 12 + 2.2.0 + 14.6 + + + + + 2.1.0 + Sat, 21 Mar 2026 23:51:32 +0530 + 11 + 2.1.0 + 14.6 + + + + + + + 2.0.1 + Sun, 08 Mar 2026 18:49:08 +0530 + 8 + 2.0.1 + 14.6 + + + + + + + + + + \ No newline at end of file diff --git a/dynamic-island-demo.gif b/dynamic-island-demo.gif deleted file mode 100644 index 187b6551..00000000 Binary files a/dynamic-island-demo.gif and /dev/null differ diff --git a/dynamic-island-preview.jpeg b/dynamic-island-preview.jpeg deleted file mode 100644 index d38912fd..00000000 Binary files a/dynamic-island-preview.jpeg and /dev/null differ diff --git a/dynamic-island.jpeg b/dynamic-island.jpeg deleted file mode 100644 index c7940e72..00000000 Binary files a/dynamic-island.jpeg and /dev/null differ diff --git a/mediaremote-adapter/MediaRemoteAdapter.framework/MediaRemoteAdapter b/mediaremote-adapter/MediaRemoteAdapter.framework/MediaRemoteAdapter new file mode 120000 index 00000000..65b3513b --- /dev/null +++ b/mediaremote-adapter/MediaRemoteAdapter.framework/MediaRemoteAdapter @@ -0,0 +1 @@ +Versions/Current/MediaRemoteAdapter \ No newline at end of file diff --git a/mediaremote-adapter/MediaRemoteAdapter.framework/Resources b/mediaremote-adapter/MediaRemoteAdapter.framework/Resources new file mode 120000 index 00000000..953ee36f --- /dev/null +++ b/mediaremote-adapter/MediaRemoteAdapter.framework/Resources @@ -0,0 +1 @@ +Versions/Current/Resources \ No newline at end of file diff --git a/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter new file mode 100755 index 00000000..05db77de Binary files /dev/null and b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter differ diff --git a/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/Resources/Info.plist b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/Resources/Info.plist new file mode 100644 index 00000000..bf2b7b11 --- /dev/null +++ b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/Resources/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + MediaRemoteAdapter + CFBundleIconFile + + CFBundleIdentifier + com.vandenbe.MediaRemoteAdapter + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + MediaRemoteAdapter + CFBundlePackageType + FMWK + CFBundleSignature + ???? + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1 + CSResourcesFileMapped + + + diff --git a/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/_CodeSignature/CodeResources b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/_CodeSignature/CodeResources new file mode 100644 index 00000000..16ea63b3 --- /dev/null +++ b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/_CodeSignature/CodeResources @@ -0,0 +1,128 @@ + + + + + files + + Resources/Info.plist + + M6AF1VWVJ1A/DSliCSjg170FqsY= + + + files2 + + Resources/Info.plist + + hash2 + + z3yWmTAqjdrPJEZUQ+t6AVPhw0e/I8PAiVr0HIU2ivg= + + + + rules + + ^Resources/ + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ + + nested + + weight + 10 + + ^.* + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^Resources/ + + weight + 20 + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^[^/]+$ + + nested + + weight + 10 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + + diff --git a/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/Current b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/Current new file mode 120000 index 00000000..8c7e5a66 --- /dev/null +++ b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/Current @@ -0,0 +1 @@ +A \ No newline at end of file diff --git a/mediaremote-adapter/mediaremote-adapter.pl b/mediaremote-adapter/mediaremote-adapter.pl new file mode 100755 index 00000000..29566e66 --- /dev/null +++ b/mediaremote-adapter/mediaremote-adapter.pl @@ -0,0 +1,257 @@ +#!/usr/bin/perl +# Copyright (c) 2025 Jonas van den Berg +# This file is licensed under the BSD 3-Clause License. + +# For usage information read below or run the script without arguments. + +use strict; +use warnings; +use DynaLoader; +use File::Spec; +use File::Basename; + +sub print_help() { + print <<'HELP'; +Usage: + mediaremote-adapter.pl FRAMEWORK_PATH [NOWPLAYING_CLIENT_PATH] [FUNCTION [PARAMS|OPTIONS...]] + +FRAMEWORK_PATH: + Absolute path to MediaRemoteAdapter.framework + +NOWPLAYING_CLIENT_PATH (optional): + Path to the NowPlayingTestClient executable (used for test mode) + +FUNCTION: + stream Streams now playing information (as diff by default) + get Prints now playing information once with all available metadata + send Sends a command to the now playing application + seek Seeks to a specific timeline position + shuffle Sets the shuffle mode + repeat Sets the repeat mode + speed Sets the playback speed + +PARAMS: + send(command) + command: The MRCommand ID as a number (e.g. kMRPlay = 0) + seek(position) + position: The timeline position in microseconds + shuffle(mode) + mode: The shuffle mode + repeat(mode) + mode: The repeat mode + speed(speed) + speed: The playback speed + +OPTIONS: + get + --now: Sets "elapsedTime" to the current elapsed time. By default, + this value is the elapsed time at the time of the given "timestamp" + stream + --no-diff: Disable diffing and always dump all metadata + --debounce=N: Delay in milliseconds to prevent spam (0 by default) + get, stream + --micros: Replaces the following time keys with microsecond equivalents + "duration" -> "durationMicros" + "elapsedTime" -> "elapsedTimeMicros" + "timestamp" -> "timestampEpochMicros" (converted to epoch time) + --human-readable, -h: Makes values human-readable. Use only for debugging. + The JSON output is pretty-printed and the following keys are adapted: + "artworkData" -> Binary data is truncated to a shorter representation + +Examples (script name and framework path omitted): + stream --no-diff --debounce=100 + send 2 # Toggles play/pause in the media player (kMRATogglePlayPause) + repeat 3 # Sets the repeat mode to "playlist" (kMRARepeatModePlaylist) + +HELP + exit 0; +} + +if (!defined $ARGV[1]) { + print_help(); +} + +sub fail { + my ($error) = @_; + print STDERR "$error\n"; + exit 1; +} + +fail "Framework path not provided" unless @ARGV >= 1; + +my $framework_path = shift @ARGV; + +# Optionally accept NOWPLAYING_CLIENT path as second argument +my $maybe_helper_path = $ARGV[0] // ''; +if ($maybe_helper_path =~ m{NowPlayingTestClient} || $maybe_helper_path =~ m{/}) { + my $helper_path = shift @ARGV; + $ENV{NOWPLAYING_CLIENT} = $helper_path; +} + +my $framework_basename = File::Basename::basename($framework_path); +fail "Provided path is not a framework: $framework_path" + unless $framework_basename =~ s/\.framework$//; + +my $framework = File::Spec->catfile($framework_path, $framework_basename); +fail "Framework not found at $framework" unless -e $framework; + +my $handle = DynaLoader::dl_load_file($framework, 0) + or fail "Failed to load framework: $framework"; +my $function_name = shift @ARGV or fail "Missing function name"; +fail "Invalid function name: '$function_name'" + unless $function_name eq "stream" + || $function_name eq "get" + || $function_name eq "send" + || $function_name eq "seek" + || $function_name eq "shuffle" + || $function_name eq "repeat" + || $function_name eq "speed" + || $function_name eq "test"; + +sub parse_options { + my ($start_index) = @_; + my %arg_map; + my $i = $start_index; + while ($i <= $#ARGV) { + my $arg = $ARGV[$i]; + if ($arg =~ /^--([a-z\\-]+)(?:=(.*))?$/) { + my $key = $1; + my $value = defined $2 ? $2 : undef; + $arg_map{$key} = $value; + splice @ARGV, $i, 1; + } + elsif ($arg =~ /^-([a-zA-Z]+)$/) { + my @flags = split //, $1; + $arg_map{$_} = undef for @flags; + splice @ARGV, $i, 1; + } + else { + $i++; + } + } + return \%arg_map; +} + +sub env_func { + my $symbol_name = shift; + return "${symbol_name}_env"; +} + +sub set_env_param { + my ($func, $index, $name, $value) = @_; + $ENV{"MEDIAREMOTEADAPTER_PARAM_${func}_${index}_${name}"} = "$value"; +} + +sub set_env_option_unsafe { + my ($name, $value) = @_; + $name =~ s/-/_/g; + $ENV{"MEDIAREMOTEADAPTER_OPTION_${name}"} = defined $value ? "$value" : ""; +} + +sub set_env_option { + my ($options, $key) = @_; + my $value = $options->{$key}; + if (defined $value) { + fail "Unexpected value for option '$key'"; + } + set_env_option_unsafe($key, $value); +} + +sub set_env_option_value { + my ($options, $key) = @_; + my $value = $options->{$key}; + if (!defined $value) { + fail "Missing value for option '$key'"; + } + set_env_option_unsafe($key, $value); +} + +my $symbol_name = "adapter_$function_name"; +if ($function_name eq "send") { + my $id = shift @ARGV; + fail "Missing ID for '$function_name' command" unless defined $id; + set_env_param($symbol_name, 0, "command", "$id"); + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "stream") { + my $options = parse_options(0); + foreach my $key (keys %{$options}) { + if ($key eq "no-diff") { + set_env_option($options, $key); + } + elsif ($key eq "debounce") { + set_env_option_value($options, $key); + } + elsif ($key eq "micros") { + set_env_option($options, $key); + } + elsif ($key eq "human-readable" || $key eq "h") { + set_env_option($options, "human-readable"); + } + else { + fail "Unrecognized option '$key'"; + } + } + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "get") { + my $options = parse_options(0); + foreach my $key (keys %{$options}) { + if ($key eq "micros") { + set_env_option($options, $key); + } + elsif ($key eq "human-readable" || $key eq "h") { + set_env_option($options, "human-readable"); + } + elsif ($key eq "now") { + set_env_option($options, $key); + } + else { + fail "Unrecognized option '$key'"; + } + } + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "seek") { + my $position = shift @ARGV; + fail "Missing position for '$function_name' command" unless defined $position; + set_env_param($symbol_name, 0, "position", "$position"); + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "shuffle") { + my $mode = shift @ARGV; + fail "Missing mode for '$function_name' command" unless defined $mode; + set_env_param($symbol_name, 0, "mode", "$mode"); + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "repeat") { + my $mode = shift @ARGV; + fail "Missing mode for '$function_name' command" unless defined $mode; + set_env_param($symbol_name, 0, "mode", "$mode"); + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "speed") { + my $speed = shift @ARGV; + fail "Missing speed for '$function_name' command" unless defined $speed; + set_env_param($symbol_name, 0, "speed", "$speed"); + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "test") { + $symbol_name = "_adapter_is_it_broken_yet"; +} + +if (defined shift @ARGV) { + fail "Too many arguments"; +} + +my $symbol = DynaLoader::dl_find_symbol($handle, "$symbol_name") + or fail "Symbol '$symbol_name' not found in $framework"; +DynaLoader::dl_install_xsub("main::$function_name", $symbol); + +eval { + no strict "refs"; + &{"main::$function_name"}(); +}; +if ($@) { + fail "Error executing $function_name: $@"; +}