媒体播放组件,录音组件
This commit is contained in:
lincube
2026-03-03 18:26:29 +08:00
parent 478ed115a1
commit 094745122e
42 changed files with 4661 additions and 1093 deletions

View File

@@ -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 '<IsTestProject>\s*true\s*</IsTestProject>|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

View File

@@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -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";

View File

@@ -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",

View File

@@ -31,14 +31,11 @@
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23"
Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('Windows')))
or '$(RuntimeIdentifier)' == 'win-x64'
or '$(RuntimeIdentifier)' == 'win-x86'" />
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1"
Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('OSX')))
or '$(RuntimeIdentifier)' == 'osx-x64'" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('Windows')))&#xA; or '$(RuntimeIdentifier)' == 'win-x64'&#xA; or '$(RuntimeIdentifier)' == 'win-x86'" />
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('OSX')))&#xA; or '$(RuntimeIdentifier)' == 'osx-x64'" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
</Project>

View File

@@ -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",

View File

@@ -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": "填充",

View File

@@ -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.

View File

@@ -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<IAudioRecorderService> 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<byte>();
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<byte>.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<byte>.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<int>();
var list = new List<double>(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;
}
}

View File

@@ -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<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default);
Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default);
Task<bool> SkipNextAsync(CancellationToken cancellationToken = default);
Task<bool> SkipPreviousAsync(CancellationToken cancellationToken = default);
Task<bool> 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<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(MusicPlaybackState.Unsupported());
}
public Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
}
public Task<bool> SkipNextAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
}
public Task<bool> SkipPreviousAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
}
public Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
}
}

View File

@@ -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<string, string> _sourceAppNameCache = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _stateGate = new(1, 1);
private string _thumbnailKey = string.Empty;
private byte[]? _thumbnailBytesCache;
public async Task<MusicPlaybackState> 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<bool> 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<bool> 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<bool> 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<bool> 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<object?> GetCurrentSessionAsync(CancellationToken cancellationToken)
{
var manager = await GetSessionManagerAsync(cancellationToken);
return manager is null ? null : InvokeMethod(manager, "GetCurrentSession");
}
private static async Task<object?> 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<object?> TryGetMediaPropertiesAsync(object session, CancellationToken cancellationToken)
{
var operation = InvokeMethod(session, "TryGetMediaPropertiesAsync");
return await AwaitWinRtOperationAsync(operation, cancellationToken);
}
private async Task<byte[]?> 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<byte[]?> 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<string> 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<bool> AwaitBooleanWinRtOperationAsync(object? operation, CancellationToken cancellationToken)
{
var result = await AwaitWinRtOperationAsync(operation, cancellationToken);
return result is bool boolValue && boolValue;
}
private static async Task<object?> 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
};
}
}

View File

@@ -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",

View File

