From 094745122e72cda804c69bf1125530597065186c Mon Sep 17 00:00:00 2001 From: lincube Date: Tue, 3 Mar 2026 18:26:29 +0800 Subject: [PATCH] 0.2.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 媒体播放组件,录音组件 --- .../.github/workflows/windows-ci.yml | 294 ++++---- .../Assets/Weather/HyperOS3/ATTRIBUTION.md | 14 + .../Weather/HyperOS3/Icons/icon_cloudy.webp | Bin 0 -> 422 bytes .../Weather/HyperOS3/Icons/icon_haze.webp | Bin 0 -> 3794 bytes .../HyperOS3/Icons/icon_moon_clear.webp | Bin 0 -> 2516 bytes .../Icons/icon_partly_cloudy_day.webp | Bin 0 -> 766 bytes .../Icons/icon_partly_cloudy_night.webp | Bin 0 -> 2958 bytes .../HyperOS3/Icons/icon_rain_heavy.webp | Bin 0 -> 734 bytes .../HyperOS3/Icons/icon_rain_light.webp | Bin 0 -> 618 bytes .../Weather/HyperOS3/Icons/icon_sleet.webp | Bin 0 -> 754 bytes .../Weather/HyperOS3/Icons/icon_snow.webp | Bin 0 -> 4848 bytes .../HyperOS3/Icons/icon_sunny_day.webp | Bin 0 -> 656 bytes .../Weather/HyperOS3/Icons/icon_thunder.webp | Bin 0 -> 660 bytes .../Weather/HyperOS3/Icons/icon_windy.webp | Bin 0 -> 3862 bytes .../ComponentSystem/BuiltInComponentIds.cs | 2 + .../ComponentSystem/ComponentRegistry.cs | 18 + LanMontainDesktop/LanMontainDesktop.csproj | 11 +- LanMontainDesktop/Localization/en-US.json | 22 + LanMontainDesktop/Localization/zh-CN.json | 22 + LanMontainDesktop/PACKAGING.md | 10 +- .../Services/IAudioRecorderService.cs | 643 ++++++++++++++++++ .../Services/IMusicControlService.cs | 121 ++++ .../WindowsSmtcMusicControlService.cs | 579 ++++++++++++++++ .../DesktopComponentRuntimeRegistry.cs | 10 + .../Components/ExtendedWeatherWidget.axaml | 465 ++++++++++++- .../Components/ExtendedWeatherWidget.axaml.cs | 501 +++++++++++++- .../Components/HourlyWeatherWidget.axaml | 427 ++++++------ .../Components/HourlyWeatherWidget.axaml.cs | 235 +++---- .../Components/HyperOS3WeatherAssetLoader.cs | 34 + .../Views/Components/HyperOS3WeatherTheme.cs | 174 +++-- .../Components/MultiDayWeatherWidget.axaml | 399 +++++------ .../Components/MultiDayWeatherWidget.axaml.cs | 280 ++++---- .../Views/Components/MusicControlWidget.axaml | 255 +++++++ .../Components/MusicControlWidget.axaml.cs | 407 +++++++++++ .../Views/Components/RecordingWidget.axaml | 170 +++++ .../Views/Components/RecordingWidget.axaml.cs | 338 +++++++++ .../Views/Components/WeatherClockWidget.axaml | 12 +- .../Components/WeatherClockWidget.axaml.cs | 23 +- .../Views/Components/WeatherWidget.axaml | 129 ++-- .../Views/Components/WeatherWidget.axaml.cs | 122 ++-- .../Views/MainWindow.ComponentSystem.cs | 10 + LanMontainDesktop/scripts/package.ps1 | 27 +- 42 files changed, 4661 insertions(+), 1093 deletions(-) create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_cloudy.webp create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_moon_clear.webp create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sleet.webp create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sunny_day.webp create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp create mode 100644 LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_windy.webp create mode 100644 LanMontainDesktop/Services/IAudioRecorderService.cs create mode 100644 LanMontainDesktop/Services/IMusicControlService.cs create mode 100644 LanMontainDesktop/Services/WindowsSmtcMusicControlService.cs create mode 100644 LanMontainDesktop/Views/Components/HyperOS3WeatherAssetLoader.cs create mode 100644 LanMontainDesktop/Views/Components/MusicControlWidget.axaml create mode 100644 LanMontainDesktop/Views/Components/MusicControlWidget.axaml.cs create mode 100644 LanMontainDesktop/Views/Components/RecordingWidget.axaml create mode 100644 LanMontainDesktop/Views/Components/RecordingWidget.axaml.cs diff --git a/LanMontainDesktop/.github/workflows/windows-ci.yml b/LanMontainDesktop/.github/workflows/windows-ci.yml index be8490b..95098d7 100644 --- a/LanMontainDesktop/.github/workflows/windows-ci.yml +++ b/LanMontainDesktop/.github/workflows/windows-ci.yml @@ -14,10 +14,19 @@ on: required: false type: string +concurrency: + group: desktop-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }} + +env: + DOTNET_VERSION: "10.0.x" + PROJECT_PATH: "LanMontainDesktop.csproj" + jobs: validate: name: Validate Build (Windows) runs-on: windows-latest + timeout-minutes: 20 permissions: contents: read steps: @@ -27,22 +36,98 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: "10.0.x" + dotnet-version: ${{ env.DOTNET_VERSION }} cache: true + cache-dependency-path: | + **/*.csproj - name: Restore - run: dotnet restore .\LanMontainDesktop.csproj + run: dotnet restore .\${{ env.PROJECT_PATH }} - name: Build - run: dotnet build .\LanMontainDesktop.csproj -c Release --no-restore + run: dotnet build .\${{ env.PROJECT_PATH }} -c Release --no-restore - package_windows: - name: Package Windows - runs-on: windows-latest - needs: validate + - name: Test (if test projects exist) + shell: pwsh + run: | + $testProjects = @(Get-ChildItem -Path . -Recurse -Filter *.csproj | Where-Object { + Select-String -Path $_.FullName -Pattern '\s*true\s*|Microsoft.NET.Test.Sdk' -Quiet + }) + + if ($testProjects.Count -eq 0) { + Write-Host "No test projects found. Skipping dotnet test." + exit 0 + } + + foreach ($project in $testProjects) { + Write-Host "Running tests in $($project.FullName)" + dotnet test $project.FullName -c Release --verbosity normal + } + + resolve_version: + name: Resolve Package Version + runs-on: ubuntu-latest if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') + outputs: + value: ${{ steps.version.outputs.value }} permissions: - contents: write + contents: read + steps: + - name: Resolve version + id: version + shell: pwsh + run: | + $manualVersion = '${{ github.event.inputs.version }}' + if ($manualVersion) { + $version = $manualVersion.Trim() + } elseif ($env:GITHUB_REF -like "refs/tags/v*") { + $version = $env:GITHUB_REF_NAME.Substring(1) + } elseif ($env:GITHUB_REF -like "refs/tags/*") { + $version = $env:GITHUB_REF_NAME + } else { + $version = "0.0.$env:GITHUB_RUN_NUMBER" + } + + if (-not $version) { + throw "Failed to resolve package version." + } + + if ($version -notmatch '^\d+\.\d+\.\d+([\-+][0-9A-Za-z\.-]+)?$') { + throw "Invalid version format: $version" + } + + "value=$version" >> $env:GITHUB_OUTPUT + Write-Host "Using package version: $version" + + package: + name: Package (${{ matrix.name }}) + needs: + - validate + - resolve_version + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - name: Windows + runner: windows-latest + rid: win-x64 + artifact_name: LanMontainDesktop-Setup + artifact_path: artifacts/installer/*.exe + - name: Linux + runner: ubuntu-latest + rid: linux-x64 + artifact_name: LanMontainDesktop-linux-x64 + artifact_path: artifacts/packages/*linux-x64*.zip + - name: macOS + runner: macos-latest + rid: osx-x64 + artifact_name: LanMontainDesktop-osx-x64 + artifact_path: artifacts/packages/*osx-x64*.zip + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@v4 @@ -50,12 +135,20 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: "10.0.x" + dotnet-version: ${{ env.DOTNET_VERSION }} cache: true + cache-dependency-path: | + **/*.csproj - name: Install Inno Setup + if: matrix.rid == 'win-x64' shell: pwsh run: | + if (Get-Command iscc.exe -ErrorAction SilentlyContinue) { + Write-Host "Inno Setup is already installed." + exit 0 + } + if (Get-Command choco -ErrorAction SilentlyContinue) { choco install innosetup --yes --no-progress } elseif (Get-Command winget -ErrorAction SilentlyContinue) { @@ -64,160 +157,61 @@ jobs: throw "Neither choco nor winget is available to install Inno Setup." } - - name: Resolve Package Version - id: version + - name: Build Package shell: pwsh run: | - $manualVersion = '${{ github.event.inputs.version }}' - if ($manualVersion) { - $version = $manualVersion.Trim() - } elseif ($env:GITHUB_REF -like "refs/tags/v*") { - $version = $env:GITHUB_REF_NAME.Substring(1) - } elseif ($env:GITHUB_REF -like "refs/tags/*") { - $version = $env:GITHUB_REF_NAME - } else { - $version = "0.0.$env:GITHUB_RUN_NUMBER" - } - - if (-not $version) { - throw "Failed to resolve package version." - } - - "value=$version" >> $env:GITHUB_OUTPUT - Write-Host "Using package version: $version" - - - name: Build Windows Installer - shell: pwsh - run: | - .\scripts\package.ps1 ` + ./scripts/package.ps1 ` -Configuration Release ` - -RuntimeIdentifier win-x64 ` - -Version "${{ steps.version.outputs.value }}" + -RuntimeIdentifier ${{ matrix.rid }} ` + -Version "${{ needs.resolve_version.outputs.value }}" - - name: Upload Windows Installer Artifact + - name: Upload Package Artifact uses: actions/upload-artifact@v4 with: - name: LanMontainDesktop-Setup-${{ steps.version.outputs.value }} - path: artifacts/installer/*.exe + name: ${{ matrix.artifact_name }}-${{ needs.resolve_version.outputs.value }} + path: ${{ matrix.artifact_path }} if-no-files-found: error - name: Upload Windows Publish Artifact + if: matrix.rid == 'win-x64' uses: actions/upload-artifact@v4 with: - name: LanMontainDesktop-Publish-win-x64-${{ steps.version.outputs.value }} + name: LanMontainDesktop-Publish-win-x64-${{ needs.resolve_version.outputs.value }} path: artifacts/publish/win-x64/** if-no-files-found: error - - name: Attach Windows Installer to GitHub Release - if: startsWith(github.ref, 'refs/tags/v') + publish_release_assets: + name: Attach Artifacts to GitHub Release + runs-on: ubuntu-latest + needs: + - package + - resolve_version + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + steps: + - name: Download Windows Installer Artifact + uses: actions/download-artifact@v4 + with: + name: LanMontainDesktop-Setup-${{ needs.resolve_version.outputs.value }} + path: release-assets/windows + + - name: Download Linux Package Artifact + uses: actions/download-artifact@v4 + with: + name: LanMontainDesktop-linux-x64-${{ needs.resolve_version.outputs.value }} + path: release-assets/linux + + - name: Download macOS Package Artifact + uses: actions/download-artifact@v4 + with: + name: LanMontainDesktop-osx-x64-${{ needs.resolve_version.outputs.value }} + path: release-assets/macos + + - name: Attach Artifacts uses: softprops/action-gh-release@v2 with: - files: artifacts/installer/*.exe - - package_linux: - name: Package Linux - runs-on: ubuntu-latest - needs: validate - if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "10.0.x" - cache: true - - - name: Resolve Package Version - id: version - shell: pwsh - run: | - $manualVersion = '${{ github.event.inputs.version }}' - if ($manualVersion) { - $version = $manualVersion.Trim() - } elseif ($env:GITHUB_REF -like "refs/tags/v*") { - $version = $env:GITHUB_REF_NAME.Substring(1) - } elseif ($env:GITHUB_REF -like "refs/tags/*") { - $version = $env:GITHUB_REF_NAME - } else { - $version = "0.0.$env:GITHUB_RUN_NUMBER" - } - - if (-not $version) { - throw "Failed to resolve package version." - } - - "value=$version" >> $env:GITHUB_OUTPUT - Write-Host "Using package version: $version" - - - name: Build Linux Package - shell: pwsh - run: | - ./scripts/package.ps1 ` - -Configuration Release ` - -RuntimeIdentifier linux-x64 ` - -Version "${{ steps.version.outputs.value }}" - - - name: Upload Linux Package Artifact - uses: actions/upload-artifact@v4 - with: - name: LanMontainDesktop-linux-x64-${{ steps.version.outputs.value }} - path: artifacts/packages/*linux-x64*.zip - if-no-files-found: error - - package_macos: - name: Package macOS - runs-on: macos-latest - needs: validate - if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "10.0.x" - cache: true - - - name: Resolve Package Version - id: version - shell: pwsh - run: | - $manualVersion = '${{ github.event.inputs.version }}' - if ($manualVersion) { - $version = $manualVersion.Trim() - } elseif ($env:GITHUB_REF -like "refs/tags/v*") { - $version = $env:GITHUB_REF_NAME.Substring(1) - } elseif ($env:GITHUB_REF -like "refs/tags/*") { - $version = $env:GITHUB_REF_NAME - } else { - $version = "0.0.$env:GITHUB_RUN_NUMBER" - } - - if (-not $version) { - throw "Failed to resolve package version." - } - - "value=$version" >> $env:GITHUB_OUTPUT - Write-Host "Using package version: $version" - - - name: Build macOS Package - shell: pwsh - run: | - ./scripts/package.ps1 ` - -Configuration Release ` - -RuntimeIdentifier osx-x64 ` - -Version "${{ steps.version.outputs.value }}" - - - name: Upload macOS Package Artifact - uses: actions/upload-artifact@v4 - with: - name: LanMontainDesktop-osx-x64-${{ steps.version.outputs.value }} - path: artifacts/packages/*osx-x64*.zip - if-no-files-found: error + files: | + release-assets/windows/*.exe + release-assets/linux/*.zip + release-assets/macos/*.zip diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md b/LanMontainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md index 7ea96e7..9f9d09a 100644 --- a/LanMontainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md +++ b/LanMontainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md @@ -17,4 +17,18 @@ Extracted source paths inside APK: - `assets/map_assets/VM3DRes/cross_sky_day.png` -> `hyper_cross_sky_day.png` - `assets/map_assets/VM3DRes/cross_sky_night.png` -> `hyper_cross_sky_night.png` +Extracted weather icon paths inside APK (`res/*.webp`): +- `res/aO.webp` -> `Icons/icon_sunny_day.webp` +- `res/k2.webp` -> `Icons/icon_moon_clear.webp` +- `res/Ip.webp` -> `Icons/icon_partly_cloudy_day.webp` +- `res/HI.webp` -> `Icons/icon_partly_cloudy_night.webp` +- `res/E4.webp` -> `Icons/icon_cloudy.webp` +- `res/5f.webp` -> `Icons/icon_rain_light.webp` +- `res/fO.webp` -> `Icons/icon_rain_heavy.webp` +- `res/lV1.webp` -> `Icons/icon_thunder.webp` +- `res/mH1.webp` -> `Icons/icon_snow.webp` +- `res/jB.webp` -> `Icons/icon_sleet.webp` +- `res/Wl.webp` -> `Icons/icon_haze.webp` +- `res/Mg.webp` -> `Icons/icon_windy.webp` + Use only according to Xiaomi's applicable license and usage terms. diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_cloudy.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_cloudy.webp new file mode 100644 index 0000000000000000000000000000000000000000..228d2bdc41f8a8489322fad74a6d6b2e1bf83f78 GIT binary patch literal 422 zcmV;X0a^Z1Nk&GV0RRA3MM6+kP&iDI0RR9mU%(d-@1US<8_DGl>S+B}8VcIBkzD?u zj@Exw(6)`_@&|Ra{wu0TRssolHlTs@|1!#pa3&xSV+`GUr!APa;382tNfr&Y4G4mg zkmSJtKgr&E6WtaVhUc6BmL%D28zg2Dd{AP-yZ=oM;Jy8IRYdo zG&>g^Xy_;!;4DQG9AbD5-cd5(IZFbbx#WRo@th@QfpZica9ojv!$oERvzqb;%w54u zSaJ=Ukj$oP2PEb?=E8BC+7pV?)ITw7+`wE2Ha1P~sP1IbI8VCLVFcz$PrBI1%=as4 QSPnLleRm9^gJ<(UEi*U5vH$=8 literal 0 HcmV?d00001 diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp new file mode 100644 index 0000000000000000000000000000000000000000..f34ef53215373a49caf1ccc2b1710ae1f9c13ae4 GIT binary patch literal 3794 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 I-~a#s090C7Pyhe` literal 0 HcmV?d00001 diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_moon_clear.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_moon_clear.webp new file mode 100644 index 0000000000000000000000000000000000000000..5e1df1f7a779e6a75d147353b353ce5e49f848e8 GIT binary patch literal 2516 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 literal 0 HcmV?d00001 diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp new file mode 100644 index 0000000000000000000000000000000000000000..38aa31e04ca4b0efbcc196f843e3abd9c8096aea GIT binary patch literal 2958 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 E9lWw5H2?qr literal 0 HcmV?d00001 diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sleet.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sleet.webp new file mode 100644 index 0000000000000000000000000000000000000000..50d69bd317b1053156513b7bcca9d47e7d75bdf8 GIT binary patch literal 754 zcmV~5y)R@70NS<@O83ro9CxUL;Ha*%bv^(8pBc#((o>%z`ac2ix7-%>kJdjg zd6T64rzFp>SDTuhq0ZW-WND~ZZIkg#eWOjp4+Y#NK~vCe0yY$Q7u_?3UfIZO8H`M1 zZzOOZ)rlmonwX3Tsw1)_a~)AFgz_kw{6(8m%07Zzmn8XHTy97>3bTgfH==T+2(6?o zMJef-5|>k?|CdrM#zKTT7ojO?EW#BOySAY)Izk&}M+salgM6fb&<0pg8ovVU5mzwp@=nQHl*urCfk{Ty+bJ2l-oT`y>F<;X z4FTWMU_&f01sZ}b%lOcSfn$9CAoQ+`tcLIwqpCroeUGO+flzBAsTz=L-%(sySrEQF zR&PeE-k;#F;cS|9%kzG+$ux%SN~yD17ejS@0Y(H{zzu8Tb~Z5AJ;)n31?g=FTHG)Da8UfSHTCVkWMFdffY+f%HtV znQPt0%JI~C5;tM4!Ho>QwLXy{2RvPz4?+AB^XTzQMCAeMmWCq|QRRwr$V@%ps1FUe kV6lHYK-}4xQtB4EyHc2q?uL-GzE7=V%HV+I9{wN#09Za~wEzGB literal 0 HcmV?d00001 diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp new file mode 100644 index 0000000000000000000000000000000000000000..b17c0646c74c16cd68d4ffced2bd967d6a19b84e GIT binary patch literal 4848 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 Wu6|x_P5t_c-GU3-`@cCztN;KO8l~3& literal 0 HcmV?d00001 diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sunny_day.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sunny_day.webp new file mode 100644 index 0000000000000000000000000000000000000000..96d98c82cca91204384b08e49cc2f7d3a1479a20 GIT binary patch literal 656 zcmV;B0&o3NNk&G90ssJ4MM6+kP&iC`0ssInU%(d-Kf$1FBS}g5b6Y-pPiV})g0_tu z-{SN9sZE5CY~>IhLK%dVc_G{*_yL?N59`C9U^9_KB7>pc|6J1#3=oL;2BHumfXd*J zXaJ7MFNz=VkBm6@ynW4uY};1DHbPY*+mqfVxK1k7k<;4g=`)i@&wFpI<5ky zEM%+NP}Tsk*Wn~gS+WPnjSdJ4lipFnUB?}=C(WY-qu~MBlkQQU zC+*|xLdoyH-z_t9clkXe(Rt=c{}t$Lf*BaeT-yZu1@u&+F|=%k6P_RJ0+BIPxMO7H zda3vU*@uk?SaI5ffK{gr7_jnCT#eOTVV2ipQ`P{5u_z0TAEu;Q1!768gA00t()V(1 zT5jchY5CUSIxM$`g|n8=g$o(w zWju~)_8F?$gl4yJ;mj;5r?QH3puqJ~IC zKX^U1WszI2q0-}8k;)&}@wA2G8lJW>=8LqI4d-D;g&VU(HfA_QL(9e#aR)ATfyg$T z#?jES?SOj$0RXxLBHI64&U|8Szkl|Wd;x$-U4UqkvOgeB$^!IZQWYTgr1C0ZQWPM2 zQaVbQ)C9<$)Q%D+B{gJEibn}f$5rx_)zp>ELZcLQ+yqZqcPD@kS!fg*9S;WJDQ^n^ qT^ literal 0 HcmV?d00001 diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp new file mode 100644 index 0000000000000000000000000000000000000000..4602164c6a7cde5e4dc218a46eecb02ddb9732ce GIT binary patch literal 660 zcmV;F0&D$JNk&GD0ssJ4MM6+kP&il$0000G0000@002z@06|PpNKpX*00DqjTW{P* z5Cp*x0>KahK@h?qW{@yw7_3pA~>!_w0|?hG$ir z%?+=rIN}YKn;WjMt?Z=2y0VK6_L~Pm&)I=?Q65gvw+yG9+GrWQ^FVn&E4r6acx@Q+ zGRWL8s1Cs!#vOqxLa7L4!_f6CsgGd%Q|h4(po^Wr=W3=-uo`8!U2KY<#wJ%k*iCMZ z8e72g63i(WwYYhL#U+>$O!ibQs5!cr6h~sh>L;1d?B+zqyAw@TTzv?cbM;WD=RRJc zzM^Bbg}RH5(+bmwxxC%_wcD{U{bkPb(o>U4@pE=9@*^$6>@}nk| z+^(=Z*iC|EA-~)V3VG&qw>)Y>5$Xe-D8h{UnkdBMUQHBY!rg-@#^X+*DD_LBCIoiy3RJXjTY zRjCT3!Ak-P_=l%9DDIe$0RH>^cYpkG&*WME#gdY5|NGtG0dx7g&vt)a-#3~S@T^}e zE-m(l{w(uM-VfsUa}v3VUx}NO3on>yTCqA_^3`HFDBHxJYxg;eHz@qvHrFy&K4oJ! z5qs4(FaM&)kJ&v{U-2#vK^MJ|*jeO8O=Wb=9)TU4Blr)0$Nql&$DD#QA-s^NeR%6W ugTr1%9W&lq@i|D>tR&x8=Wh>;n0+Q26^v98!2e_T4wn6+<_lj!0000(J~Ltf literal 0 HcmV?d00001 diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_windy.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_windy.webp new file mode 100644 index 0000000000000000000000000000000000000000..2c55d40143358245cfb08b4b71f8a837e00d6ab1 GIT binary patch literal 3862 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 Y)mP4QegoXYf%?J|@{T8fKVAR;001Lm-~a#s literal 0 HcmV?d00001 diff --git a/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs index dd4c11f..ad51e50 100644 --- a/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -11,6 +11,8 @@ public static class BuiltInComponentIds public const string DesktopMultiDayWeather = "DesktopMultiDayWeather"; public const string DesktopExtendedWeather = "DesktopExtendedWeather"; public const string DesktopClassSchedule = "DesktopClassSchedule"; + public const string DesktopMusicControl = "DesktopMusicControl"; + public const string DesktopAudioRecorder = "DesktopAudioRecorder"; public const string Blank2x4 = "Blank2x4"; public const string Date = "Date"; public const string MonthCalendar = "MonthCalendar"; diff --git a/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs index e052662..81276b3 100644 --- a/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs @@ -103,6 +103,24 @@ public sealed class ComponentRegistry AllowStatusBarPlacement: false, AllowDesktopPlacement: true, ResizeMode: DesktopComponentResizeMode.Free), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopMusicControl, + "Music Control", + "Play", + "Media", + MinWidthCells: 4, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopAudioRecorder, + "Recorder", + "MicOn", + "Media", + MinWidthCells: 2, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), new DesktopComponentDefinition( BuiltInComponentIds.DesktopWhiteboard, "Blackboard Portrait", diff --git a/LanMontainDesktop/LanMontainDesktop.csproj b/LanMontainDesktop/LanMontainDesktop.csproj index 4760629..87fb919 100644 --- a/LanMontainDesktop/LanMontainDesktop.csproj +++ b/LanMontainDesktop/LanMontainDesktop.csproj @@ -31,14 +31,11 @@ + + - - + + diff --git a/LanMontainDesktop/Localization/en-US.json b/LanMontainDesktop/Localization/en-US.json index 80a9f29..4c21bed 100644 --- a/LanMontainDesktop/Localization/en-US.json +++ b/LanMontainDesktop/Localization/en-US.json @@ -210,6 +210,7 @@ "component_category.date": "Calendar", "component_category.weather": "Weather", "component_category.board": "Board", + "component_category.media": "Media", "component.date": "Calendar", "component.month_calendar": "Month Calendar", "component.lunar_calendar": "Lunar Calendar", @@ -221,9 +222,30 @@ "component.multiday_weather": "Multi-day Weather", "component.extended_weather": "Extended Weather", "component.class_schedule": "Class Schedule", + "component.music_control": "Music Control", + "component.audio_recorder": "Recorder", "component.whiteboard": "Blackboard (Portrait)", "component.blackboard_landscape": "Blackboard (Landscape)", "component.holiday_calendar": "Holiday Calendar", + "music.widget.unsupported": "Music control is not supported on this platform", + "music.widget.unsupported_hint": "This widget requires Windows SMTC", + "music.widget.no_session": "No active media session", + "music.widget.no_session_hint": "Open a player that supports SMTC", + "music.widget.open_player": "Open player", + "music.widget.unknown_title": "Unknown title", + "music.widget.unknown_artist": "Unknown artist", + "music.widget.status.opened": "Opened", + "music.widget.status.changing": "Changing", + "music.widget.status.stopped": "Stopped", + "music.widget.status.playing": "Playing", + "music.widget.status.paused": "Paused", + "recording.widget.title": "Recorder", + "recording.widget.hint.ready": "Tap red button to record", + "recording.widget.hint.recording": "Recording", + "recording.widget.hint.paused": "Paused", + "recording.widget.hint.unsupported": "Microphone is unavailable", + "recording.widget.hint.error": "Recording failed", + "recording.widget.hint.saved_format": "Saved {0}", "desktop.add_page": "Add page", "desktop.delete_page": "Delete page", "placement.fill": "Fill", diff --git a/LanMontainDesktop/Localization/zh-CN.json b/LanMontainDesktop/Localization/zh-CN.json index ff0be7d..72f5049 100644 --- a/LanMontainDesktop/Localization/zh-CN.json +++ b/LanMontainDesktop/Localization/zh-CN.json @@ -210,6 +210,7 @@ "component_category.date": "日历", "component_category.weather": "天气", "component_category.board": "白板", + "component_category.media": "媒体", "component.date": "日历", "component.month_calendar": "月历", "component.lunar_calendar": "农历", @@ -221,9 +222,30 @@ "component.multiday_weather": "多日天气", "component.extended_weather": "扩展天气", "component.class_schedule": "课表", + "component.music_control": "音乐控制", + "component.audio_recorder": "录音", "component.whiteboard": "竖向小黑板", "component.blackboard_landscape": "横向小黑板", "component.holiday_calendar": "节假日日历", + "music.widget.unsupported": "当前平台不支持音乐控制", + "music.widget.unsupported_hint": "该组件仅支持 Windows SMTC", + "music.widget.no_session": "未检测到正在播放的媒体", + "music.widget.no_session_hint": "请打开支持 SMTC 的播放器", + "music.widget.open_player": "打开播放器", + "music.widget.unknown_title": "未知歌曲", + "music.widget.unknown_artist": "未知艺术家", + "music.widget.status.opened": "已打开", + "music.widget.status.changing": "切换中", + "music.widget.status.stopped": "已停止", + "music.widget.status.playing": "播放中", + "music.widget.status.paused": "已暂停", + "recording.widget.title": "录音", + "recording.widget.hint.ready": "点击红色按钮开始", + "recording.widget.hint.recording": "录音中", + "recording.widget.hint.paused": "已暂停", + "recording.widget.hint.unsupported": "麦克风不可用", + "recording.widget.hint.error": "录音失败", + "recording.widget.hint.saved_format": "已保存 {0}", "desktop.add_page": "新增页面", "desktop.delete_page": "删除页面", "placement.fill": "填充", diff --git a/LanMontainDesktop/PACKAGING.md b/LanMontainDesktop/PACKAGING.md index 9145379..acb7578 100644 --- a/LanMontainDesktop/PACKAGING.md +++ b/LanMontainDesktop/PACKAGING.md @@ -55,10 +55,12 @@ pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -SkipArchive Jobs: - `Validate Build (Windows)` runs on every push and pull request. -- Package jobs run on manual trigger or `v*` tag push: - - `Package Windows` (`win-x64` installer) - - `Package Linux` (`linux-x64` zip) - - `Package macOS` (`osx-x64` zip) +- Package flow runs on manual trigger or `v*` tag push: + - `Resolve Package Version` (single shared version source) + - `Package (Windows)` (`win-x64` installer) + - `Package (Linux)` (`linux-x64` zip) + - `Package (macOS)` (`osx-x64` zip) +- On `v*` tags, `Attach Artifacts to GitHub Release` uploads Windows/Linux/macOS packages to the release. ### Trigger manual packaging 1. Open GitHub Actions. diff --git a/LanMontainDesktop/Services/IAudioRecorderService.cs b/LanMontainDesktop/Services/IAudioRecorderService.cs new file mode 100644 index 0000000..22e0a11 --- /dev/null +++ b/LanMontainDesktop/Services/IAudioRecorderService.cs @@ -0,0 +1,643 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using PortAudioSharp; +using PortAudioStream = PortAudioSharp.Stream; + +namespace LanMontainDesktop.Services; + +public enum AudioRecorderRuntimeState +{ + Unsupported = 0, + Ready = 1, + Recording = 2, + Paused = 3, + Error = 4 +} + +public sealed record AudioRecorderSnapshot( + AudioRecorderRuntimeState State, + TimeSpan Duration, + double InputLevel, + string LastSavedFilePath, + string LastError) +{ + public bool IsSupported => State != AudioRecorderRuntimeState.Unsupported; +} + +public interface IAudioRecorderService : IDisposable +{ + AudioRecorderSnapshot GetSnapshot(); + + bool StartOrResume(); + + bool Pause(); + + string? StopAndSave(); + + void Discard(); +} + +public static class AudioRecorderServiceFactory +{ + private static readonly Lazy SharedService = new( + () => + { + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + return new NoOpAudioRecorderService("Unsupported platform"); + } + + return new PortAudioRecorderService(); + }, + isThreadSafe: true); + + public static IAudioRecorderService CreateDefault() + { + return SharedService.Value; + } +} + +internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService +{ + private readonly AudioRecorderSnapshot _snapshot = new( + AudioRecorderRuntimeState.Unsupported, + TimeSpan.Zero, + 0, + string.Empty, + reason); + + public AudioRecorderSnapshot GetSnapshot() + { + return _snapshot; + } + + public bool StartOrResume() + { + return false; + } + + public bool Pause() + { + return false; + } + + public string? StopAndSave() + { + return null; + } + + public void Discard() + { + } + + public void Dispose() + { + } +} + +public sealed class PortAudioRecorderService : IAudioRecorderService +{ + private const int ChannelCount = 1; + private const int BitsPerSample = 16; + private const int BytesPerSample = BitsPerSample / 8; + private const int PreferredSampleRate = 16000; + private const uint FramesPerBuffer = 320; + + private readonly object _syncRoot = new(); + + private PortAudioStream? _stream; + private PortAudioStream.Callback? _streamCallback; + private MemoryStream? _pcmBuffer; + + private AudioRecorderRuntimeState _state = AudioRecorderRuntimeState.Unsupported; + private string _lastSavedFilePath = string.Empty; + private string _lastError = string.Empty; + private int _inputDeviceIndex = -1; + private int _sampleRate = PreferredSampleRate; + private double _deviceDefaultSampleRate = PreferredSampleRate; + private long _capturedFrames; + private double _inputLevel; + private bool _isPortAudioInitialized; + private bool _isDisposed; + + public PortAudioRecorderService() + { + InitializeRuntime(); + } + + public AudioRecorderSnapshot GetSnapshot() + { + lock (_syncRoot) + { + var level = _state == AudioRecorderRuntimeState.Recording + ? Math.Clamp(_inputLevel, 0, 1) + : 0; + + var duration = _capturedFrames <= 0 || _sampleRate <= 0 + ? TimeSpan.Zero + : TimeSpan.FromSeconds(_capturedFrames / (double)_sampleRate); + + return new AudioRecorderSnapshot( + State: _state, + Duration: duration, + InputLevel: level, + LastSavedFilePath: _lastSavedFilePath, + LastError: _lastError); + } + } + + public bool StartOrResume() + { + lock (_syncRoot) + { + if (_isDisposed) + { + return false; + } + + if (_state == AudioRecorderRuntimeState.Unsupported) + { + return false; + } + + if (_state == AudioRecorderRuntimeState.Error) + { + _state = AudioRecorderRuntimeState.Ready; + } + + if (_state == AudioRecorderRuntimeState.Recording) + { + return true; + } + + if (_state == AudioRecorderRuntimeState.Paused && _stream is not null) + { + try + { + _stream.Start(); + _state = AudioRecorderRuntimeState.Recording; + _lastError = string.Empty; + return true; + } + catch (Exception ex) + { + SetErrorLocked(ex); + return false; + } + } + + EnsureBufferLocked(); + ResetCaptureStateLocked(); + if (!TryOpenInputStreamLocked()) + { + return false; + } + + _state = AudioRecorderRuntimeState.Recording; + _lastError = string.Empty; + return true; + } + } + + public bool Pause() + { + lock (_syncRoot) + { + if (_isDisposed || _state != AudioRecorderRuntimeState.Recording || _stream is null) + { + return false; + } + + try + { + _stream.Stop(); + _state = AudioRecorderRuntimeState.Paused; + _inputLevel = 0; + _lastError = string.Empty; + return true; + } + catch (Exception ex) + { + SetErrorLocked(ex); + return false; + } + } + } + + public string? StopAndSave() + { + byte[] pcmData; + int sampleRate; + + lock (_syncRoot) + { + if (_isDisposed || + (_state != AudioRecorderRuntimeState.Recording && _state != AudioRecorderRuntimeState.Paused)) + { + return null; + } + + StopStreamLocked(); + + pcmData = _pcmBuffer?.ToArray() ?? Array.Empty(); + sampleRate = _sampleRate; + + ResetCaptureStateLocked(); + _state = AudioRecorderRuntimeState.Ready; + _inputLevel = 0; + } + + if (pcmData.Length == 0) + { + return null; + } + + var outputPath = BuildOutputPath(); + try + { + WriteWaveFile(outputPath, pcmData, sampleRate, ChannelCount, BitsPerSample); + } + catch (Exception ex) + { + lock (_syncRoot) + { + SetErrorLocked(ex); + } + + return null; + } + + lock (_syncRoot) + { + _lastSavedFilePath = outputPath; + _lastError = string.Empty; + } + + return outputPath; + } + + public void Discard() + { + lock (_syncRoot) + { + if (_isDisposed) + { + return; + } + + StopStreamLocked(); + ResetCaptureStateLocked(); + _inputLevel = 0; + _lastError = string.Empty; + + if (_state != AudioRecorderRuntimeState.Unsupported) + { + _state = AudioRecorderRuntimeState.Ready; + } + } + } + + public void Dispose() + { + lock (_syncRoot) + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + StopStreamLocked(); + _pcmBuffer?.Dispose(); + _pcmBuffer = null; + + if (_isPortAudioInitialized) + { + try + { + PortAudio.Terminate(); + } + catch + { + // Ignore shutdown failures. + } + + _isPortAudioInitialized = false; + } + } + } + + private void InitializeRuntime() + { + lock (_syncRoot) + { + if (_isDisposed || _isPortAudioInitialized) + { + return; + } + + try + { + PortAudio.LoadNativeLibrary(); + PortAudio.Initialize(); + _isPortAudioInitialized = true; + } + catch (Exception ex) + { + _state = AudioRecorderRuntimeState.Unsupported; + _lastError = ResolveErrorMessage(ex); + return; + } + + try + { + _inputDeviceIndex = PortAudio.DefaultInputDevice; + if (_inputDeviceIndex < 0) + { + _state = AudioRecorderRuntimeState.Unsupported; + _lastError = "No input device"; + return; + } + + var deviceInfo = PortAudio.GetDeviceInfo(_inputDeviceIndex); + if (deviceInfo.maxInputChannels < 1) + { + _state = AudioRecorderRuntimeState.Unsupported; + _lastError = "Input channels unavailable"; + return; + } + + _deviceDefaultSampleRate = deviceInfo.defaultSampleRate > 0 + ? deviceInfo.defaultSampleRate + : PreferredSampleRate; + _state = AudioRecorderRuntimeState.Ready; + _lastError = string.Empty; + } + catch (Exception ex) + { + _state = AudioRecorderRuntimeState.Unsupported; + _lastError = ResolveErrorMessage(ex); + } + } + } + + private bool TryOpenInputStreamLocked() + { + if (!_isPortAudioInitialized || _inputDeviceIndex < 0) + { + _state = AudioRecorderRuntimeState.Unsupported; + return false; + } + + var inputParameters = new StreamParameters + { + device = _inputDeviceIndex, + channelCount = ChannelCount, + sampleFormat = SampleFormat.Int16, + suggestedLatency = ResolveSuggestedLatency(), + hostApiSpecificStreamInfo = IntPtr.Zero + }; + + _streamCallback ??= OnStreamCallback; + foreach (var candidateRate in BuildSampleRateCandidates()) + { + try + { + _stream?.Dispose(); + _stream = new PortAudioStream( + inputParameters, + null, + candidateRate, + FramesPerBuffer, + StreamFlags.ClipOff, + _streamCallback, + this); + _sampleRate = Math.Clamp((int)Math.Round(candidateRate), 8000, 96000); + _stream.Start(); + return true; + } + catch (Exception ex) + { + _stream?.Dispose(); + _stream = null; + _lastError = ResolveErrorMessage(ex); + } + } + + _state = AudioRecorderRuntimeState.Error; + return false; + } + + private StreamCallbackResult OnStreamCallback( + IntPtr input, + IntPtr output, + uint frameCount, + ref StreamCallbackTimeInfo timeInfo, + StreamCallbackFlags statusFlags, + IntPtr userData) + { + _ = output; + _ = timeInfo; + _ = statusFlags; + _ = userData; + + if (frameCount == 0 || input == IntPtr.Zero) + { + return StreamCallbackResult.Continue; + } + + var byteCount = checked((int)(frameCount * ChannelCount * BytesPerSample)); + var buffer = ArrayPool.Shared.Rent(byteCount); + + try + { + Marshal.Copy(input, buffer, 0, byteCount); + var peak = CalculatePeak(buffer, byteCount); + + lock (_syncRoot) + { + if (_state != AudioRecorderRuntimeState.Recording) + { + return StreamCallbackResult.Continue; + } + + _pcmBuffer?.Write(buffer, 0, byteCount); + _capturedFrames += frameCount; + _inputLevel = (_inputLevel * 0.72) + (peak * 0.28); + } + } + catch + { + // Keep callback resilient to transient IO/interop errors. + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + return StreamCallbackResult.Continue; + } + + private void StopStreamLocked() + { + if (_stream is null) + { + return; + } + + try + { + if (_stream.IsActive) + { + _stream.Stop(); + } + } + catch + { + // Ignore stop errors. + } + + try + { + _stream.Close(); + } + catch + { + // Ignore close errors. + } + + _stream.Dispose(); + _stream = null; + } + + private void ResetCaptureStateLocked() + { + _capturedFrames = 0; + _sampleRate = Math.Clamp(_sampleRate, 8000, 96000); + _inputLevel = 0; + _pcmBuffer?.SetLength(0); + } + + private void EnsureBufferLocked() + { + if (_pcmBuffer is not null) + { + return; + } + + _pcmBuffer = new MemoryStream(capacity: 128 * 1024); + } + + private double ResolveSuggestedLatency() + { + try + { + var info = PortAudio.GetDeviceInfo(_inputDeviceIndex); + if (info.defaultLowInputLatency > 0) + { + return info.defaultLowInputLatency; + } + + if (info.defaultHighInputLatency > 0) + { + return info.defaultHighInputLatency; + } + } + catch + { + // Fall through to default latency. + } + + return 0.04; + } + + private double[] BuildSampleRateCandidates() + { + var ordered = new[] { PreferredSampleRate, _deviceDefaultSampleRate, 44100d, 48000d }; + var unique = new HashSet(); + var list = new List(ordered.Length); + foreach (var rate in ordered) + { + var rounded = (int)Math.Round(rate); + if (rounded < 8000 || rounded > 96000 || !unique.Add(rounded)) + { + continue; + } + + list.Add(rounded); + } + + return list.ToArray(); + } + + private static double CalculatePeak(byte[] buffer, int byteCount) + { + double peak = 0; + for (var i = 0; i + 1 < byteCount; i += 2) + { + var sample = (short)(buffer[i] | (buffer[i + 1] << 8)); + var normalized = Math.Abs(sample) / 32768d; + if (normalized > peak) + { + peak = normalized; + } + } + + return Math.Clamp(peak, 0, 1); + } + + private static string BuildOutputPath() + { + var root = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + if (string.IsNullOrWhiteSpace(root)) + { + root = AppContext.BaseDirectory; + } + + var folder = Path.Combine(root, "LanMontainDesktop", "Recordings"); + Directory.CreateDirectory(folder); + + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff"); + return Path.Combine(folder, $"recording_{timestamp}.wav"); + } + + private static void WriteWaveFile(string path, byte[] pcmData, int sampleRate, int channels, int bitsPerSample) + { + var byteRate = sampleRate * channels * (bitsPerSample / 8); + var blockAlign = channels * (bitsPerSample / 8); + using var stream = File.Create(path); + using var writer = new BinaryWriter(stream); + + writer.Write(new[] { (byte)'R', (byte)'I', (byte)'F', (byte)'F' }); + writer.Write(36 + pcmData.Length); + writer.Write(new[] { (byte)'W', (byte)'A', (byte)'V', (byte)'E' }); + writer.Write(new[] { (byte)'f', (byte)'m', (byte)'t', (byte)' ' }); + writer.Write(16); + writer.Write((short)1); + writer.Write((short)channels); + writer.Write(sampleRate); + writer.Write(byteRate); + writer.Write((short)blockAlign); + writer.Write((short)bitsPerSample); + writer.Write(new[] { (byte)'d', (byte)'a', (byte)'t', (byte)'a' }); + writer.Write(pcmData.Length); + writer.Write(pcmData); + } + + private void SetErrorLocked(Exception ex) + { + _lastError = ResolveErrorMessage(ex); + _state = AudioRecorderRuntimeState.Error; + } + + private static string ResolveErrorMessage(Exception ex) + { + return ex.Message.Trim().Length > 0 + ? ex.Message.Trim() + : ex.GetType().Name; + } +} diff --git a/LanMontainDesktop/Services/IMusicControlService.cs b/LanMontainDesktop/Services/IMusicControlService.cs new file mode 100644 index 0000000..af4cd14 --- /dev/null +++ b/LanMontainDesktop/Services/IMusicControlService.cs @@ -0,0 +1,121 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace LanMontainDesktop.Services; + +public enum MusicPlaybackStatus +{ + Unknown = 0, + Opened = 1, + Changing = 2, + Stopped = 3, + Playing = 4, + Paused = 5 +} + +public sealed record MusicPlaybackState( + bool IsSupported, + bool HasSession, + string SourceAppId, + string SourceAppName, + string Title, + string Artist, + string AlbumTitle, + byte[]? ThumbnailBytes, + TimeSpan Position, + TimeSpan Duration, + MusicPlaybackStatus PlaybackStatus, + bool CanPlayPause, + bool CanSkipPrevious, + bool CanSkipNext) +{ + public static MusicPlaybackState Unsupported() + { + return new MusicPlaybackState( + IsSupported: false, + HasSession: false, + SourceAppId: string.Empty, + SourceAppName: string.Empty, + Title: string.Empty, + Artist: string.Empty, + AlbumTitle: string.Empty, + ThumbnailBytes: null, + Position: TimeSpan.Zero, + Duration: TimeSpan.Zero, + PlaybackStatus: MusicPlaybackStatus.Unknown, + CanPlayPause: false, + CanSkipPrevious: false, + CanSkipNext: false); + } + + public static MusicPlaybackState NoSession(bool isSupported = true) + { + return new MusicPlaybackState( + IsSupported: isSupported, + HasSession: false, + SourceAppId: string.Empty, + SourceAppName: string.Empty, + Title: string.Empty, + Artist: string.Empty, + AlbumTitle: string.Empty, + ThumbnailBytes: null, + Position: TimeSpan.Zero, + Duration: TimeSpan.Zero, + PlaybackStatus: MusicPlaybackStatus.Unknown, + CanPlayPause: false, + CanSkipPrevious: false, + CanSkipNext: false); + } +} + +public interface IMusicControlService +{ + Task GetCurrentStateAsync(CancellationToken cancellationToken = default); + + Task TogglePlayPauseAsync(CancellationToken cancellationToken = default); + + Task SkipNextAsync(CancellationToken cancellationToken = default); + + Task SkipPreviousAsync(CancellationToken cancellationToken = default); + + Task LaunchSourceAppAsync(CancellationToken cancellationToken = default); +} + +public static class MusicControlServiceFactory +{ + public static IMusicControlService CreateDefault() + { + return OperatingSystem.IsWindows() + ? new WindowsSmtcMusicControlService() + : new NoOpMusicControlService(); + } +} + +internal sealed class NoOpMusicControlService : IMusicControlService +{ + public Task GetCurrentStateAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(MusicPlaybackState.Unsupported()); + } + + public Task TogglePlayPauseAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(false); + } + + public Task SkipNextAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(false); + } + + public Task SkipPreviousAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(false); + } + + public Task LaunchSourceAppAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(false); + } +} diff --git a/LanMontainDesktop/Services/WindowsSmtcMusicControlService.cs b/LanMontainDesktop/Services/WindowsSmtcMusicControlService.cs new file mode 100644 index 0000000..c07ebf8 --- /dev/null +++ b/LanMontainDesktop/Services/WindowsSmtcMusicControlService.cs @@ -0,0 +1,579 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace LanMontainDesktop.Services; + +public sealed class WindowsSmtcMusicControlService : IMusicControlService +{ + private static readonly Type? SessionManagerType = ResolveWinRtType("Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager"); + private static readonly Type? AppInfoType = ResolveWinRtType("Windows.ApplicationModel.AppInfo"); + private static readonly MethodInfo? RequestSessionManagerAsyncMethod = + SessionManagerType?.GetMethod("RequestAsync", BindingFlags.Public | BindingFlags.Static); + private static readonly MethodInfo? AsTaskGenericMethodDefinition = ResolveAsTaskGenericMethod(); + private static readonly MethodInfo? AsStreamForReadMethod = ResolveAsStreamForReadMethod(); + + private static readonly SemaphoreSlim ManagerLock = new(1, 1); + private static object? _sessionManager; + + private readonly ConcurrentDictionary _sourceAppNameCache = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _stateGate = new(1, 1); + + private string _thumbnailKey = string.Empty; + private byte[]? _thumbnailBytesCache; + + public async Task GetCurrentStateAsync(CancellationToken cancellationToken = default) + { + if (!IsRuntimeSupported()) + { + return MusicPlaybackState.Unsupported(); + } + + await _stateGate.WaitAsync(cancellationToken); + try + { + var session = await GetCurrentSessionAsync(cancellationToken); + if (session is null) + { + return MusicPlaybackState.NoSession(isSupported: true); + } + + var mediaProperties = await TryGetMediaPropertiesAsync(session, cancellationToken); + var title = ReadStringProperty(mediaProperties, "Title"); + var artist = ReadStringProperty(mediaProperties, "Artist"); + var albumTitle = ReadStringProperty(mediaProperties, "AlbumTitle"); + + var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo"); + var controls = GetPropertyValue(playbackInfo, "Controls"); + + var playbackStatusRaw = ReadIntProperty(playbackInfo, "PlaybackStatus"); + var canPlayPause = ReadBoolProperty(controls, "IsPauseEnabled") || ReadBoolProperty(controls, "IsPlayEnabled"); + var canSkipNext = ReadBoolProperty(controls, "IsNextEnabled"); + var canSkipPrevious = ReadBoolProperty(controls, "IsPreviousEnabled"); + + var sourceAppId = ReadStringProperty(session, "SourceAppUserModelId"); + var sourceAppName = await ResolveSourceAppDisplayNameAsync(sourceAppId, cancellationToken); + + var timeline = InvokeMethod(session, "GetTimelineProperties"); + var position = ReadTimeSpanProperty(timeline, "Position"); + var start = ReadTimeSpanProperty(timeline, "StartTime"); + var end = ReadTimeSpanProperty(timeline, "EndTime"); + + var duration = end - start; + if (duration < TimeSpan.Zero) + { + duration = TimeSpan.Zero; + } + + var normalizedPosition = position - start; + if (normalizedPosition < TimeSpan.Zero) + { + normalizedPosition = TimeSpan.Zero; + } + + if (duration > TimeSpan.Zero && normalizedPosition > duration) + { + normalizedPosition = duration; + } + + var thumbnailBytes = await ResolveThumbnailBytesAsync( + mediaProperties, + sourceAppId, + title, + artist, + albumTitle, + cancellationToken); + + return new MusicPlaybackState( + IsSupported: true, + HasSession: true, + SourceAppId: sourceAppId, + SourceAppName: sourceAppName, + Title: title, + Artist: artist, + AlbumTitle: albumTitle, + ThumbnailBytes: thumbnailBytes, + Position: normalizedPosition, + Duration: duration, + PlaybackStatus: MapPlaybackStatus(playbackStatusRaw), + CanPlayPause: canPlayPause, + CanSkipPrevious: canSkipPrevious, + CanSkipNext: canSkipNext); + } + catch + { + return MusicPlaybackState.NoSession(isSupported: true); + } + finally + { + _stateGate.Release(); + } + } + + public async Task TogglePlayPauseAsync(CancellationToken cancellationToken = default) + { + if (!IsRuntimeSupported()) + { + return false; + } + + var session = await GetCurrentSessionAsync(cancellationToken); + if (session is null) + { + return false; + } + + var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo"); + var controls = GetPropertyValue(playbackInfo, "Controls"); + var playbackStatusRaw = ReadIntProperty(playbackInfo, "PlaybackStatus"); + + object? operation = null; + if (playbackStatusRaw == 4 && ReadBoolProperty(controls, "IsPauseEnabled")) + { + operation = InvokeMethod(session, "TryPauseAsync"); + } + else if (ReadBoolProperty(controls, "IsPlayEnabled")) + { + operation = InvokeMethod(session, "TryPlayAsync"); + } + else if (ReadBoolProperty(controls, "IsPauseEnabled")) + { + operation = InvokeMethod(session, "TryPauseAsync"); + } + else + { + operation = InvokeMethod(session, "TryTogglePlayPauseAsync"); + } + + return await AwaitBooleanWinRtOperationAsync(operation, cancellationToken); + } + + public async Task SkipNextAsync(CancellationToken cancellationToken = default) + { + if (!IsRuntimeSupported()) + { + return false; + } + + var session = await GetCurrentSessionAsync(cancellationToken); + if (session is null) + { + return false; + } + + var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo"); + var controls = GetPropertyValue(playbackInfo, "Controls"); + if (!ReadBoolProperty(controls, "IsNextEnabled")) + { + return false; + } + + return await AwaitBooleanWinRtOperationAsync(InvokeMethod(session, "TrySkipNextAsync"), cancellationToken); + } + + public async Task SkipPreviousAsync(CancellationToken cancellationToken = default) + { + if (!IsRuntimeSupported()) + { + return false; + } + + var session = await GetCurrentSessionAsync(cancellationToken); + if (session is null) + { + return false; + } + + var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo"); + var controls = GetPropertyValue(playbackInfo, "Controls"); + if (!ReadBoolProperty(controls, "IsPreviousEnabled")) + { + return false; + } + + return await AwaitBooleanWinRtOperationAsync(InvokeMethod(session, "TrySkipPreviousAsync"), cancellationToken); + } + + public async Task LaunchSourceAppAsync(CancellationToken cancellationToken = default) + { + if (!IsRuntimeSupported()) + { + return false; + } + + var session = await GetCurrentSessionAsync(cancellationToken); + if (session is null) + { + return false; + } + + var sourceAppId = ReadStringProperty(session, "SourceAppUserModelId"); + if (string.IsNullOrWhiteSpace(sourceAppId)) + { + return false; + } + + return TryOpenSourceApp(sourceAppId); + } + + private async Task GetCurrentSessionAsync(CancellationToken cancellationToken) + { + var manager = await GetSessionManagerAsync(cancellationToken); + return manager is null ? null : InvokeMethod(manager, "GetCurrentSession"); + } + + private static async Task GetSessionManagerAsync(CancellationToken cancellationToken) + { + if (_sessionManager is not null) + { + return _sessionManager; + } + + await ManagerLock.WaitAsync(cancellationToken); + try + { + if (_sessionManager is not null) + { + return _sessionManager; + } + + var operation = RequestSessionManagerAsyncMethod?.Invoke(null, null); + var manager = await AwaitWinRtOperationAsync(operation, cancellationToken); + _sessionManager = manager; + return manager; + } + finally + { + ManagerLock.Release(); + } + } + + private async Task TryGetMediaPropertiesAsync(object session, CancellationToken cancellationToken) + { + var operation = InvokeMethod(session, "TryGetMediaPropertiesAsync"); + return await AwaitWinRtOperationAsync(operation, cancellationToken); + } + + private async Task ResolveThumbnailBytesAsync( + object? mediaProperties, + string sourceAppId, + string title, + string artist, + string albumTitle, + CancellationToken cancellationToken) + { + var key = $"{sourceAppId}|{title}|{artist}|{albumTitle}"; + if (string.Equals(key, _thumbnailKey, StringComparison.Ordinal) && _thumbnailBytesCache is not null) + { + return _thumbnailBytesCache; + } + + var thumbnailReference = GetPropertyValue(mediaProperties, "Thumbnail"); + var thumbnailBytes = await TryReadThumbnailBytesAsync(thumbnailReference, cancellationToken); + + _thumbnailKey = key; + _thumbnailBytesCache = thumbnailBytes; + return thumbnailBytes; + } + + private static async Task TryReadThumbnailBytesAsync(object? thumbnailReference, CancellationToken cancellationToken) + { + if (thumbnailReference is null) + { + return null; + } + + object? randomAccessStream = null; + try + { + var openReadAsyncOperation = InvokeMethod(thumbnailReference, "OpenReadAsync"); + randomAccessStream = await AwaitWinRtOperationAsync(openReadAsyncOperation, cancellationToken); + if (randomAccessStream is null || AsStreamForReadMethod is null) + { + return null; + } + + using var dotnetStream = AsStreamForReadMethod.Invoke(null, [randomAccessStream]) as Stream; + if (dotnetStream is null) + { + return null; + } + + using var buffer = new MemoryStream(); + await dotnetStream.CopyToAsync(buffer, cancellationToken); + return buffer.Length > 0 ? buffer.ToArray() : null; + } + catch + { + return null; + } + finally + { + if (randomAccessStream is IDisposable disposable) + { + disposable.Dispose(); + } + } + } + + private async Task ResolveSourceAppDisplayNameAsync(string sourceAppId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(sourceAppId)) + { + return string.Empty; + } + + if (_sourceAppNameCache.TryGetValue(sourceAppId, out var cached)) + { + return cached; + } + + var resolved = sourceAppId; + try + { + if (AppInfoType is not null) + { + var getFromAumidMethod = AppInfoType.GetMethod( + "GetFromAppUserModelId", + BindingFlags.Public | BindingFlags.Static, + null, + [typeof(string)], + null); + var appInfo = getFromAumidMethod?.Invoke(null, [sourceAppId]); + var displayInfo = GetPropertyValue(appInfo, "DisplayInfo"); + var displayName = ReadStringProperty(displayInfo, "DisplayName"); + if (!string.IsNullOrWhiteSpace(displayName)) + { + resolved = displayName; + } + else + { + resolved = SimplifySourceAppId(sourceAppId); + } + } + else + { + resolved = SimplifySourceAppId(sourceAppId); + } + } + catch + { + resolved = SimplifySourceAppId(sourceAppId); + } + + _sourceAppNameCache[sourceAppId] = resolved; + await Task.CompletedTask; + return resolved; + } + + private static string SimplifySourceAppId(string sourceAppId) + { + var text = sourceAppId.Trim(); + if (text.Length == 0) + { + return string.Empty; + } + + var exclamationIndex = text.IndexOf('!'); + if (exclamationIndex > 0) + { + text = text[..exclamationIndex]; + } + + var packageSplit = text.Split('_'); + if (packageSplit.Length > 0 && packageSplit[0].Length > 0) + { + text = packageSplit[0]; + } + + if (text.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + { + text = Path.GetFileNameWithoutExtension(text); + } + + if (text.Contains('.')) + { + var lastSegment = text.Split('.', StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); + if (!string.IsNullOrWhiteSpace(lastSegment)) + { + text = lastSegment; + } + } + + return text.Replace('_', ' ').Replace('-', ' ').Trim(); + } + + private static bool TryOpenSourceApp(string sourceAppId) + { + try + { + var launchTarget = $"shell:AppsFolder\\{sourceAppId}"; + Process.Start(new ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = launchTarget, + UseShellExecute = true + }); + return true; + } + catch + { + return false; + } + } + + private static async Task AwaitBooleanWinRtOperationAsync(object? operation, CancellationToken cancellationToken) + { + var result = await AwaitWinRtOperationAsync(operation, cancellationToken); + return result is bool boolValue && boolValue; + } + + private static async Task AwaitWinRtOperationAsync(object? operation, CancellationToken cancellationToken) + { + if (operation is null || AsTaskGenericMethodDefinition is null) + { + return null; + } + + var resultType = ResolveWinRtOperationResultType(operation.GetType()); + if (resultType is null) + { + return null; + } + + var asTaskMethod = AsTaskGenericMethodDefinition.MakeGenericMethod(resultType); + var taskObject = asTaskMethod.Invoke(null, [operation]) as Task; + if (taskObject is null) + { + return null; + } + + await taskObject.WaitAsync(cancellationToken); + return taskObject.GetType().GetProperty("Result", BindingFlags.Public | BindingFlags.Instance)?.GetValue(taskObject); + } + + private static Type? ResolveWinRtOperationResultType(Type operationType) + { + if (operationType.IsGenericType) + { + var genericArguments = operationType.GetGenericArguments(); + if (genericArguments.Length == 1) + { + return genericArguments[0]; + } + } + + foreach (var iface in operationType.GetInterfaces()) + { + if (!iface.IsGenericType) + { + continue; + } + + var genericTypeDef = iface.GetGenericTypeDefinition(); + if (string.Equals(genericTypeDef.FullName, "Windows.Foundation.IAsyncOperation`1", StringComparison.Ordinal)) + { + return iface.GetGenericArguments()[0]; + } + } + + return null; + } + + private static MethodInfo? ResolveAsTaskGenericMethod() + { + var type = Type.GetType("System.WindowsRuntimeSystemExtensions, System.Runtime.WindowsRuntime", throwOnError: false); + return type? + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(method => + method.Name == "AsTask" && + method.IsGenericMethodDefinition && + method.GetParameters().Length == 1); + } + + private static MethodInfo? ResolveAsStreamForReadMethod() + { + var type = Type.GetType("System.IO.WindowsRuntimeStreamExtensions, System.Runtime.WindowsRuntime", throwOnError: false); + return type? + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(method => + method.Name == "AsStreamForRead" && + method.GetParameters().Length == 1); + } + + private static Type? ResolveWinRtType(string typeName) + { + return Type.GetType($"{typeName}, Windows, ContentType=WindowsRuntime", throwOnError: false); + } + + private static bool IsRuntimeSupported() + { + return OperatingSystem.IsWindows() && + SessionManagerType is not null && + RequestSessionManagerAsyncMethod is not null && + AsTaskGenericMethodDefinition is not null; + } + + private static object? InvokeMethod(object? target, string methodName) + { + return target?.GetType().GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance)?.Invoke(target, null); + } + + private static object? GetPropertyValue(object? target, string propertyName) + { + return target?.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance)?.GetValue(target); + } + + private static string ReadStringProperty(object? target, string propertyName) + { + return GetPropertyValue(target, propertyName)?.ToString()?.Trim() ?? string.Empty; + } + + private static bool ReadBoolProperty(object? target, string propertyName) + { + var value = GetPropertyValue(target, propertyName); + return value is bool boolValue && boolValue; + } + + private static int ReadIntProperty(object? target, string propertyName) + { + var value = GetPropertyValue(target, propertyName); + if (value is null) + { + return 0; + } + + try + { + return Convert.ToInt32(value); + } + catch + { + return 0; + } + } + + private static TimeSpan ReadTimeSpanProperty(object? target, string propertyName) + { + var value = GetPropertyValue(target, propertyName); + return value is TimeSpan timeSpan ? timeSpan : TimeSpan.Zero; + } + + private static MusicPlaybackStatus MapPlaybackStatus(int rawStatus) + { + return rawStatus switch + { + 1 => MusicPlaybackStatus.Opened, + 2 => MusicPlaybackStatus.Changing, + 3 => MusicPlaybackStatus.Stopped, + 4 => MusicPlaybackStatus.Playing, + 5 => MusicPlaybackStatus.Paused, + _ => MusicPlaybackStatus.Unknown + }; + } +} diff --git a/LanMontainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMontainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 6e7c2b3..ae814e2 100644 --- a/LanMontainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMontainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -155,6 +155,16 @@ public sealed class DesktopComponentRuntimeRegistry "component.class_schedule", () => new ClassScheduleWidget(), cellSize => Math.Clamp(cellSize * 0.45, 24, 44)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopMusicControl, + "component.music_control", + () => new MusicControlWidget(), + cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopAudioRecorder, + "component.audio_recorder", + () => new RecordingWidget(), + cellSize => Math.Clamp(cellSize * 0.36, 16, 34)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopWhiteboard, "component.whiteboard", diff --git a/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml b/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml index 2c9fc6b..2e0ee5b 100644 --- a/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml +++ b/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml @@ -1,19 +1,464 @@ - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs b/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs index e19f6e6..c4a7c36 100644 --- a/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs +++ b/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs @@ -1,48 +1,525 @@ -using System; +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using LanMontainDesktop.Models; using LanMontainDesktop.Services; namespace LanMontainDesktop.Views.Components; public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget { + private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService(); + + private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromMinutes(12) }; + private readonly DispatcherTimer _animationTimer = new() { Interval = TimeSpan.FromMilliseconds(48) }; + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); + + private IWeatherInfoService _weatherInfoService = DefaultWeatherInfoService; private TimeZoneService? _timeZoneService; - private IWeatherInfoService? _weatherInfoService; + private CancellationTokenSource? _refreshCts; private double _currentCellSize = 48; + private double _phase; + private bool _isAttached; + private bool _isRefreshing; + private string _languageCode = "zh-CN"; + private HyperOS3WeatherVisualKind _activeVisualKind = HyperOS3WeatherVisualKind.ClearDay; + private readonly TextBlock[] _hourlyTempBlocks; + private readonly TextBlock[] _hourlyTimeBlocks; + private readonly Image[] _hourlyIconBlocks; + private readonly TextBlock[] _dailyLabelBlocks; + private readonly TextBlock[] _dailyHighBlocks; + private readonly TextBlock[] _dailyLowBlocks; + private readonly Image[] _dailyIconBlocks; public ExtendedWeatherWidget() { InitializeComponent(); + _hourlyTempBlocks = + [ + HourlyTemp0, HourlyTemp1, HourlyTemp2, HourlyTemp3, HourlyTemp4, HourlyTemp5 + ]; + _hourlyTimeBlocks = + [ + HourlyTime0, HourlyTime1, HourlyTime2, HourlyTime3, HourlyTime4, HourlyTime5 + ]; + _hourlyIconBlocks = + [ + HourlyIcon0, HourlyIcon1, HourlyIcon2, HourlyIcon3, HourlyIcon4, HourlyIcon5 + ]; + _dailyLabelBlocks = + [ + DailyLabel0, DailyLabel1, DailyLabel2, DailyLabel3, DailyLabel4 + ]; + _dailyHighBlocks = + [ + DailyHigh0, DailyHigh1, DailyHigh2, DailyHigh3, DailyHigh4 + ]; + _dailyLowBlocks = + [ + DailyLow0, DailyLow1, DailyLow2, DailyLow3, DailyLow4 + ]; + _dailyIconBlocks = + [ + DailyIcon0, DailyIcon1, DailyIcon2, DailyIcon3, DailyIcon4 + ]; + _refreshTimer.Tick += OnRefreshTimerTick; + _animationTimer.Tick += OnAnimationTick; + AttachedToVisualTree += (_, _) => + { + _isAttached = true; + _refreshTimer.Start(); + _animationTimer.Start(); + _ = RefreshWeatherAsync(false); + }; + DetachedFromVisualTree += (_, _) => + { + _isAttached = false; + _refreshTimer.Stop(); + _animationTimer.Stop(); + CancelRefresh(); + }; + SizeChanged += (_, _) => ApplyCellSize(_currentCellSize); ApplyCellSize(_currentCellSize); + ApplyVisualTheme(_activeVisualKind); + ApplyFallback(); } public void ApplyCellSize(double cellSize) { _currentCellSize = Math.Max(1, cellSize); var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.Extended4x4); - ContainerGrid.RowSpacing = Math.Clamp(_currentCellSize * metrics.SectionGap * 0.22, 6, 18); - HourlyHost.ApplyCellSize(_currentCellSize); - MultiDayHost.ApplyCellSize(_currentCellSize); + var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 4; + var height = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * 4; + var radius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 28, 54); + RootBorder.CornerRadius = new CornerRadius(radius); + BackgroundImageLayer.CornerRadius = new CornerRadius(radius); + BackgroundMotionLayer.CornerRadius = new CornerRadius(radius); + BackgroundTintLayer.CornerRadius = new CornerRadius(radius); + BackgroundLightLayer.CornerRadius = new CornerRadius(radius); + BackgroundShadeLayer.CornerRadius = new CornerRadius(radius); + ContentPaddingBorder.Padding = new Thickness( + Math.Clamp(width * metrics.HorizontalPaddingScale * 0.30, 10, 30), + Math.Clamp(height * metrics.VerticalPaddingScale * 0.30, 10, 30)); + ApplyTypography(width, height); } public void SetTimeZoneService(TimeZoneService timeZoneService) { + if (_timeZoneService is not null) + { + _timeZoneService.TimeZoneChanged -= OnTimeZoneChanged; + } + _timeZoneService = timeZoneService; - HourlyHost.SetTimeZoneService(timeZoneService); - MultiDayHost.SetTimeZoneService(timeZoneService); + _timeZoneService.TimeZoneChanged += OnTimeZoneChanged; } public void ClearTimeZoneService() { - HourlyHost.ClearTimeZoneService(); - MultiDayHost.ClearTimeZoneService(); + if (_timeZoneService is null) + { + return; + } + + _timeZoneService.TimeZoneChanged -= OnTimeZoneChanged; _timeZoneService = null; } public void SetWeatherInfoService(IWeatherInfoService weatherInfoService) { - _weatherInfoService = weatherInfoService; - HourlyHost.SetWeatherInfoService(weatherInfoService); - MultiDayHost.SetWeatherInfoService(weatherInfoService); + _weatherInfoService = weatherInfoService ?? DefaultWeatherInfoService; + if (_isAttached) + { + _ = RefreshWeatherAsync(false); + } + } + + private void OnTimeZoneChanged(object? sender, EventArgs e) + { + if (_isAttached) + { + _ = RefreshWeatherAsync(false); + } + } + + private async void OnRefreshTimerTick(object? sender, EventArgs e) + { + await RefreshWeatherAsync(false); + } + + private void OnAnimationTick(object? sender, EventArgs e) + { + _phase += 0.018; + if (_phase > Math.PI * 2) _phase -= Math.PI * 2; + var sin = Math.Sin(_phase); + var cos = Math.Cos(_phase * 0.83); + BackgroundMotionLayer.RenderTransform = new TransformGroup + { + Children = new Transforms + { + new ScaleTransform(1.05 + (sin * 0.01), 1.05 + (sin * 0.01)), + new TranslateTransform(sin * 7.0, cos * 5.0) + } + }; + BackgroundMotionLayer.Opacity = Math.Clamp(0.27 + (cos * 0.05), 0.10, 0.90); + BackgroundLightLayer.Opacity = Math.Clamp(0.62 + (sin * 0.06), 0.20, 0.95); + BackgroundShadeLayer.Opacity = Math.Clamp(0.80 + (cos * 0.03), 0.45, 0.95); + } + + private async Task RefreshWeatherAsync(bool forceRefresh) + { + if (!_isAttached || _isRefreshing) + { + return; + } + + _isRefreshing = true; + var app = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(app.LanguageCode); + var locale = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase) ? "zh_cn" : "en_us"; + var latitude = double.IsFinite(app.WeatherLatitude) ? Math.Clamp(app.WeatherLatitude, -90, 90) : 39.9042; + var longitude = double.IsFinite(app.WeatherLongitude) ? Math.Clamp(app.WeatherLongitude, -180, 180) : 116.4074; + var locationKey = (app.WeatherLocationKey ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(locationKey) && string.Equals(app.WeatherLocationMode, "Coordinates", StringComparison.OrdinalIgnoreCase)) + { + locationKey = string.Create(CultureInfo.InvariantCulture, $"coord:{latitude:F4},{longitude:F4}"); + } + + if (string.IsNullOrWhiteSpace(locationKey)) + { + ApplyFallback(); + _isRefreshing = false; + return; + } + + SetLoadingSkeleton(true); + + var cts = new CancellationTokenSource(); + var previous = Interlocked.Exchange(ref _refreshCts, cts); + previous?.Cancel(); + previous?.Dispose(); + + try + { + var query = new WeatherQuery(locationKey, latitude, longitude, 7, locale, ForceRefresh: forceRefresh); + var result = await _weatherInfoService.GetWeatherAsync(query, cts.Token); + if (cts.IsCancellationRequested || !_isAttached) + { + return; + } + + if (!result.Success || result.Data is null) + { + ApplyFallback(); + return; + } + + ApplySnapshot(result.Data, app.WeatherLocationName); + } + catch (OperationCanceledException) + { + // Ignore canceled requests. + } + catch + { + ApplyFallback(); + } + finally + { + if (ReferenceEquals(_refreshCts, cts)) + { + _refreshCts = null; + } + + cts.Dispose(); + _isRefreshing = false; + } + } + + private void ApplySnapshot(WeatherSnapshot snapshot, string? fallbackLocationName) + { + var isNight = HyperOS3WeatherTheme.ResolveIsNightPreferred( + snapshot, + _timeZoneService?.CurrentTimeZone, + _timeZoneService?.GetCurrentTime() ?? DateTime.Now); + var kind = HyperOS3WeatherTheme.ResolveVisualKind(snapshot.Current.WeatherCode, isNight); + ApplyVisualTheme(kind); + SetLoadingSkeleton(false); + WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveIconAsset(kind)); + CityTextBlock.Text = ResolveLocation(snapshot.LocationName, fallbackLocationName); + ConditionTextBlock.Text = ResolveWeatherText(snapshot.Current.WeatherText, kind); + TemperatureTextBlock.Text = FormatTemperature(snapshot.Current.TemperatureC); + + var today = snapshot.DailyForecasts.FirstOrDefault(); + RangeTextBlock.Text = $"{FormatTemperature(today?.HighTemperatureC)}/{FormatTemperature(today?.LowTemperatureC)}"; + + var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now; + var localHourly = snapshot.HourlyForecasts + .Select(item => new { Source = item, Time = ConvertToConfiguredTime(item.Time) }) + .OrderBy(item => item.Time) + .ToList(); + + for (var i = 0; i < _hourlyTempBlocks.Length; i++) + { + var target = now.AddHours(i); + var item = localHourly + .OrderBy(entry => Math.Abs((entry.Time - target).TotalMinutes)) + .FirstOrDefault(); + var weatherCode = item?.Source.WeatherCode ?? snapshot.Current.WeatherCode; + var hourKind = HyperOS3WeatherTheme.ResolveVisualKind(weatherCode, IsNightHour(target)); + _hourlyTempBlocks[i].Text = FormatTemperature(item?.Source.TemperatureC ?? snapshot.Current.TemperatureC); + _hourlyTimeBlocks[i].Text = i == 0 ? L("weather.hourly.now", "Now") : target.ToString("HH:mm", CultureInfo.InvariantCulture); + _hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveIconAsset(hourKind)); + } + + var todayDate = DateOnly.FromDateTime(now); + for (var i = 0; i < _dailyLabelBlocks.Length; i++) + { + var date = todayDate.AddDays(i + 1); + var daily = snapshot.DailyForecasts.FirstOrDefault(entry => entry.Date == date) ?? snapshot.DailyForecasts.FirstOrDefault(); + var weatherCode = daily?.DayWeatherCode ?? daily?.NightWeatherCode ?? snapshot.Current.WeatherCode; + var dayKind = HyperOS3WeatherTheme.ResolveVisualKind(weatherCode, false); + var dayText = ResolveWeatherText(daily?.DayWeatherText ?? daily?.NightWeatherText, dayKind); + _dailyLabelBlocks[i].Text = $"{ResolveDayLabel(date, i + 1)} · {dayText}"; + _dailyHighBlocks[i].Text = FormatTemperatureValue(daily?.HighTemperatureC); + _dailyLowBlocks[i].Text = FormatTemperatureValue(daily?.LowTemperatureC); + _dailyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveIconAsset(dayKind)); + } + } + + private void ApplyFallback() + { + ApplyVisualTheme(HyperOS3WeatherVisualKind.CloudyDay); + SetLoadingSkeleton(false); + WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveIconAsset(HyperOS3WeatherVisualKind.CloudyDay)); + CityTextBlock.Text = L("weather.widget.location_unknown", "Unknown location"); + ConditionTextBlock.Text = L("weather.widget.loading", "Loading..."); + TemperatureTextBlock.Text = "--°"; + RangeTextBlock.Text = "--/--"; + for (var i = 0; i < _hourlyTempBlocks.Length; i++) + { + _hourlyTempBlocks[i].Text = "--°"; + _hourlyTimeBlocks[i].Text = i == 0 ? L("weather.hourly.now", "Now") : $"{(i + 14):00}:00"; + _hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveIconAsset(HyperOS3WeatherVisualKind.CloudyDay)); + } + + for (var i = 0; i < _dailyLabelBlocks.Length; i++) + { + _dailyLabelBlocks[i].Text = $"{ResolveDayLabel(DateOnly.FromDateTime(DateTime.Now).AddDays(i + 1), i + 1)} · {L("weather.widget.condition_cloudy", "Cloudy")}"; + _dailyHighBlocks[i].Text = "--"; + _dailyLowBlocks[i].Text = "--"; + _dailyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveIconAsset(HyperOS3WeatherVisualKind.CloudyDay)); + } + } + + private void ApplyVisualTheme(HyperOS3WeatherVisualKind kind) + { + _activeVisualKind = kind; + var palette = HyperOS3WeatherTheme.ResolvePalette(kind); + RootBorder.Background = CreateGradientBrush(palette.GradientFrom, palette.GradientTo); + + var background = CreateImageBrush(HyperOS3WeatherTheme.ResolveBackgroundAsset(kind)); + BackgroundImageLayer.Background = background ?? CreateGradientBrush(palette.GradientFrom, palette.GradientTo); + BackgroundMotionLayer.Background = background ?? CreateGradientBrush(palette.GradientFrom, palette.GradientTo); + BackgroundTintLayer.Background = CreateSolidBrush(palette.Tint); + + var isNightVisual = kind is HyperOS3WeatherVisualKind.ClearNight or HyperOS3WeatherVisualKind.CloudyNight; + TemperatureTextBlock.Foreground = CreateSolidBrush(palette.PrimaryText); + CityTextBlock.Foreground = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xDC : (byte)0xCC); + ConditionTextBlock.Foreground = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xEE : (byte)0xE2); + RangeTextBlock.Foreground = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xDE : (byte)0xD2); + HourlyPanelBorder.Background = CreateSolidBrush(isNightVisual ? "#12FFFFFF" : "#0BFFFFFF"); + SeparatorLine.Background = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0x3A : (byte)0x28); + + var hourlyTempBrush = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xEA : (byte)0xDC); + var hourlyTimeBrush = CreateSolidBrush(palette.TertiaryText, isNightVisual ? (byte)0xCA : (byte)0xB6); + var dailyTextBrush = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xE8 : (byte)0xDE); + var dailyLowBrush = CreateSolidBrush(palette.TertiaryText, isNightVisual ? (byte)0xB6 : (byte)0xA0); + for (var i = 0; i < _hourlyTempBlocks.Length; i++) + { + _hourlyTempBlocks[i].Foreground = hourlyTempBrush; + _hourlyTimeBlocks[i].Foreground = hourlyTimeBrush; + } + + for (var i = 0; i < _dailyLabelBlocks.Length; i++) + { + _dailyLabelBlocks[i].Foreground = dailyTextBrush; + _dailyHighBlocks[i].Foreground = dailyTextBrush; + _dailyLowBlocks[i].Foreground = dailyLowBrush; + } + } + + private void ApplyTypography(double width, double height) + { + var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.Extended4x4); + var scale = ResolveScale(width, height); + var compactness = Math.Clamp((0.90 - scale) / 0.55, 0, 1); + LayoutRoot.RowSpacing = Math.Clamp(height * 0.014, 5, 14); + SummaryGrid.ColumnSpacing = Math.Clamp(width * 0.017, 8, 24); + HourlyGrid.ColumnSpacing = Math.Clamp(width * 0.008, 3, 10); + DailyGrid.RowSpacing = Math.Clamp(height * 0.010, 4, 11); + TemperatureTextBlock.FontSize = Math.Clamp(height * 0.19, 54, 162); + TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 380, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); + CityTextBlock.FontSize = Math.Clamp(height * 0.042, 12, 32); + ConditionTextBlock.FontSize = Math.Clamp(height * 0.050, 13, 38); + RangeTextBlock.FontSize = Math.Clamp(height * 0.053, 13, 40); + CityTextBlock.FontWeight = ToVariableWeight(Lerp(520, 600, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); + ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(560, 640, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); + RangeTextBlock.FontWeight = ToVariableWeight(Lerp(560, 650, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); + var iconSize = Math.Clamp(height * 0.112, 36, 96); + WeatherIconImage.Width = iconSize; + WeatherIconImage.Height = iconSize; + ConditionTextBlock.MaxWidth = Math.Clamp(width * 0.23, 86, 260); + RangeTextBlock.MaxWidth = Math.Clamp(width * 0.23, 86, 260); + CityTextBlock.MaxWidth = Math.Clamp(width * 0.30, 92, 300); + + HourlyPanelBorder.Padding = new Thickness( + Math.Clamp(width * metrics.HorizontalPaddingScale * 0.16, 6, 16), + Math.Clamp(height * metrics.VerticalPaddingScale * 0.16, 5, 14)); + HourlyPanelBorder.CornerRadius = new CornerRadius(Math.Clamp(height * 0.042, 10, 20)); + + var hourlyBandHeight = Math.Clamp(height * 0.20, 74, 164); + var hourlyCellWidth = Math.Max(34, (width - HourlyPanelBorder.Padding.Left - HourlyPanelBorder.Padding.Right - (HourlyGrid.ColumnSpacing * 5)) / 6d); + var hourlyTempSize = Math.Clamp(hourlyBandHeight * 0.24, 10, 34); + var hourlyTimeSize = Math.Clamp(hourlyBandHeight * 0.18, 8, 24); + var hourlyIconSize = Math.Clamp(hourlyBandHeight * 0.20, 12, 32); + var hourlyStackSpacing = Math.Clamp(hourlyBandHeight * 0.03, 1, 4); + for (var i = 0; i < _hourlyTempBlocks.Length; i++) + { + _hourlyTempBlocks[i].FontSize = hourlyTempSize; + _hourlyTimeBlocks[i].FontSize = hourlyTimeSize; + _hourlyTempBlocks[i].FontWeight = ToVariableWeight(Lerp(540, 620, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); + _hourlyTimeBlocks[i].FontWeight = ToVariableWeight(Lerp(450, 530, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); + _hourlyTempBlocks[i].MaxWidth = hourlyCellWidth; + _hourlyTimeBlocks[i].MaxWidth = hourlyCellWidth; + _hourlyIconBlocks[i].Width = hourlyIconSize; + _hourlyIconBlocks[i].Height = hourlyIconSize; + if (_hourlyTempBlocks[i].Parent is StackPanel stack) stack.Spacing = hourlyStackSpacing; + } + + var dailyLabelSize = Math.Clamp(height * 0.043, 10, 32); + var dailyTempSize = Math.Clamp(height * 0.044, 10, 34); + var dailyIconSize = Math.Clamp(height * 0.040, 12, 30); + var dailyLabelMaxWidth = Math.Clamp(width * (compactness > 0.3 ? 0.48 : 0.56), 120, 380); + var dailyHighWidth = Math.Clamp(width * 0.11, 34, 72); + var dailyLowWidth = Math.Clamp(width * 0.10, 30, 68); + for (var i = 0; i < _dailyLabelBlocks.Length; i++) + { + _dailyLabelBlocks[i].FontSize = dailyLabelSize; + _dailyHighBlocks[i].FontSize = dailyTempSize; + _dailyLowBlocks[i].FontSize = dailyTempSize; + _dailyLabelBlocks[i].FontWeight = ToVariableWeight(Lerp(520, 600, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); + _dailyHighBlocks[i].FontWeight = ToVariableWeight(Lerp(560, 640, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); + _dailyLowBlocks[i].FontWeight = ToVariableWeight(Lerp(470, 560, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); + _dailyLabelBlocks[i].MaxWidth = dailyLabelMaxWidth; + _dailyHighBlocks[i].Width = dailyHighWidth; + _dailyLowBlocks[i].Width = dailyLowWidth; + _dailyHighBlocks[i].HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right; + _dailyLowBlocks[i].HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right; + _dailyHighBlocks[i].TextAlignment = TextAlignment.Right; + _dailyLowBlocks[i].TextAlignment = TextAlignment.Right; + _dailyIconBlocks[i].Width = dailyIconSize; + _dailyIconBlocks[i].Height = dailyIconSize; + } + } + + private static bool IsNightHour(DateTime time) => time.Hour < 6 || time.Hour >= 18; + + private string ResolveDayLabel(DateOnly date, int offset) + { + if (offset == 1) return L("weather.multiday.tomorrow", "Tomorrow"); + var dt = date.ToDateTime(TimeOnly.MinValue); + if (string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)) + { + return dt.ToString("ddd", CultureInfo.GetCultureInfo("zh-CN")) + .Replace("星期", "周", StringComparison.Ordinal); + } + + return dt.ToString("ddd", CultureInfo.InvariantCulture); + } + + private string ResolveLocation(string? rawLocation, string? fallbackLocation) + { + var input = string.IsNullOrWhiteSpace(rawLocation) ? fallbackLocation : rawLocation; + if (string.IsNullOrWhiteSpace(input)) + { + return L("weather.widget.location_unknown", "Unknown location"); + } + + var tokens = input.Split(['|', '/', '\\', ',', ',', '、'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (tokens.Length == 0) return input.Trim(); + return string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase) ? tokens.OrderByDescending(item => item.Length).First() : tokens.Last(); + } + + private string ResolveWeatherText(string? weatherText, HyperOS3WeatherVisualKind kind) + { + if (!string.IsNullOrWhiteSpace(weatherText)) return weatherText; + return kind switch + { + HyperOS3WeatherVisualKind.ClearDay or HyperOS3WeatherVisualKind.ClearNight => L("weather.widget.condition_clear", "Clear"), + HyperOS3WeatherVisualKind.CloudyDay or HyperOS3WeatherVisualKind.CloudyNight => L("weather.widget.condition_cloudy", "Cloudy"), + HyperOS3WeatherVisualKind.RainLight or HyperOS3WeatherVisualKind.RainHeavy => L("weather.widget.condition_rain", "Rain"), + HyperOS3WeatherVisualKind.Storm => L("weather.widget.condition_storm", "Thunderstorm"), + HyperOS3WeatherVisualKind.Snow => L("weather.widget.condition_snow", "Snow"), + _ => L("weather.widget.condition_fog", "Fog") + }; + } + + private DateTime ConvertToConfiguredTime(DateTimeOffset sourceTime) + { + try + { + return _timeZoneService is null + ? sourceTime.ToLocalTime().DateTime + : TimeZoneInfo.ConvertTime(sourceTime, _timeZoneService.CurrentTimeZone).DateTime; + } + catch + { + return sourceTime.ToLocalTime().DateTime; + } + } + + private static string FormatTemperature(double? value) => !value.HasValue || double.IsNaN(value.Value) || double.IsInfinity(value.Value) ? "--°" : $"{(int)Math.Round(value.Value, MidpointRounding.AwayFromZero)}°"; + private static string FormatTemperatureValue(double? value) => !value.HasValue || double.IsNaN(value.Value) || double.IsInfinity(value.Value) ? "--" : $"{(int)Math.Round(value.Value, MidpointRounding.AwayFromZero)}"; + + private static IBrush? CreateImageBrush(string? uriText) + { + var source = HyperOS3WeatherAssetLoader.LoadImage(uriText); + if (source is not IImageBrushSource brushSource) + { + return null; + } + + return new ImageBrush { Source = brushSource, Stretch = Stretch.UniformToFill, AlignmentX = AlignmentX.Center, AlignmentY = AlignmentY.Center }; + } + + private string L(string key, string fallback) => _localizationService.GetString(_languageCode, key, fallback); + + private void CancelRefresh() + { + var cts = Interlocked.Exchange(ref _refreshCts, null); + cts?.Cancel(); + cts?.Dispose(); + } + + private static double ResolveScale(double width, double height) => Math.Clamp(Math.Min(Math.Clamp(width / 620d, 0.42, 2.4), Math.Clamp(height / 620d, 0.42, 2.4)), 0.42, 2.4); + private static double Lerp(double from, double to, double t) => from + ((to - from) * t); + private static FontWeight ToVariableWeight(double weight) => (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000); + private static IBrush CreateSolidBrush(string colorHex) => new SolidColorBrush(Color.Parse(colorHex)); + private static IBrush CreateSolidBrush(string colorHex, byte alpha) { var c = Color.Parse(colorHex); return new SolidColorBrush(Color.FromArgb(alpha, c.R, c.G, c.B)); } + private static IBrush CreateGradientBrush(string fromHex, string toHex) => new LinearGradientBrush { StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative), GradientStops = new GradientStops { new(Color.Parse(fromHex), 0), new(Color.Parse(toHex), 1) } }; + + private void SetLoadingSkeleton(bool isLoading) + { + CityInfoBadge.Background = isLoading ? CreateSolidBrush("#24FFFFFF") : Brushes.Transparent; + ConditionInfoBadge.Background = isLoading ? CreateSolidBrush("#1DFFFFFF") : Brushes.Transparent; } } + diff --git a/LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml b/LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml index 7e99447..182e22c 100644 --- a/LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml +++ b/LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml @@ -1,4 +1,4 @@ - @@ -32,12 +32,12 @@ @@ -54,7 +54,7 @@ @@ -73,224 +73,243 @@ ClipToBounds="True" /> - - - + + + - - - - - - - + + + + + + + + VerticalAlignment="Center"> - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs b/LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs index 53bd68c..8af163e 100644 --- a/LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs +++ b/LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs @@ -11,8 +11,6 @@ using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Platform; using Avalonia.Threading; -using FluentIcons.Avalonia; -using FluentIcons.Common; using LanMontainDesktop.Models; using LanMontainDesktop.Services; @@ -79,7 +77,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, private readonly record struct HourlyForecastItem( DateTime Time, string TimeLabel, - Symbol Icon, + HyperOS3WeatherVisualKind IconKind, string TemperatureText); private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService(); @@ -114,7 +112,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, private bool _isAttached; private bool _isRefreshing; private readonly TextBlock[] _hourlyTimeBlocks; - private readonly SymbolIcon[] _hourlyIconBlocks; + private readonly Image[] _hourlyIconBlocks; private readonly TextBlock[] _hourlyTempBlocks; public HourlyWeatherWidget() @@ -215,7 +213,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, var scale = ResolveScale(); var hostWidth = Bounds.Width > 1 ? Bounds.Width : Math.Max(140, _currentCellSize * 4); var hostHeight = Bounds.Height > 1 ? Bounds.Height : Math.Max(78, _currentCellSize * 2); - var cornerRadius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 24, 44); + var cornerRadius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 24, 46); RootBorder.CornerRadius = new CornerRadius(cornerRadius); BackgroundImageLayer.CornerRadius = new CornerRadius(cornerRadius); @@ -224,8 +222,8 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, BackgroundLightLayer.CornerRadius = new CornerRadius(cornerRadius); BackgroundShadeLayer.CornerRadius = new CornerRadius(cornerRadius); ContentPaddingBorder.Padding = new Thickness( - Math.Clamp(Math.Min((_currentCellSize * metrics.HorizontalPaddingScale) * scale, hostWidth * 0.028), 3, 18), - Math.Clamp(Math.Min((_currentCellSize * metrics.VerticalPaddingScale) * scale, hostHeight * 0.060), 2, 14)); + Math.Clamp(Math.Min((_currentCellSize * metrics.HorizontalPaddingScale) * scale, hostWidth * 0.034), 4, 22), + Math.Clamp(Math.Min((_currentCellSize * metrics.VerticalPaddingScale) * scale, hostHeight * 0.068), 3, 18)); ApplyAdaptiveTypography(); ResetParticles(); } @@ -448,11 +446,8 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, CityTextBlock.Text = ResolvePreciseDisplayLocation(rawLocation, _languageCode, L("weather.widget.location_unknown", "Unknown location")); ConditionTextBlock.Text = ResolveWeatherConditionText(snapshot.Current.WeatherText, visualKind); - WeatherIconSymbol.Symbol = ResolveWeatherSymbol(visualKind); - WeatherIconSymbol.Foreground = CreateSolidBrush( - ResolveWeatherIconAccent( - WeatherIconSymbol.Symbol, - visualKind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight)); + SetMainWeatherIcon(visualKind); + SetLoadingSkeleton(false); TemperatureTextBlock.Text = FormatTemperature(snapshot.Current.TemperatureC); var (low, high) = ResolveTemperatureRange(snapshot); @@ -465,13 +460,8 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, { var fallbackKind = ResolveFallbackVisualKind(); ApplyVisualTheme(fallbackKind); - WeatherIconSymbol.Symbol = fallbackKind == WeatherVisualKind.ClearNight - ? Symbol.WeatherMoon - : Symbol.WeatherSunny; - WeatherIconSymbol.Foreground = CreateSolidBrush( - ResolveWeatherIconAccent( - WeatherIconSymbol.Symbol, - fallbackKind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight)); + SetMainWeatherIcon(fallbackKind); + SetLoadingSkeleton(false); CityTextBlock.Text = L("weather.widget.location_not_configured", "Weather location is not configured"); ConditionTextBlock.Text = L("weather.widget.configure_hint", "Open Settings > Weather to configure"); TemperatureTextBlock.Text = "--°"; @@ -485,13 +475,8 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, { var loadingKind = IsNightNow() ? WeatherVisualKind.CloudyNight : WeatherVisualKind.CloudyDay; ApplyVisualTheme(loadingKind); - WeatherIconSymbol.Symbol = loadingKind == WeatherVisualKind.CloudyNight - ? Symbol.WeatherPartlyCloudyNight - : Symbol.WeatherPartlyCloudyDay; - WeatherIconSymbol.Foreground = CreateSolidBrush( - ResolveWeatherIconAccent( - WeatherIconSymbol.Symbol, - loadingKind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight)); + SetMainWeatherIcon(loadingKind); + SetLoadingSkeleton(true); CityTextBlock.Text = ResolvePreciseDisplayLocation( locationName, _languageCode, @@ -506,8 +491,8 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, private void ApplyFailedState(string locationName) { ApplyVisualTheme(WeatherVisualKind.Fog); - WeatherIconSymbol.Symbol = Symbol.WeatherFog; - WeatherIconSymbol.Foreground = CreateSolidBrush(ResolveWeatherIconAccent(WeatherIconSymbol.Symbol, false)); + SetMainWeatherIcon(WeatherVisualKind.Fog); + SetLoadingSkeleton(false); CityTextBlock.Text = ResolvePreciseDisplayLocation( locationName, _languageCode, @@ -532,22 +517,21 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, var primary = CreateSolidBrush(palette.PrimaryText); var particleBrush = ResolveParticleBrush(ToThemeKind(kind), palette.ParticleColor); var isNightVisual = kind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight; - var conditionSecondary = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xF0 : (byte)0xE6); - var rangeSecondary = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xE8 : (byte)0xD6); - var forecastTimeBrush = CreateSolidBrush(palette.TertiaryText, isNightVisual ? (byte)0xDA : (byte)0xC6); - var forecastTempBrush = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xF4 : (byte)0xEA); - HourlyPanelBorder.Background = CreateSolidBrush(isNightVisual ? "#1BFFFFFF" : "#1EFFFFFF"); + var cityBrush = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xDC : (byte)0xCC); + var conditionSecondary = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xEE : (byte)0xE2); + var rangeSecondary = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xE6 : (byte)0xD9); + var forecastTimeBrush = CreateSolidBrush(palette.TertiaryText, isNightVisual ? (byte)0xCA : (byte)0xB6); + var forecastTempBrush = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xEA : (byte)0xDC); + HourlyPanelBorder.Background = CreateSolidBrush(isNightVisual ? "#12FFFFFF" : "#0CFFFFFF"); LocationIcon.Foreground = primary; - CityTextBlock.Foreground = primary; + CityTextBlock.Foreground = cityBrush; TemperatureTextBlock.Foreground = primary; - WeatherIconSymbol.Foreground = CreateSolidBrush(ResolveWeatherIconAccent(WeatherIconSymbol.Symbol, isNightVisual)); ConditionTextBlock.Foreground = conditionSecondary; RangeTextBlock.Foreground = rangeSecondary; for (var i = 0; i < _hourlyTimeBlocks.Length; i++) { _hourlyTimeBlocks[i].Foreground = forecastTimeBrush; _hourlyTempBlocks[i].Foreground = forecastTempBrush; - _hourlyIconBlocks[i].Foreground = CreateSolidBrush(ResolveWeatherIconAccent(_hourlyIconBlocks[i].Symbol, isNightVisual)); } foreach (var particle in _particleVisuals) @@ -660,11 +644,6 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, palette.ParticleColor); } - private static Symbol ResolveWeatherSymbol(WeatherVisualKind kind) - { - return HyperOS3WeatherTheme.ResolveWeatherSymbol(ToThemeKind(kind)); - } - private static HyperOS3WeatherVisualKind ToThemeKind(WeatherVisualKind kind) { return kind switch @@ -720,14 +699,14 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, { if (!low.HasValue && !high.HasValue) { - return L("weather.widget.range_unknown", "-- / --"); + return L("weather.widget.range_unknown", "--/--"); } var lowText = FormatTemperature(low); var highText = FormatTemperature(high); return string.Format( GetUiCulture(), - L("weather.widget.range_format", "{0} / {1}"), + L("weather.widget.range_format", "{0}/{1}"), lowText, highText); } @@ -769,7 +748,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, var candidate = TryFindNearestHourlyCandidate(hourlyCandidates, targetTime); var weatherCode = candidate?.Hourly.WeatherCode ?? ResolveFallbackWeatherCode(targetTime, snapshot, fallbackDaily); - var icon = ResolveWeatherSymbol(ResolveVisualKind(weatherCode, IsNightHour(targetTime))); + var iconKind = ToThemeKind(ResolveVisualKind(weatherCode, IsNightHour(targetTime))); var estimatedTemp = candidate?.Hourly.TemperatureC ?? EstimateHourlyTemperature( @@ -782,7 +761,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, items.Add(new HourlyForecastItem( targetTime, displayLabel, - icon, + iconKind, FormatTemperature(estimatedTemp))); } @@ -794,7 +773,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, const int itemCount = 6; var items = new List(itemCount); var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now; - var symbol = ResolveWeatherSymbol(visualKind); + var iconKind = ToThemeKind(visualKind); for (var i = 0; i < itemCount; i++) { var targetTime = now.AddHours(i); @@ -803,7 +782,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, i == 0 ? L("weather.hourly.now", "Now") : targetTime.ToString("HH:mm", CultureInfo.InvariantCulture), - symbol, + iconKind, "--°")); } @@ -812,21 +791,22 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, private void ApplyHourlyForecastItems(IReadOnlyList items) { - var isNightVisual = _activeVisualKind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight; + var fallbackIcon = HyperOS3WeatherAssetLoader.LoadImage( + HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(_activeVisualKind))); for (var i = 0; i < _hourlyTimeBlocks.Length; i++) { if (i >= items.Count) { _hourlyTimeBlocks[i].Text = "--"; _hourlyTempBlocks[i].Text = "--°"; - _hourlyIconBlocks[i].Symbol = ResolveWeatherSymbol(_activeVisualKind); + _hourlyIconBlocks[i].Source = fallbackIcon; continue; } var item = items[i]; _hourlyTimeBlocks[i].Text = item.TimeLabel; - _hourlyIconBlocks[i].Symbol = item.Icon; - _hourlyIconBlocks[i].Foreground = CreateSolidBrush(ResolveWeatherIconAccent(item.Icon, isNightVisual)); + _hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage( + HyperOS3WeatherTheme.ResolveIconAsset(item.IconKind)); _hourlyTempBlocks[i].Text = item.TemperatureText; } } @@ -951,12 +931,6 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, return estimated; } - private static string ResolveWeatherIconAccent(Symbol symbol, bool isNightVisual) - { - var kind = isNightVisual ? HyperOS3WeatherVisualKind.ClearNight : HyperOS3WeatherVisualKind.ClearDay; - return HyperOS3WeatherTheme.ResolveIconAccent(kind, symbol); - } - private static string ResolvePreciseDisplayLocation(string? rawName, string languageCode, string fallback) { if (string.IsNullOrWhiteSpace(rawName)) @@ -1103,93 +1077,78 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, private void ApplyAdaptiveTypography() { var (layoutWidth, layoutHeight) = ResolveLayoutViewport(); - var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.Hourly4x2); - var scale = ResolveScale(layoutWidth, layoutHeight); - var densityBoost = scale <= 0.55 ? 0.80 : scale <= 0.72 ? 0.88 : scale <= 0.92 ? 0.95 : scale >= 1.45 ? 1.06 : 1.0; - var compactness = Math.Clamp((0.88 - scale) / 0.50, 0, 1); - var cityLength = Math.Max(1, CityTextBlock.Text?.Length ?? 2); - var cityCompression = cityLength >= 12 ? 0.68 : cityLength >= 9 ? 0.80 : cityLength >= 6 ? 0.90 : 1.0; - var conditionLength = Math.Max(1, ConditionTextBlock.Text?.Length ?? 2); - var conditionCompression = conditionLength >= 12 ? 0.72 : conditionLength >= 8 ? 0.85 : conditionLength >= 6 ? 0.92 : 1.0; + var scaleX = Math.Clamp(layoutWidth / 608d, 0.58, 1.90); + var scaleY = Math.Clamp(layoutHeight / 288d, 0.58, 1.90); + var uiScale = Math.Clamp(Math.Min(scaleX, scaleY), 0.58, 1.75); + var innerWidth = Math.Max(120, layoutWidth); + var innerHeight = Math.Max(72, layoutHeight); - ContentGrid.RowSpacing = Math.Clamp(Math.Max(metrics.MainGap * scale, layoutHeight * Lerp(0.030, 0.018, compactness)), 2, 14); - TopRowGrid.ColumnSpacing = Math.Clamp(Math.Max(metrics.MainGap * scale, layoutWidth * 0.014), 3, 14); - BottomInfoStack.Spacing = Math.Clamp(Math.Max(metrics.SectionGap * scale, layoutHeight * 0.016), 2, 10); - BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(layoutHeight * 0.018, 0, 10)); - ConditionRangeStack.Spacing = Math.Clamp(layoutWidth * 0.010, 3, 12); - ConditionRangeStack.Margin = new Thickness(0, 0, 0, Math.Clamp(layoutHeight * 0.018, 0, 10)); + ContentGrid.RowSpacing = Math.Clamp(7 * scaleY, 2, 12); + TopRowGrid.ColumnSpacing = Math.Clamp(10 * scaleX, 6, 16); + TopRowGrid.RowSpacing = Math.Clamp(5 * scaleY, 2, 9); + BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(2 * scaleY, 0, 5)); + BottomInfoStack.Spacing = Math.Clamp(2 * scaleY, 1, 5); + + var summaryHeight = Math.Clamp(116 * scaleY, 82, 164); + var bodyHeight = Math.Max(52, innerHeight - summaryHeight - ContentGrid.RowSpacing); + + TemperatureTextBlock.FontSize = Math.Clamp(94 * uiScale, 56, 126); + TemperatureTextBlock.FontWeight = ToVariableWeight(320); + TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-2 * uiScale, -5, 0), 0, 0); + TemperatureTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.22, 84, 168); + + CityInfoBadge.Padding = new Thickness( + Math.Clamp(10 * uiScale, 6, 14), + Math.Clamp(4 * uiScale, 2, 8)); + CityInfoBadge.CornerRadius = new CornerRadius(Math.Clamp(11 * uiScale, 8, 16)); + LocationIcon.FontSize = Math.Clamp(14 * uiScale, 10, 20); + CityTextBlock.FontSize = Math.Clamp(21 * uiScale, 13, 31); + CityTextBlock.FontWeight = ToVariableWeight(560); + CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.25, 80, 220); + + ConditionInfoBadge.Padding = new Thickness(0); + ConditionInfoBadge.CornerRadius = new CornerRadius(Math.Clamp(8 * uiScale, 4, 12)); + ConditionRangeStack.Spacing = Math.Clamp(12 * uiScale, 6, 18); + ConditionTextBlock.FontSize = Math.Clamp(34 * uiScale, 16, 46); + RangeTextBlock.FontSize = Math.Clamp(34 * uiScale, 16, 46); + ConditionTextBlock.FontWeight = ToVariableWeight(610); + RangeTextBlock.FontWeight = ToVariableWeight(620); + ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.16, 46, 170); + RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.20, 60, 200); + + var iconSize = Math.Clamp(68 * uiScale, 40, 90); + WeatherIconImage.Width = iconSize; + WeatherIconImage.Height = iconSize; HourlyPanelBorder.Padding = new Thickness( - Math.Clamp(layoutWidth * 0.018, 4, 16), - Math.Clamp(layoutHeight * 0.020, 3, 12)); - HourlyPanelBorder.CornerRadius = new CornerRadius(Math.Clamp(Math.Min(layoutWidth, layoutHeight) * 0.065, 8, 22)); - HourlyGrid.ColumnSpacing = Math.Clamp(layoutWidth * 0.010, 1.5, 12); - - var topBandHeight = Math.Max(18, layoutHeight * 0.22); - var middleBandHeight = Math.Max(24, layoutHeight * 0.30); - var bottomBandHeight = Math.Max(22, layoutHeight - topBandHeight - middleBandHeight - (ContentGrid.RowSpacing * 2)); - - LocationIcon.FontSize = Math.Min(Math.Clamp((metrics.IconFont * 0.6) * scale * densityBoost, 9, 30), topBandHeight * 0.58); - CityTextBlock.FontSize = Math.Min(Math.Clamp((metrics.PrimaryTextFont * 1.42) * scale * cityCompression * densityBoost, 12, 46), topBandHeight * 0.76); - WeatherIconSymbol.FontSize = Math.Min(Math.Clamp((metrics.IconFont * 1.02) * scale * densityBoost, 12, 56), topBandHeight * 0.95); - TemperatureTextBlock.FontSize = Math.Min(Math.Clamp((metrics.PrimaryTemperatureFont * 1.40) * scale * densityBoost, 26, 138), middleBandHeight * 0.92); - ConditionTextBlock.FontSize = Math.Min(Math.Clamp((metrics.PrimaryTextFont * 1.14) * scale * conditionCompression * densityBoost, 9, 40), middleBandHeight * 0.42); - RangeTextBlock.FontSize = Math.Min(Math.Clamp((metrics.SecondaryTextFont * 1.54) * scale * densityBoost, 10, 46), middleBandHeight * 0.50); - TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(layoutHeight * 0.008, 0, 6), 0, Math.Clamp(layoutHeight * 0.012, 0, 8)); - - var weightProgress = Math.Clamp((scale - 0.34) / 1.18, 0, 1); - CityTextBlock.FontWeight = ToVariableWeight(Lerp(540, 680, weightProgress)); - TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(600, 760, weightProgress)); - ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(490, 620, weightProgress)); - RangeTextBlock.FontWeight = ToVariableWeight(Lerp(490, 610, weightProgress)); - - var topRightMaxWidth = Math.Clamp(layoutWidth * Lerp(0.42, 0.34, compactness), 128, 280); - ConditionRangeStack.MaxWidth = topRightMaxWidth; - ConditionTextBlock.MaxWidth = Math.Max(44, topRightMaxWidth * Lerp(0.45, 0.40, compactness)); - RangeTextBlock.MaxWidth = Math.Max(62, topRightMaxWidth * Lerp(0.55, 0.60, compactness)); - var leftTopBudget = Math.Max(140, layoutWidth - topRightMaxWidth - Math.Clamp(64 * scale, 26, 92)); - TemperatureTextBlock.MaxWidth = leftTopBudget; - CityTextBlock.MaxWidth = Math.Max(110, layoutWidth - Math.Clamp(86 * scale, 28, 120)); + Math.Clamp(5 * scaleX, 3, 10), + Math.Clamp(3 * scaleY, 1, 7)); + HourlyPanelBorder.CornerRadius = new CornerRadius(Math.Clamp(14 * uiScale, 8, 20)); + HourlyGrid.ColumnSpacing = Math.Clamp(9 * scaleX, 4, 14); var hourlyColumnCount = Math.Max(1, _hourlyTimeBlocks.Length); var hourlyInnerWidth = Math.Max( - 80, - layoutWidth - HourlyPanelBorder.Padding.Left - HourlyPanelBorder.Padding.Right - (HourlyGrid.ColumnSpacing * (hourlyColumnCount - 1))); - var hourlyCellWidth = Math.Max(32, hourlyInnerWidth / hourlyColumnCount); - var hourlyStackSpacing = Math.Clamp(bottomBandHeight * 0.065, 1, 5); - var hourlyInnerHeight = Math.Max( - 20, - bottomBandHeight - HourlyPanelBorder.Padding.Top - HourlyPanelBorder.Padding.Bottom); - var hourlyLineHeight = Math.Max(6, (hourlyInnerHeight - (hourlyStackSpacing * 2)) / 3d); - var hourlyTimeMaxByWidth = Math.Clamp(hourlyCellWidth / Lerp(4.4, 3.8, 1 - compactness), 7, 24); - var hourlyTempMaxByWidth = Math.Clamp(hourlyCellWidth / Lerp(5.0, 4.4, 1 - compactness), 7, 28); - var hourlyIconMaxByWidth = Math.Clamp(hourlyCellWidth * Lerp(0.32, 0.38, 1 - compactness), 7, 30); - var hourlyTimeMaxByHeight = Math.Clamp(hourlyLineHeight * 0.95, 7, 24); - var hourlyTempMaxByHeight = Math.Clamp(hourlyLineHeight * 0.95, 7, 28); - var hourlyIconMaxByHeight = Math.Clamp(hourlyLineHeight * 1.05, 8, 30); - - var hourlyTimeSize = Math.Min( - Math.Clamp((metrics.CaptionFont * 1.20) * scale * densityBoost, 8, 30), - Math.Min(hourlyTimeMaxByWidth, hourlyTimeMaxByHeight)); - var hourlyIconSize = Math.Min( - Math.Clamp((metrics.IconFont * 0.64) * scale * densityBoost, 8, 34), - Math.Min(hourlyIconMaxByWidth, hourlyIconMaxByHeight)); - var hourlyTempSize = Math.Min( - Math.Clamp((metrics.SecondaryTextFont * 1.34) * scale * densityBoost, 8, 34), - Math.Min(hourlyTempMaxByWidth, hourlyTempMaxByHeight)); + 96, + innerWidth - HourlyPanelBorder.Padding.Left - HourlyPanelBorder.Padding.Right - (HourlyGrid.ColumnSpacing * (hourlyColumnCount - 1))); + var hourlyCellWidth = Math.Max(34, hourlyInnerWidth / hourlyColumnCount); + var stackSpacing = Math.Clamp(2 * scaleY, 1, 4); + var hourlyTempSize = Math.Clamp(bodyHeight * 0.24, 14, 30); + var hourlyTimeSize = Math.Clamp(bodyHeight * 0.20, 10, 24); + var hourlyIconSize = Math.Clamp(bodyHeight * 0.28, 14, 34); for (var i = 0; i < _hourlyTimeBlocks.Length; i++) { - _hourlyTimeBlocks[i].FontSize = hourlyTimeSize; _hourlyTempBlocks[i].FontSize = hourlyTempSize; - _hourlyIconBlocks[i].FontSize = hourlyIconSize; - _hourlyTimeBlocks[i].MaxWidth = hourlyCellWidth; - _hourlyTempBlocks[i].MaxWidth = hourlyCellWidth; - _hourlyTimeBlocks[i].FontWeight = ToVariableWeight(Lerp(480, 620, weightProgress)); - _hourlyTempBlocks[i].FontWeight = ToVariableWeight(Lerp(500, 650, weightProgress)); + _hourlyTimeBlocks[i].FontSize = hourlyTimeSize; + _hourlyIconBlocks[i].Width = hourlyIconSize; + _hourlyIconBlocks[i].Height = hourlyIconSize; + _hourlyTimeBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 36, 128); + _hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 36, 128); + _hourlyTimeBlocks[i].FontWeight = ToVariableWeight(500); + _hourlyTempBlocks[i].FontWeight = ToVariableWeight(590); if (_hourlyTimeBlocks[i].Parent is StackPanel hourlyStack) { - hourlyStack.Spacing = hourlyStackSpacing; + hourlyStack.Spacing = stackSpacing; } } } @@ -1199,6 +1158,18 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, return from + ((to - from) * t); } + private void SetMainWeatherIcon(WeatherVisualKind kind) + { + WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage( + HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(kind))); + } + + private void SetLoadingSkeleton(bool isLoading) + { + CityInfoBadge.Background = isLoading ? CreateSolidBrush("#24FFFFFF") : Brushes.Transparent; + ConditionInfoBadge.Background = isLoading ? CreateSolidBrush("#1CFFFFFF") : Brushes.Transparent; + } + private static FontWeight ToVariableWeight(double weight) { return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000); diff --git a/LanMontainDesktop/Views/Components/HyperOS3WeatherAssetLoader.cs b/LanMontainDesktop/Views/Components/HyperOS3WeatherAssetLoader.cs new file mode 100644 index 0000000..e76d002 --- /dev/null +++ b/LanMontainDesktop/Views/Components/HyperOS3WeatherAssetLoader.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Concurrent; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace LanMontainDesktop.Views.Components; + +internal static class HyperOS3WeatherAssetLoader +{ + private static readonly ConcurrentDictionary ImageCache = new(StringComparer.OrdinalIgnoreCase); + + public static IImage? LoadImage(string? uriText) + { + if (string.IsNullOrWhiteSpace(uriText)) + { + return null; + } + + return ImageCache.GetOrAdd(uriText, static key => + { + try + { + var uri = new Uri(key, UriKind.Absolute); + using var stream = AssetLoader.Open(uri); + return new Bitmap(stream); + } + catch + { + return null; + } + }); + } +} diff --git a/LanMontainDesktop/Views/Components/HyperOS3WeatherTheme.cs b/LanMontainDesktop/Views/Components/HyperOS3WeatherTheme.cs index a002cb9..0407ffa 100644 --- a/LanMontainDesktop/Views/Components/HyperOS3WeatherTheme.cs +++ b/LanMontainDesktop/Views/Components/HyperOS3WeatherTheme.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; -using FluentIcons.Common; using LanMontainDesktop.Models; namespace LanMontainDesktop.Views.Components; @@ -72,13 +71,13 @@ public readonly record struct HyperOS3WeatherMetrics( public static class HyperOS3WeatherTheme { private static readonly HyperOS3WeatherPalette FallbackPalette = new( - GradientFrom: "#7187A8", - GradientTo: "#92A5C2", - Tint: "#3C4E66", + GradientFrom: "#5C7696", + GradientTo: "#90A6C1", + Tint: "#4E6682", PrimaryText: "#FFFFFFFF", - SecondaryText: "#E4ECF7", - TertiaryText: "#C9D4E4", - ParticleColor: "#66EAF2FF"); + SecondaryText: "#DCE6F1", + TertiaryText: "#B8C7D9", + ParticleColor: "#70D3E2F4"); private static readonly HyperOS3WeatherMotion FallbackMotion = new( DriftX: 8.0, DriftY: 6.0, ZoomBase: 1.050, ZoomAmplitude: 0.010, @@ -103,81 +102,95 @@ public static class HyperOS3WeatherTheme [HyperOS3WeatherVisualKind.Fog] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png" }; + private static readonly IReadOnlyDictionary IconAssets = + new Dictionary + { + [HyperOS3WeatherVisualKind.ClearDay] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sunny_day.webp", + [HyperOS3WeatherVisualKind.ClearNight] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_moon_clear.webp", + [HyperOS3WeatherVisualKind.CloudyDay] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp", + [HyperOS3WeatherVisualKind.CloudyNight] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp", + [HyperOS3WeatherVisualKind.RainLight] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp", + [HyperOS3WeatherVisualKind.RainHeavy] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp", + [HyperOS3WeatherVisualKind.Storm] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp", + [HyperOS3WeatherVisualKind.Snow] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp", + [HyperOS3WeatherVisualKind.Fog] = "avares://LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp" + }; + private static readonly IReadOnlyDictionary Palettes = new Dictionary { [HyperOS3WeatherVisualKind.ClearDay] = new( - GradientFrom: "#2D87DA", - GradientTo: "#79BAF2", - Tint: "#2E6CB5", - PrimaryText: "#F7FCFF", - SecondaryText: "#E8F1FD", - TertiaryText: "#D6E5F8", + GradientFrom: "#4D7097", + GradientTo: "#89A4C3", + Tint: "#4E6D8E", + PrimaryText: "#F8FCFF", + SecondaryText: "#DDE8F4", + TertiaryText: "#BACADB", ParticleColor: "#00FFFFFF"), [HyperOS3WeatherVisualKind.ClearNight] = new( - GradientFrom: "#5A6B85", - GradientTo: "#9DADC2", - Tint: "#495B78", + GradientFrom: "#576B86", + GradientTo: "#889CB6", + Tint: "#495F79", PrimaryText: "#F9FBFF", - SecondaryText: "#E2EAF6", - TertiaryText: "#C6D2E3", + SecondaryText: "#D9E4F0", + TertiaryText: "#B4C3D6", ParticleColor: "#00FFFFFF"), [HyperOS3WeatherVisualKind.CloudyDay] = new( - GradientFrom: "#5F88B6", - GradientTo: "#8FB0D1", - Tint: "#496F98", + GradientFrom: "#607896", + GradientTo: "#94A9C1", + Tint: "#526C88", PrimaryText: "#F8FCFF", - SecondaryText: "#E4EDF8", - TertiaryText: "#CBD9EA", + SecondaryText: "#DCE7F3", + TertiaryText: "#B9C8D9", ParticleColor: "#26FFFFFF"), [HyperOS3WeatherVisualKind.CloudyNight] = new( - GradientFrom: "#556A85", - GradientTo: "#95A5BC", - Tint: "#43566E", + GradientFrom: "#51637A", + GradientTo: "#8398AF", + Tint: "#45586D", PrimaryText: "#F6FAFF", - SecondaryText: "#DEE7F4", - TertiaryText: "#C1CDDE", + SecondaryText: "#D4E0ED", + TertiaryText: "#B0BFD2", ParticleColor: "#30F0F5FF"), [HyperOS3WeatherVisualKind.RainLight] = new( - GradientFrom: "#5A7DA7", - GradientTo: "#8FAAC8", - Tint: "#3F5F84", + GradientFrom: "#4F6786", + GradientTo: "#7A92AF", + Tint: "#425C7A", PrimaryText: "#F8FBFF", - SecondaryText: "#E3EAF5", - TertiaryText: "#C4D0E0", - ParticleColor: "#88D7E8FF"), + SecondaryText: "#D7E2EE", + TertiaryText: "#AEBED0", + ParticleColor: "#86CCDEFF"), [HyperOS3WeatherVisualKind.RainHeavy] = new( - GradientFrom: "#4C678A", - GradientTo: "#7D95AF", - Tint: "#354C69", + GradientFrom: "#435770", + GradientTo: "#667F98", + Tint: "#364961", PrimaryText: "#F9FCFF", - SecondaryText: "#E0E8F4", - TertiaryText: "#C0CBDA", - ParticleColor: "#A2CDE1FF"), + SecondaryText: "#D3DEEB", + TertiaryText: "#A9B8CB", + ParticleColor: "#9FC4D8FF"), [HyperOS3WeatherVisualKind.Storm] = new( - GradientFrom: "#435D7B", - GradientTo: "#6F869F", - Tint: "#2B3D53", + GradientFrom: "#3A4D63", + GradientTo: "#5C7288", + Tint: "#2F4055", PrimaryText: "#F9FCFF", - SecondaryText: "#DBE5F2", - TertiaryText: "#B9C5D7", - ParticleColor: "#A8C2D6F2"), + SecondaryText: "#CEDAE8", + TertiaryText: "#A6B6C8", + ParticleColor: "#9EB8CCF2"), [HyperOS3WeatherVisualKind.Snow] = new( - GradientFrom: "#9FB7D0", - GradientTo: "#B7CAE0", - Tint: "#6D839D", + GradientFrom: "#8A9FBA", + GradientTo: "#AEC1D6", + Tint: "#6E829A", PrimaryText: "#F8FBFF", - SecondaryText: "#E5EDF7", - TertiaryText: "#CDD9E7", + SecondaryText: "#D9E4EF", + TertiaryText: "#B5C4D6", ParticleColor: "#CCFFFFFF"), [HyperOS3WeatherVisualKind.Fog] = new( - GradientFrom: "#687E9A", - GradientTo: "#9AACBE", - Tint: "#4B6078", + GradientFrom: "#657B97", + GradientTo: "#90A5BC", + Tint: "#4F637B", PrimaryText: "#F8FBFF", - SecondaryText: "#E3EAF4", - TertiaryText: "#C4D0DF", - ParticleColor: "#88E4EDF7") + SecondaryText: "#D8E3EE", + TertiaryText: "#AFBED0", + ParticleColor: "#88D9E5F1") }; private static readonly IReadOnlyDictionary Motions = @@ -260,11 +273,11 @@ public static class HyperOS3WeatherTheme private static readonly IReadOnlyDictionary Metrics = new Dictionary { - [HyperOS3WeatherWidgetKind.Realtime2x2] = new(0.45, 0.38, 0.38, 108, 30, 30, 24, 40, 8, 4), - [HyperOS3WeatherWidgetKind.Hourly4x2] = new(0.45, 0.32, 0.30, 96, 28, 24, 20, 30, 8, 4), - [HyperOS3WeatherWidgetKind.MultiDay4x2] = new(0.45, 0.32, 0.30, 96, 28, 24, 20, 30, 8, 4), + [HyperOS3WeatherWidgetKind.Realtime2x2] = new(0.47, 0.32, 0.30, 112, 28, 24, 20, 36, 8, 5), + [HyperOS3WeatherWidgetKind.Hourly4x2] = new(0.47, 0.24, 0.22, 96, 24, 20, 16, 26, 7, 4), + [HyperOS3WeatherWidgetKind.MultiDay4x2] = new(0.47, 0.24, 0.22, 96, 24, 20, 16, 26, 7, 4), [HyperOS3WeatherWidgetKind.WeatherClock2x1] = new(0.40, 0.18, 0.14, 42, 18, 15, 12, 18, 4, 3), - [HyperOS3WeatherWidgetKind.Extended4x4] = new(0.45, 0.28, 0.28, 88, 24, 20, 18, 24, 8, 6) + [HyperOS3WeatherWidgetKind.Extended4x4] = new(0.47, 0.24, 0.22, 112, 26, 22, 18, 28, 9, 6) }; public static HyperOS3WeatherVisualKind ResolveVisualKind(int? weatherCode, bool isNight) @@ -304,6 +317,11 @@ public static class HyperOS3WeatherTheme return BackgroundAssets.TryGetValue(kind, out var asset) ? asset : null; } + public static string? ResolveIconAsset(HyperOS3WeatherVisualKind kind) + { + return IconAssets.TryGetValue(kind, out var asset) ? asset : null; + } + public static string ResolveSunCoreAsset() { return "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sun_core.png"; @@ -328,40 +346,6 @@ public static class HyperOS3WeatherTheme }; } - public static Symbol ResolveWeatherSymbol(HyperOS3WeatherVisualKind kind) - { - return kind switch - { - HyperOS3WeatherVisualKind.ClearDay => Symbol.WeatherSunny, - HyperOS3WeatherVisualKind.ClearNight => Symbol.WeatherMoon, - HyperOS3WeatherVisualKind.CloudyDay => Symbol.WeatherPartlyCloudyDay, - HyperOS3WeatherVisualKind.CloudyNight => Symbol.WeatherPartlyCloudyNight, - HyperOS3WeatherVisualKind.RainLight => Symbol.WeatherRainShowersDay, - HyperOS3WeatherVisualKind.RainHeavy => Symbol.WeatherRain, - HyperOS3WeatherVisualKind.Storm => Symbol.WeatherThunderstorm, - HyperOS3WeatherVisualKind.Snow => Symbol.WeatherSnow, - _ => Symbol.WeatherFog - }; - } - - public static string ResolveIconAccent(HyperOS3WeatherVisualKind kind, Symbol symbol) - { - var isNight = kind is HyperOS3WeatherVisualKind.ClearNight or HyperOS3WeatherVisualKind.CloudyNight; - return symbol switch - { - Symbol.WeatherSunny => isNight ? "#F0D18A" : "#F5C65C", - Symbol.WeatherMoon => "#EED49A", - Symbol.WeatherPartlyCloudyDay => "#F3D68E", - Symbol.WeatherPartlyCloudyNight => "#CFDCFF", - Symbol.WeatherRainShowersDay => "#C7DCF9", - Symbol.WeatherRain => "#BCD4F4", - Symbol.WeatherThunderstorm => "#F0D38B", - Symbol.WeatherSnow => "#EBF5FF", - Symbol.WeatherFog => "#E3EBF6", - _ => isNight ? "#D2DDEE" : "#E5EEF9" - }; - } - public static bool ResolveIsNightPreferred( WeatherSnapshot snapshot, TimeZoneInfo? timeZone, diff --git a/LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml b/LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml index c110871..bad3033 100644 --- a/LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml +++ b/LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml @@ -1,4 +1,4 @@ - @@ -32,12 +32,12 @@ @@ -54,7 +54,7 @@ @@ -73,204 +73,221 @@ ClipToBounds="True" /> - - - + + + - - - + + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs b/LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs index 5d37a98..5736a9d 100644 --- a/LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs +++ b/LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs @@ -8,11 +8,7 @@ using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Media; -using Avalonia.Media.Imaging; -using Avalonia.Platform; using Avalonia.Threading; -using FluentIcons.Avalonia; -using FluentIcons.Common; using LanMontainDesktop.Models; using LanMontainDesktop.Services; @@ -79,7 +75,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge private readonly record struct HourlyForecastItem( DateTime Time, string TimeLabel, - Symbol Icon, + HyperOS3WeatherVisualKind IconKind, string TemperatureText); private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService(); @@ -114,7 +110,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge private bool _isAttached; private bool _isRefreshing; private readonly TextBlock[] _hourlyTimeBlocks; - private readonly SymbolIcon[] _hourlyIconBlocks; + private readonly Image[] _hourlyIconBlocks; private readonly TextBlock[] _hourlyTempBlocks; public MultiDayWeatherWidget() @@ -215,7 +211,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge var scale = ResolveScale(); var hostWidth = Bounds.Width > 1 ? Bounds.Width : Math.Max(140, _currentCellSize * 4); var hostHeight = Bounds.Height > 1 ? Bounds.Height : Math.Max(78, _currentCellSize * 2); - var cornerRadius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 24, 44); + var cornerRadius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 24, 46); RootBorder.CornerRadius = new CornerRadius(cornerRadius); BackgroundImageLayer.CornerRadius = new CornerRadius(cornerRadius); @@ -224,8 +220,8 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge BackgroundLightLayer.CornerRadius = new CornerRadius(cornerRadius); BackgroundShadeLayer.CornerRadius = new CornerRadius(cornerRadius); ContentPaddingBorder.Padding = new Thickness( - Math.Clamp(Math.Min((_currentCellSize * metrics.HorizontalPaddingScale) * scale, hostWidth * 0.028), 3, 18), - Math.Clamp(Math.Min((_currentCellSize * metrics.VerticalPaddingScale) * scale, hostHeight * 0.060), 2, 14)); + Math.Clamp(Math.Min((_currentCellSize * metrics.HorizontalPaddingScale) * scale, hostWidth * 0.034), 4, 22), + Math.Clamp(Math.Min((_currentCellSize * metrics.VerticalPaddingScale) * scale, hostHeight * 0.068), 3, 18)); ApplyAdaptiveTypography(); ResetParticles(); } @@ -448,14 +444,12 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge CityTextBlock.Text = ResolvePreciseDisplayLocation(rawLocation, _languageCode, L("weather.widget.location_unknown", "Unknown location")); ConditionTextBlock.Text = ResolveWeatherConditionText(snapshot.Current.WeatherText, visualKind); - WeatherIconSymbol.Symbol = ResolveWeatherSymbol(visualKind); - WeatherIconSymbol.Foreground = CreateSolidBrush( - ResolveWeatherIconAccent( - WeatherIconSymbol.Symbol, - visualKind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight)); + SetMainWeatherIcon(visualKind); + SetLoadingSkeleton(false); TemperatureTextBlock.Text = FormatTemperature(snapshot.Current.TemperatureC); - RangeTextBlock.Text = FormatAirQualityText(snapshot.Current.AirQualityIndex); + var (low, high) = ResolveTemperatureRange(snapshot); + RangeTextBlock.Text = FormatTemperatureRange(low, high); ApplyHourlyForecastItems(BuildHourlyForecastItems(snapshot)); ApplyAdaptiveTypography(); } @@ -464,17 +458,12 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge { var fallbackKind = ResolveFallbackVisualKind(); ApplyVisualTheme(fallbackKind); - WeatherIconSymbol.Symbol = fallbackKind == WeatherVisualKind.ClearNight - ? Symbol.WeatherMoon - : Symbol.WeatherSunny; - WeatherIconSymbol.Foreground = CreateSolidBrush( - ResolveWeatherIconAccent( - WeatherIconSymbol.Symbol, - fallbackKind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight)); + SetMainWeatherIcon(fallbackKind); + SetLoadingSkeleton(false); CityTextBlock.Text = L("weather.widget.location_not_configured", "Weather location is not configured"); ConditionTextBlock.Text = L("weather.widget.condition_unknown", "Unknown"); TemperatureTextBlock.Text = "--°"; - RangeTextBlock.Text = L("weather.multiday.aqi_unknown", "Air --"); + RangeTextBlock.Text = L("weather.widget.range_unknown", "-- / --"); ApplyHourlyForecastItems(BuildPlaceholderHourlyForecastItems(fallbackKind)); ApplyAdaptiveTypography(); _latestSnapshot = null; @@ -484,20 +473,15 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge { var loadingKind = IsNightNow() ? WeatherVisualKind.CloudyNight : WeatherVisualKind.CloudyDay; ApplyVisualTheme(loadingKind); - WeatherIconSymbol.Symbol = loadingKind == WeatherVisualKind.CloudyNight - ? Symbol.WeatherPartlyCloudyNight - : Symbol.WeatherPartlyCloudyDay; - WeatherIconSymbol.Foreground = CreateSolidBrush( - ResolveWeatherIconAccent( - WeatherIconSymbol.Symbol, - loadingKind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight)); + SetMainWeatherIcon(loadingKind); + SetLoadingSkeleton(true); CityTextBlock.Text = ResolvePreciseDisplayLocation( locationName, _languageCode, L("weather.widget.location_unknown", "Unknown location")); ConditionTextBlock.Text = L("weather.widget.loading", "Loading..."); TemperatureTextBlock.Text = "--°"; - RangeTextBlock.Text = L("weather.multiday.aqi_unknown", "Air --"); + RangeTextBlock.Text = L("weather.widget.range_unknown", "-- / --"); ApplyHourlyForecastItems(BuildPlaceholderHourlyForecastItems(loadingKind)); ApplyAdaptiveTypography(); } @@ -505,15 +489,15 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge private void ApplyFailedState(string locationName) { ApplyVisualTheme(WeatherVisualKind.Fog); - WeatherIconSymbol.Symbol = Symbol.WeatherFog; - WeatherIconSymbol.Foreground = CreateSolidBrush(ResolveWeatherIconAccent(WeatherIconSymbol.Symbol, false)); + SetMainWeatherIcon(WeatherVisualKind.Fog); + SetLoadingSkeleton(false); CityTextBlock.Text = ResolvePreciseDisplayLocation( locationName, _languageCode, L("weather.widget.location_unknown", "Unknown location")); ConditionTextBlock.Text = L("weather.widget.fetch_failed", "Weather fetch failed"); TemperatureTextBlock.Text = "--°"; - RangeTextBlock.Text = L("weather.multiday.aqi_unknown", "Air --"); + RangeTextBlock.Text = L("weather.widget.range_unknown", "-- / --"); ApplyHourlyForecastItems(BuildPlaceholderHourlyForecastItems(WeatherVisualKind.Fog)); ApplyAdaptiveTypography(); _latestSnapshot = null; @@ -531,22 +515,21 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge var primary = CreateSolidBrush(palette.PrimaryText); var particleBrush = ResolveParticleBrush(ToThemeKind(kind), palette.ParticleColor); var isNightVisual = kind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight; - var conditionSecondary = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xF0 : (byte)0xE6); - var airQualitySecondary = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xDE : (byte)0xCC); - var forecastTimeBrush = CreateSolidBrush(palette.TertiaryText, isNightVisual ? (byte)0xEA : (byte)0xB6); - var forecastTempBrush = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xF8 : (byte)0xE4); - HourlyPanelBorder.Background = CreateSolidBrush(isNightVisual ? "#24FFFFFF" : "#1EFFFFFF"); + var cityBrush = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xDC : (byte)0xCC); + var conditionSecondary = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xEE : (byte)0xE2); + var rangeSecondary = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xE6 : (byte)0xD9); + var forecastTimeBrush = CreateSolidBrush(palette.TertiaryText, isNightVisual ? (byte)0xCA : (byte)0xB6); + var forecastTempBrush = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xEA : (byte)0xDC); + HourlyPanelBorder.Background = CreateSolidBrush(isNightVisual ? "#12FFFFFF" : "#0BFFFFFF"); LocationIcon.Foreground = primary; - CityTextBlock.Foreground = primary; + CityTextBlock.Foreground = cityBrush; TemperatureTextBlock.Foreground = primary; - WeatherIconSymbol.Foreground = CreateSolidBrush(ResolveWeatherIconAccent(WeatherIconSymbol.Symbol, isNightVisual)); ConditionTextBlock.Foreground = conditionSecondary; - RangeTextBlock.Foreground = airQualitySecondary; + RangeTextBlock.Foreground = rangeSecondary; for (var i = 0; i < _hourlyTimeBlocks.Length; i++) { _hourlyTimeBlocks[i].Foreground = forecastTimeBrush; _hourlyTempBlocks[i].Foreground = forecastTempBrush; - _hourlyIconBlocks[i].Foreground = CreateSolidBrush(ResolveWeatherIconAccent(_hourlyIconBlocks[i].Symbol, isNightVisual)); } foreach (var particle in _particleVisuals) @@ -568,14 +551,12 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge var uriText = HyperOS3WeatherTheme.ResolveBackgroundAsset(ToThemeKind(kind)); if (!string.IsNullOrWhiteSpace(uriText)) { - try + var imageSource = HyperOS3WeatherAssetLoader.LoadImage(uriText); + if (imageSource is IImageBrushSource brushSource) { - var uri = new Uri(uriText, UriKind.Absolute); - using var stream = AssetLoader.Open(uri); - var bitmap = new Bitmap(stream); var imageBrush = new ImageBrush { - Source = bitmap, + Source = brushSource, Stretch = Stretch.UniformToFill, AlignmentX = AlignmentX.Center, AlignmentY = AlignmentY.Center @@ -583,10 +564,6 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge _backgroundBrushCache[kind] = imageBrush; return imageBrush; } - catch - { - // Fall through to gradient background when the image cannot be loaded. - } } var gradientBrush = CreateGradientBrush(palette.GradientFrom, palette.GradientTo); @@ -604,14 +581,12 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge var uriText = HyperOS3WeatherTheme.ResolveParticleAsset(kind); if (!string.IsNullOrWhiteSpace(uriText)) { - try + var imageSource = HyperOS3WeatherAssetLoader.LoadImage(uriText); + if (imageSource is IImageBrushSource brushSource) { - var uri = new Uri(uriText, UriKind.Absolute); - using var stream = AssetLoader.Open(uri); - var bitmap = new Bitmap(stream); var imageBrush = new ImageBrush { - Source = bitmap, + Source = brushSource, Stretch = Stretch.UniformToFill, AlignmentX = AlignmentX.Center, AlignmentY = AlignmentY.Center @@ -619,10 +594,6 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge _particleBrushCache[kind] = imageBrush; return imageBrush; } - catch - { - // Fall through to solid particle color when the image cannot be loaded. - } } var solidBrush = CreateSolidBrush(fallbackColor); @@ -659,11 +630,6 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge palette.ParticleColor); } - private static Symbol ResolveWeatherSymbol(WeatherVisualKind kind) - { - return HyperOS3WeatherTheme.ResolveWeatherSymbol(ToThemeKind(kind)); - } - private static HyperOS3WeatherVisualKind ToThemeKind(WeatherVisualKind kind) { return kind switch @@ -719,14 +685,14 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge { if (!low.HasValue && !high.HasValue) { - return L("weather.widget.range_unknown", "-- / --"); + return L("weather.widget.range_unknown", "--/--"); } var lowText = FormatTemperature(low); var highText = FormatTemperature(high); return string.Format( GetUiCulture(), - L("weather.widget.range_format", "{0} / {1}"), + L("weather.widget.range_format", "{0}/{1}"), lowText, highText); } @@ -774,14 +740,14 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge var high = daily?.HighTemperatureC; var rangeText = string.Format( CultureInfo.InvariantCulture, - "{0} / {1}", + "{0}/{1}", FormatTemperature(low), FormatTemperature(high)); items.Add(new HourlyForecastItem( date.ToDateTime(TimeOnly.MinValue), ResolveForecastDayLabel(date, i), - ResolveWeatherSymbol(visualKind), + ToThemeKind(visualKind), rangeText)); } @@ -793,15 +759,15 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge const int itemCount = 5; var items = new List(itemCount); var start = DateOnly.FromDateTime(_timeZoneService?.GetCurrentTime() ?? DateTime.Now); - var symbol = ResolveWeatherSymbol(visualKind); + var iconKind = ToThemeKind(visualKind); for (var i = 0; i < itemCount; i++) { var date = start.AddDays(i); items.Add(new HourlyForecastItem( date.ToDateTime(TimeOnly.MinValue), ResolveForecastDayLabel(date, i), - symbol, - "--° / --°")); + iconKind, + "--°/--°")); } return items; @@ -809,22 +775,22 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge private void ApplyHourlyForecastItems(IReadOnlyList items) { - var isNightVisual = _activeVisualKind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight; var compactRangeText = ResolveScale() <= 0.78; for (var i = 0; i < _hourlyTimeBlocks.Length; i++) { if (i >= items.Count) { _hourlyTimeBlocks[i].Text = "--"; - _hourlyTempBlocks[i].Text = compactRangeText ? "--°/--°" : "--° / --°"; - _hourlyIconBlocks[i].Symbol = ResolveWeatherSymbol(_activeVisualKind); + _hourlyTempBlocks[i].Text = "--°/--°"; + _hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage( + HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(_activeVisualKind))); continue; } var item = items[i]; _hourlyTimeBlocks[i].Text = item.TimeLabel; - _hourlyIconBlocks[i].Symbol = item.Icon; - _hourlyIconBlocks[i].Foreground = CreateSolidBrush(ResolveWeatherIconAccent(item.Icon, isNightVisual)); + _hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage( + HyperOS3WeatherTheme.ResolveIconAsset(item.IconKind)); _hourlyTempBlocks[i].Text = compactRangeText ? CompactRangeLabel(item.TemperatureText) : item.TemperatureText; @@ -889,12 +855,6 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge } } - private static string ResolveWeatherIconAccent(Symbol symbol, bool isNightVisual) - { - var kind = isNightVisual ? HyperOS3WeatherVisualKind.ClearNight : HyperOS3WeatherVisualKind.ClearDay; - return HyperOS3WeatherTheme.ResolveIconAccent(kind, symbol); - } - private static string ResolvePreciseDisplayLocation(string? rawName, string languageCode, string fallback) { if (string.IsNullOrWhiteSpace(rawName)) @@ -1041,96 +1001,78 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge private void ApplyAdaptiveTypography() { var (layoutWidth, layoutHeight) = ResolveLayoutViewport(); - var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.MultiDay4x2); - var scale = ResolveScale(layoutWidth, layoutHeight); - var densityBoost = scale <= 0.55 ? 0.80 : scale <= 0.72 ? 0.88 : scale <= 0.92 ? 0.95 : scale >= 1.45 ? 1.06 : 1.0; - var compactness = Math.Clamp((0.88 - scale) / 0.50, 0, 1); - var cityLength = Math.Max(1, CityTextBlock.Text?.Length ?? 2); - var cityCompression = cityLength >= 12 ? 0.68 : cityLength >= 9 ? 0.80 : cityLength >= 6 ? 0.90 : 1.0; - var conditionLength = Math.Max(1, ConditionTextBlock.Text?.Length ?? 2); - var conditionCompression = conditionLength >= 12 ? 0.72 : conditionLength >= 8 ? 0.85 : conditionLength >= 6 ? 0.92 : 1.0; + var scaleX = Math.Clamp(layoutWidth / 608d, 0.58, 1.90); + var scaleY = Math.Clamp(layoutHeight / 288d, 0.58, 1.90); + var uiScale = Math.Clamp(Math.Min(scaleX, scaleY), 0.58, 1.75); + var innerWidth = Math.Max(120, layoutWidth); + var innerHeight = Math.Max(72, layoutHeight); - ContentGrid.RowSpacing = Math.Clamp(Math.Max(metrics.MainGap * scale, layoutHeight * Lerp(0.030, 0.018, compactness)), 2, 14); - TopRowGrid.ColumnSpacing = Math.Clamp(Math.Max(metrics.MainGap * scale, layoutWidth * 0.014), 3, 14); - BottomInfoStack.Spacing = Math.Clamp(Math.Max(metrics.SectionGap * scale, layoutHeight * 0.016), 2, 10); - BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(layoutHeight * 0.018, 0, 10)); - ConditionIconStack.Spacing = Math.Clamp(layoutWidth * 0.009, 3, 12); - RangeTextBlock.Margin = new Thickness(0, 0, 0, Math.Clamp(layoutHeight * 0.020, 0, 12)); + ContentGrid.RowSpacing = Math.Clamp(7 * scaleY, 2, 12); + TopRowGrid.ColumnSpacing = Math.Clamp(10 * scaleX, 6, 16); + TopRowGrid.RowSpacing = Math.Clamp(5 * scaleY, 2, 9); + BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(2 * scaleY, 0, 5)); + BottomInfoStack.Spacing = Math.Clamp(2 * scaleY, 1, 5); + + var summaryHeight = Math.Clamp(116 * scaleY, 82, 164); + var bodyHeight = Math.Max(52, innerHeight - summaryHeight - ContentGrid.RowSpacing); + + TemperatureTextBlock.FontSize = Math.Clamp(94 * uiScale, 56, 126); + TemperatureTextBlock.FontWeight = ToVariableWeight(320); + TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-2 * uiScale, -5, 0), 0, 0); + TemperatureTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.22, 84, 168); + + CityInfoBadge.Padding = new Thickness( + Math.Clamp(10 * uiScale, 6, 14), + Math.Clamp(4 * uiScale, 2, 8)); + CityInfoBadge.CornerRadius = new CornerRadius(Math.Clamp(11 * uiScale, 8, 16)); + LocationIcon.FontSize = Math.Clamp(14 * uiScale, 10, 20); + CityTextBlock.FontSize = Math.Clamp(21 * uiScale, 13, 31); + CityTextBlock.FontWeight = ToVariableWeight(560); + CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.25, 80, 220); + + ConditionInfoBadge.Padding = new Thickness(0); + ConditionInfoBadge.CornerRadius = new CornerRadius(Math.Clamp(8 * uiScale, 4, 12)); + ConditionIconStack.Spacing = Math.Clamp(12 * uiScale, 6, 18); + ConditionTextBlock.FontSize = Math.Clamp(34 * uiScale, 16, 46); + RangeTextBlock.FontSize = Math.Clamp(34 * uiScale, 16, 46); + ConditionTextBlock.FontWeight = ToVariableWeight(610); + RangeTextBlock.FontWeight = ToVariableWeight(620); + ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.16, 46, 170); + RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.20, 60, 200); + + var iconSize = Math.Clamp(68 * uiScale, 40, 90); + WeatherIconImage.Width = iconSize; + WeatherIconImage.Height = iconSize; HourlyPanelBorder.Padding = new Thickness( - Math.Clamp(layoutWidth * 0.018, 4, 16), - Math.Clamp(layoutHeight * 0.020, 3, 12)); - HourlyPanelBorder.CornerRadius = new CornerRadius(Math.Clamp(Math.Min(layoutWidth, layoutHeight) * 0.065, 8, 22)); - HourlyGrid.ColumnSpacing = Math.Clamp(layoutWidth * 0.010, 1.5, 12); - - var topBandHeight = Math.Max(18, layoutHeight * 0.22); - var middleBandHeight = Math.Max(24, layoutHeight * 0.30); - var bottomBandHeight = Math.Max(22, layoutHeight - topBandHeight - middleBandHeight - (ContentGrid.RowSpacing * 2)); - - LocationIcon.FontSize = Math.Min(Math.Clamp((metrics.IconFont * 0.6) * scale * densityBoost, 9, 30), topBandHeight * 0.58); - CityTextBlock.FontSize = Math.Min(Math.Clamp((metrics.PrimaryTextFont * 1.42) * scale * cityCompression * densityBoost, 12, 46), topBandHeight * 0.76); - WeatherIconSymbol.FontSize = Math.Min(Math.Clamp((metrics.IconFont * 1.02) * scale * densityBoost, 12, 56), topBandHeight * 0.95); - TemperatureTextBlock.FontSize = Math.Min(Math.Clamp((metrics.PrimaryTemperatureFont * 1.40) * scale * densityBoost, 26, 138), middleBandHeight * 0.92); - ConditionTextBlock.FontSize = Math.Min(Math.Clamp((metrics.PrimaryTextFont * 1.10) * scale * conditionCompression * densityBoost, 9, 40), topBandHeight * 0.70); - RangeTextBlock.FontSize = Math.Min(Math.Clamp((metrics.SecondaryTextFont * 1.42) * scale * densityBoost, 9, 42), middleBandHeight * 0.50); - TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(layoutHeight * 0.008, 0, 6), 0, Math.Clamp(layoutHeight * 0.012, 0, 8)); - - var weightProgress = Math.Clamp((scale - 0.34) / 1.18, 0, 1); - CityTextBlock.FontWeight = ToVariableWeight(Lerp(540, 680, weightProgress)); - TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(600, 760, weightProgress)); - ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(490, 620, weightProgress)); - RangeTextBlock.FontWeight = ToVariableWeight(Lerp(480, 600, weightProgress)); - - var topRightMaxWidth = Math.Clamp(layoutWidth * Lerp(0.36, 0.31, compactness), 112, 230); - ConditionIconStack.MaxWidth = topRightMaxWidth; - ConditionTextBlock.MaxWidth = Math.Max( - 42, - topRightMaxWidth - WeatherIconSymbol.FontSize - ConditionIconStack.Spacing - 4); - RangeTextBlock.MaxWidth = Math.Clamp(layoutWidth * Lerp(0.36, 0.32, compactness), 112, 250); - var leftTopBudget = Math.Max( - 132, - layoutWidth - Math.Max(topRightMaxWidth, RangeTextBlock.MaxWidth) - Math.Clamp(66 * scale, 26, 96)); - TemperatureTextBlock.MaxWidth = leftTopBudget; - CityTextBlock.MaxWidth = Math.Max(110, layoutWidth - Math.Clamp(90 * scale, 30, 128)); + Math.Clamp(5 * scaleX, 3, 10), + Math.Clamp(3 * scaleY, 1, 7)); + HourlyPanelBorder.CornerRadius = new CornerRadius(Math.Clamp(14 * uiScale, 8, 20)); + HourlyGrid.ColumnSpacing = Math.Clamp(10 * scaleX, 4, 15); var forecastColumnCount = Math.Max(1, _hourlyTimeBlocks.Length); var forecastInnerWidth = Math.Max( - 80, - layoutWidth - HourlyPanelBorder.Padding.Left - HourlyPanelBorder.Padding.Right - (HourlyGrid.ColumnSpacing * (forecastColumnCount - 1))); - var forecastCellWidth = Math.Max(32, forecastInnerWidth / forecastColumnCount); - var forecastStackSpacing = Math.Clamp(bottomBandHeight * 0.065, 1, 5); - var forecastInnerHeight = Math.Max( - 20, - bottomBandHeight - HourlyPanelBorder.Padding.Top - HourlyPanelBorder.Padding.Bottom); - var forecastLineHeight = Math.Max(6, (forecastInnerHeight - (forecastStackSpacing * 2)) / 3d); - var hourlyTimeMaxByWidth = Math.Clamp(forecastCellWidth / Lerp(4.3, 3.7, 1 - compactness), 7, 24); - var hourlyTempMaxByWidth = Math.Clamp(forecastCellWidth / Lerp(5.8, 5.0, 1 - compactness), 7, 24); - var hourlyIconMaxByWidth = Math.Clamp(forecastCellWidth * Lerp(0.30, 0.36, 1 - compactness), 7, 30); - var hourlyTimeMaxByHeight = Math.Clamp(forecastLineHeight * 0.95, 7, 22); - var hourlyTempMaxByHeight = Math.Clamp(forecastLineHeight * 0.95, 7, 22); - var hourlyIconMaxByHeight = Math.Clamp(forecastLineHeight * 1.05, 8, 28); + 96, + innerWidth - HourlyPanelBorder.Padding.Left - HourlyPanelBorder.Padding.Right - (HourlyGrid.ColumnSpacing * (forecastColumnCount - 1))); + var forecastCellWidth = Math.Max(40, forecastInnerWidth / forecastColumnCount); + var stackSpacing = Math.Clamp(2 * scaleY, 1, 4); + var forecastLabelSize = Math.Clamp(bodyHeight * 0.20, 10, 23); + var forecastIconSize = Math.Clamp(bodyHeight * 0.28, 14, 34); + var forecastRangeSize = Math.Clamp(bodyHeight * 0.24, 11, 28); - var hourlyTimeSize = Math.Min( - Math.Clamp((metrics.CaptionFont * 1.15) * scale * densityBoost, 8, 30), - Math.Min(hourlyTimeMaxByWidth, hourlyTimeMaxByHeight)); - var hourlyIconSize = Math.Min( - Math.Clamp((metrics.IconFont * 0.64) * scale * densityBoost, 8, 34), - Math.Min(hourlyIconMaxByWidth, hourlyIconMaxByHeight)); - var hourlyTempSize = Math.Min( - Math.Clamp((metrics.SecondaryTextFont * 1.24) * scale * densityBoost, 8, 32), - Math.Min(hourlyTempMaxByWidth, hourlyTempMaxByHeight)); for (var i = 0; i < _hourlyTimeBlocks.Length; i++) { - _hourlyTimeBlocks[i].FontSize = hourlyTimeSize; - _hourlyTempBlocks[i].FontSize = hourlyTempSize; - _hourlyIconBlocks[i].FontSize = hourlyIconSize; - _hourlyTimeBlocks[i].MaxWidth = forecastCellWidth; - _hourlyTempBlocks[i].MaxWidth = forecastCellWidth; - _hourlyTimeBlocks[i].FontWeight = ToVariableWeight(Lerp(470, 600, weightProgress)); - _hourlyTempBlocks[i].FontWeight = ToVariableWeight(Lerp(490, 620, weightProgress)); + _hourlyTimeBlocks[i].FontSize = forecastLabelSize; + _hourlyTempBlocks[i].FontSize = forecastRangeSize; + _hourlyIconBlocks[i].Width = forecastIconSize; + _hourlyIconBlocks[i].Height = forecastIconSize; + _hourlyTimeBlocks[i].MaxWidth = Math.Clamp(forecastCellWidth, 42, 148); + _hourlyTempBlocks[i].MaxWidth = Math.Clamp(forecastCellWidth, 42, 148); + _hourlyTimeBlocks[i].FontWeight = ToVariableWeight(500); + _hourlyTempBlocks[i].FontWeight = ToVariableWeight(590); if (_hourlyTimeBlocks[i].Parent is StackPanel forecastStack) { - forecastStack.Spacing = forecastStackSpacing; + forecastStack.Spacing = stackSpacing; } } } @@ -1140,6 +1082,18 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge return from + ((to - from) * t); } + private void SetMainWeatherIcon(WeatherVisualKind kind) + { + WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage( + HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(kind))); + } + + private void SetLoadingSkeleton(bool isLoading) + { + CityInfoBadge.Background = isLoading ? CreateSolidBrush("#24FFFFFF") : Brushes.Transparent; + ConditionInfoBadge.Background = isLoading ? CreateSolidBrush("#1CFFFFFF") : Brushes.Transparent; + } + private static FontWeight ToVariableWeight(double weight) { return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000); diff --git a/LanMontainDesktop/Views/Components/MusicControlWidget.axaml b/LanMontainDesktop/Views/Components/MusicControlWidget.axaml new file mode 100644 index 0000000..0fdb1db --- /dev/null +++ b/LanMontainDesktop/Views/Components/MusicControlWidget.axaml @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/MusicControlWidget.axaml.cs b/LanMontainDesktop/Views/Components/MusicControlWidget.axaml.cs new file mode 100644 index 0000000..69aa490 --- /dev/null +++ b/LanMontainDesktop/Views/Components/MusicControlWidget.axaml.cs @@ -0,0 +1,407 @@ +using System; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using LanMontainDesktop.Services; + +namespace LanMontainDesktop.Views.Components; + +public partial class MusicControlWidget : UserControl, IDesktopComponentWidget +{ + private static readonly Geometry PlayGlyph = Geometry.Parse("M 2,1 L 2,13 L 12,7 Z"); + private static readonly Geometry PauseGlyph = Geometry.Parse("M 2,1 H 5 V 13 H 2 Z M 9,1 H 12 V 13 H 9 Z"); + + private readonly DispatcherTimer _refreshTimer = new() + { + Interval = TimeSpan.FromSeconds(2.4) + }; + + private readonly IMusicControlService _musicControlService = MusicControlServiceFactory.CreateDefault(); + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); + + private CancellationTokenSource? _refreshCts; + private Bitmap? _coverBitmap; + private MusicPlaybackState _currentState = MusicPlaybackState.NoSession(isSupported: true); + private string _languageCode = "zh-CN"; + private double _currentCellSize = 48; + private bool _isAttached; + private bool _isRefreshing; + private bool _isExecutingCommand; + + public MusicControlWidget() + { + InitializeComponent(); + + _refreshTimer.Tick += OnRefreshTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ApplyCellSize(_currentCellSize); + ApplyState(MusicPlaybackState.NoSession(isSupported: OperatingSystem.IsWindows())); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + var scale = ResolveScale(); + + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(30 * scale, 16, 44)); + RootBorder.Padding = new Thickness( + Math.Clamp(14 * scale, 8, 24), + Math.Clamp(11 * scale, 7, 18), + Math.Clamp(14 * scale, 8, 24), + Math.Clamp(11 * scale, 7, 18)); + + CoverBorder.Width = Math.Clamp(56 * scale, 38, 92); + CoverBorder.Height = Math.Clamp(56 * scale, 38, 92); + CoverBorder.CornerRadius = new CornerRadius(Math.Clamp(12 * scale, 8, 18)); + + StatusBadgeBorder.CornerRadius = new CornerRadius(Math.Clamp(10 * scale, 6, 14)); + StatusBadgeBorder.Padding = new Thickness( + Math.Clamp(8 * scale, 5, 12), + Math.Clamp(4 * scale, 3, 8)); + + TitleTextBlock.FontSize = Math.Clamp(22 * scale, 13, 30); + ArtistTextBlock.FontSize = Math.Clamp(16 * scale, 10, 20); + SourceAppTextBlock.FontSize = Math.Clamp(12 * scale, 9, 15); + SourceAppButton.Padding = new Thickness( + Math.Clamp(8 * scale, 5, 12), + Math.Clamp(3 * scale, 2, 6)); + StatusTextBlock.FontSize = Math.Clamp(12 * scale, 9, 14); + + PositionTextBlock.FontSize = Math.Clamp(13 * scale, 9, 16); + DurationTextBlock.FontSize = Math.Clamp(13 * scale, 9, 16); + ProgressBar.Height = Math.Clamp(5 * scale, 3, 8); + + QueueButton.Width = QueueButton.Height = Math.Clamp(32 * scale, 24, 44); + FavoriteButton.Width = FavoriteButton.Height = Math.Clamp(32 * scale, 24, 44); + PreviousButton.Width = PreviousButton.Height = Math.Clamp(34 * scale, 25, 46); + NextButton.Width = NextButton.Height = Math.Clamp(34 * scale, 25, 46); + PlayPauseButton.Width = PlayPauseButton.Height = Math.Clamp(42 * scale, 30, 58); + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + _refreshTimer.Start(); + _ = RefreshStateAsync(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _refreshTimer.Stop(); + CancelRefreshRequest(); + DisposeCoverBitmap(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyCellSize(_currentCellSize); + } + + private async void OnRefreshTimerTick(object? sender, EventArgs e) + { + await RefreshStateAsync(); + } + + private async void OnPlayPauseButtonClick(object? sender, RoutedEventArgs e) + { + await ExecuteCommandAsync(token => _musicControlService.TogglePlayPauseAsync(token)); + } + + private async void OnPreviousButtonClick(object? sender, RoutedEventArgs e) + { + await ExecuteCommandAsync(token => _musicControlService.SkipPreviousAsync(token)); + } + + private async void OnNextButtonClick(object? sender, RoutedEventArgs e) + { + await ExecuteCommandAsync(token => _musicControlService.SkipNextAsync(token)); + } + + private async void OnSourceAppButtonClick(object? sender, RoutedEventArgs e) + { + await ExecuteCommandAsync(token => _musicControlService.LaunchSourceAppAsync(token), refreshAfterCommand: false); + } + + private async Task ExecuteCommandAsync(Func> command, bool refreshAfterCommand = true) + { + if (_isExecutingCommand || !_currentState.IsSupported || !_currentState.HasSession) + { + return; + } + + _isExecutingCommand = true; + ApplyActionButtonState(_currentState); + + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4)); + _ = await command(cts.Token); + } + catch + { + // Ignore command transport errors and recover on next poll. + } + finally + { + _isExecutingCommand = false; + } + + if (refreshAfterCommand) + { + await RefreshStateAsync(); + } + } + + private async Task RefreshStateAsync() + { + if (!_isAttached || _isRefreshing) + { + return; + } + + _isRefreshing = true; + UpdateLanguageCode(); + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var previous = Interlocked.Exchange(ref _refreshCts, cts); + previous?.Cancel(); + previous?.Dispose(); + + try + { + var state = await _musicControlService.GetCurrentStateAsync(cts.Token); + if (cts.IsCancellationRequested || !_isAttached) + { + return; + } + + _currentState = state; + ApplyState(state); + } + catch (OperationCanceledException) + { + // Ignore cancellation. + } + catch + { + var fallbackState = MusicPlaybackState.NoSession(isSupported: OperatingSystem.IsWindows()); + _currentState = fallbackState; + ApplyState(fallbackState); + } + finally + { + if (ReferenceEquals(_refreshCts, cts)) + { + _refreshCts = null; + } + + cts.Dispose(); + _isRefreshing = false; + } + } + + private void ApplyState(MusicPlaybackState state) + { + var hasMediaSession = state.IsSupported && state.HasSession; + + if (!state.IsSupported) + { + TitleTextBlock.Text = L("music.widget.unsupported", "Music control is only available on Windows"); + ArtistTextBlock.Text = L("music.widget.unsupported_hint", "SMTC backend is unavailable"); + SourceAppTextBlock.Text = L("music.widget.open_player", "Open player"); + StatusTextBlock.Text = "--"; + PositionTextBlock.Text = "00:00"; + DurationTextBlock.Text = "00:00"; + ProgressBar.IsIndeterminate = false; + ProgressBar.Value = 0; + PlayPauseGlyphPath.Data = PlayGlyph; + SetCoverImage(null); + ApplyActionButtonState(state); + return; + } + + if (!state.HasSession) + { + TitleTextBlock.Text = L("music.widget.no_session", "No active media session"); + ArtistTextBlock.Text = L("music.widget.no_session_hint", "Open a player that supports SMTC"); + SourceAppTextBlock.Text = L("music.widget.open_player", "Open player"); + StatusTextBlock.Text = "--"; + PositionTextBlock.Text = "00:00"; + DurationTextBlock.Text = "00:00"; + ProgressBar.IsIndeterminate = false; + ProgressBar.Value = 0; + PlayPauseGlyphPath.Data = PlayGlyph; + SetCoverImage(null); + ApplyActionButtonState(state); + return; + } + + var title = string.IsNullOrWhiteSpace(state.Title) + ? L("music.widget.unknown_title", "Unknown title") + : state.Title; + var subtitle = !string.IsNullOrWhiteSpace(state.Artist) + ? state.Artist + : !string.IsNullOrWhiteSpace(state.AlbumTitle) + ? state.AlbumTitle + : L("music.widget.unknown_artist", "Unknown artist"); + + TitleTextBlock.Text = title; + ArtistTextBlock.Text = subtitle; + SourceAppTextBlock.Text = string.IsNullOrWhiteSpace(state.SourceAppName) + ? L("music.widget.open_player", "Open player") + : state.SourceAppName; + StatusTextBlock.Text = ResolveStatusText(state.PlaybackStatus); + + var position = ClampToNonNegative(state.Position); + var duration = ClampToNonNegative(state.Duration); + var progress = duration.TotalMilliseconds <= 1 + ? 0 + : Math.Clamp((position.TotalMilliseconds / duration.TotalMilliseconds) * 100d, 0, 100); + + PositionTextBlock.Text = FormatTimeline(position); + DurationTextBlock.Text = duration.TotalMilliseconds > 1 + ? FormatTimeline(duration) + : "00:00"; + ProgressBar.IsIndeterminate = hasMediaSession && duration.TotalMilliseconds <= 1; + ProgressBar.Value = ProgressBar.IsIndeterminate ? 0 : progress; + + PlayPauseGlyphPath.Data = state.PlaybackStatus == MusicPlaybackStatus.Playing + ? PauseGlyph + : PlayGlyph; + + SetCoverImage(state.ThumbnailBytes); + ApplyActionButtonState(state); + } + + private void ApplyActionButtonState(MusicPlaybackState state) + { + var canOperate = !_isExecutingCommand && state.IsSupported && state.HasSession; + PlayPauseButton.IsEnabled = canOperate && state.CanPlayPause; + PreviousButton.IsEnabled = canOperate && state.CanSkipPrevious; + NextButton.IsEnabled = canOperate && state.CanSkipNext; + SourceAppButton.IsEnabled = canOperate && !string.IsNullOrWhiteSpace(state.SourceAppId); + QueueButton.IsEnabled = false; + FavoriteButton.IsEnabled = false; + } + + private void UpdateLanguageCode() + { + try + { + var snapshot = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + catch + { + _languageCode = "zh-CN"; + } + } + + private void CancelRefreshRequest() + { + var cts = Interlocked.Exchange(ref _refreshCts, null); + if (cts is null) + { + return; + } + + cts.Cancel(); + cts.Dispose(); + } + + private string ResolveStatusText(MusicPlaybackStatus status) + { + return status switch + { + MusicPlaybackStatus.Playing => L("music.widget.status.playing", "Playing"), + MusicPlaybackStatus.Paused => L("music.widget.status.paused", "Paused"), + MusicPlaybackStatus.Stopped => L("music.widget.status.stopped", "Stopped"), + MusicPlaybackStatus.Changing => L("music.widget.status.changing", "Changing"), + MusicPlaybackStatus.Opened => L("music.widget.status.opened", "Opened"), + _ => "--" + }; + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } + + private double ResolveScale() + { + var cellScale = Math.Clamp(_currentCellSize / 48d, 0.62, 2.1); + var widthScale = Bounds.Width > 1 + ? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * 4), 0.60, 1.8) + : 1; + var heightScale = Bounds.Height > 1 + ? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * 2), 0.60, 1.8) + : 1; + return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.05), 0.58, 2.0); + } + + private static TimeSpan ClampToNonNegative(TimeSpan value) + { + return value < TimeSpan.Zero ? TimeSpan.Zero : value; + } + + private static string FormatTimeline(TimeSpan value) + { + if (value.TotalHours >= 1) + { + return value.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture); + } + + return value.ToString(@"mm\:ss", CultureInfo.InvariantCulture); + } + + private void SetCoverImage(byte[]? thumbnailBytes) + { + DisposeCoverBitmap(); + + if (thumbnailBytes is null || thumbnailBytes.Length == 0) + { + CoverImage.Source = null; + CoverImage.IsVisible = false; + CoverFallbackGlyph.IsVisible = true; + return; + } + + try + { + using var stream = new MemoryStream(thumbnailBytes, writable: false); + _coverBitmap = new Bitmap(stream); + CoverImage.Source = _coverBitmap; + CoverImage.IsVisible = true; + CoverFallbackGlyph.IsVisible = false; + } + catch + { + CoverImage.Source = null; + CoverImage.IsVisible = false; + CoverFallbackGlyph.IsVisible = true; + } + } + + private void DisposeCoverBitmap() + { + if (_coverBitmap is null) + { + return; + } + + _coverBitmap.Dispose(); + _coverBitmap = null; + } +} diff --git a/LanMontainDesktop/Views/Components/RecordingWidget.axaml b/LanMontainDesktop/Views/Components/RecordingWidget.axaml new file mode 100644 index 0000000..08291b5 --- /dev/null +++ b/LanMontainDesktop/Views/Components/RecordingWidget.axaml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/RecordingWidget.axaml.cs b/LanMontainDesktop/Views/Components/RecordingWidget.axaml.cs new file mode 100644 index 0000000..f0a489d --- /dev/null +++ b/LanMontainDesktop/Views/Components/RecordingWidget.axaml.cs @@ -0,0 +1,338 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using LanMontainDesktop.Services; + +namespace LanMontainDesktop.Views.Components; + +public partial class RecordingWidget : UserControl, IDesktopComponentWidget +{ + private const int WaveBarCount = 22; + + private readonly DispatcherTimer _uiTimer = new() + { + Interval = TimeSpan.FromMilliseconds(96) + }; + + private readonly IAudioRecorderService _audioRecorderService = AudioRecorderServiceFactory.CreateDefault(); + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly List _waveBars = []; + private readonly double[] _waveLevels = new double[WaveBarCount]; + + private string _languageCode = "zh-CN"; + private string _lastSavedFilePath = string.Empty; + private double _currentCellSize = 48; + private bool _isAttached; + + public RecordingWidget() + { + InitializeComponent(); + + _uiTimer.Tick += OnUiTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + InitializeWaveBars(); + ReloadLanguageCode(); + ApplyCellSize(_currentCellSize); + RefreshVisual(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + var scale = ResolveScale(); + + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 56)); + RootBorder.Padding = new Thickness(Math.Clamp(10 * scale, 6, 18)); + RecorderCardBorder.CornerRadius = new CornerRadius(Math.Clamp(30 * scale, 14, 48)); + + var sideButtonSize = Math.Clamp(54 * scale, 38, 72); + DiscardButtonBorder.Width = sideButtonSize; + DiscardButtonBorder.Height = sideButtonSize; + DiscardButtonBorder.CornerRadius = new CornerRadius(sideButtonSize / 2d); + + SaveButtonBorder.Width = sideButtonSize; + SaveButtonBorder.Height = sideButtonSize; + SaveButtonBorder.CornerRadius = new CornerRadius(sideButtonSize / 2d); + + var centerButtonSize = Math.Clamp(68 * scale, 48, 86); + RecordToggleButtonBorder.Width = centerButtonSize; + RecordToggleButtonBorder.Height = centerButtonSize; + RecordToggleButtonBorder.CornerRadius = new CornerRadius(centerButtonSize / 2d); + + WaveformBarsPanel.Spacing = Math.Clamp(3 * scale, 1.8, 5.4); + TitleTextBlock.FontSize = Math.Clamp(19 * scale, 13, 26); + TimerTextBlock.FontSize = Math.Clamp(66 * scale, 38, 84); + HintTextBlock.FontSize = Math.Clamp(13 * scale, 9, 16); + + UpdateWaveformVisual(); + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + _uiTimer.Start(); + ReloadLanguageCode(); + RefreshVisual(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _uiTimer.Stop(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyCellSize(_currentCellSize); + } + + private void OnUiTick(object? sender, EventArgs e) + { + if (!_isAttached) + { + return; + } + + RefreshVisual(); + } + + private void OnDiscardButtonPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + + _audioRecorderService.Discard(); + RefreshVisual(); + e.Handled = true; + } + + private void OnRecordToggleButtonPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + + var snapshot = _audioRecorderService.GetSnapshot(); + if (!snapshot.IsSupported) + { + RefreshVisual(); + e.Handled = true; + return; + } + + if (snapshot.State == AudioRecorderRuntimeState.Recording) + { + _audioRecorderService.Pause(); + } + else + { + _audioRecorderService.StartOrResume(); + } + + RefreshVisual(); + e.Handled = true; + } + + private void OnSaveButtonPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + + _ = _audioRecorderService.StopAndSave(); + RefreshVisual(); + e.Handled = true; + } + + private void RefreshVisual() + { + var snapshot = _audioRecorderService.GetSnapshot(); + + TitleTextBlock.Text = L("recording.widget.title", "Recorder"); + TimerTextBlock.Text = FormatDuration(snapshot.Duration); + + var incomingLevel = snapshot.State == AudioRecorderRuntimeState.Recording + ? snapshot.InputLevel + : snapshot.State == AudioRecorderRuntimeState.Paused + ? 0.10 + : 0; + + PushWaveLevel(incomingLevel); + UpdateWaveformVisual(); + + ApplyControlState(snapshot); + } + + private void ApplyControlState(AudioRecorderSnapshot snapshot) + { + var isSupported = snapshot.IsSupported; + var canFinalize = snapshot.State == AudioRecorderRuntimeState.Recording || + snapshot.State == AudioRecorderRuntimeState.Paused; + + DiscardButtonBorder.IsHitTestVisible = isSupported && canFinalize; + SaveButtonBorder.IsHitTestVisible = isSupported && canFinalize; + RecordToggleButtonBorder.IsHitTestVisible = isSupported; + + DiscardButtonBorder.Opacity = DiscardButtonBorder.IsHitTestVisible ? 1 : 0.42; + SaveButtonBorder.Opacity = SaveButtonBorder.IsHitTestVisible ? 1 : 0.42; + RecordToggleButtonBorder.Opacity = RecordToggleButtonBorder.IsHitTestVisible ? 1 : 0.54; + + RecordDot.IsVisible = snapshot.State == AudioRecorderRuntimeState.Ready; + PauseGlyphPath.IsVisible = snapshot.State == AudioRecorderRuntimeState.Recording; + PlayGlyphPath.IsVisible = snapshot.State == AudioRecorderRuntimeState.Paused; + + if (!isSupported) + { + HintTextBlock.Text = L("recording.widget.hint.unsupported", "Microphone is unavailable"); + return; + } + + if (snapshot.State == AudioRecorderRuntimeState.Recording) + { + HintTextBlock.Text = L("recording.widget.hint.recording", "Recording"); + return; + } + + if (snapshot.State == AudioRecorderRuntimeState.Paused) + { + HintTextBlock.Text = L("recording.widget.hint.paused", "Paused"); + return; + } + + if (snapshot.State == AudioRecorderRuntimeState.Error) + { + HintTextBlock.Text = string.IsNullOrWhiteSpace(snapshot.LastError) + ? L("recording.widget.hint.error", "Recording failed") + : snapshot.LastError; + return; + } + + if (!string.IsNullOrWhiteSpace(snapshot.LastSavedFilePath) && + !string.Equals(snapshot.LastSavedFilePath, _lastSavedFilePath, StringComparison.OrdinalIgnoreCase)) + { + _lastSavedFilePath = snapshot.LastSavedFilePath; + } + + if (!string.IsNullOrWhiteSpace(_lastSavedFilePath)) + { + var fileName = Path.GetFileName(_lastSavedFilePath); + HintTextBlock.Text = string.Format( + CultureInfo.InvariantCulture, + L("recording.widget.hint.saved_format", "Saved {0}"), + fileName); + return; + } + + HintTextBlock.Text = L("recording.widget.hint.ready", "Tap red button to record"); + } + + private void InitializeWaveBars() + { + if (_waveBars.Count > 0) + { + return; + } + + for (var i = 0; i < WaveBarCount; i++) + { + var bar = new Border + { + Width = 3, + Height = 6, + CornerRadius = new CornerRadius(1.5), + Background = CreateBrush("#121722"), + Opacity = 0.24, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center + }; + + _waveBars.Add(bar); + WaveformBarsPanel.Children.Add(bar); + } + } + + private void PushWaveLevel(double level) + { + for (var i = 0; i < _waveLevels.Length - 1; i++) + { + _waveLevels[i] = _waveLevels[i + 1]; + } + + var previous = _waveLevels[^2]; + var target = Math.Clamp(level, 0, 1); + _waveLevels[^1] = Math.Clamp((previous * 0.35) + (target * 0.65), 0, 1); + } + + private void UpdateWaveformVisual() + { + var scale = ResolveScale(); + var barWidth = Math.Clamp(3 * scale, 2, 5); + for (var i = 0; i < _waveBars.Count; i++) + { + var bar = _waveBars[i]; + var eased = Math.Pow(Math.Clamp(_waveLevels[i], 0, 1), 0.62); + bar.Width = barWidth; + bar.Height = Math.Clamp((4 + (eased * 30)) * scale, 3, 46); + bar.CornerRadius = new CornerRadius(Math.Clamp(barWidth / 2d, 1, 3)); + bar.Opacity = Math.Clamp(0.20 + (eased * 0.82), 0.20, 1.0); + } + } + + private void ReloadLanguageCode() + { + try + { + var snapshot = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + catch + { + _languageCode = "zh-CN"; + } + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } + + private double ResolveScale() + { + var cellScale = Math.Clamp(_currentCellSize / 44d, 0.60, 2.0); + var widthScale = Bounds.Width > 1 + ? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * 2), 0.60, 2.0) + : 1; + var heightScale = Bounds.Height > 1 + ? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * 2), 0.60, 2.0) + : 1; + return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.02), 0.58, 2.04); + } + + private static string FormatDuration(TimeSpan duration) + { + if (duration.TotalHours >= 1) + { + return duration.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture); + } + + return duration.ToString(@"mm\:ss", CultureInfo.InvariantCulture); + } + + private static IBrush CreateBrush(string colorHex) + { + return new SolidColorBrush(Color.Parse(colorHex)); + } +} diff --git a/LanMontainDesktop/Views/Components/WeatherClockWidget.axaml b/LanMontainDesktop/Views/Components/WeatherClockWidget.axaml index 4b88b1f..b85c053 100644 --- a/LanMontainDesktop/Views/Components/WeatherClockWidget.axaml +++ b/LanMontainDesktop/Views/Components/WeatherClockWidget.axaml @@ -2,7 +2,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:fi="using:FluentIcons.Avalonia" mc:Ignorable="d" d:DesignWidth="260" d:DesignHeight="120" @@ -46,12 +45,11 @@ TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" /> - + diff --git a/LanMontainDesktop/Views/Components/WeatherClockWidget.axaml.cs b/LanMontainDesktop/Views/Components/WeatherClockWidget.axaml.cs index 55d5d8e..42c7574 100644 --- a/LanMontainDesktop/Views/Components/WeatherClockWidget.axaml.cs +++ b/LanMontainDesktop/Views/Components/WeatherClockWidget.axaml.cs @@ -8,7 +8,6 @@ using Avalonia.Controls.Shapes; using Avalonia.Media; using Avalonia.Styling; using Avalonia.Threading; -using FluentIcons.Common; using LanMontainDesktop.Models; using LanMontainDesktop.Services; @@ -54,7 +53,6 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, private bool _isRefreshing; private bool? _isNightModeApplied; private string _languageCode = "zh-CN"; - private Symbol _activeWeatherSymbol = Symbol.WeatherPartlyCloudyDay; private HyperOS3WeatherVisualKind _activeVisualKind = HyperOS3WeatherVisualKind.CloudyDay; public WeatherClockWidget() @@ -173,7 +171,9 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, var leftWidthFactor = Math.Clamp(leftContentWidth / 122d, 0.48, 1.35); TimeTextBlock.FontSize = Math.Clamp((metrics.PrimaryTemperatureFont * 0.74) * scale * compactFactor * leftWidthFactor, 10, 62); DateTextBlock.FontSize = Math.Clamp(metrics.SecondaryTextFont * scale * compactFactor * leftWidthFactor, 8, 30); - WeatherIconSymbol.FontSize = Math.Clamp(metrics.IconFont * scale * compactFactor * leftWidthFactor, 9, 32); + var weatherIconSize = Math.Clamp(metrics.IconFont * scale * compactFactor * leftWidthFactor, 9, 32); + WeatherIconImage.Width = weatherIconSize; + WeatherIconImage.Height = weatherIconSize; TimeTextBlock.FontWeight = ToVariableWeight(Lerp(620, 760, Math.Clamp((scale - 0.68) / 1.35, 0, 1))); DateTextBlock.FontWeight = ToVariableWeight(Lerp(540, 680, Math.Clamp((scale - 0.68) / 1.35, 0, 1))); @@ -185,10 +185,10 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, var showDateLine = leftContentWidth >= Math.Max(40, TimeTextBlock.FontSize * 1.72); DateWeatherStack.IsVisible = showDateLine; - WeatherIconSymbol.IsVisible = showDateLine && leftContentWidth >= Math.Max(56, DateTextBlock.FontSize * 2.4); + WeatherIconImage.IsVisible = showDateLine && leftContentWidth >= Math.Max(56, DateTextBlock.FontSize * 2.4); - var dateReservedWidth = WeatherIconSymbol.IsVisible - ? WeatherIconSymbol.FontSize + DateWeatherStack.Spacing + var dateReservedWidth = WeatherIconImage.IsVisible + ? weatherIconSize + DateWeatherStack.Spacing : 0; DateTextBlock.MaxWidth = Math.Max(12, leftContentWidth - dateReservedWidth); @@ -312,18 +312,16 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, { var isNight = ResolveIsNight(snapshot); _activeVisualKind = HyperOS3WeatherTheme.ResolveVisualKind(snapshot.Current.WeatherCode, isNight); - _activeWeatherSymbol = HyperOS3WeatherTheme.ResolveWeatherSymbol(_activeVisualKind); - WeatherIconSymbol.Symbol = _activeWeatherSymbol; - WeatherIconSymbol.Foreground = CreateBrush(HyperOS3WeatherTheme.ResolveIconAccent(_activeVisualKind, _activeWeatherSymbol)); + WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage( + HyperOS3WeatherTheme.ResolveIconAsset(_activeVisualKind)); } private void ApplyDefaultWeatherIcon() { var isNight = IsNightNow(); _activeVisualKind = isNight ? HyperOS3WeatherVisualKind.ClearNight : HyperOS3WeatherVisualKind.CloudyDay; - _activeWeatherSymbol = HyperOS3WeatherTheme.ResolveWeatherSymbol(_activeVisualKind); - WeatherIconSymbol.Symbol = _activeWeatherSymbol; - WeatherIconSymbol.Foreground = CreateBrush(HyperOS3WeatherTheme.ResolveIconAccent(_activeVisualKind, _activeWeatherSymbol)); + WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage( + HyperOS3WeatherTheme.ResolveIconAsset(_activeVisualKind)); } private void UpdateClockVisual() @@ -430,7 +428,6 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, CenterDotInner.Fill = CreateBrush("#1A74F2"); BuildTicks(isNightMode); - WeatherIconSymbol.Foreground = CreateBrush(HyperOS3WeatherTheme.ResolveIconAccent(_activeVisualKind, _activeWeatherSymbol)); } private WeatherClockConfig LoadConfig() diff --git a/LanMontainDesktop/Views/Components/WeatherWidget.axaml b/LanMontainDesktop/Views/Components/WeatherWidget.axaml index 4946e47..3f9344e 100644 --- a/LanMontainDesktop/Views/Components/WeatherWidget.axaml +++ b/LanMontainDesktop/Views/Components/WeatherWidget.axaml @@ -9,16 +9,16 @@ x:Class="LanMontainDesktop.Views.Components.WeatherWidget"> @@ -32,12 +32,12 @@ @@ -54,7 +54,7 @@ @@ -73,76 +73,91 @@ ClipToBounds="True" /> - - - - - - - - - - - + + + - + + + + + - + + + + + + + + + + - + diff --git a/LanMontainDesktop/Views/Components/WeatherWidget.axaml.cs b/LanMontainDesktop/Views/Components/WeatherWidget.axaml.cs index 55de526..7f7cf6b 100644 --- a/LanMontainDesktop/Views/Components/WeatherWidget.axaml.cs +++ b/LanMontainDesktop/Views/Components/WeatherWidget.axaml.cs @@ -11,7 +11,6 @@ using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Platform; using Avalonia.Threading; -using FluentIcons.Common; using LanMontainDesktop.Models; using LanMontainDesktop.Services; @@ -154,9 +153,9 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime _currentCellSize = Math.Max(1, cellSize); var scale = ResolveScale(); var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.Realtime2x2); - var cornerRadius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 24, 44); - var horizontalPadding = Math.Clamp(_currentCellSize * metrics.HorizontalPaddingScale, 12, 24); - var verticalPadding = Math.Clamp(_currentCellSize * metrics.VerticalPaddingScale, 12, 24); + var cornerRadius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 26, 46); + var horizontalPadding = Math.Clamp(_currentCellSize * metrics.HorizontalPaddingScale, 10, 24); + var verticalPadding = Math.Clamp(_currentCellSize * metrics.VerticalPaddingScale, 10, 24); RootBorder.CornerRadius = new CornerRadius(cornerRadius); BackgroundImageLayer.CornerRadius = new CornerRadius(cornerRadius); @@ -165,8 +164,8 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime BackgroundLightLayer.CornerRadius = new CornerRadius(cornerRadius); BackgroundShadeLayer.CornerRadius = new CornerRadius(cornerRadius); ContentPaddingBorder.Padding = new Thickness( - Math.Clamp(horizontalPadding * scale, 12, 24), - Math.Clamp(verticalPadding * scale, 12, 24)); + Math.Clamp(horizontalPadding * scale, 10, 24), + Math.Clamp(verticalPadding * scale, 10, 24)); ApplyAdaptiveTypography(); ResetParticles(); } @@ -389,7 +388,8 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime CityTextBlock.Text = ResolvePreciseDisplayLocation(rawLocation, _languageCode, L("weather.widget.location_unknown", "Unknown location")); ConditionTextBlock.Text = ResolveWeatherConditionText(snapshot.Current.WeatherText, visualKind); - WeatherIconSymbol.Symbol = ResolveWeatherSymbol(visualKind); + SetWeatherIcon(visualKind); + SetLoadingSkeleton(false); TemperatureTextBlock.Text = FormatTemperature(snapshot.Current.TemperatureC); var (low, high) = ResolveTemperatureRange(snapshot); @@ -401,9 +401,8 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime { var fallbackKind = ResolveFallbackVisualKind(); ApplyVisualTheme(fallbackKind); - WeatherIconSymbol.Symbol = fallbackKind == WeatherVisualKind.ClearNight - ? Symbol.WeatherMoon - : Symbol.WeatherSunny; + SetWeatherIcon(fallbackKind); + SetLoadingSkeleton(false); CityTextBlock.Text = L("weather.widget.location_not_configured", "Weather location is not configured"); ConditionTextBlock.Text = L("weather.widget.configure_hint", "Open Settings > Weather to configure"); TemperatureTextBlock.Text = "--°"; @@ -416,9 +415,8 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime { var loadingKind = IsNightNow() ? WeatherVisualKind.CloudyNight : WeatherVisualKind.CloudyDay; ApplyVisualTheme(loadingKind); - WeatherIconSymbol.Symbol = loadingKind == WeatherVisualKind.CloudyNight - ? Symbol.WeatherPartlyCloudyNight - : Symbol.WeatherPartlyCloudyDay; + SetWeatherIcon(loadingKind); + SetLoadingSkeleton(true); CityTextBlock.Text = ResolvePreciseDisplayLocation( locationName, _languageCode, @@ -432,7 +430,8 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime private void ApplyFailedState(string locationName) { ApplyVisualTheme(WeatherVisualKind.Fog); - WeatherIconSymbol.Symbol = Symbol.WeatherFog; + SetWeatherIcon(WeatherVisualKind.Fog); + SetLoadingSkeleton(false); CityTextBlock.Text = ResolvePreciseDisplayLocation( locationName, _languageCode, @@ -454,14 +453,15 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime BackgroundTintLayer.Background = CreateSolidBrush(palette.Tint); var primary = CreateSolidBrush(palette.PrimaryText); - var secondary = CreateSolidBrush(palette.SecondaryText); + var isNightVisual = kind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight; + var secondary = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xEA : (byte)0xDC); + var cityBrush = CreateSolidBrush(palette.SecondaryText, isNightVisual ? (byte)0xD8 : (byte)0xC8); var particleBrush = ResolveParticleBrush(ToThemeKind(kind), palette.ParticleColor); LocationIcon.Foreground = primary; - CityTextBlock.Foreground = primary; + CityTextBlock.Foreground = cityBrush; TemperatureTextBlock.Foreground = primary; - WeatherIconSymbol.Foreground = primary; ConditionTextBlock.Foreground = secondary; - RangeTextBlock.Foreground = secondary; + RangeTextBlock.Foreground = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xE0 : (byte)0xD4); foreach (var particle in _particleVisuals) { @@ -572,11 +572,6 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime palette.ParticleColor); } - private static Symbol ResolveWeatherSymbol(WeatherVisualKind kind) - { - return HyperOS3WeatherTheme.ResolveWeatherSymbol(ToThemeKind(kind)); - } - private static HyperOS3WeatherVisualKind ToThemeKind(WeatherVisualKind kind) { return kind switch @@ -632,14 +627,14 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime { if (!low.HasValue && !high.HasValue) { - return L("weather.widget.range_unknown", "-- / --"); + return L("weather.widget.range_unknown", "--/--"); } var lowText = FormatTemperature(low); var highText = FormatTemperature(high); return string.Format( GetUiCulture(), - L("weather.widget.range_format", "{0} / {1}"), + L("weather.widget.range_format", "{0}/{1}"), lowText, highText); } @@ -800,31 +795,46 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime private void ApplyAdaptiveTypography() { - var scale = ResolveScale(); - var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.Realtime2x2); - var densityBoost = scale <= 0.70 ? 0.88 : scale <= 0.88 ? 0.94 : scale >= 1.45 ? 1.06 : 1.0; - var cityLength = Math.Max(1, CityTextBlock.Text?.Length ?? 2); - var cityCompression = cityLength >= 10 ? 0.72 : cityLength >= 7 ? 0.83 : cityLength >= 5 ? 0.92 : 1.0; - var conditionLength = Math.Max(1, ConditionTextBlock.Text?.Length ?? 2); - var conditionCompression = conditionLength >= 9 ? 0.84 : conditionLength >= 6 ? 0.92 : 1.0; + var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 2; + var height = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * 2; + var innerWidth = Math.Max(90, width - ContentPaddingBorder.Padding.Left - ContentPaddingBorder.Padding.Right); + var innerHeight = Math.Max(90, height - ContentPaddingBorder.Padding.Top - ContentPaddingBorder.Padding.Bottom); + var scaleX = innerWidth / 288d; + var scaleY = innerHeight / 288d; + var uiScale = Math.Clamp(Math.Min(scaleX, scaleY), 0.62, 1.58); + var verticalScale = Math.Clamp(scaleY, 0.58, 1.70); - ContentGrid.RowSpacing = Math.Clamp(metrics.MainGap * scale, 4, 14); - TopRowGrid.ColumnSpacing = Math.Clamp(metrics.MainGap * scale, 4, 12); - BottomInfoStack.Spacing = Math.Clamp(metrics.SectionGap * scale, 2, 8); - BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(10 * scale, 4, 16)); + ContentGrid.RowSpacing = Math.Clamp(2 * verticalScale, 1, 5); + TopRowGrid.ColumnSpacing = Math.Clamp(8 * uiScale, 4, 14); + BottomInfoStack.Spacing = 0; + BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(2 * uiScale, 0, 4)); - LocationIcon.FontSize = Math.Clamp((metrics.IconFont * 0.50) * scale * densityBoost, 10, 30); - CityTextBlock.FontSize = Math.Clamp(metrics.PrimaryTextFont * scale * cityCompression * densityBoost, 12, 42); - WeatherIconSymbol.FontSize = Math.Clamp(metrics.IconFont * scale * densityBoost, 14, 56); - TemperatureTextBlock.FontSize = Math.Clamp(metrics.PrimaryTemperatureFont * scale * densityBoost, 36, 144); - ConditionTextBlock.FontSize = Math.Clamp(metrics.PrimaryTextFont * scale * conditionCompression * densityBoost, 11, 44); - RangeTextBlock.FontSize = Math.Clamp(metrics.SecondaryTextFont * scale * densityBoost, 12, 50); - TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(4 * scale, 1, 8), 0, Math.Clamp(10 * scale, 4, 16)); + var iconSize = Math.Clamp(74 * uiScale, 46, 96); + WeatherIconImage.Width = iconSize; + WeatherIconImage.Height = iconSize; - CityTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, Math.Clamp((scale - 0.58) / 1.3, 0, 1))); - TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(620, 800, Math.Clamp((scale - 0.58) / 1.2, 0, 1))); - ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, Math.Clamp((scale - 0.58) / 1.2, 0, 1))); - RangeTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, Math.Clamp((scale - 0.58) / 1.2, 0, 1))); + TemperatureTextBlock.FontSize = Math.Clamp(92 * uiScale, 60, 132); + TemperatureTextBlock.FontWeight = ToVariableWeight(320); + TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-2 * uiScale, -5, 0), 0, 0); + TemperatureTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.50, 96, 176); + + ConditionInfoBadge.Padding = new Thickness(0); + ConditionInfoBadge.CornerRadius = new CornerRadius(Math.Clamp(10 * uiScale, 6, 14)); + ConditionTextBlock.FontSize = Math.Clamp(44 * uiScale, 22, 58); + RangeTextBlock.FontSize = Math.Clamp(46 * uiScale, 24, 62); + ConditionTextBlock.FontWeight = ToVariableWeight(610); + RangeTextBlock.FontWeight = ToVariableWeight(620); + ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.62, 92, 204); + RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.66, 100, 224); + + CityInfoBadge.Padding = new Thickness( + Math.Clamp(10 * uiScale, 6, 14), + Math.Clamp(5 * uiScale, 2, 8)); + CityInfoBadge.CornerRadius = new CornerRadius(Math.Clamp(13 * uiScale, 8, 18)); + LocationIcon.FontSize = Math.Clamp(14 * uiScale, 10, 20); + CityTextBlock.FontSize = Math.Clamp(23 * uiScale, 14, 34); + CityTextBlock.FontWeight = ToVariableWeight(560); + CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.56, 70, 196); } private static double Lerp(double from, double to, double t) @@ -832,6 +842,18 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime return from + ((to - from) * t); } + private void SetWeatherIcon(WeatherVisualKind kind) + { + WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage( + HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(kind))); + } + + private void SetLoadingSkeleton(bool isLoading) + { + CityInfoBadge.Background = isLoading ? CreateSolidBrush("#24FFFFFF") : Brushes.Transparent; + ConditionInfoBadge.Background = isLoading ? CreateSolidBrush("#1FFFFFFF") : Brushes.Transparent; + } + private static FontWeight ToVariableWeight(double weight) { return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000); @@ -1112,6 +1134,12 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime return new SolidColorBrush(Color.Parse(colorHex)); } + private static IBrush CreateSolidBrush(string colorHex, byte alpha) + { + var color = Color.Parse(colorHex); + return new SolidColorBrush(Color.FromArgb(alpha, color.R, color.G, color.B)); + } + private static IBrush CreateGradientBrush(string fromColorHex, string toColorHex) { return new LinearGradientBrush diff --git a/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs index 83ad87f..4ab1e18 100644 --- a/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs @@ -2217,6 +2217,11 @@ public partial class MainWindow return Symbol.Edit; } + if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) + { + return Symbol.Play; + } + return Symbol.Apps; } @@ -2242,6 +2247,11 @@ public partial class MainWindow return L("component_category.board", "Board"); } + if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) + { + return L("component_category.media", "Media"); + } + return categoryId; } diff --git a/LanMontainDesktop/scripts/package.ps1 b/LanMontainDesktop/scripts/package.ps1 index 4ce4093..c5f32e0 100644 --- a/LanMontainDesktop/scripts/package.ps1 +++ b/LanMontainDesktop/scripts/package.ps1 @@ -89,8 +89,33 @@ function Remove-LibVlcForOtherArch { } foreach ($dir in $dirsToDelete) { - if (Test-Path -LiteralPath $dir) { + if (-not (Test-Path -LiteralPath $dir)) { + continue + } + + $pruned = $false + try { [System.IO.Directory]::Delete($dir, $true) + $pruned = $true + } catch { + if (-not (Test-Path -LiteralPath $dir)) { + $pruned = $true + } else { + Write-Warning "Prune retry for '$dir': $($_.Exception.Message)" + try { + Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction Stop + $pruned = $true + } catch { + if (-not (Test-Path -LiteralPath $dir)) { + $pruned = $true + } else { + throw "Failed to prune '$dir': $($_.Exception.Message)" + } + } + } + } + + if ($pruned) { Write-Host "Pruned: $dir" } }