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 0000000..228d2bd Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_cloudy.webp differ 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 0000000..f34ef53 Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp differ 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 0000000..5e1df1f Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_moon_clear.webp differ diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp new file mode 100644 index 0000000..65e334f Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp differ 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 0000000..38aa31e Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp differ diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp new file mode 100644 index 0000000..6f233f6 Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp differ diff --git a/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp new file mode 100644 index 0000000..e373622 Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp differ 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 0000000..50d69bd Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sleet.webp differ 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 0000000..b17c064 Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp differ 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 0000000..96d98c8 Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_sunny_day.webp differ 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 0000000..4602164 Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp differ 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 0000000..2c55d40 Binary files /dev/null and b/LanMontainDesktop/Assets/Weather/HyperOS3/Icons/icon_windy.webp differ 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" } }