@@ -1,19 +1,464 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
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:local="using:LanMontainDesktop.Views.Components"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="640"
x:Class="LanMontainDesktop.Views.Components.ExtendedWeatherWidget">
<Grid x:Name="ContainerGrid"
RowDefinitions="*,*"
RowSpacing="8">
<local:HourlyWeatherWidget x:Name="HourlyHost"
Grid.Row="0" />
<local:MultiDayWeatherWidget x:Name="MultiDayHost"
Grid.Row="1" />
</Grid>
<Border x:Name="RootBorder"
CornerRadius="34"
ClipToBounds="True"
Background="#6A8BB3">
<Grid>
<Border x:Name="BackgroundImageLayer"
CornerRadius="34"
ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="34"
ClipToBounds="True"
Opacity="0.24"
RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform ScaleX="1.05"
ScaleY="1.05" />
<TranslateTransform />
</TransformGroup>
</Border.RenderTransform>
</Border>
<Border x:Name="BackgroundTintLayer"
CornerRadius="34"
ClipToBounds="True"
Opacity="0.20" />
<Border x:Name="BackgroundLightLayer"
CornerRadius="34"
ClipToBounds="True"
Opacity="0.64">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="1,1">
<GradientStop Color="#56FFFFFF"
Offset="0" />
<GradientStop Color="#18FFFFFF"
Offset="0.30" />
<GradientStop Color="#00000000"
Offset="0.58" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Border x:Name="BackgroundShadeLayer"
CornerRadius="34"
ClipToBounds="True"
Opacity="0.80">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="0,1">
<GradientStop Color="#00040A16"
Offset="0.50" />
<GradientStop Color="#2E0B1C34"
Offset="1" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Canvas x:Name="ParticleLayer"
IsHitTestVisible="False"
ClipToBounds="True" />
<Border x:Name="ContentPaddingBorder"
Padding="20"
Background="Transparent">
<Grid x:Name="LayoutRoot"
RowDefinitions="Auto,Auto,Auto,*"
RowSpacing="9">
<Grid x:Name="SummaryGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="14">
<TextBlock x:Name="TemperatureTextBlock"
Grid.Column="0"
Text="7°"
FontSize="112"
FontWeight="Light"
FontFeatures="tnum"
VerticalAlignment="Top"
Margin="0,-2,0,0"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<StackPanel Grid.Column="1"
VerticalAlignment="Top"
Spacing="10"
Margin="0,6,0,0">
<Border x:Name="CityInfoBadge"
Background="#2AFFFFFF"
CornerRadius="12"
Padding="12,5"
HorizontalAlignment="Left">
<TextBlock x:Name="CityTextBlock"
Text="Beijing"
FontSize="24"
FontWeight="Medium"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</Border>
<Border x:Name="ConditionInfoBadge"
Background="#22FFFFFF"
CornerRadius="12"
Padding="10,5"
HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<TextBlock x:Name="ConditionTextBlock"
Text="Fog"
FontSize="30"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="RangeTextBlock"
Text="11°/4°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</StackPanel>
</Border>
</StackPanel>
<Image x:Name="WeatherIconImage"
Grid.Column="2"
Width="70"
Height="70"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,2,0,0"
Stretch="Uniform" />
</Grid>
<Border x:Name="HourlyPanelBorder"
Grid.Row="1"
Background="#0EFFFFFF"
CornerRadius="16"
ClipToBounds="True"
Padding="8,6">
<Grid x:Name="HourlyGrid"
ColumnDefinitions="*,*,*,*,*,*"
ColumnSpacing="6">
<StackPanel Grid.Column="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTemp0"
Text="7°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon0"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTime0"
Text="15:00"
FontSize="22"
FontWeight="Medium"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTemp1"
Text="7°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon1"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTime1"
Text="16:00"
FontSize="22"
FontWeight="Medium"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTemp2"
Text="7°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon2"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTime2"
Text="17:00"
FontSize="22"
FontWeight="Medium"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="3"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTemp3"
Text="Sunset"
FontSize="28"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon3"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTime3"
Text="18:00"
FontSize="22"
FontWeight="Medium"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="4"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTemp4"
Text="7°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon4"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTime4"
Text="19:00"
FontSize="22"
FontWeight="Medium"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="5"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTemp5"
Text="7°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon5"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTime5"
Text="20:00"
FontSize="22"
FontWeight="Medium"
HorizontalAlignment="Center" />
</StackPanel>
</Grid>
</Border>
<Border x:Name="SeparatorLine"
Grid.Row="2"
Height="1"
Margin="0,2,0,2"
Background="#2AFFFFFF" />
<Grid x:Name="DailyGrid"
Grid.Row="3"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="8">
<Grid Grid.Row="0"
ColumnDefinitions="Auto,*,Auto,Auto"
ColumnSpacing="10">
<Image x:Name="DailyIcon0"
Width="24"
Height="24"
VerticalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="DailyLabel0"
Grid.Column="1"
Text="Tomorrow · Cloudy"
FontSize="30"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
<TextBlock x:Name="DailyHigh0"
Grid.Column="2"
Text="10"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
VerticalAlignment="Center" />
<TextBlock x:Name="DailyLow0"
Grid.Column="3"
Text="5"
FontSize="30"
FontWeight="Medium"
FontFeatures="tnum"
VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto,Auto"
ColumnSpacing="10">
<Image x:Name="DailyIcon1"
Width="24"
Height="24"
VerticalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="DailyLabel1"
Grid.Column="1"
Text="Thu · Partly Cloudy"
FontSize="30"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
<TextBlock x:Name="DailyHigh1"
Grid.Column="2"
Text="13"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
VerticalAlignment="Center" />
<TextBlock x:Name="DailyLow1"
Grid.Column="3"
Text="4"
FontSize="30"
FontWeight="Medium"
FontFeatures="tnum"
VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="2"
ColumnDefinitions="Auto,*,Auto,Auto"
ColumnSpacing="10">
<Image x:Name="DailyIcon2"
Width="24"
Height="24"
VerticalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="DailyLabel2"
Grid.Column="1"
Text="Fri · Cloudy"
FontSize="30"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
<TextBlock x:Name="DailyHigh2"
Grid.Column="2"
Text="12"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
VerticalAlignment="Center" />
<TextBlock x:Name="DailyLow2"
Grid.Column="3"
Text="3"
FontSize="30"
FontWeight="Medium"
FontFeatures="tnum"
VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="3"
ColumnDefinitions="Auto,*,Auto,Auto"
ColumnSpacing="10">
<Image x:Name="DailyIcon3"
Width="24"
Height="24"
VerticalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="DailyLabel3"
Grid.Column="1"
Text="Sat · Partly Cloudy"
FontSize="30"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
<TextBlock x:Name="DailyHigh3"
Grid.Column="2"
Text="10"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
VerticalAlignment="Center" />
<TextBlock x:Name="DailyLow3"
Grid.Column="3"
Text="2"
FontSize="30"
FontWeight="Medium"
FontFeatures="tnum"
VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="4"
ColumnDefinitions="Auto,*,Auto,Auto"
ColumnSpacing="10">
<Image x:Name="DailyIcon4"
Width="24"
Height="24"
VerticalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="DailyLabel4"
Grid.Column="1"
Text="Sun · Cloudy"
FontSize="30"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
<TextBlock x:Name="DailyHigh4"
Grid.Column="2"
Text="11"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
VerticalAlignment="Center" />
<TextBlock x:Name="DailyLow4"
Grid.Column="3"
Text="3"
FontSize="30"
FontWeight="Medium"
FontFeatures="tnum"
VerticalAlignment="Center" />
</Grid>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
</UserControl>

View File

@@ -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;
}
}

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
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"
@@ -9,16 +9,16 @@
x:Class="LanMontainDesktop.Views.Components.HourlyWeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Background="#68A9EC">
<Grid>
<Border x:Name="BackgroundImageLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Opacity="0.24"
RenderTransformOrigin="0.5,0.5">
@@ -32,12 +32,12 @@
</Border>
<Border x:Name="BackgroundTintLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Opacity="0.20" />
<Border x:Name="BackgroundLightLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Opacity="0.66">
<Border.Background>
@@ -54,7 +54,7 @@
</Border>
<Border x:Name="BackgroundShadeLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Opacity="0.78">
<Border.Background>
@@ -73,224 +73,243 @@
ClipToBounds="True" />
<Border x:Name="ContentPaddingBorder"
Padding="18"
Padding="16"
Background="Transparent">
<Grid x:Name="LayoutRoot">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<Grid x:Name="TopRowGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<fi:SymbolIcon x:Name="LocationIcon"
Symbol="Location"
FontSize="20"
VerticalAlignment="Center" />
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,*"
RowSpacing="6">
<Grid x:Name="TopRowGrid"
Grid.Row="0"
RowDefinitions="Auto,Auto"
ColumnDefinitions="Auto,*,Auto"
RowSpacing="4"
ColumnSpacing="10">
<TextBlock x:Name="TemperatureTextBlock"
Grid.Column="0"
Grid.RowSpan="2"
Text="24°"
FontSize="98"
FontWeight="Light"
FontFeatures="tnum"
VerticalAlignment="Top"
Margin="0,-1,0,0"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="CityTextBlock"
Grid.Column="1"
Text="北京"
FontSize="30"
FontWeight="SemiBold"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<fi:SymbolIcon x:Name="WeatherIconSymbol"
Grid.Column="2"
Symbol="WeatherSunny"
IconVariant="Regular"
FontSize="40"
HorizontalAlignment="Right"
VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto">
<TextBlock x:Name="TemperatureTextBlock"
Grid.Column="0"
Text="26°"
FontSize="108"
FontWeight="Bold"
FontFeatures="tnum"
VerticalAlignment="Center"
Margin="0,4,0,10"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<Border x:Name="CityInfoBadge"
Grid.Column="1"
Grid.Row="0"
Background="#2AFFFFFF"
CornerRadius="11"
Padding="10,4"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<StackPanel Orientation="Horizontal"
Spacing="6"
VerticalAlignment="Center">
<fi:SymbolIcon x:Name="LocationIcon"
Symbol="Location"
FontSize="14"
IsVisible="False"
VerticalAlignment="Center" />
<TextBlock x:Name="CityTextBlock"
Text="Beijing"
FontSize="19"
FontWeight="Medium"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</StackPanel>
</Border>
<Border x:Name="ConditionInfoBadge"
Grid.Column="1"
Grid.Row="1"
Background="Transparent"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<StackPanel x:Name="ConditionRangeStack"
Grid.Column="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Spacing="8"
Margin="0,0,0,10">
VerticalAlignment="Center">
<TextBlock x:Name="ConditionTextBlock"
Text=""
FontSize="30"
Text="Clear"
FontSize="21"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="RangeTextBlock"
Text="20° / 28°"
FontSize="36"
Text="20°/28°"
FontSize="21"
FontWeight="SemiBold"
FontFeatures="tnum"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</StackPanel>
</Grid>
</Border>
<StackPanel x:Name="BottomInfoStack"
Grid.Row="2"
VerticalAlignment="Bottom"
Spacing="4"
Margin="0,0,0,8">
<Border x:Name="HourlyPanelBorder"
Background="#1CFFFFFF"
CornerRadius="18"
ClipToBounds="True"
Padding="12,8">
<Grid x:Name="HourlyGrid"
ColumnDefinitions="*,*,*,*,*,*">
<StackPanel Grid.Column="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime0"
Text="现在"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon0"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp0"
Text="26°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime1"
Text="09:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon1"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp1"
Text="28°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime2"
Text="10:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon2"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp2"
Text="26°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="3"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime3"
Text="11:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon3"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp3"
Text="24°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="4"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime4"
Text="12:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon4"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp4"
Text="24°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="5"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime5"
Text="13:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon5"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp5"
Text="23°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
<Image x:Name="WeatherIconImage"
Grid.Column="2"
Grid.RowSpan="2"
Width="56"
Height="56"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Stretch="Uniform" />
</Grid>
<StackPanel x:Name="BottomInfoStack"
Grid.Row="1"
VerticalAlignment="Bottom"
Spacing="2"
Margin="0,0,0,1">
<Border x:Name="HourlyPanelBorder"
Background="#10FFFFFF"
CornerRadius="15"
ClipToBounds="True"
Padding="5,3">
<Grid x:Name="HourlyGrid"
ColumnDefinitions="*,*,*,*,*,*"
ColumnSpacing="8">
<StackPanel Grid.Column="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="1">
<TextBlock x:Name="HourlyTemp0"
Text="24°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon0"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTime0"
Text="Now"
FontSize="22"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="1">
<TextBlock x:Name="HourlyTemp1"
Text="23°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon1"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTime1"
Text="14:00"
FontSize="22"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="1">
<TextBlock x:Name="HourlyTemp2"
Text="23°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon2"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTime2"
Text="15:00"
FontSize="22"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="3"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="1">
<TextBlock x:Name="HourlyTemp3"
Text="21°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon3"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTime3"
Text="16:00"
FontSize="22"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="4"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="1">
<TextBlock x:Name="HourlyTemp4"
Text="20°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon4"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTime4"
Text="17:00"
FontSize="22"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="5"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="1">
<TextBlock x:Name="HourlyTemp5"
Text="20°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon5"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTime5"
Text="18:00"
FontSize="22"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
</UserControl>

View File

@@ -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<HourlyForecastItem>(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<HourlyForecastItem> 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);

View File

@@ -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<string, IImage?> 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;
}
});
}
}

View File

@@ -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<HyperOS3WeatherVisualKind, string> IconAssets =
new Dictionary<HyperOS3WeatherVisualKind, string>
{
[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<HyperOS3WeatherVisualKind, HyperOS3WeatherPalette> Palettes =
new Dictionary<HyperOS3WeatherVisualKind, HyperOS3WeatherPalette>
{
[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<HyperOS3WeatherVisualKind, HyperOS3WeatherMotion> Motions =
@@ -260,11 +273,11 @@ public static class HyperOS3WeatherTheme
private static readonly IReadOnlyDictionary<HyperOS3WeatherWidgetKind, HyperOS3WeatherMetrics> Metrics =
new Dictionary<HyperOS3WeatherWidgetKind, HyperOS3WeatherMetrics>
{
[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,

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
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"
@@ -9,16 +9,16 @@
x:Class="LanMontainDesktop.Views.Components.MultiDayWeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Background="#68A9EC">
<Grid>
<Border x:Name="BackgroundImageLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Opacity="0.24"
RenderTransformOrigin="0.5,0.5">
@@ -32,12 +32,12 @@
</Border>
<Border x:Name="BackgroundTintLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Opacity="0.20" />
<Border x:Name="BackgroundLightLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Opacity="0.66">
<Border.Background>
@@ -54,7 +54,7 @@
</Border>
<Border x:Name="BackgroundShadeLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Opacity="0.78">
<Border.Background>
@@ -73,204 +73,221 @@
ClipToBounds="True" />
<Border x:Name="ContentPaddingBorder"
Padding="18"
Padding="16"
Background="Transparent">
<Grid x:Name="LayoutRoot">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<Grid x:Name="TopRowGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<fi:SymbolIcon x:Name="LocationIcon"
Symbol="Location"
FontSize="20"
VerticalAlignment="Center" />
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,*"
RowSpacing="6">
<Grid x:Name="TopRowGrid"
Grid.Row="0"
RowDefinitions="Auto,Auto"
ColumnDefinitions="Auto,*,Auto"
RowSpacing="4"
ColumnSpacing="10">
<TextBlock x:Name="TemperatureTextBlock"
Grid.Column="0"
Grid.RowSpan="2"
Text="24°"
FontSize="98"
FontWeight="Light"
FontFeatures="tnum"
VerticalAlignment="Top"
Margin="0,-1,0,0"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="CityTextBlock"
Grid.Column="1"
Text="&#x5317;&#x4EAC;"
FontSize="30"
FontWeight="SemiBold"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<StackPanel x:Name="ConditionIconStack"
Grid.Column="2"
Orientation="Horizontal"
<Border x:Name="CityInfoBadge"
Grid.Column="1"
Grid.Row="0"
Background="#2AFFFFFF"
CornerRadius="11"
Padding="10,4"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<StackPanel Orientation="Horizontal"
Spacing="6"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<fi:SymbolIcon x:Name="LocationIcon"
Symbol="Location"
FontSize="14"
IsVisible="False"
VerticalAlignment="Center" />
<TextBlock x:Name="CityTextBlock"
Text="Beijing"
FontSize="19"
FontWeight="Medium"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</StackPanel>
</Border>
<Border x:Name="ConditionInfoBadge"
Grid.Column="1"
Grid.Row="1"
Background="Transparent"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<StackPanel x:Name="ConditionIconStack"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<TextBlock x:Name="ConditionTextBlock"
Text="&#x6674;"
FontSize="30"
Text="Clear"
FontSize="21"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<fi:SymbolIcon x:Name="WeatherIconSymbol"
Symbol="WeatherSunny"
IconVariant="Regular"
FontSize="40"
HorizontalAlignment="Right"
VerticalAlignment="Center" />
<TextBlock x:Name="RangeTextBlock"
Text="20°/28°"
FontSize="21"
FontWeight="SemiBold"
FontFeatures="tnum"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto">
<TextBlock x:Name="TemperatureTextBlock"
Grid.Column="0"
Text="26&#176;"
FontSize="108"
FontWeight="Bold"
FontFeatures="tnum"
VerticalAlignment="Center"
Margin="0,4,0,10"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="RangeTextBlock"
Grid.Column="2"
Text="&#x7A7A;&#x6C14;&#x4F18; 22"
FontSize="36"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="0,0,0,10"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</Grid>
<StackPanel x:Name="BottomInfoStack"
Grid.Row="2"
VerticalAlignment="Bottom"
Spacing="4"
Margin="0,0,0,8">
<Border x:Name="HourlyPanelBorder"
Background="#1CFFFFFF"
CornerRadius="18"
ClipToBounds="True"
Padding="12,8">
<Grid x:Name="HourlyGrid"
ColumnDefinitions="*,*,*,*,*">
<StackPanel Grid.Column="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime0"
Text="&#x4ECA;&#x5929;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon0"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp0"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime1"
Text="&#x660E;&#x5929;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon1"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp1"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime2"
Text="&#x5468;&#x516D;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon2"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp2"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="3"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime3"
Text="&#x5468;&#x65E5;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon3"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp3"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="4"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime4"
Text="&#x5468;&#x4E00;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon4"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp4"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
<Image x:Name="WeatherIconImage"
Grid.Column="2"
Grid.RowSpan="2"
Width="56"
Height="56"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Stretch="Uniform" />
</Grid>
<StackPanel x:Name="BottomInfoStack"
Grid.Row="1"
VerticalAlignment="Bottom"
Spacing="2"
Margin="0,0,0,1">
<Border x:Name="HourlyPanelBorder"
Background="#0EFFFFFF"
CornerRadius="15"
ClipToBounds="True"
Padding="5,3">
<Grid x:Name="HourlyGrid"
ColumnDefinitions="*,*,*,*,*"
ColumnSpacing="8">
<StackPanel Grid.Column="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="1">
<TextBlock x:Name="HourlyTime0"
Text="Today"
FontSize="21"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon0"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTemp0"
Text="20° / 28°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="1">
<TextBlock x:Name="HourlyTime1"
Text="Tomorrow"
FontSize="21"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon1"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTemp1"
Text="20° / 28°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="1">
<TextBlock x:Name="HourlyTime2"
Text="Sat"
FontSize="21"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon2"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTemp2"
Text="20° / 28°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="3"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="1">
<TextBlock x:Name="HourlyTime3"
Text="Sun"
FontSize="21"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon3"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTemp3"
Text="20° / 28°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="4"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="1">
<TextBlock x:Name="HourlyTime4"
Text="Mon"
FontSize="21"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<Image x:Name="HourlyIcon4"
Width="26"
Height="26"
HorizontalAlignment="Center"
Stretch="Uniform" />
<TextBlock x:Name="HourlyTemp4"
Text="20° / 28°"
FontSize="28"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
</UserControl>

View File

@@ -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<HourlyForecastItem>(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<HourlyForecastItem> 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);

View File

@@ -0,0 +1,255 @@
<UserControl xmlns="https://github.com/avaloniaui"
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"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMontainDesktop.Views.Components.MusicControlWidget">
<UserControl.Styles>
<Style Selector="Button.music-action">
<Setter Property="Background" Value="#24FFFFFF" />
<Setter Property="BorderBrush" Value="#44FFFFFF" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.music-action:pointerover">
<Setter Property="Background" Value="#30FFFFFF" />
</Style>
<Style Selector="Button.music-action:pressed">
<Setter Property="Background" Value="#4AFFFFFF" />
</Style>
<Style Selector="Button.music-action:disabled">
<Setter Property="Opacity" Value="0.55" />
</Style>
<Style Selector="Button.music-link">
<Setter Property="Background" Value="#14FFFFFF" />
<Setter Property="BorderBrush" Value="#3FFFFFFF" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="8,3" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Left" />
</Style>
<Style Selector="Button.music-link:pointerover">
<Setter Property="Background" Value="#24FFFFFF" />
</Style>
<Style Selector="Button.music-link:pressed">
<Setter Property="Background" Value="#3AFFFFFF" />
</Style>
</UserControl.Styles>
<Border x:Name="RootBorder"
CornerRadius="30"
ClipToBounds="True"
BorderThickness="1"
BorderBrush="#54FFFFFF"
Padding="14,11,14,11">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="1,1">
<GradientStop Color="#AB9E84"
Offset="0" />
<GradientStop Color="#8D8066"
Offset="0.52" />
<GradientStop Color="#75684F"
Offset="1" />
</LinearGradientBrush>
</Border.Background>
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="8">
<Border Grid.Row="0"
Grid.RowSpan="2"
Background="#22FFFFFF"
CornerRadius="16"
IsHitTestVisible="False" />
<Grid Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="10">
<Border x:Name="CoverBorder"
Width="56"
Height="56"
CornerRadius="12"
ClipToBounds="True"
BorderThickness="1"
BorderBrush="#6AFFFFFF"
Background="#38FFFFFF">
<Grid>
<Image x:Name="CoverImage"
IsVisible="False"
Stretch="UniformToFill" />
<Path x:Name="CoverFallbackGlyph"
Width="18"
Height="18"
Stretch="Uniform"
Fill="#F3FFFFFF"
Data="M 9,1 C 6.2,1 4,3.2 4,6 C 4,8.8 6.2,11 9,11 C 11.8,11 14,8.8 14,6 C 14,3.2 11.8,1 9,1 Z M 11,6 C 11,7.1 10.1,8 9,8 C 7.9,8 7,7.1 7,6 C 7,4.9 7.9,4 9,4 C 10.1,4 11,4.9 11,6 Z M 9.5,10.8 L 8.5,10.8 L 8.5,18 L 9.5,18 Z" />
</Grid>
</Border>
<StackPanel Grid.Column="1"
Spacing="2"
VerticalAlignment="Center">
<TextBlock x:Name="TitleTextBlock"
Text="Music"
FontSize="22"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
Foreground="#FFFFFFFF" />
<TextBlock x:Name="ArtistTextBlock"
Text="No active media session"
FontSize="16"
FontWeight="Medium"
TextTrimming="CharacterEllipsis"
MaxLines="1"
Foreground="#DBFFFFFF" />
<Button x:Name="SourceAppButton"
Classes="music-link"
Click="OnSourceAppButtonClick">
<StackPanel Orientation="Horizontal"
Spacing="5"
VerticalAlignment="Center">
<Path Width="11"
Height="11"
Stretch="Uniform"
Fill="#F7FFFFFF"
Data="M 2,2 H 12 V 5 H 10 V 4 H 4 V 12 H 8 V 10 H 9 V 13 H 3 C 2.4,13 2,12.6 2,12 Z M 7,1 H 14 V 8 H 13 V 3.4 L 9.4,7 L 8.6,6.2 L 12.2,2.6 H 7 Z" />
<TextBlock x:Name="SourceAppTextBlock"
Text="Open player"
FontSize="12"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
Foreground="#F7FFFFFF" />
</StackPanel>
</Button>
</StackPanel>
<Border Grid.Column="2"
x:Name="StatusBadgeBorder"
CornerRadius="10"
BorderThickness="1"
BorderBrush="#5FFFFFFF"
Background="#1EFFFFFF"
Padding="8,4"
VerticalAlignment="Top">
<TextBlock x:Name="StatusTextBlock"
Text="--"
FontSize="12"
FontWeight="SemiBold"
Foreground="#F3FFFFFF" />
</Border>
</Grid>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8"
VerticalAlignment="Center">
<TextBlock x:Name="PositionTextBlock"
Text="00:00"
FontSize="13"
FontWeight="SemiBold"
Foreground="#E8FFFFFF"
VerticalAlignment="Center" />
<ProgressBar x:Name="ProgressBar"
Grid.Column="1"
MinWidth="160"
Minimum="0"
Maximum="100"
Value="0"
Height="5"
VerticalAlignment="Center"
Foreground="#ECFFFFFF"
Background="#45FFFFFF" />
<TextBlock x:Name="DurationTextBlock"
Grid.Column="2"
Text="00:00"
FontSize="13"
FontWeight="SemiBold"
Foreground="#E8FFFFFF"
VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="2"
ColumnDefinitions="Auto,Auto,Auto,Auto,Auto"
ColumnSpacing="8"
HorizontalAlignment="Center"
VerticalAlignment="Bottom">
<Button x:Name="QueueButton"
Grid.Column="0"
Classes="music-action"
Width="32"
Height="32"
IsEnabled="False">
<Path Width="14"
Height="14"
Stretch="Uniform"
Fill="#FFFFFFFF"
Data="M 2,3 H 18 V 5 H 2 Z M 2,8 H 14 V 10 H 2 Z M 2,13 H 10 V 15 H 2 Z" />
</Button>
<Button x:Name="PreviousButton"
Grid.Column="1"
Classes="music-action"
Width="34"
Height="34"
Click="OnPreviousButtonClick">
<Path Width="14"
Height="14"
Stretch="Uniform"
Fill="#FFFFFFFF"
Data="M 3,2 V 14 H 5 V 2 Z M 6,8 L 14,2 V 14 Z" />
</Button>
<Button x:Name="PlayPauseButton"
Grid.Column="2"
Classes="music-action"
Width="42"
Height="42"
Click="OnPlayPauseButtonClick">
<Path x:Name="PlayPauseGlyphPath"
Width="14"
Height="14"
Stretch="Uniform"
Fill="#FFFFFFFF"
Data="M 2,1 L 2,13 L 12,7 Z" />
</Button>
<Button x:Name="NextButton"
Grid.Column="3"
Classes="music-action"
Width="34"
Height="34"
Click="OnNextButtonClick">
<Path Width="14"
Height="14"
Stretch="Uniform"
Fill="#FFFFFFFF"
Data="M 11,2 V 14 H 13 V 2 Z M 2,2 L 10,8 L 2,14 Z" />
</Button>
<Button x:Name="FavoriteButton"
Grid.Column="4"
Classes="music-action"
Width="32"
Height="32"
IsEnabled="False">
<Path Width="14"
Height="14"
Stretch="Uniform"
Fill="#FFFFFFFF"
Data="M 10,3 L 12.4,7.2 L 17.2,8.1 L 13.8,11.5 L 14.4,16.3 L 10,14.1 L 5.6,16.3 L 6.2,11.5 L 2.8,8.1 L 7.6,7.2 Z" />
</Button>
</Grid>
</Grid>
</Border>
</UserControl>

View File

@@ -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<CancellationToken, Task<bool>> 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;
}
}

View File

@@ -0,0 +1,170 @@
<UserControl xmlns="https://github.com/avaloniaui"
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"
mc:Ignorable="d"
d:DesignWidth="320"
d:DesignHeight="320"
x:Class="LanMontainDesktop.Views.Components.RecordingWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
Padding="10"
ClipToBounds="True"
Background="#ECEFF3"
BorderBrush="#DEE3EA"
BorderThickness="1">
<Viewbox Stretch="Uniform">
<Grid Width="300"
Height="300">
<Border x:Name="RecorderCardBorder"
Width="248"
Height="248"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="30"
BorderBrush="#E6EAF0"
BorderThickness="1"
Background="#F4F6FA">
<Grid Margin="16,14,16,12"
RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<TextBlock x:Name="TitleTextBlock"
Grid.Row="0"
Text="录音"
FontSize="19"
FontWeight="SemiBold"
Foreground="#11151D"
HorizontalAlignment="Center" />
<TextBlock x:Name="TimerTextBlock"
Grid.Row="1"
Margin="0,8,0,0"
Text="00:00"
FontSize="66"
FontWeight="SemiBold"
FontFeatures="tnum"
Foreground="#151922"
HorizontalAlignment="Center" />
<Grid Grid.Row="2"
Margin="0,10,0,0"
ColumnDefinitions="*,2,68"
VerticalAlignment="Center">
<StackPanel x:Name="WaveformBarsPanel"
Grid.Column="0"
Orientation="Horizontal"
Spacing="3"
HorizontalAlignment="Left"
VerticalAlignment="Center" />
<Border Grid.Column="1"
Margin="0,0,0,0"
Width="2"
Height="32"
CornerRadius="1"
Background="#F14A40"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
<Border x:Name="FutureLine"
Grid.Column="2"
Margin="8,0,0,0"
Height="2"
CornerRadius="1"
Background="#A3A8B3"
Opacity="0.55"
HorizontalAlignment="Stretch"
VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="3"
Margin="0,16,0,0"
HorizontalAlignment="Center"
ColumnDefinitions="Auto,Auto,Auto"
ColumnSpacing="16">
<Border x:Name="DiscardButtonBorder"
Grid.Column="0"
Width="54"
Height="54"
CornerRadius="27"
Background="#F8FAFD"
BorderBrush="#E0E5EC"
BorderThickness="1"
Cursor="Hand"
PointerPressed="OnDiscardButtonPointerPressed">
<Viewbox Width="20"
Height="20"
Stretch="Uniform">
<Path Data="M 5,2 V 18 M 5,3 H 15 L 13,7 L 15,11 H 5"
Stroke="#141922"
StrokeThickness="1.9" />
</Viewbox>
</Border>
<Border x:Name="RecordToggleButtonBorder"
Grid.Column="1"
Width="68"
Height="68"
CornerRadius="34"
Background="#EF3E38"
Cursor="Hand"
PointerPressed="OnRecordToggleButtonPointerPressed">
<Grid>
<Ellipse x:Name="RecordDot"
Width="15"
Height="15"
Fill="#FFFFFF"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
<Path x:Name="PauseGlyphPath"
Width="14"
Height="16"
Stretch="Uniform"
Fill="#FFFFFF"
Data="M 0,0 H 4 V 16 H 0 Z M 8,0 H 12 V 16 H 8 Z"
IsVisible="False" />
<Path x:Name="PlayGlyphPath"
Width="16"
Height="16"
Stretch="Uniform"
Fill="#FFFFFF"
Data="M 0,0 L 0,16 L 13,8 Z"
IsVisible="False" />
</Grid>
</Border>
<Border x:Name="SaveButtonBorder"
Grid.Column="2"
Width="54"
Height="54"
CornerRadius="27"
Background="#F8FAFD"
BorderBrush="#E0E5EC"
BorderThickness="1"
Cursor="Hand"
PointerPressed="OnSaveButtonPointerPressed">
<Viewbox Width="22"
Height="22"
Stretch="Uniform">
<Path Data="M 3,11 L 8,16 L 19,5"
Stroke="#141922"
StrokeThickness="2.2" />
</Viewbox>
</Border>
</Grid>
<TextBlock x:Name="HintTextBlock"
Grid.Row="4"
Margin="0,10,0,0"
HorizontalAlignment="Center"
TextAlignment="Center"
FontSize="13"
FontWeight="Medium"
Foreground="#7A818E"
Text="点击红色按钮开始" />
</Grid>
</Border>
</Grid>
</Viewbox>
</Border>
</UserControl>

View File

@@ -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<Border> _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));
}
}

View File

@@ -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" />
<fi:SymbolIcon x:Name="WeatherIconSymbol"
Symbol="WeatherPartlyCloudyDay"
FontSize="18"
Foreground="#5A9CFF"
VerticalAlignment="Center"
IconVariant="Regular" />
<Image x:Name="WeatherIconImage"
Width="18"
Height="18"
VerticalAlignment="Center"
Stretch="Uniform" />
</StackPanel>
</StackPanel>

View File

@@ -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()

View File

@@ -9,16 +9,16 @@
x:Class="LanMontainDesktop.Views.Components.WeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Background="#68A9EC">
<Grid>
<Border x:Name="BackgroundImageLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Opacity="0.24"
RenderTransformOrigin="0.5,0.5">
@@ -32,12 +32,12 @@
</Border>
<Border x:Name="BackgroundTintLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Opacity="0.20" />
<Border x:Name="BackgroundLightLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Opacity="0.66">
<Border.Background>
@@ -54,7 +54,7 @@
</Border>
<Border x:Name="BackgroundShadeLayer"
CornerRadius="28"
CornerRadius="30"
ClipToBounds="True"
Opacity="0.78">
<Border.Background>
@@ -73,76 +73,91 @@
ClipToBounds="True" />
<Border x:Name="ContentPaddingBorder"
Padding="18"
Padding="16"
Background="Transparent">
<Viewbox Stretch="Uniform">
<Grid x:Name="LayoutRoot"
Width="300"
Height="300">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,*,Auto"
RowSpacing="8">
<Grid x:Name="TopRowGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<fi:SymbolIcon x:Name="LocationIcon"
Symbol="Location"
FontSize="20"
VerticalAlignment="Center" />
<TextBlock x:Name="CityTextBlock"
Grid.Column="1"
Text="Beijing"
FontSize="30"
FontWeight="SemiBold"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<fi:SymbolIcon x:Name="WeatherIconSymbol"
Grid.Column="2"
Symbol="WeatherSunny"
IconVariant="Regular"
FontSize="40"
HorizontalAlignment="Right"
VerticalAlignment="Center" />
</Grid>
<Grid x:Name="LayoutRoot">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,*,Auto"
RowSpacing="2">
<Grid x:Name="TopRowGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*">
<TextBlock x:Name="TemperatureTextBlock"
Grid.Row="1"
Text="26"
FontSize="108"
FontWeight="Bold"
Grid.Column="0"
Text="26°"
FontSize="96"
FontWeight="Light"
FontFeatures="tnum"
VerticalAlignment="Center"
Margin="0,4,0,10"
VerticalAlignment="Top"
Margin="0,-1,0,0"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<StackPanel x:Name="BottomInfoStack"
Grid.Row="2"
VerticalAlignment="Bottom"
Spacing="4"
Margin="0,0,0,10">
<Image x:Name="WeatherIconImage"
Grid.Column="1"
Width="76"
Height="76"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Stretch="Uniform" />
</Grid>
<Border x:Name="ConditionInfoBadge"
Grid.Row="1"
Background="Transparent"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Padding="0">
<StackPanel Orientation="Vertical"
Spacing="0"
VerticalAlignment="Center">
<TextBlock x:Name="ConditionTextBlock"
Text="Clear"
FontSize="30"
FontSize="44"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="RangeTextBlock"
Text="20 / 28"
FontSize="36"
Text="20°/28°"
FontSize="46"
FontWeight="SemiBold"
FontFeatures="tnum"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</StackPanel>
</Grid>
</Border>
<StackPanel x:Name="BottomInfoStack"
Grid.Row="2"
VerticalAlignment="Bottom"
Spacing="0"
Margin="0,0,0,1">
<Border x:Name="CityInfoBadge"
Background="#24FFFFFF"
CornerRadius="13"
Padding="10,5"
HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal"
Spacing="6"
VerticalAlignment="Center">
<fi:SymbolIcon x:Name="LocationIcon"
Symbol="Location"
FontSize="14"
IsVisible="False"
VerticalAlignment="Center" />
<TextBlock x:Name="CityTextBlock"
Text="Beijing"
FontSize="23"
FontWeight="Medium"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Viewbox>
</Grid>
</Border>
</Grid>
</Border>

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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"
}
}