mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-26 03:44:25 +08:00
0.2.7
修改天气组件,ci工作流
This commit is contained in:
@@ -9,11 +9,15 @@ Extracted source paths inside APK:
|
||||
- `assets/map_custom/particle/sun_0.png` -> `hyper_sun_core.png`
|
||||
- `assets/map_custom/particle/sun_1.png` -> `hyper_sun_ring.png`
|
||||
- `assets/map_custom/particle/fog.png` -> `hyper_fog.png`
|
||||
- `assets/map_custom/particle/haze.png` -> `hyper_haze.png`
|
||||
- `assets/map_custom/particle/rain.png` -> `hyper_rain_drop.png`
|
||||
- `assets/map_custom/particle/snow.png` -> `hyper_snow_flake.png`
|
||||
- `assets/map_custom/skybox/top.png` -> `hyper_sky_top.png`
|
||||
- `assets/map_custom/skybox/back.png` -> `hyper_sky_back.png`
|
||||
- `assets/map_custom/skybox/front.png` -> `hyper_sky_front.png`
|
||||
- `assets/map_custom/skybox/left.png` -> `hyper_sky_left.png`
|
||||
- `assets/map_custom/skybox/right.png` -> `hyper_sky_right.png`
|
||||
- `assets/map_custom/skybox/bottom.png` -> `hyper_sky_bottom.png`
|
||||
- `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`
|
||||
|
||||
|
||||
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_haze.png
Normal file
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_haze.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 260 B |
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_bottom.png
Normal file
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_bottom.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 B |
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_left.png
Normal file
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_left.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_right.png
Normal file
BIN
LanMontainDesktop/Assets/Weather/HyperOS3/hyper_sky_right.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -18,6 +18,8 @@ public static class BuiltInComponentIds
|
||||
public const string MonthCalendar = "MonthCalendar";
|
||||
public const string LunarCalendar = "LunarCalendar";
|
||||
public const string HolidayCalendar = "HolidayCalendar";
|
||||
public const string DesktopDailyPoetry = "DesktopDailyPoetry";
|
||||
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
|
||||
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||
}
|
||||
|
||||
@@ -121,6 +121,24 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopDailyPoetry,
|
||||
"Daily Poetry",
|
||||
"Book",
|
||||
"Info",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopDailyArtwork,
|
||||
"Daily Artwork",
|
||||
"Image",
|
||||
"Info",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopWhiteboard,
|
||||
"Blackboard Portrait",
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
"weather.widget.aqi_format": "AQI {0}",
|
||||
"weather.widget.updated_format": "Updated {0:HH:mm}",
|
||||
"weather.hourly.now": "Now",
|
||||
"weather.hourly.sunset": "Sunset",
|
||||
"weather.multiday.today": "Today",
|
||||
"weather.multiday.tomorrow": "Tomorrow",
|
||||
"weather.multiday.aqi_format": "Air Quality {0}",
|
||||
@@ -211,6 +212,7 @@
|
||||
"component_category.weather": "Weather",
|
||||
"component_category.board": "Board",
|
||||
"component_category.media": "Media",
|
||||
"component_category.info": "Info",
|
||||
"component.date": "Calendar",
|
||||
"component.month_calendar": "Month Calendar",
|
||||
"component.lunar_calendar": "Lunar Calendar",
|
||||
@@ -224,13 +226,29 @@
|
||||
"component.class_schedule": "Class Schedule",
|
||||
"component.music_control": "Music Control",
|
||||
"component.audio_recorder": "Recorder",
|
||||
"component.daily_poetry": "Daily Poetry",
|
||||
"component.daily_artwork": "Daily Artwork",
|
||||
"component.whiteboard": "Blackboard (Portrait)",
|
||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||
"component.holiday_calendar": "Holiday Calendar",
|
||||
"poetry.widget.loading_content": "Loading poetry...",
|
||||
"poetry.widget.loading_author": "Loading...",
|
||||
"poetry.widget.fetch_failed": "Poetry fetch failed",
|
||||
"poetry.widget.fallback_content": "Daily poetry is temporarily unavailable.",
|
||||
"poetry.widget.fallback_author": "Try again later",
|
||||
"poetry.widget.unknown_author": "Unknown",
|
||||
"artwork.widget.loading": "Loading...",
|
||||
"artwork.widget.loading_title": "Daily Artwork",
|
||||
"artwork.widget.loading_subtitle": "Fetching today's masterpiece",
|
||||
"artwork.widget.fetch_failed": "Artwork fetch failed",
|
||||
"artwork.widget.fallback_title": "Daily Artwork",
|
||||
"artwork.widget.fallback_artist": "Recommendation backend unavailable",
|
||||
"artwork.widget.fallback_year": "Try again later",
|
||||
"artwork.widget.unknown_artist": "Unknown artist",
|
||||
"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.no_session": "No music source",
|
||||
"music.widget.no_session_hint": "Install QQ Music / KuGou / NetEase Cloud Music from the app store",
|
||||
"music.widget.open_player": "Open player",
|
||||
"music.widget.unknown_title": "Unknown title",
|
||||
"music.widget.unknown_artist": "Unknown artist",
|
||||
@@ -246,6 +264,8 @@
|
||||
"recording.widget.hint.unsupported": "Microphone is unavailable",
|
||||
"recording.widget.hint.error": "Recording failed",
|
||||
"recording.widget.hint.saved_format": "Saved {0}",
|
||||
"recording.widget.save_picker_title": "Save recording file",
|
||||
"recording.widget.save_picker_type": "WAV audio",
|
||||
"desktop.add_page": "Add page",
|
||||
"desktop.delete_page": "Delete page",
|
||||
"placement.fill": "Fill",
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
"weather.widget.aqi_format": "AQI {0}",
|
||||
"weather.widget.updated_format": "更新于 {0:HH:mm}",
|
||||
"weather.hourly.now": "现在",
|
||||
"weather.hourly.sunset": "日落",
|
||||
"weather.multiday.today": "今天",
|
||||
"weather.multiday.tomorrow": "明天",
|
||||
"weather.multiday.aqi_format": "空气优 {0}",
|
||||
@@ -211,6 +212,7 @@
|
||||
"component_category.weather": "天气",
|
||||
"component_category.board": "白板",
|
||||
"component_category.media": "媒体",
|
||||
"component_category.info": "信息推荐",
|
||||
"component.date": "日历",
|
||||
"component.month_calendar": "月历",
|
||||
"component.lunar_calendar": "农历",
|
||||
@@ -224,13 +226,29 @@
|
||||
"component.class_schedule": "课表",
|
||||
"component.music_control": "音乐控制",
|
||||
"component.audio_recorder": "录音",
|
||||
"component.daily_poetry": "每日诗词",
|
||||
"component.daily_artwork": "每日名画",
|
||||
"component.whiteboard": "竖向小黑板",
|
||||
"component.blackboard_landscape": "横向小黑板",
|
||||
"component.holiday_calendar": "节假日日历",
|
||||
"poetry.widget.loading_content": "正在加载诗词",
|
||||
"poetry.widget.loading_author": "加载中",
|
||||
"poetry.widget.fetch_failed": "诗词获取失败",
|
||||
"poetry.widget.fallback_content": "今日诗词暂不可用",
|
||||
"poetry.widget.fallback_author": "稍后重试",
|
||||
"poetry.widget.unknown_author": "佚名",
|
||||
"artwork.widget.loading": "加载中",
|
||||
"artwork.widget.loading_title": "每日名画",
|
||||
"artwork.widget.loading_subtitle": "正在获取今日名画",
|
||||
"artwork.widget.fetch_failed": "名画获取失败",
|
||||
"artwork.widget.fallback_title": "每日名画",
|
||||
"artwork.widget.fallback_artist": "推荐后端不可用",
|
||||
"artwork.widget.fallback_year": "稍后重试",
|
||||
"artwork.widget.unknown_artist": "未知作者",
|
||||
"music.widget.unsupported": "当前平台不支持音乐控制",
|
||||
"music.widget.unsupported_hint": "该组件仅支持 Windows SMTC",
|
||||
"music.widget.no_session": "未检测到正在播放的媒体",
|
||||
"music.widget.no_session_hint": "请打开支持 SMTC 的播放器",
|
||||
"music.widget.no_session": "暂无音源",
|
||||
"music.widget.no_session_hint": "点击前往应用商店下载“QQ音乐/酷狗音乐/网易云音乐”后使用",
|
||||
"music.widget.open_player": "打开播放器",
|
||||
"music.widget.unknown_title": "未知歌曲",
|
||||
"music.widget.unknown_artist": "未知艺术家",
|
||||
@@ -246,6 +264,8 @@
|
||||
"recording.widget.hint.unsupported": "麦克风不可用",
|
||||
"recording.widget.hint.error": "录音失败",
|
||||
"recording.widget.hint.saved_format": "已保存 {0}",
|
||||
"recording.widget.save_picker_title": "保存录音文件",
|
||||
"recording.widget.save_picker_type": "WAV 音频",
|
||||
"desktop.add_page": "新增页面",
|
||||
"desktop.delete_page": "删除页面",
|
||||
"placement.fill": "填充",
|
||||
|
||||
21
LanMontainDesktop/Models/RecommendationDataModels.cs
Normal file
21
LanMontainDesktop/Models/RecommendationDataModels.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
|
||||
namespace LanMontainDesktop.Models;
|
||||
|
||||
public sealed record DailyArtworkSnapshot(
|
||||
string Provider,
|
||||
string Title,
|
||||
string? Artist,
|
||||
string? Year,
|
||||
string? Museum,
|
||||
string? ArtworkUrl,
|
||||
string? ImageUrl,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
public sealed record DailyPoetrySnapshot(
|
||||
string Provider,
|
||||
string Content,
|
||||
string? Origin,
|
||||
string? Author,
|
||||
string? Category,
|
||||
DateTimeOffset FetchedAt);
|
||||
@@ -35,7 +35,7 @@ public interface IAudioRecorderService : IDisposable
|
||||
|
||||
bool Pause();
|
||||
|
||||
string? StopAndSave();
|
||||
string? StopAndSave(string? outputPath = null);
|
||||
|
||||
void Discard();
|
||||
}
|
||||
@@ -84,7 +84,7 @@ internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderSe
|
||||
return false;
|
||||
}
|
||||
|
||||
public string? StopAndSave()
|
||||
public string? StopAndSave(string? outputPath = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -227,7 +227,7 @@ public sealed class PortAudioRecorderService : IAudioRecorderService
|
||||
}
|
||||
}
|
||||
|
||||
public string? StopAndSave()
|
||||
public string? StopAndSave(string? outputPath = null)
|
||||
{
|
||||
byte[] pcmData;
|
||||
int sampleRate;
|
||||
@@ -255,10 +255,10 @@ public sealed class PortAudioRecorderService : IAudioRecorderService
|
||||
return null;
|
||||
}
|
||||
|
||||
var outputPath = BuildOutputPath();
|
||||
var resolvedOutputPath = ResolveOutputPath(outputPath);
|
||||
try
|
||||
{
|
||||
WriteWaveFile(outputPath, pcmData, sampleRate, ChannelCount, BitsPerSample);
|
||||
WriteWaveFile(resolvedOutputPath, pcmData, sampleRate, ChannelCount, BitsPerSample);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -272,11 +272,11 @@ public sealed class PortAudioRecorderService : IAudioRecorderService
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_lastSavedFilePath = outputPath;
|
||||
_lastSavedFilePath = resolvedOutputPath;
|
||||
_lastError = string.Empty;
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
return resolvedOutputPath;
|
||||
}
|
||||
|
||||
public void Discard()
|
||||
@@ -590,7 +590,31 @@ public sealed class PortAudioRecorderService : IAudioRecorderService
|
||||
return Math.Clamp(peak, 0, 1);
|
||||
}
|
||||
|
||||
private static string BuildOutputPath()
|
||||
private static string ResolveOutputPath(string? outputPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
return BuildDefaultOutputPath();
|
||||
}
|
||||
|
||||
var normalizedPath = outputPath.Trim();
|
||||
if (!string.Equals(Path.GetExtension(normalizedPath), ".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalizedPath = Path.ChangeExtension(normalizedPath, ".wav");
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(normalizedPath);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
directory = Environment.CurrentDirectory;
|
||||
normalizedPath = Path.Combine(directory, Path.GetFileName(normalizedPath));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
private static string BuildDefaultOutputPath()
|
||||
{
|
||||
var root = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
|
||||
692
LanMontainDesktop/Services/IRecommendationDataService.cs
Normal file
692
LanMontainDesktop/Services/IRecommendationDataService.cs
Normal file
@@ -0,0 +1,692 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMontainDesktop.Models;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
public sealed record DailyArtworkQuery(
|
||||
string? Locale = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record DailyPoetryQuery(
|
||||
string? Locale = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record RecommendationQueryResult<T>(
|
||||
bool Success,
|
||||
T? Data,
|
||||
string? ErrorCode = null,
|
||||
string? ErrorMessage = null)
|
||||
{
|
||||
public static RecommendationQueryResult<T> Ok(T data)
|
||||
{
|
||||
return new RecommendationQueryResult<T>(true, data);
|
||||
}
|
||||
|
||||
public static RecommendationQueryResult<T> Fail(string errorCode, string errorMessage)
|
||||
{
|
||||
return new RecommendationQueryResult<T>(false, default, errorCode, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RecommendationBackendOptions
|
||||
{
|
||||
public string BaseUrl { get; init; } = "http://127.0.0.1:5057";
|
||||
|
||||
public string DailyArtworkPath { get; init; } = "/api/recommendation/daily-artwork";
|
||||
|
||||
public string DailyPoetryPath { get; init; } = "/api/recommendation/daily-poetry";
|
||||
|
||||
public string JinriShiciPoetryUrl { get; init; } = "https://v1.jinrishici.com/all.json";
|
||||
|
||||
public string ArtInstituteArtworkApiTemplate { get; init; } =
|
||||
"https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link";
|
||||
|
||||
public string ArtInstituteImageUrlTemplate { get; init; } =
|
||||
"https://www.artic.edu/iiif/2/{0}/full/843,/0/default.jpg";
|
||||
|
||||
public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(20);
|
||||
|
||||
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8);
|
||||
|
||||
public int DefaultArtworkCandidateCount { get; init; } = 50;
|
||||
}
|
||||
|
||||
public interface IRecommendationInfoService
|
||||
{
|
||||
Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkAsync(
|
||||
DailyArtworkQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryAsync(
|
||||
DailyPoetryQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
void ClearCache();
|
||||
}
|
||||
|
||||
public sealed class RecommendationBackendService : IRecommendationInfoService, IDisposable
|
||||
{
|
||||
private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||
private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt);
|
||||
private sealed record ArtworkCandidate(
|
||||
string Title,
|
||||
string? Artist,
|
||||
string? Year,
|
||||
string? ArtworkUrl,
|
||||
string? ImageId);
|
||||
|
||||
private readonly RecommendationBackendOptions _options;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
private readonly object _cacheGate = new();
|
||||
private DailyArtworkCacheEntry? _dailyArtworkCache;
|
||||
private DailyPoetryCacheEntry? _dailyPoetryCache;
|
||||
|
||||
public RecommendationBackendService(
|
||||
RecommendationBackendOptions? options = null,
|
||||
HttpClient? httpClient = null)
|
||||
{
|
||||
_options = options ?? new RecommendationBackendOptions();
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = _options.RequestTimeout
|
||||
};
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearCache()
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
_dailyArtworkCache = null;
|
||||
_dailyPoetryCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryAsync(
|
||||
DailyPoetryQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = query ?? new DailyPoetryQuery();
|
||||
if (!normalizedQuery.ForceRefresh && TryGetDailyPoetryFromCache(out var cached))
|
||||
{
|
||||
return RecommendationQueryResult<DailyPoetrySnapshot>.Ok(cached);
|
||||
}
|
||||
|
||||
var uri = BuildDailyPoetryUri(normalizedQuery.Locale, normalizedQuery.ForceRefresh);
|
||||
string responseText;
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(uri, cancellationToken);
|
||||
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return await TryDirectPoetryFallbackAsync(
|
||||
normalizedQuery,
|
||||
"http_error",
|
||||
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}",
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await TryDirectPoetryFallbackAsync(
|
||||
normalizedQuery,
|
||||
"network_error",
|
||||
ex.Message,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
|
||||
var success = ReadBool(root, "success");
|
||||
if (!success.GetValueOrDefault())
|
||||
{
|
||||
return await TryDirectPoetryFallbackAsync(
|
||||
normalizedQuery,
|
||||
ReadString(root, "errorCode") ?? "upstream_error",
|
||||
ReadString(root, "errorMessage") ?? "Recommendation backend returned an unsuccessful response.",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("data", out var dataNode) || dataNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return await TryDirectPoetryFallbackAsync(
|
||||
normalizedQuery,
|
||||
"parse_error",
|
||||
"Daily poetry payload is missing.",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var content = ReadString(dataNode, "content");
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return await TryDirectPoetryFallbackAsync(
|
||||
normalizedQuery,
|
||||
"parse_error",
|
||||
"Poetry content is missing.",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var snapshot = new DailyPoetrySnapshot(
|
||||
Provider: ReadString(dataNode, "provider") ?? "RecommendationBackend",
|
||||
Content: content.Trim(),
|
||||
Origin: ReadString(dataNode, "origin"),
|
||||
Author: ReadString(dataNode, "author"),
|
||||
Category: ReadString(dataNode, "category"),
|
||||
FetchedAt: ParseDateTimeOffset(ReadString(dataNode, "fetchedAt")) ?? DateTimeOffset.UtcNow);
|
||||
|
||||
SetDailyPoetryCache(snapshot);
|
||||
return RecommendationQueryResult<DailyPoetrySnapshot>.Ok(snapshot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await TryDirectPoetryFallbackAsync(
|
||||
normalizedQuery,
|
||||
"parse_error",
|
||||
ex.Message,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkAsync(
|
||||
DailyArtworkQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedQuery = query ?? new DailyArtworkQuery();
|
||||
if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(out var cached))
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(cached);
|
||||
}
|
||||
|
||||
var uri = BuildDailyArtworkUri(normalizedQuery.Locale, normalizedQuery.ForceRefresh);
|
||||
string responseText;
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(uri, cancellationToken);
|
||||
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return await TryDirectFallbackAsync(
|
||||
normalizedQuery,
|
||||
"http_error",
|
||||
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}",
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await TryDirectFallbackAsync(
|
||||
normalizedQuery,
|
||||
"network_error",
|
||||
ex.Message,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
|
||||
var success = ReadBool(root, "success");
|
||||
if (!success.GetValueOrDefault())
|
||||
{
|
||||
return await TryDirectFallbackAsync(
|
||||
normalizedQuery,
|
||||
ReadString(root, "errorCode") ?? "upstream_error",
|
||||
ReadString(root, "errorMessage") ?? "Recommendation backend returned an unsuccessful response.",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("data", out var dataNode) || dataNode.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return await TryDirectFallbackAsync(
|
||||
normalizedQuery,
|
||||
"parse_error",
|
||||
"Daily artwork payload is missing.",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var title = ReadString(dataNode, "title");
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return await TryDirectFallbackAsync(
|
||||
normalizedQuery,
|
||||
"parse_error",
|
||||
"Artwork title is missing.",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var snapshot = new DailyArtworkSnapshot(
|
||||
Provider: ReadString(dataNode, "provider") ?? "RecommendationBackend",
|
||||
Title: title.Trim(),
|
||||
Artist: ReadString(dataNode, "artist"),
|
||||
Year: ReadString(dataNode, "year"),
|
||||
Museum: ReadString(dataNode, "museum"),
|
||||
ArtworkUrl: ReadString(dataNode, "artworkUrl"),
|
||||
ImageUrl: ReadString(dataNode, "imageUrl"),
|
||||
FetchedAt: ParseDateTimeOffset(ReadString(dataNode, "fetchedAt")) ?? DateTimeOffset.UtcNow);
|
||||
|
||||
SetDailyArtworkCache(snapshot);
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await TryDirectFallbackAsync(
|
||||
normalizedQuery,
|
||||
"parse_error",
|
||||
ex.Message,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> TryDirectFallbackAsync(
|
||||
DailyArtworkQuery query,
|
||||
string errorCode,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fallback = await GetDailyArtworkDirectAsync(query, cancellationToken);
|
||||
if (fallback.Success && fallback.Data is not null)
|
||||
{
|
||||
SetDailyArtworkCache(fallback.Data);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var fallbackMessage = string.IsNullOrWhiteSpace(fallback.ErrorMessage)
|
||||
? "Direct upstream fallback failed."
|
||||
: fallback.ErrorMessage;
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail(
|
||||
errorCode,
|
||||
$"{errorMessage}; fallback: {fallbackMessage}");
|
||||
}
|
||||
|
||||
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkDirectAsync(
|
||||
DailyArtworkQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100);
|
||||
var localDate = GetChinaLocalDate();
|
||||
var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100);
|
||||
var requestUrl = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_options.ArtInstituteArtworkApiTemplate,
|
||||
page,
|
||||
candidateCount);
|
||||
|
||||
string responseText;
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail(
|
||||
"upstream_http_error",
|
||||
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_network_error", ex.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", "Artwork list is missing.");
|
||||
}
|
||||
|
||||
var candidates = new List<ArtworkCandidate>();
|
||||
foreach (var item in dataArray.EnumerateArray())
|
||||
{
|
||||
var title = ReadString(item, "title");
|
||||
var imageId = ReadString(item, "image_id");
|
||||
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(imageId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var artist = ReadString(item, "artist_title");
|
||||
if (string.IsNullOrWhiteSpace(artist))
|
||||
{
|
||||
artist = ReadFirstNonEmptyLine(ReadString(item, "artist_display"));
|
||||
}
|
||||
|
||||
candidates.Add(new ArtworkCandidate(
|
||||
title.Trim(),
|
||||
artist,
|
||||
ReadString(item, "date_display"),
|
||||
ReadString(item, "api_link"),
|
||||
imageId.Trim()));
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_empty_result", "No artwork candidates were returned.");
|
||||
}
|
||||
|
||||
var indexSeed = localDate.Year * 1000 + localDate.DayOfYear;
|
||||
var selected = candidates[Math.Abs(indexSeed) % candidates.Count];
|
||||
var snapshot = new DailyArtworkSnapshot(
|
||||
Provider: "ArtInstituteOfChicago",
|
||||
Title: selected.Title,
|
||||
Artist: selected.Artist,
|
||||
Year: selected.Year,
|
||||
Museum: "The Art Institute of Chicago",
|
||||
ArtworkUrl: selected.ArtworkUrl,
|
||||
ImageUrl: BuildArtworkImageUrl(selected.ImageId),
|
||||
FetchedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<RecommendationQueryResult<DailyPoetrySnapshot>> TryDirectPoetryFallbackAsync(
|
||||
DailyPoetryQuery query,
|
||||
string errorCode,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fallback = await GetDailyPoetryDirectAsync(query, cancellationToken);
|
||||
if (fallback.Success && fallback.Data is not null)
|
||||
{
|
||||
SetDailyPoetryCache(fallback.Data);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var fallbackMessage = string.IsNullOrWhiteSpace(fallback.ErrorMessage)
|
||||
? "Direct upstream fallback failed."
|
||||
: fallback.ErrorMessage;
|
||||
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail(
|
||||
errorCode,
|
||||
$"{errorMessage}; fallback: {fallbackMessage}");
|
||||
}
|
||||
|
||||
private async Task<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryDirectAsync(
|
||||
DailyPoetryQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_ = query;
|
||||
|
||||
string responseText;
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _options.JinriShiciPoetryUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail(
|
||||
"upstream_http_error",
|
||||
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail("upstream_network_error", ex.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
var content = ReadString(root, "content");
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail(
|
||||
"upstream_parse_error",
|
||||
"Poetry content is empty.");
|
||||
}
|
||||
|
||||
var snapshot = new DailyPoetrySnapshot(
|
||||
Provider: "JinriShici",
|
||||
Content: content.Trim(),
|
||||
Origin: ReadString(root, "origin"),
|
||||
Author: ReadString(root, "author"),
|
||||
Category: ReadString(root, "category"),
|
||||
FetchedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
return RecommendationQueryResult<DailyPoetrySnapshot>.Ok(snapshot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail("upstream_parse_error", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private Uri BuildDailyArtworkUri(string? locale, bool forceRefresh)
|
||||
{
|
||||
var baseUrl = _options.BaseUrl.TrimEnd('/');
|
||||
var path = _options.DailyArtworkPath.StartsWith("/", StringComparison.Ordinal)
|
||||
? _options.DailyArtworkPath
|
||||
: $"/{_options.DailyArtworkPath}";
|
||||
var localePart = string.IsNullOrWhiteSpace(locale)
|
||||
? string.Empty
|
||||
: $"locale={Uri.EscapeDataString(locale.Trim())}&";
|
||||
var forcePart = forceRefresh ? "true" : "false";
|
||||
return new Uri($"{baseUrl}{path}?{localePart}forceRefresh={forcePart}", UriKind.Absolute);
|
||||
}
|
||||
|
||||
private Uri BuildDailyPoetryUri(string? locale, bool forceRefresh)
|
||||
{
|
||||
var baseUrl = _options.BaseUrl.TrimEnd('/');
|
||||
var path = _options.DailyPoetryPath.StartsWith("/", StringComparison.Ordinal)
|
||||
? _options.DailyPoetryPath
|
||||
: $"/{_options.DailyPoetryPath}";
|
||||
var localePart = string.IsNullOrWhiteSpace(locale)
|
||||
? string.Empty
|
||||
: $"locale={Uri.EscapeDataString(locale.Trim())}&";
|
||||
var forcePart = forceRefresh ? "true" : "false";
|
||||
return new Uri($"{baseUrl}{path}?{localePart}forceRefresh={forcePart}", UriKind.Absolute);
|
||||
}
|
||||
|
||||
private bool TryGetDailyArtworkFromCache(out DailyArtworkSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
if (_dailyArtworkCache is not null && _dailyArtworkCache.ExpireAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
snapshot = _dailyArtworkCache.Snapshot;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void SetDailyArtworkCache(DailyArtworkSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
_dailyArtworkCache = new DailyArtworkCacheEntry(
|
||||
snapshot,
|
||||
DateTimeOffset.UtcNow.Add(_options.CacheDuration));
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetDailyPoetryFromCache(out DailyPoetrySnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
if (_dailyPoetryCache is not null && _dailyPoetryCache.ExpireAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
snapshot = _dailyPoetryCache.Snapshot;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void SetDailyPoetryCache(DailyPoetrySnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
{
|
||||
_dailyPoetryCache = new DailyPoetryCacheEntry(
|
||||
snapshot,
|
||||
DateTimeOffset.UtcNow.Add(_options.CacheDuration));
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement node, params string[] path)
|
||||
{
|
||||
var target = TryGetNode(node, path);
|
||||
if (!target.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return target.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => target.Value.GetString(),
|
||||
JsonValueKind.Number => target.Value.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool? ReadBool(JsonElement node, params string[] path)
|
||||
{
|
||||
var target = TryGetNode(node, path);
|
||||
if (!target.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return target.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.String when bool.TryParse(target.Value.GetString(), out var parsed) => parsed,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonElement? TryGetNode(JsonElement node, params string[] path)
|
||||
{
|
||||
var current = node;
|
||||
foreach (var segment in path)
|
||||
{
|
||||
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
current = next;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseDateTimeOffset(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private string? BuildArtworkImageUrl(string? imageId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_options.ArtInstituteImageUrlTemplate,
|
||||
imageId.Trim());
|
||||
}
|
||||
|
||||
private static string? ReadFirstNonEmptyLine(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return text
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line => line.Trim())
|
||||
.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
|
||||
}
|
||||
|
||||
private static DateOnly GetChinaLocalDate()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8));
|
||||
return DateOnly.FromDateTime(now.Date);
|
||||
}
|
||||
|
||||
private static string Truncate(string? text, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return text.Length <= maxLength
|
||||
? text
|
||||
: $"{text[..maxLength]}...";
|
||||
}
|
||||
}
|
||||
137
LanMontainDesktop/Views/Components/DailyArtworkWidget.axaml
Normal file
137
LanMontainDesktop/Views/Components/DailyArtworkWidget.axaml
Normal file
@@ -0,0 +1,137 @@
|
||||
<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:shapes="clr-namespace:Avalonia.Controls.Shapes;assembly=Avalonia.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="640"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMontainDesktop.Views.Components.DailyArtworkWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Background="#D5D5D5">
|
||||
<Grid x:Name="MainLayoutGrid"
|
||||
ColumnDefinitions="2.08*,1*">
|
||||
<Border x:Name="ArtworkPanel"
|
||||
Grid.Column="0"
|
||||
ClipToBounds="True"
|
||||
Background="#B8AE9A">
|
||||
<Grid>
|
||||
<Image x:Name="ArtworkImage"
|
||||
Stretch="UniformToFill" />
|
||||
|
||||
<Border x:Name="ImageBottomShade"
|
||||
VerticalAlignment="Bottom"
|
||||
Height="132">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="0,1">
|
||||
<GradientStop Color="#00000000"
|
||||
Offset="0" />
|
||||
<GradientStop Color="#AF000000"
|
||||
Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<StackPanel x:Name="DateInfoStack"
|
||||
VerticalAlignment="Bottom"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="22,0,0,22"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="DateTextBlock"
|
||||
Text="03/03"
|
||||
Foreground="#F9F9F9"
|
||||
FontSize="52"
|
||||
FontWeight="Bold"
|
||||
FontFeatures="tnum"
|
||||
LineHeight="54" />
|
||||
<TextBlock x:Name="WeekdayTextBlock"
|
||||
Text="星期二"
|
||||
Foreground="#F9F9F9"
|
||||
FontSize="52"
|
||||
FontWeight="Bold"
|
||||
LineHeight="54" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1"
|
||||
x:Name="InfoPanel"
|
||||
Background="#111418"
|
||||
Padding="18,14,18,14">
|
||||
<Grid>
|
||||
<Canvas x:Name="BrickPatternCanvas"
|
||||
IsHitTestVisible="False"
|
||||
Opacity="0.44">
|
||||
<shapes:Path x:Name="BrickHorizontalPath"
|
||||
Stroke="#7D838E"
|
||||
StrokeThickness="1.2"
|
||||
Data="M0,12 L800,12 M0,40 L800,40 M0,68 L800,68 M0,96 L800,96 M0,124 L800,124 M0,152 L800,152 M0,180 L800,180 M0,208 L800,208 M0,236 L800,236 M0,264 L800,264 M0,292 L800,292 M0,320 L800,320" />
|
||||
<shapes:Path x:Name="BrickVerticalPathA"
|
||||
Stroke="#5A606B"
|
||||
StrokeThickness="1"
|
||||
Opacity="0.55"
|
||||
Data="M56,12 L56,40 M116,12 L116,40 M176,12 L176,40 M236,12 L236,40 M26,40 L26,68 M86,40 L86,68 M146,40 L146,68 M206,40 L206,68 M56,68 L56,96 M116,68 L116,96 M176,68 L176,96 M236,68 L236,96" />
|
||||
<shapes:Path x:Name="BrickVerticalPathB"
|
||||
Stroke="#49505A"
|
||||
StrokeThickness="1"
|
||||
Opacity="0.36"
|
||||
Data="M26,96 L26,124 M86,96 L86,124 M146,96 L146,124 M206,96 L206,124 M56,124 L56,152 M116,124 L116,152 M176,124 L176,152 M236,124 L236,152 M26,152 L26,180 M86,152 L86,180 M146,152 L146,180 M206,152 L206,180" />
|
||||
</Canvas>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||
<TextBlock x:Name="PaintingTitleTextBlock"
|
||||
Text="“拉波特夫人”"
|
||||
Foreground="#F8F8F8"
|
||||
FontSize="44"
|
||||
FontWeight="Bold"
|
||||
TextWrapping="Wrap"
|
||||
MaxLines="2"
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<Border x:Name="RightPanelSeparator"
|
||||
Grid.Row="2"
|
||||
Width="118"
|
||||
Height="3"
|
||||
CornerRadius="2"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="0,0,0,10"
|
||||
Background="#F0F0F0" />
|
||||
|
||||
<StackPanel Grid.Row="3"
|
||||
Spacing="3">
|
||||
<TextBlock x:Name="ArtistTextBlock"
|
||||
Text="新南威尔士州艺术画廊"
|
||||
Foreground="#ECECEC"
|
||||
FontSize="26"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
MaxLines="2" />
|
||||
<TextBlock x:Name="YearTextBlock"
|
||||
Text="1754"
|
||||
Foreground="#D7DCE3"
|
||||
FontSize="22"
|
||||
FontWeight="Medium"
|
||||
FontFeatures="tnum"
|
||||
TextWrapping="NoWrap"
|
||||
MaxLines="1" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
Grid.ColumnSpan="2"
|
||||
IsVisible="False"
|
||||
Text="Loading"
|
||||
Foreground="#FFFFFFFF"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
542
LanMontainDesktop/Views/Components/DailyArtworkWidget.axaml.cs
Normal file
542
LanMontainDesktop/Views/Components/DailyArtworkWidget.axaml.cs
Normal file
@@ -0,0 +1,542 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using LanMontainDesktop.Models;
|
||||
using LanMontainDesktop.Services;
|
||||
|
||||
namespace LanMontainDesktop.Views.Components;
|
||||
|
||||
public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<DayOfWeek, string> ZhWeekdays =
|
||||
new Dictionary<DayOfWeek, string>
|
||||
{
|
||||
[DayOfWeek.Monday] = "星期一",
|
||||
[DayOfWeek.Tuesday] = "星期二",
|
||||
[DayOfWeek.Wednesday] = "星期三",
|
||||
[DayOfWeek.Thursday] = "星期四",
|
||||
[DayOfWeek.Friday] = "星期五",
|
||||
[DayOfWeek.Saturday] = "星期六",
|
||||
[DayOfWeek.Sunday] = "星期日"
|
||||
};
|
||||
|
||||
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
|
||||
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMontainDesktop/Assets/Fonts#MiSans");
|
||||
|
||||
private static readonly HttpClient ImageHttpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
private const string BrowserUserAgent =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36";
|
||||
|
||||
private const double BaseCellSize = 48d;
|
||||
private const int BaseWidthCells = 4;
|
||||
private const int BaseHeightCells = 2;
|
||||
|
||||
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationBackendService();
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromHours(6)
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
|
||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private Bitmap? _currentArtworkBitmap;
|
||||
private string _languageCode = "zh-CN";
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
|
||||
public DailyArtworkWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
DateTextBlock.FontFamily = MiSansFontFamily;
|
||||
WeekdayTextBlock.FontFamily = MiSansFontFamily;
|
||||
PaintingTitleTextBlock.FontFamily = MiSansFontFamily;
|
||||
ArtistTextBlock.FontFamily = MiSansFontFamily;
|
||||
YearTextBlock.FontFamily = MiSansFontFamily;
|
||||
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
UpdateLanguageCode();
|
||||
UpdateDateLabels();
|
||||
ApplyLoadingState();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var scale = ResolveScale();
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
|
||||
InfoPanel.Padding = new Thickness(
|
||||
Math.Clamp(18 * scale, 10, 28),
|
||||
Math.Clamp(14 * scale, 8, 22),
|
||||
Math.Clamp(18 * scale, 10, 28),
|
||||
Math.Clamp(14 * scale, 8, 22));
|
||||
|
||||
DateInfoStack.Margin = new Thickness(
|
||||
Math.Clamp(22 * scale, 10, 36),
|
||||
0,
|
||||
0,
|
||||
Math.Clamp(20 * scale, 10, 34));
|
||||
DateInfoStack.Spacing = Math.Clamp(2 * scale, 1, 6);
|
||||
|
||||
ImageBottomShade.Height = Math.Clamp(132 * scale, 64, 182);
|
||||
|
||||
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 10, 24);
|
||||
|
||||
BrickPatternCanvas.Opacity = Math.Clamp(0.44 * scale, 0.20, 0.50);
|
||||
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
_refreshTimer.Start();
|
||||
_ = RefreshArtworkAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
CancelRefreshRequest();
|
||||
DisposeArtworkBitmap();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
await RefreshArtworkAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private async Task RefreshArtworkAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
UpdateLanguageCode();
|
||||
UpdateDateLabels();
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
||||
previous?.Cancel();
|
||||
previous?.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
var query = new DailyArtworkQuery(
|
||||
Locale: _languageCode,
|
||||
ForceRefresh: forceRefresh);
|
||||
var result = await _recommendationService.GetDailyArtworkAsync(query, cts.Token);
|
||||
if (!_isAttached || cts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success || result.Data is null)
|
||||
{
|
||||
ApplyFailedState();
|
||||
return;
|
||||
}
|
||||
|
||||
await ApplySnapshotAsync(result.Data, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ignore canceled requests.
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (_isAttached && !cts.IsCancellationRequested)
|
||||
{
|
||||
ApplyFailedState();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_refreshCts, cts))
|
||||
{
|
||||
_refreshCts = null;
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
_isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplySnapshotAsync(DailyArtworkSnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
PaintingTitleTextBlock.Text = BuildQuotedTitle(snapshot.Title);
|
||||
|
||||
var artist = string.IsNullOrWhiteSpace(snapshot.Artist)
|
||||
? L("artwork.widget.unknown_artist", "Unknown artist")
|
||||
: snapshot.Artist.Trim();
|
||||
ArtistTextBlock.Text = NormalizeCompactText(artist);
|
||||
|
||||
YearTextBlock.Text = ResolveYearText(snapshot);
|
||||
StatusTextBlock.IsVisible = false;
|
||||
|
||||
UpdateAdaptiveLayout();
|
||||
|
||||
var bitmap = await TryLoadArtworkBitmapAsync(snapshot.ImageUrl, cancellationToken);
|
||||
if (cancellationToken.IsCancellationRequested || !_isAttached)
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
SetArtworkBitmap(bitmap);
|
||||
}
|
||||
|
||||
private static async Task<Bitmap?> TryLoadArtworkBitmapAsync(string? imageUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, imageUrl.Trim());
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
|
||||
request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
|
||||
if (Uri.TryCreate(imageUrl.Trim(), UriKind.Absolute, out var imageUri))
|
||||
{
|
||||
request.Headers.Referrer = new Uri($"{imageUri.Scheme}://{imageUri.Host}/", UriKind.Absolute);
|
||||
}
|
||||
|
||||
using var response = await ImageHttpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var memory = new MemoryStream();
|
||||
await stream.CopyToAsync(memory, cancellationToken);
|
||||
memory.Position = 0;
|
||||
return new Bitmap(memory);
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
StatusTextBlock.IsVisible = true;
|
||||
StatusTextBlock.Text = L("artwork.widget.loading", "Loading...");
|
||||
PaintingTitleTextBlock.Text = BuildQuotedTitle(L("artwork.widget.loading_title", "Daily Artwork"));
|
||||
ArtistTextBlock.Text = L("artwork.widget.loading_subtitle", "Fetching today's masterpiece");
|
||||
YearTextBlock.Text = "--";
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyFailedState()
|
||||
{
|
||||
StatusTextBlock.IsVisible = true;
|
||||
StatusTextBlock.Text = L("artwork.widget.fetch_failed", "Artwork fetch failed");
|
||||
PaintingTitleTextBlock.Text = BuildQuotedTitle(L("artwork.widget.fallback_title", "Daily Artwork"));
|
||||
ArtistTextBlock.Text = L("artwork.widget.fallback_artist", "Recommendation backend unavailable");
|
||||
YearTextBlock.Text = L("artwork.widget.fallback_year", "Try again later");
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||
|
||||
var leftStar = totalWidth < _currentCellSize * 4.2 ? 2.0 : 2.08;
|
||||
MainLayoutGrid.ColumnDefinitions[0].Width = new GridLength(leftStar, GridUnitType.Star);
|
||||
MainLayoutGrid.ColumnDefinitions[1].Width = new GridLength(1, GridUnitType.Star);
|
||||
|
||||
var rightPanelWidth = Math.Max(84, totalWidth / (leftStar + 1));
|
||||
var rightContentWidth = Math.Max(58, rightPanelWidth - InfoPanel.Padding.Left - InfoPanel.Padding.Right);
|
||||
var leftPanelWidth = Math.Max(84, totalWidth - rightPanelWidth);
|
||||
var leftContentWidth = Math.Max(52, leftPanelWidth - DateInfoStack.Margin.Left - 10);
|
||||
|
||||
var dateBase = Math.Clamp(52 * scale, 18, 72);
|
||||
DateTextBlock.FontSize = FitFontSize(
|
||||
DateTextBlock.Text,
|
||||
leftContentWidth,
|
||||
Math.Max(22, totalHeight * 0.22),
|
||||
maxLines: 1,
|
||||
minFontSize: Math.Max(14, dateBase * 0.70),
|
||||
maxFontSize: dateBase,
|
||||
weight: FontWeight.Bold,
|
||||
lineHeightFactor: 1.02);
|
||||
DateTextBlock.LineHeight = DateTextBlock.FontSize * 1.02;
|
||||
|
||||
WeekdayTextBlock.FontSize = FitFontSize(
|
||||
WeekdayTextBlock.Text,
|
||||
leftContentWidth,
|
||||
Math.Max(22, totalHeight * 0.24),
|
||||
maxLines: 1,
|
||||
minFontSize: Math.Max(14, dateBase * 0.70),
|
||||
maxFontSize: dateBase,
|
||||
weight: FontWeight.Bold,
|
||||
lineHeightFactor: 1.03);
|
||||
WeekdayTextBlock.LineHeight = WeekdayTextBlock.FontSize * 1.03;
|
||||
|
||||
var titleBase = Math.Clamp(44 * scale, 16, 58);
|
||||
PaintingTitleTextBlock.MaxWidth = rightContentWidth;
|
||||
PaintingTitleTextBlock.FontSize = FitFontSize(
|
||||
PaintingTitleTextBlock.Text,
|
||||
rightContentWidth,
|
||||
Math.Max(20, totalHeight * 0.34),
|
||||
maxLines: 2,
|
||||
minFontSize: Math.Max(12, titleBase * 0.62),
|
||||
maxFontSize: titleBase,
|
||||
weight: FontWeight.Bold,
|
||||
lineHeightFactor: 1.08);
|
||||
PaintingTitleTextBlock.LineHeight = PaintingTitleTextBlock.FontSize * 1.08;
|
||||
|
||||
var artistBase = Math.Clamp(26 * scale, 11, 34);
|
||||
ArtistTextBlock.MaxWidth = rightContentWidth;
|
||||
ArtistTextBlock.FontSize = FitFontSize(
|
||||
ArtistTextBlock.Text,
|
||||
rightContentWidth,
|
||||
Math.Max(18, totalHeight * 0.24),
|
||||
maxLines: 2,
|
||||
minFontSize: Math.Max(10, artistBase * 0.72),
|
||||
maxFontSize: artistBase,
|
||||
weight: FontWeight.SemiBold,
|
||||
lineHeightFactor: 1.12);
|
||||
ArtistTextBlock.LineHeight = ArtistTextBlock.FontSize * 1.12;
|
||||
|
||||
var yearBase = Math.Clamp(22 * scale, 10, 30);
|
||||
YearTextBlock.MaxWidth = rightContentWidth;
|
||||
YearTextBlock.FontSize = FitFontSize(
|
||||
YearTextBlock.Text,
|
||||
rightContentWidth,
|
||||
Math.Max(14, totalHeight * 0.12),
|
||||
maxLines: 1,
|
||||
minFontSize: Math.Max(9.5, yearBase * 0.78),
|
||||
maxFontSize: yearBase,
|
||||
weight: FontWeight.Medium,
|
||||
lineHeightFactor: 1.04);
|
||||
YearTextBlock.LineHeight = YearTextBlock.FontSize * 1.04;
|
||||
|
||||
RightPanelSeparator.Width = Math.Clamp(rightContentWidth * 0.58, 42, 136);
|
||||
RightPanelSeparator.Margin = new Thickness(0, 0, 0, Math.Clamp(10 * scale, 4, 14));
|
||||
|
||||
BrickPatternCanvas.Opacity = totalWidth < _currentCellSize * 4.2
|
||||
? 0.34
|
||||
: Math.Clamp(0.44 * scale, 0.24, 0.50);
|
||||
}
|
||||
|
||||
private void SetArtworkBitmap(Bitmap? bitmap)
|
||||
{
|
||||
DisposeArtworkBitmap();
|
||||
_currentArtworkBitmap = bitmap;
|
||||
ArtworkImage.Source = bitmap;
|
||||
}
|
||||
|
||||
private void DisposeArtworkBitmap()
|
||||
{
|
||||
if (_currentArtworkBitmap is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ReferenceEquals(ArtworkImage.Source, _currentArtworkBitmap))
|
||||
{
|
||||
ArtworkImage.Source = null;
|
||||
}
|
||||
|
||||
_currentArtworkBitmap.Dispose();
|
||||
_currentArtworkBitmap = null;
|
||||
}
|
||||
|
||||
private void UpdateLanguageCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDateLabels()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
DateTextBlock.Text = now.ToString("MM/dd", CultureInfo.InvariantCulture);
|
||||
|
||||
if (string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase) &&
|
||||
ZhWeekdays.TryGetValue(now.DayOfWeek, out var weekdayZh))
|
||||
{
|
||||
WeekdayTextBlock.Text = weekdayZh;
|
||||
return;
|
||||
}
|
||||
|
||||
var culture = ResolveCulture();
|
||||
WeekdayTextBlock.Text = culture.DateTimeFormat.GetDayName(now.DayOfWeek);
|
||||
}
|
||||
|
||||
private string ResolveYearText(DailyArtworkSnapshot snapshot)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.Year))
|
||||
{
|
||||
return snapshot.Year.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.Museum))
|
||||
{
|
||||
return snapshot.Museum.Trim();
|
||||
}
|
||||
|
||||
return "--";
|
||||
}
|
||||
|
||||
private static string BuildQuotedTitle(string title)
|
||||
{
|
||||
var normalized = NormalizeCompactText(title);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
normalized = "Untitled";
|
||||
}
|
||||
|
||||
return $"“{normalized}”";
|
||||
}
|
||||
|
||||
private void CancelRefreshRequest()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
if (cts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private CultureInfo ResolveCulture()
|
||||
{
|
||||
try
|
||||
{
|
||||
return CultureInfo.GetCultureInfo(_languageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return CultureInfo.InvariantCulture;
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.62, 2.0);
|
||||
var widthScale = Bounds.Width > 1
|
||||
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.56, 2.0)
|
||||
: 1;
|
||||
var heightScale = Bounds.Height > 1
|
||||
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.56, 2.0)
|
||||
: 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.56, 2.0);
|
||||
}
|
||||
|
||||
private static string NormalizeCompactText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
|
||||
}
|
||||
|
||||
private static double FitFontSize(
|
||||
string? text,
|
||||
double maxWidth,
|
||||
double maxHeight,
|
||||
int maxLines,
|
||||
double minFontSize,
|
||||
double maxFontSize,
|
||||
FontWeight weight,
|
||||
double lineHeightFactor)
|
||||
{
|
||||
var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
|
||||
var min = Math.Max(6, minFontSize);
|
||||
var max = Math.Max(min, maxFontSize);
|
||||
var low = min;
|
||||
var high = max;
|
||||
var best = min;
|
||||
|
||||
for (var i = 0; i < 18; i++)
|
||||
{
|
||||
var candidate = (low + high) / 2d;
|
||||
var lineHeight = candidate * lineHeightFactor;
|
||||
var size = MeasureTextSize(content, candidate, weight, Math.Max(1, maxWidth), lineHeight);
|
||||
var lineCount = Math.Max(1, (int)Math.Ceiling(size.Height / Math.Max(1, lineHeight)));
|
||||
var fits = size.Height <= maxHeight + 0.6 && lineCount <= Math.Max(1, maxLines);
|
||||
|
||||
if (fits)
|
||||
{
|
||||
best = candidate;
|
||||
low = candidate;
|
||||
}
|
||||
else
|
||||
{
|
||||
high = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static Size MeasureTextSize(string text, double fontSize, FontWeight weight, double maxWidth, double lineHeight)
|
||||
{
|
||||
var probe = new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
FontFamily = MiSansFontFamily,
|
||||
FontSize = fontSize,
|
||||
FontWeight = weight,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
LineHeight = lineHeight
|
||||
};
|
||||
|
||||
probe.Measure(new Size(Math.Max(1, maxWidth), double.PositiveInfinity));
|
||||
return probe.DesiredSize;
|
||||
}
|
||||
}
|
||||
126
LanMontainDesktop/Views/Components/DailyPoetryWidget.axaml
Normal file
126
LanMontainDesktop/Views/Components/DailyPoetryWidget.axaml
Normal file
@@ -0,0 +1,126 @@
|
||||
<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:shapes="clr-namespace:Avalonia.Controls.Shapes;assembly=Avalonia.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="640"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMontainDesktop.Views.Components.DailyPoetryWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Background="#C20A0A"
|
||||
Padding="20,16,20,14">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<Canvas x:Name="DayDecorationCanvas"
|
||||
Grid.RowSpan="3"
|
||||
IsVisible="False"
|
||||
Width="212"
|
||||
Height="148"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,52,18,0"
|
||||
IsHitTestVisible="False">
|
||||
<shapes:Path x:Name="WavePath"
|
||||
Canvas.Left="78"
|
||||
Canvas.Top="8"
|
||||
Stroke="#BAC0C7"
|
||||
StrokeThickness="3.2"
|
||||
StrokeLineCap="Round"
|
||||
Data="M4,31 C26,31 30,22 50,22 C68,22 72,31 92,31 C111,31 116,22 136,22" />
|
||||
<shapes:Path x:Name="MountainBackPath"
|
||||
Canvas.Left="84"
|
||||
Canvas.Top="46"
|
||||
Fill="#0E262B33"
|
||||
Data="M10,66 L42,36 L66,51 L89,33 L134,66 Z" />
|
||||
<shapes:Path x:Name="MountainFrontPath"
|
||||
Canvas.Left="62"
|
||||
Canvas.Top="64"
|
||||
Fill="#13262B33"
|
||||
Data="M8,54 L38,24 L64,52 L8,54 Z" />
|
||||
</Canvas>
|
||||
|
||||
<TextBlock x:Name="QuoteMarkTextBlock"
|
||||
Text="“"
|
||||
Foreground="#5CFAD0B7"
|
||||
FontSize="96"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Margin="1,0,0,0"
|
||||
LineHeight="86" />
|
||||
|
||||
<TextBlock x:Name="PoetryContentTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="芳草年年惹恨幽。想前事悠悠。"
|
||||
Foreground="#F8D8A8"
|
||||
FontSize="54"
|
||||
FontWeight="Medium"
|
||||
LineHeight="60"
|
||||
TextWrapping="Wrap"
|
||||
VerticalAlignment="Top"
|
||||
Margin="8,2,0,0" />
|
||||
|
||||
<Grid x:Name="AuthorPanel"
|
||||
Grid.Row="2"
|
||||
ColumnDefinitions="Auto,*"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,6,4,0"
|
||||
IsHitTestVisible="False">
|
||||
<Border x:Name="AuthorAccent"
|
||||
Grid.Column="0"
|
||||
Width="6"
|
||||
Height="26"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,8,0"
|
||||
CornerRadius="3"
|
||||
Background="#6BF2A497" />
|
||||
|
||||
<TextBlock x:Name="AuthorTextBlock"
|
||||
Grid.Column="1"
|
||||
Text="宋代 · 石延年"
|
||||
Foreground="#F8D8A8"
|
||||
FontSize="36"
|
||||
FontWeight="Medium"
|
||||
TextWrapping="NoWrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
Text="Loading..."
|
||||
IsVisible="False"
|
||||
Foreground="#D9FFFFFF"
|
||||
FontSize="18"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top" />
|
||||
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.RowSpan="3"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,12,16,0"
|
||||
Width="42"
|
||||
Height="42"
|
||||
CornerRadius="21"
|
||||
Background="#10A6ADB7"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
Focusable="False">
|
||||
<TextBlock x:Name="RefreshGlyphTextBlock"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="↻"
|
||||
Foreground="#8C9097"
|
||||
FontSize="26"
|
||||
FontWeight="SemiLight"
|
||||
LineHeight="26" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
1039
LanMontainDesktop/Views/Components/DailyPoetryWidget.axaml.cs
Normal file
1039
LanMontainDesktop/Views/Components/DailyPoetryWidget.axaml.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -165,6 +165,16 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
"component.audio_recorder",
|
||||
() => new RecordingWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.36, 16, 34)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopDailyPoetry,
|
||||
"component.daily_poetry",
|
||||
() => new DailyPoetryWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopDailyArtwork,
|
||||
"component.daily_artwork",
|
||||
() => new DailyArtworkWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopWhiteboard,
|
||||
"component.whiteboard",
|
||||
|
||||
@@ -8,60 +8,60 @@
|
||||
x:Class="LanMontainDesktop.Views.Components.ExtendedWeatherWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
CornerRadius="28"
|
||||
ClipToBounds="True"
|
||||
Background="#6A8BB3">
|
||||
Background="#6B7B8F">
|
||||
<Grid>
|
||||
<Border x:Name="BackgroundImageLayer"
|
||||
CornerRadius="34"
|
||||
CornerRadius="28"
|
||||
ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="BackgroundMotionLayer"
|
||||
CornerRadius="34"
|
||||
CornerRadius="28"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.24"
|
||||
Opacity="0.26"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<Border.RenderTransform>
|
||||
<TransformGroup>
|
||||
<ScaleTransform ScaleX="1.05"
|
||||
ScaleY="1.05" />
|
||||
<ScaleTransform ScaleX="1.07"
|
||||
ScaleY="1.07" />
|
||||
<TranslateTransform />
|
||||
</TransformGroup>
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundTintLayer"
|
||||
CornerRadius="34"
|
||||
CornerRadius="28"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.20" />
|
||||
Opacity="0.12" />
|
||||
|
||||
<Border x:Name="BackgroundLightLayer"
|
||||
CornerRadius="34"
|
||||
CornerRadius="28"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.64">
|
||||
Opacity="0.54">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="1,1">
|
||||
<GradientStop Color="#56FFFFFF"
|
||||
<GradientStop Color="#45FFFFFF"
|
||||
Offset="0" />
|
||||
<GradientStop Color="#18FFFFFF"
|
||||
Offset="0.30" />
|
||||
<GradientStop Color="#16FFFFFF"
|
||||
Offset="0.34" />
|
||||
<GradientStop Color="#00000000"
|
||||
Offset="0.58" />
|
||||
Offset="0.66" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundShadeLayer"
|
||||
CornerRadius="34"
|
||||
CornerRadius="28"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.80">
|
||||
Opacity="0.70">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="0,1">
|
||||
<GradientStop Color="#00040A16"
|
||||
Offset="0.50" />
|
||||
<GradientStop Color="#2E0B1C34"
|
||||
<GradientStop Color="#00000000"
|
||||
Offset="0.40" />
|
||||
<GradientStop Color="#1A000000"
|
||||
Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
@@ -72,216 +72,130 @@
|
||||
ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="ContentPaddingBorder"
|
||||
Padding="20"
|
||||
Padding="24,20"
|
||||
Background="Transparent">
|
||||
<Grid x:Name="LayoutRoot"
|
||||
RowDefinitions="Auto,Auto,Auto,*"
|
||||
RowSpacing="9">
|
||||
RowDefinitions="Auto,Auto,Auto,*">
|
||||
<Grid x:Name="SummaryGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="14">
|
||||
ColumnSpacing="16">
|
||||
<TextBlock x:Name="TemperatureTextBlock"
|
||||
Grid.Column="0"
|
||||
Text="7°"
|
||||
FontSize="112"
|
||||
FontSize="64"
|
||||
FontWeight="Light"
|
||||
FontFeatures="tnum"
|
||||
VerticalAlignment="Top"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,-2,0,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Top"
|
||||
Spacing="10"
|
||||
Margin="0,6,0,0">
|
||||
<Grid x:Name="SummaryInfoGrid"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,2,0,0"
|
||||
RowDefinitions="Auto,Auto"
|
||||
ColumnDefinitions="Auto,*"
|
||||
RowSpacing="2"
|
||||
ColumnSpacing="8">
|
||||
<Border x:Name="CityInfoBadge"
|
||||
Background="#2AFFFFFF"
|
||||
CornerRadius="12"
|
||||
Padding="12,5"
|
||||
HorizontalAlignment="Left">
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="2"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<TextBlock x:Name="CityTextBlock"
|
||||
Text="Beijing"
|
||||
FontSize="24"
|
||||
FontWeight="Medium"
|
||||
Text="北京"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
TextAlignment="Left"
|
||||
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 x:Name="RangeInfoBadge"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="11°/4°"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
Opacity="0.92" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Border x:Name="ConditionInfoBadge"
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="雾"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Image x:Name="WeatherIconImage"
|
||||
Grid.Column="2"
|
||||
Width="70"
|
||||
Height="70"
|
||||
Width="72"
|
||||
Height="72"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,2,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Stretch="Uniform" />
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="HourlyPanelBorder"
|
||||
Grid.Row="1"
|
||||
Background="#0EFFFFFF"
|
||||
CornerRadius="16"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
ClipToBounds="True"
|
||||
Padding="8,6">
|
||||
Padding="0,2,0,0"
|
||||
Margin="0,10,0,0">
|
||||
<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" />
|
||||
ColumnSpacing="4">
|
||||
<StackPanel Grid.Column="0" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp0" Text="7°" FontSize="16" 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="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</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 Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp1" Text="7°" FontSize="16" 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="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</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 Grid.Column="2" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp2" Text="7°" FontSize="16" 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="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</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 Grid.Column="3" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp3" Text="日落" FontSize="16" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon3" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime3" Text="18:00" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</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 Grid.Column="4" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp4" Text="7°" FontSize="16" 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="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</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.Column="5" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp5" Text="7°" FontSize="16" 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="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -289,171 +203,47 @@
|
||||
<Border x:Name="SeparatorLine"
|
||||
Grid.Row="2"
|
||||
Height="1"
|
||||
Margin="0,2,0,2"
|
||||
Background="#2AFFFFFF" />
|
||||
Margin="0,12,0,0"
|
||||
Background="#25FFFFFF" />
|
||||
|
||||
<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" />
|
||||
RowSpacing="10"
|
||||
Margin="0,12,0,0">
|
||||
<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="明天·阴" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyHigh0" Grid.Column="2" Text="10" FontSize="17" FontWeight="SemiBold" FontFeatures="tnum" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyLow0" Grid.Column="3" Text="5" FontSize="17" FontWeight="Medium" FontFeatures="tnum" VerticalAlignment="Center" Opacity="0.70" />
|
||||
</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.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="周四·多云" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyHigh1" Grid.Column="2" Text="13" FontSize="17" FontWeight="SemiBold" FontFeatures="tnum" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyLow1" Grid.Column="3" Text="4" FontSize="17" FontWeight="Medium" FontFeatures="tnum" VerticalAlignment="Center" Opacity="0.70" />
|
||||
</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.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="周五·阴" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyHigh2" Grid.Column="2" Text="12" FontSize="17" FontWeight="SemiBold" FontFeatures="tnum" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyLow2" Grid.Column="3" Text="3" FontSize="17" FontWeight="Medium" FontFeatures="tnum" VerticalAlignment="Center" Opacity="0.70" />
|
||||
</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.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="周六·多云" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyHigh3" Grid.Column="2" Text="10" FontSize="17" FontWeight="SemiBold" FontFeatures="tnum" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyLow3" Grid.Column="3" Text="2" FontSize="17" FontWeight="Medium" FontFeatures="tnum" VerticalAlignment="Center" Opacity="0.70" />
|
||||
</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.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="周日·阴" FontSize="17" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyHigh4" Grid.Column="2" Text="11" FontSize="17" FontWeight="SemiBold" FontFeatures="tnum" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="DailyLow4" Grid.Column="3" Text="3" FontSize="17" FontWeight="Medium" FontFeatures="tnum" VerticalAlignment="Center" Opacity="0.70" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -461,4 +251,3 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
@@ -69,6 +71,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
[
|
||||
DailyIcon0, DailyIcon1, DailyIcon2, DailyIcon3, DailyIcon4
|
||||
];
|
||||
ConfigureTextOverflowGuards();
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
_animationTimer.Tick += OnAnimationTick;
|
||||
AttachedToVisualTree += (_, _) =>
|
||||
@@ -91,6 +94,25 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
ApplyFallback();
|
||||
}
|
||||
|
||||
private void ConfigureTextOverflowGuards()
|
||||
{
|
||||
CityTextBlock.TextWrapping = TextWrapping.NoWrap;
|
||||
CityTextBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
||||
CityTextBlock.MaxLines = 1;
|
||||
|
||||
ConditionTextBlock.TextWrapping = TextWrapping.NoWrap;
|
||||
ConditionTextBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
||||
ConditionTextBlock.MaxLines = 1;
|
||||
|
||||
RangeTextBlock.TextWrapping = TextWrapping.NoWrap;
|
||||
RangeTextBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
||||
RangeTextBlock.MaxLines = 1;
|
||||
|
||||
TemperatureTextBlock.TextWrapping = TextWrapping.NoWrap;
|
||||
TemperatureTextBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
||||
TemperatureTextBlock.MaxLines = 1;
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
@@ -261,6 +283,8 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
RangeTextBlock.Text = $"{FormatTemperature(today?.HighTemperatureC)}/{FormatTemperature(today?.LowTemperatureC)}";
|
||||
|
||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||
var timelineStart = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Kind);
|
||||
var sunsetSlotIndex = ResolveSunsetSlotIndex(snapshot, timelineStart, _hourlyTempBlocks.Length);
|
||||
var localHourly = snapshot.HourlyForecasts
|
||||
.Select(item => new { Source = item, Time = ConvertToConfiguredTime(item.Time) })
|
||||
.OrderBy(item => item.Time)
|
||||
@@ -268,14 +292,16 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
for (var i = 0; i < _hourlyTempBlocks.Length; i++)
|
||||
{
|
||||
var target = now.AddHours(i);
|
||||
var target = timelineStart.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);
|
||||
_hourlyTempBlocks[i].Text = i == sunsetSlotIndex
|
||||
? L("weather.hourly.sunset", "Sunset")
|
||||
: FormatTemperature(item?.Source.TemperatureC ?? snapshot.Current.TemperatureC);
|
||||
_hourlyTimeBlocks[i].Text = target.ToString("HH:mm", CultureInfo.InvariantCulture);
|
||||
_hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveIconAsset(hourKind));
|
||||
}
|
||||
|
||||
@@ -287,7 +313,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
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}";
|
||||
_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));
|
||||
@@ -302,17 +328,19 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
CityTextBlock.Text = L("weather.widget.location_unknown", "Unknown location");
|
||||
ConditionTextBlock.Text = L("weather.widget.loading", "Loading...");
|
||||
TemperatureTextBlock.Text = "--°";
|
||||
RangeTextBlock.Text = "--/--";
|
||||
RangeTextBlock.Text = "--°/--°";
|
||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||
var timelineStart = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Kind);
|
||||
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";
|
||||
_hourlyTempBlocks[i].Text = i == 3 ? L("weather.hourly.sunset", "Sunset") : "--°";
|
||||
_hourlyTimeBlocks[i].Text = timelineStart.AddHours(i).ToString("HH:mm", CultureInfo.InvariantCulture);
|
||||
_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")}";
|
||||
_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));
|
||||
@@ -331,17 +359,53 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
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");
|
||||
var backgroundSamples = WeatherTypographyAccessibility.BuildBackgroundSamples(
|
||||
palette.GradientFrom,
|
||||
palette.GradientTo,
|
||||
palette.Tint,
|
||||
isNightVisual);
|
||||
TemperatureTextBlock.Foreground = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagLargeTextContrast);
|
||||
CityTextBlock.Foreground = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.SecondaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xE6 : (byte)0xD4);
|
||||
ConditionTextBlock.Foreground = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagLargeTextContrast,
|
||||
isNightVisual ? (byte)0xED : (byte)0xDF);
|
||||
RangeTextBlock.Foreground = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagLargeTextContrast,
|
||||
isNightVisual ? (byte)0xE2 : (byte)0xCE);
|
||||
HourlyPanelBorder.Background = Brushes.Transparent;
|
||||
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);
|
||||
var hourlyTempBrush = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xEE : (byte)0xE1);
|
||||
var hourlyTimeBrush = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.TertiaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xC8 : (byte)0xAC);
|
||||
var dailyTextBrush = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xEA : (byte)0xDF);
|
||||
var dailyLowBrush = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.TertiaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xBE : (byte)0xA6);
|
||||
for (var i = 0; i < _hourlyTempBlocks.Length; i++)
|
||||
{
|
||||
_hourlyTempBlocks[i].Foreground = hourlyTempBrush;
|
||||
@@ -358,44 +422,41 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
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);
|
||||
LayoutRoot.RowSpacing = Math.Clamp(height * 0.012, 5, 13);
|
||||
SummaryGrid.ColumnSpacing = Math.Clamp(width * 0.016, 8, 22);
|
||||
HourlyGrid.ColumnSpacing = Math.Clamp(width * 0.007, 3, 10);
|
||||
DailyGrid.RowSpacing = Math.Clamp(height * 0.009, 4, 10);
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(height * 0.18, 52, 154);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 370, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
CityTextBlock.FontSize = Math.Clamp(height * 0.040, 12, 30);
|
||||
ConditionTextBlock.FontSize = Math.Clamp(height * 0.046, 13, 34);
|
||||
RangeTextBlock.FontSize = Math.Clamp(height * 0.043, 12, 32);
|
||||
CityTextBlock.FontWeight = ToVariableWeight(Lerp(520, 590, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(560, 630, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
RangeTextBlock.FontWeight = ToVariableWeight(Lerp(560, 620, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
var iconSize = Math.Clamp(height * 0.116, 36, 102);
|
||||
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);
|
||||
ConditionTextBlock.MaxWidth = Math.Clamp(width * 0.20, 80, 240);
|
||||
RangeTextBlock.MaxWidth = Math.Clamp(width * 0.20, 80, 240);
|
||||
CityTextBlock.MaxWidth = Math.Clamp(width * 0.28, 90, 290);
|
||||
|
||||
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));
|
||||
HourlyPanelBorder.Padding = new Thickness(0);
|
||||
HourlyPanelBorder.CornerRadius = new CornerRadius(0);
|
||||
|
||||
var hourlyBandHeight = Math.Clamp(height * 0.20, 74, 164);
|
||||
var hourlyBandHeight = Math.Clamp(height * 0.195, 74, 160);
|
||||
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 hourlyTempSize = Math.Clamp(hourlyBandHeight * 0.24, 10, 32);
|
||||
var hourlyTimeSize = Math.Clamp(hourlyBandHeight * 0.18, 8, 22);
|
||||
var hourlyIconSize = Math.Clamp(hourlyBandHeight * 0.20, 12, 30);
|
||||
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)));
|
||||
_hourlyTempBlocks[i].FontWeight = ToVariableWeight(Lerp(540, 610, 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;
|
||||
@@ -404,8 +465,8 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
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 dailyLabelSize = Math.Clamp(height * 0.041, 10, 30);
|
||||
var dailyTempSize = Math.Clamp(height * 0.043, 10, 33);
|
||||
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);
|
||||
@@ -430,6 +491,67 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
}
|
||||
}
|
||||
|
||||
private int ResolveSunsetSlotIndex(WeatherSnapshot snapshot, DateTime startTime, int slotCount)
|
||||
{
|
||||
if (slotCount <= 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var todayForecast = snapshot.DailyForecasts.FirstOrDefault(item => item.Date == DateOnly.FromDateTime(startTime));
|
||||
if (todayForecast is null || !TryParseClockTime(todayForecast.SunsetTime, out var sunsetClock))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var sunsetTime = startTime.Date + sunsetClock;
|
||||
var bestIndex = -1;
|
||||
var bestDelta = double.MaxValue;
|
||||
for (var i = 0; i < slotCount; i++)
|
||||
{
|
||||
var slotTime = startTime.AddHours(i);
|
||||
var deltaMinutes = Math.Abs((slotTime - sunsetTime).TotalMinutes);
|
||||
if (deltaMinutes >= bestDelta)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bestDelta = deltaMinutes;
|
||||
bestIndex = i;
|
||||
}
|
||||
|
||||
return bestDelta <= 60 ? bestIndex : -1;
|
||||
}
|
||||
|
||||
private static bool TryParseClockTime(string? text, out TimeSpan value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = text.Trim();
|
||||
if (TimeSpan.TryParse(candidate, CultureInfo.InvariantCulture, out value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dto))
|
||||
{
|
||||
value = dto.TimeOfDay;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dt))
|
||||
{
|
||||
value = dt.TimeOfDay;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsNightHour(DateTime time) => time.Hour < 6 || time.Hour >= 18;
|
||||
|
||||
private string ResolveDayLabel(DateOnly date, int offset)
|
||||
@@ -448,14 +570,153 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private string ResolveLocation(string? rawLocation, string? fallbackLocation)
|
||||
{
|
||||
var input = string.IsNullOrWhiteSpace(rawLocation) ? fallbackLocation : rawLocation;
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return ResolvePreciseDisplayLocation(
|
||||
input,
|
||||
_languageCode,
|
||||
L("weather.widget.location_unknown", "Unknown location"));
|
||||
}
|
||||
|
||||
private static string ResolvePreciseDisplayLocation(string? rawName, string languageCode, string fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawName))
|
||||
{
|
||||
return L("weather.widget.location_unknown", "Unknown location");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
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();
|
||||
var name = rawName.Trim();
|
||||
if (name.Length == 0)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var isZh = string.Equals(languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase);
|
||||
var candidates = new List<string> { name };
|
||||
|
||||
// Prefer detailed parts inside parenthesis, e.g. "Beijing (Haidian)".
|
||||
var parenthesisMatches = Regex.Matches(name, @"\(([^()]+)\)|\uFF08([^\uFF08\uFF09]+)\uFF09");
|
||||
foreach (Match match in parenthesisMatches)
|
||||
{
|
||||
var inner = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value;
|
||||
if (!string.IsNullOrWhiteSpace(inner))
|
||||
{
|
||||
candidates.Add(inner.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
var nameWithoutParenthesis = Regex.Replace(name, @"\([^()]*\)|\uFF08[^\uFF08\uFF09]*\uFF09", " ");
|
||||
candidates.Add(nameWithoutParenthesis);
|
||||
|
||||
const string splitPattern = @"[\s\|/\\,\uFF0C\u3001\u00B7]+";
|
||||
foreach (var piece in Regex.Split(string.Join(" ", candidates), splitPattern))
|
||||
{
|
||||
var token = piece.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
candidates.Add(token);
|
||||
}
|
||||
}
|
||||
|
||||
var best = fallback;
|
||||
var bestScore = int.MinValue;
|
||||
foreach (var candidate in candidates
|
||||
.Select(c => c.Trim())
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var score = ScoreLocationToken(candidate, isZh);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(best) ? fallback : best;
|
||||
}
|
||||
|
||||
private static int ScoreLocationToken(string token, bool isZh)
|
||||
{
|
||||
var cleaned = token.Trim();
|
||||
if (cleaned.Length == 0)
|
||||
{
|
||||
return int.MinValue;
|
||||
}
|
||||
|
||||
if (Regex.IsMatch(cleaned, @"^[0-9.+-]+$") ||
|
||||
cleaned.StartsWith("coord:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return -500;
|
||||
}
|
||||
|
||||
var score = Math.Min(cleaned.Length, 32);
|
||||
if (isZh)
|
||||
{
|
||||
// Prefer granular places: street > district > city > province.
|
||||
if (cleaned.EndsWith("\u8857\u9053", StringComparison.Ordinal) ||
|
||||
cleaned.EndsWith("\u8DEF", StringComparison.Ordinal) ||
|
||||
cleaned.EndsWith("\u793E\u533A", StringComparison.Ordinal) ||
|
||||
cleaned.EndsWith("\u6751", StringComparison.Ordinal))
|
||||
{
|
||||
score += 120;
|
||||
}
|
||||
else if (cleaned.EndsWith("\u9547", StringComparison.Ordinal) ||
|
||||
cleaned.EndsWith("\u4E61", StringComparison.Ordinal) ||
|
||||
cleaned.EndsWith("\u65B0\u533A", StringComparison.Ordinal))
|
||||
{
|
||||
score += 100;
|
||||
}
|
||||
else if (cleaned.EndsWith("\u533A", StringComparison.Ordinal) ||
|
||||
cleaned.EndsWith("\u53BF", StringComparison.Ordinal) ||
|
||||
cleaned.EndsWith("\u65D7", StringComparison.Ordinal))
|
||||
{
|
||||
score += 80;
|
||||
}
|
||||
else if (cleaned.EndsWith("\u5E02", StringComparison.Ordinal) ||
|
||||
cleaned.EndsWith("\u5DDE", StringComparison.Ordinal) ||
|
||||
cleaned.EndsWith("\u76DF", StringComparison.Ordinal))
|
||||
{
|
||||
score += 60;
|
||||
}
|
||||
else if (cleaned.EndsWith("\u7701", StringComparison.Ordinal) ||
|
||||
cleaned.EndsWith("\u81EA\u6CBB\u533A", StringComparison.Ordinal) ||
|
||||
cleaned.EndsWith("\u7279\u522B\u884C\u653F\u533A", StringComparison.Ordinal))
|
||||
{
|
||||
score += 40;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var lower = cleaned.ToLowerInvariant();
|
||||
if (lower.Contains("street", StringComparison.Ordinal) ||
|
||||
lower.Contains("st.", StringComparison.Ordinal) ||
|
||||
lower.Contains("road", StringComparison.Ordinal) ||
|
||||
lower.Contains("rd.", StringComparison.Ordinal) ||
|
||||
lower.Contains("avenue", StringComparison.Ordinal) ||
|
||||
lower.Contains("district", StringComparison.Ordinal))
|
||||
{
|
||||
score += 120;
|
||||
}
|
||||
else if (lower.Contains("county", StringComparison.Ordinal) ||
|
||||
lower.Contains("borough", StringComparison.Ordinal))
|
||||
{
|
||||
score += 90;
|
||||
}
|
||||
else if (lower.Contains("city", StringComparison.Ordinal))
|
||||
{
|
||||
score += 70;
|
||||
}
|
||||
else if (lower.Contains("province", StringComparison.Ordinal) ||
|
||||
lower.Contains("state", StringComparison.Ordinal))
|
||||
{
|
||||
score += 50;
|
||||
}
|
||||
else if (lower.Contains("country", StringComparison.Ordinal))
|
||||
{
|
||||
score += 30;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private string ResolveWeatherText(string? weatherText, HyperOS3WeatherVisualKind kind)
|
||||
@@ -518,8 +779,15 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private void SetLoadingSkeleton(bool isLoading)
|
||||
{
|
||||
CityInfoBadge.Background = isLoading ? CreateSolidBrush("#24FFFFFF") : Brushes.Transparent;
|
||||
ConditionInfoBadge.Background = isLoading ? CreateSolidBrush("#1DFFFFFF") : Brushes.Transparent;
|
||||
var opacity = isLoading ? 0.58 : 1.0;
|
||||
TemperatureTextBlock.Opacity = opacity;
|
||||
ConditionTextBlock.Opacity = opacity;
|
||||
RangeTextBlock.Opacity = opacity;
|
||||
CityTextBlock.Opacity = isLoading ? 0.50 : 0.96;
|
||||
for (var i = 0; i < _hourlyTempBlocks.Length; i++)
|
||||
{
|
||||
_hourlyTempBlocks[i].Opacity = opacity;
|
||||
_hourlyTimeBlocks[i].Opacity = isLoading ? 0.74 : 0.94;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,307 +9,171 @@
|
||||
x:Class="LanMontainDesktop.Views.Components.HourlyWeatherWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="30"
|
||||
CornerRadius="28"
|
||||
ClipToBounds="True"
|
||||
Background="#68A9EC">
|
||||
Background="#6B7B8F">
|
||||
<Grid>
|
||||
<Border x:Name="BackgroundImageLayer"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True" />
|
||||
<Border x:Name="BackgroundImageLayer" CornerRadius="28" ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="BackgroundMotionLayer"
|
||||
CornerRadius="30"
|
||||
CornerRadius="28"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.24"
|
||||
Opacity="0.25"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<Border.RenderTransform>
|
||||
<TransformGroup>
|
||||
<ScaleTransform ScaleX="1.05"
|
||||
ScaleY="1.05" />
|
||||
<ScaleTransform ScaleX="1.07" ScaleY="1.07" />
|
||||
<TranslateTransform />
|
||||
</TransformGroup>
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundTintLayer"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.20" />
|
||||
<Border x:Name="BackgroundTintLayer" CornerRadius="28" ClipToBounds="True" Opacity="0.12" />
|
||||
|
||||
<Border x:Name="BackgroundLightLayer"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.66">
|
||||
<Border x:Name="BackgroundLightLayer" CornerRadius="28" ClipToBounds="True" Opacity="0.52">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="1,1">
|
||||
<GradientStop Color="#5BFFFFFF"
|
||||
Offset="0" />
|
||||
<GradientStop Color="#1FFFFFFF"
|
||||
Offset="0.30" />
|
||||
<GradientStop Color="#00000000"
|
||||
Offset="0.55" />
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#45FFFFFF" Offset="0" />
|
||||
<GradientStop Color="#16FFFFFF" Offset="0.35" />
|
||||
<GradientStop Color="#00000000" Offset="0.64" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundShadeLayer"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.78">
|
||||
<Border x:Name="BackgroundShadeLayer" CornerRadius="28" ClipToBounds="True" Opacity="0.68">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="0,1">
|
||||
<GradientStop Color="#00040A16"
|
||||
Offset="0.50" />
|
||||
<GradientStop Color="#2E0B1C34"
|
||||
Offset="1" />
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#00000000" Offset="0.42" />
|
||||
<GradientStop Color="#19000000" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Canvas x:Name="ParticleLayer"
|
||||
IsHitTestVisible="False"
|
||||
ClipToBounds="True" />
|
||||
<Canvas x:Name="ParticleLayer" IsHitTestVisible="False" ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="ContentPaddingBorder"
|
||||
Padding="16"
|
||||
Background="Transparent">
|
||||
<Border x:Name="ContentPaddingBorder" Padding="24,18" Background="Transparent">
|
||||
<Grid x:Name="LayoutRoot">
|
||||
<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">
|
||||
<Grid x:Name="ContentGrid" RowDefinitions="Auto,*" RowSpacing="8">
|
||||
<Grid x:Name="TopRowGrid" Grid.Row="0" ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
|
||||
<TextBlock x:Name="TemperatureTextBlock"
|
||||
Grid.Column="0"
|
||||
Grid.RowSpan="2"
|
||||
Text="24°"
|
||||
FontSize="98"
|
||||
Text="7°"
|
||||
FontSize="54"
|
||||
FontWeight="Light"
|
||||
FontFeatures="tnum"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,-1,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,-2,0,0"
|
||||
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"
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2"
|
||||
Margin="2,0,0,0">
|
||||
<StackPanel x:Name="BottomInfoStack"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
Spacing="3"
|
||||
Margin="0,0,0,1"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="Clear"
|
||||
FontSize="21"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="20°/28°"
|
||||
FontSize="21"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
<Border x:Name="CityInfoBadge"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="0">
|
||||
<fi:SymbolIcon x:Name="LocationIcon"
|
||||
Symbol="Location"
|
||||
FontSize="13"
|
||||
IsVisible="False"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="CityTextBlock"
|
||||
Text="北京"
|
||||
FontSize="17"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="ConditionInfoBadge"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0"
|
||||
Margin="0">
|
||||
<StackPanel x:Name="ConditionRangeStack"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="9">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="雾"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="11°/4°"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
Opacity="0.92" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Image x:Name="WeatherIconImage"
|
||||
Grid.Column="2"
|
||||
Grid.RowSpan="2"
|
||||
Width="56"
|
||||
Height="56"
|
||||
Width="66"
|
||||
Height="66"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
VerticalAlignment="Center"
|
||||
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>
|
||||
<Border x:Name="HourlyPanelBorder"
|
||||
Grid.Row="1"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
ClipToBounds="True"
|
||||
Padding="0,2,0,0"
|
||||
VerticalAlignment="Top">
|
||||
<Grid x:Name="HourlyGrid" ColumnDefinitions="*,*,*,*,*,*" ColumnSpacing="4">
|
||||
<StackPanel Grid.Column="0" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp0" Text="7°" FontSize="17" FontWeight="SemiBold" FontFeatures="tnum" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon0" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime0" Text="15:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp1" Text="7°" FontSize="17" FontWeight="SemiBold" FontFeatures="tnum" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon1" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime1" Text="16:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp2" Text="7°" FontSize="17" FontWeight="SemiBold" FontFeatures="tnum" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon2" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime2" Text="17:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="3" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp3" Text="日落" FontSize="17" FontWeight="SemiBold" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon3" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime3" Text="18:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="4" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp4" Text="7°" FontSize="17" FontWeight="SemiBold" FontFeatures="tnum" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon4" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime4" Text="19:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="5" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp5" Text="7°" FontSize="17" FontWeight="SemiBold" FontFeatures="tnum" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon5" Width="28" Height="28" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime5" Text="20:00" FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
@@ -514,16 +514,44 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
BackgroundMotionLayer.Background = ResolveWeatherBackgroundBrush(kind, palette);
|
||||
BackgroundTintLayer.Background = CreateSolidBrush(palette.Tint);
|
||||
|
||||
var primary = CreateSolidBrush(palette.PrimaryText);
|
||||
var particleBrush = ResolveParticleBrush(ToThemeKind(kind), palette.ParticleColor);
|
||||
var isNightVisual = kind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight;
|
||||
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;
|
||||
var backgroundSamples = WeatherTypographyAccessibility.BuildBackgroundSamples(
|
||||
palette.GradientFrom,
|
||||
palette.GradientTo,
|
||||
palette.Tint,
|
||||
isNightVisual);
|
||||
var primary = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagLargeTextContrast);
|
||||
var cityBrush = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.SecondaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xE6 : (byte)0xD4);
|
||||
var conditionSecondary = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagLargeTextContrast,
|
||||
isNightVisual ? (byte)0xED : (byte)0xDF);
|
||||
var rangeSecondary = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagLargeTextContrast,
|
||||
isNightVisual ? (byte)0xE2 : (byte)0xCE);
|
||||
var forecastTimeBrush = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.TertiaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xC8 : (byte)0xAC);
|
||||
var forecastTempBrush = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xEE : (byte)0xE1);
|
||||
HourlyPanelBorder.Background = Brushes.Transparent;
|
||||
LocationIcon.Foreground = cityBrush;
|
||||
CityTextBlock.Foreground = cityBrush;
|
||||
TemperatureTextBlock.Foreground = primary;
|
||||
ConditionTextBlock.Foreground = conditionSecondary;
|
||||
@@ -726,9 +754,11 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
{
|
||||
const int itemCount = 6;
|
||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||
var timelineStart = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Kind);
|
||||
var fallbackDaily = ResolveDailyForecastForDate(snapshot, DateOnly.FromDateTime(now))
|
||||
?? snapshot.DailyForecasts.FirstOrDefault();
|
||||
var (low, high) = ResolveTemperatureRange(snapshot);
|
||||
var sunsetSlotIndex = ResolveSunsetSlotIndex(snapshot, timelineStart, itemCount);
|
||||
|
||||
var hourlyCandidates = snapshot.HourlyForecasts
|
||||
.Select(hourly => (Hourly: hourly, Time: ConvertToConfiguredTime(hourly.Time).DateTime))
|
||||
@@ -740,10 +770,8 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
var items = new List<HourlyForecastItem>(itemCount);
|
||||
for (var i = 0; i < itemCount; i++)
|
||||
{
|
||||
var targetTime = now.AddHours(i);
|
||||
var displayLabel = i == 0
|
||||
? L("weather.hourly.now", "Now")
|
||||
: targetTime.ToString("HH:mm", CultureInfo.InvariantCulture);
|
||||
var targetTime = timelineStart.AddHours(i);
|
||||
var displayLabel = targetTime.ToString("HH:mm", CultureInfo.InvariantCulture);
|
||||
|
||||
var candidate = TryFindNearestHourlyCandidate(hourlyCandidates, targetTime);
|
||||
var weatherCode = candidate?.Hourly.WeatherCode ??
|
||||
@@ -757,12 +785,15 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
snapshot.Current.TemperatureC,
|
||||
low,
|
||||
high);
|
||||
var temperatureLabel = i == sunsetSlotIndex
|
||||
? L("weather.hourly.sunset", "Sunset")
|
||||
: FormatTemperature(estimatedTemp);
|
||||
|
||||
items.Add(new HourlyForecastItem(
|
||||
targetTime,
|
||||
displayLabel,
|
||||
iconKind,
|
||||
FormatTemperature(estimatedTemp)));
|
||||
temperatureLabel));
|
||||
}
|
||||
|
||||
return items;
|
||||
@@ -773,17 +804,16 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
const int itemCount = 6;
|
||||
var items = new List<HourlyForecastItem>(itemCount);
|
||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||
var timelineStart = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Kind);
|
||||
var iconKind = ToThemeKind(visualKind);
|
||||
for (var i = 0; i < itemCount; i++)
|
||||
{
|
||||
var targetTime = now.AddHours(i);
|
||||
var targetTime = timelineStart.AddHours(i);
|
||||
items.Add(new HourlyForecastItem(
|
||||
targetTime,
|
||||
i == 0
|
||||
? L("weather.hourly.now", "Now")
|
||||
: targetTime.ToString("HH:mm", CultureInfo.InvariantCulture),
|
||||
targetTime.ToString("HH:mm", CultureInfo.InvariantCulture),
|
||||
iconKind,
|
||||
"--°"));
|
||||
i == 3 ? L("weather.hourly.sunset", "Sunset") : "--°"));
|
||||
}
|
||||
|
||||
return items;
|
||||
@@ -867,6 +897,67 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
return null;
|
||||
}
|
||||
|
||||
private int ResolveSunsetSlotIndex(WeatherSnapshot snapshot, DateTime startTime, int slotCount)
|
||||
{
|
||||
if (slotCount <= 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var todayForecast = ResolveDailyForecastForDate(snapshot, DateOnly.FromDateTime(startTime));
|
||||
if (todayForecast is null || !TryParseClockTime(todayForecast.SunsetTime, out var sunsetClock))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var sunsetTime = startTime.Date + sunsetClock;
|
||||
var bestIndex = -1;
|
||||
var bestDelta = double.MaxValue;
|
||||
for (var i = 0; i < slotCount; i++)
|
||||
{
|
||||
var slotTime = startTime.AddHours(i);
|
||||
var deltaMinutes = Math.Abs((slotTime - sunsetTime).TotalMinutes);
|
||||
if (deltaMinutes >= bestDelta)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bestDelta = deltaMinutes;
|
||||
bestIndex = i;
|
||||
}
|
||||
|
||||
return bestDelta <= 60 ? bestIndex : -1;
|
||||
}
|
||||
|
||||
private static bool TryParseClockTime(string? text, out TimeSpan value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = text.Trim();
|
||||
if (TimeSpan.TryParse(candidate, CultureInfo.InvariantCulture, out value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dto))
|
||||
{
|
||||
value = dto.TimeOfDay;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dt))
|
||||
{
|
||||
value = dt.TimeOfDay;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsNightHour(DateTime time)
|
||||
{
|
||||
return time.Hour < 6 || time.Hour >= 18;
|
||||
@@ -1079,62 +1170,66 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
var (layoutWidth, layoutHeight) = ResolveLayoutViewport();
|
||||
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);
|
||||
var compactness = Math.Clamp((1.0 - scaleY) / 0.55, 0, 1);
|
||||
|
||||
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);
|
||||
ContentGrid.RowSpacing = Math.Clamp((4.2 - (compactness * 0.7)) * scaleY, 2, 8);
|
||||
TopRowGrid.ColumnSpacing = Math.Clamp(8 * scaleX, 6, 13);
|
||||
BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp((1.0 - (compactness * 0.4)) * scaleY, 0, 2));
|
||||
|
||||
var summaryHeight = Math.Clamp(116 * scaleY, 82, 164);
|
||||
var bodyHeight = Math.Max(52, innerHeight - summaryHeight - ContentGrid.RowSpacing);
|
||||
var contentHeight = Math.Max(60, innerHeight - ContentGrid.RowSpacing);
|
||||
var topZoneRatio = Math.Clamp(0.38 + (compactness * 0.09), 0.36, 0.50);
|
||||
var topZoneHeight = Math.Clamp(contentHeight * topZoneRatio, 60, 170);
|
||||
var bottomZoneHeight = Math.Max(42, contentHeight - topZoneHeight);
|
||||
var topScaleH = Math.Clamp(topZoneHeight / 102d, 0.62, 2.0);
|
||||
var topScaleW = Math.Clamp(innerWidth / 620d, 0.62, 2.0);
|
||||
var topScale = Math.Clamp((topScaleH * 0.68) + (topScaleW * 0.32), 0.62, 2.0);
|
||||
var bottomScaleH = Math.Clamp(bottomZoneHeight / 122d, 0.56, 2.0);
|
||||
var bottomScale = Math.Clamp((bottomScaleH * 0.74) + (scaleX * 0.26), 0.56, 1.95);
|
||||
var bodyHeight = bottomZoneHeight;
|
||||
|
||||
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);
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(88 * topScale, 56, 132);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(315);
|
||||
TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-1.2 * topScale, -4, 0), 0, 0);
|
||||
TemperatureTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 88, 196);
|
||||
|
||||
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);
|
||||
CityInfoBadge.Padding = new Thickness(0);
|
||||
CityInfoBadge.CornerRadius = new CornerRadius(0);
|
||||
LocationIcon.FontSize = Math.Clamp(12 * topScale, 9, 17);
|
||||
CityTextBlock.FontSize = Math.Clamp(18 * topScale, 11, 26);
|
||||
CityTextBlock.FontWeight = ToVariableWeight(540);
|
||||
CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.36, 112, 300);
|
||||
|
||||
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);
|
||||
ConditionInfoBadge.CornerRadius = new CornerRadius(0);
|
||||
ConditionRangeStack.Spacing = Math.Clamp(7 * topScale, 4, 13);
|
||||
ConditionTextBlock.FontSize = Math.Clamp(19 * topScale, 12, 27);
|
||||
RangeTextBlock.FontSize = Math.Clamp(20 * topScale, 12, 30);
|
||||
ConditionTextBlock.FontWeight = ToVariableWeight(600);
|
||||
RangeTextBlock.FontWeight = ToVariableWeight(620);
|
||||
ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.16, 46, 170);
|
||||
RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.20, 60, 200);
|
||||
ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 58, 220);
|
||||
RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.30, 88, 270);
|
||||
BottomInfoStack.Spacing = Math.Clamp(2.2 * topScale, 1, 6);
|
||||
|
||||
var iconSize = Math.Clamp(68 * uiScale, 40, 90);
|
||||
var iconSize = Math.Clamp(68 * topScale, 42, 98);
|
||||
WeatherIconImage.Width = iconSize;
|
||||
WeatherIconImage.Height = iconSize;
|
||||
|
||||
HourlyPanelBorder.Padding = new Thickness(
|
||||
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);
|
||||
HourlyPanelBorder.Padding = new Thickness(0, Math.Clamp(1 * scaleY, 0, 2), 0, 0);
|
||||
HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(1.2 * scaleY, 0, 3), 0, 0);
|
||||
HourlyPanelBorder.CornerRadius = new CornerRadius(0);
|
||||
HourlyGrid.ColumnSpacing = Math.Clamp(7 * scaleX, 4, 11);
|
||||
|
||||
var hourlyColumnCount = Math.Max(1, _hourlyTimeBlocks.Length);
|
||||
var hourlyInnerWidth = Math.Max(
|
||||
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);
|
||||
var stackSpacing = Math.Clamp((1.6 + (bottomScale * 0.8)) * scaleY, 1, 4);
|
||||
var hourlyTempSize = Math.Clamp(Math.Max(13, bodyHeight * 0.22) * (0.76 + (bottomScale * 0.24)), 13, 31);
|
||||
var hourlyTimeSize = Math.Clamp(Math.Max(10, bodyHeight * 0.17) * (0.78 + (bottomScale * 0.22)), 10, 23);
|
||||
var hourlyIconSize = Math.Clamp(Math.Max(14, bodyHeight * 0.25) * (0.78 + (bottomScale * 0.22)), 14, 35);
|
||||
|
||||
for (var i = 0; i < _hourlyTimeBlocks.Length; i++)
|
||||
{
|
||||
@@ -1142,8 +1237,8 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
_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].MaxWidth = Math.Clamp(hourlyCellWidth, 34, 112);
|
||||
_hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 34, 112);
|
||||
_hourlyTimeBlocks[i].FontWeight = ToVariableWeight(500);
|
||||
_hourlyTempBlocks[i].FontWeight = ToVariableWeight(590);
|
||||
if (_hourlyTimeBlocks[i].Parent is StackPanel hourlyStack)
|
||||
@@ -1166,8 +1261,16 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
private void SetLoadingSkeleton(bool isLoading)
|
||||
{
|
||||
CityInfoBadge.Background = isLoading ? CreateSolidBrush("#24FFFFFF") : Brushes.Transparent;
|
||||
ConditionInfoBadge.Background = isLoading ? CreateSolidBrush("#1CFFFFFF") : Brushes.Transparent;
|
||||
var opacity = isLoading ? 0.58 : 1.0;
|
||||
TemperatureTextBlock.Opacity = opacity;
|
||||
ConditionTextBlock.Opacity = opacity;
|
||||
RangeTextBlock.Opacity = opacity;
|
||||
CityTextBlock.Opacity = isLoading ? 0.50 : 0.96;
|
||||
for (var i = 0; i < _hourlyTempBlocks.Length; i++)
|
||||
{
|
||||
_hourlyTempBlocks[i].Opacity = opacity;
|
||||
_hourlyTimeBlocks[i].Opacity = isLoading ? 0.74 : 0.94;
|
||||
}
|
||||
}
|
||||
|
||||
private static FontWeight ToVariableWeight(double weight)
|
||||
|
||||
@@ -71,12 +71,12 @@ public readonly record struct HyperOS3WeatherMetrics(
|
||||
public static class HyperOS3WeatherTheme
|
||||
{
|
||||
private static readonly HyperOS3WeatherPalette FallbackPalette = new(
|
||||
GradientFrom: "#5C7696",
|
||||
GradientTo: "#90A6C1",
|
||||
Tint: "#4E6682",
|
||||
GradientFrom: "#607C9E",
|
||||
GradientTo: "#9DB3CB",
|
||||
Tint: "#55708D",
|
||||
PrimaryText: "#FFFFFFFF",
|
||||
SecondaryText: "#DCE6F1",
|
||||
TertiaryText: "#B8C7D9",
|
||||
SecondaryText: "#E4EDF7",
|
||||
TertiaryText: "#BFD0E1",
|
||||
ParticleColor: "#70D3E2F4");
|
||||
|
||||
private static readonly HyperOS3WeatherMotion FallbackMotion = new(
|
||||
@@ -120,12 +120,12 @@ public static class HyperOS3WeatherTheme
|
||||
new Dictionary<HyperOS3WeatherVisualKind, HyperOS3WeatherPalette>
|
||||
{
|
||||
[HyperOS3WeatherVisualKind.ClearDay] = new(
|
||||
GradientFrom: "#4D7097",
|
||||
GradientTo: "#89A4C3",
|
||||
Tint: "#4E6D8E",
|
||||
GradientFrom: "#5F7FA3",
|
||||
GradientTo: "#9BB4CF",
|
||||
Tint: "#567495",
|
||||
PrimaryText: "#F8FCFF",
|
||||
SecondaryText: "#DDE8F4",
|
||||
TertiaryText: "#BACADB",
|
||||
SecondaryText: "#E5EEF8",
|
||||
TertiaryText: "#C3D3E4",
|
||||
ParticleColor: "#00FFFFFF"),
|
||||
[HyperOS3WeatherVisualKind.ClearNight] = new(
|
||||
GradientFrom: "#576B86",
|
||||
@@ -136,17 +136,17 @@ public static class HyperOS3WeatherTheme
|
||||
TertiaryText: "#B4C3D6",
|
||||
ParticleColor: "#00FFFFFF"),
|
||||
[HyperOS3WeatherVisualKind.CloudyDay] = new(
|
||||
GradientFrom: "#607896",
|
||||
GradientTo: "#94A9C1",
|
||||
Tint: "#526C88",
|
||||
GradientFrom: "#5D799A",
|
||||
GradientTo: "#95ADC6",
|
||||
Tint: "#526E8B",
|
||||
PrimaryText: "#F8FCFF",
|
||||
SecondaryText: "#DCE7F3",
|
||||
TertiaryText: "#B9C8D9",
|
||||
SecondaryText: "#E2ECF7",
|
||||
TertiaryText: "#C0D0E0",
|
||||
ParticleColor: "#26FFFFFF"),
|
||||
[HyperOS3WeatherVisualKind.CloudyNight] = new(
|
||||
GradientFrom: "#51637A",
|
||||
GradientTo: "#8398AF",
|
||||
Tint: "#45586D",
|
||||
GradientFrom: "#536882",
|
||||
GradientTo: "#869CB4",
|
||||
Tint: "#495E76",
|
||||
PrimaryText: "#F6FAFF",
|
||||
SecondaryText: "#D4E0ED",
|
||||
TertiaryText: "#B0BFD2",
|
||||
@@ -184,12 +184,12 @@ public static class HyperOS3WeatherTheme
|
||||
TertiaryText: "#B5C4D6",
|
||||
ParticleColor: "#CCFFFFFF"),
|
||||
[HyperOS3WeatherVisualKind.Fog] = new(
|
||||
GradientFrom: "#657B97",
|
||||
GradientTo: "#90A5BC",
|
||||
Tint: "#4F637B",
|
||||
GradientFrom: "#607793",
|
||||
GradientTo: "#90A7C2",
|
||||
Tint: "#4F6580",
|
||||
PrimaryText: "#F8FBFF",
|
||||
SecondaryText: "#D8E3EE",
|
||||
TertiaryText: "#AFBED0",
|
||||
SecondaryText: "#DFEAF5",
|
||||
TertiaryText: "#B7C8DA",
|
||||
ParticleColor: "#88D9E5F1")
|
||||
};
|
||||
|
||||
@@ -217,7 +217,7 @@ public static class HyperOS3WeatherTheme
|
||||
MotionOpacityBase: 0.32, MotionOpacityPulse: 0.06,
|
||||
LightOpacityBase: 0.62, LightOpacityPulse: 0.07,
|
||||
ShadeOpacityBase: 0.80, ShadeOpacityPulse: 0.03,
|
||||
PhaseStep: 0.020, ParticleCount: 6,
|
||||
PhaseStep: 0.020, ParticleCount: 0,
|
||||
ParticleSpeedMin: 0.30, ParticleSpeedMax: 0.70,
|
||||
ParticleLengthMin: 14, ParticleLengthMax: 28, ParticleDriftPerTick: 0.10),
|
||||
[HyperOS3WeatherVisualKind.CloudyNight] = new(
|
||||
@@ -225,7 +225,7 @@ public static class HyperOS3WeatherTheme
|
||||
MotionOpacityBase: 0.34, MotionOpacityPulse: 0.07,
|
||||
LightOpacityBase: 0.54, LightOpacityPulse: 0.06,
|
||||
ShadeOpacityBase: 0.85, ShadeOpacityPulse: 0.03,
|
||||
PhaseStep: 0.021, ParticleCount: 8,
|
||||
PhaseStep: 0.021, ParticleCount: 0,
|
||||
ParticleSpeedMin: 0.35, ParticleSpeedMax: 0.80,
|
||||
ParticleLengthMin: 16, ParticleLengthMax: 30, ParticleDriftPerTick: 0.12),
|
||||
[HyperOS3WeatherVisualKind.RainLight] = new(
|
||||
@@ -265,7 +265,7 @@ public static class HyperOS3WeatherTheme
|
||||
MotionOpacityBase: 0.30, MotionOpacityPulse: 0.05,
|
||||
LightOpacityBase: 0.58, LightOpacityPulse: 0.05,
|
||||
ShadeOpacityBase: 0.86, ShadeOpacityPulse: 0.03,
|
||||
PhaseStep: 0.018, ParticleCount: 10,
|
||||
PhaseStep: 0.018, ParticleCount: 0,
|
||||
ParticleSpeedMin: 0.25, ParticleSpeedMax: 0.70,
|
||||
ParticleLengthMin: 16, ParticleLengthMax: 34, ParticleDriftPerTick: 0.12)
|
||||
};
|
||||
@@ -341,7 +341,7 @@ public static class HyperOS3WeatherTheme
|
||||
HyperOS3WeatherVisualKind.Snow
|
||||
=> "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_snow_flake.png",
|
||||
HyperOS3WeatherVisualKind.Fog
|
||||
=> "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_fog.png",
|
||||
=> "avares://LanMontainDesktop/Assets/Weather/HyperOS3/hyper_haze.png",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,285 +9,171 @@
|
||||
x:Class="LanMontainDesktop.Views.Components.MultiDayWeatherWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="30"
|
||||
CornerRadius="28"
|
||||
ClipToBounds="True"
|
||||
Background="#68A9EC">
|
||||
Background="#6B7B8F">
|
||||
<Grid>
|
||||
<Border x:Name="BackgroundImageLayer"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True" />
|
||||
<Border x:Name="BackgroundImageLayer" CornerRadius="28" ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="BackgroundMotionLayer"
|
||||
CornerRadius="30"
|
||||
CornerRadius="28"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.24"
|
||||
Opacity="0.25"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<Border.RenderTransform>
|
||||
<TransformGroup>
|
||||
<ScaleTransform ScaleX="1.05"
|
||||
ScaleY="1.05" />
|
||||
<ScaleTransform ScaleX="1.07" ScaleY="1.07" />
|
||||
<TranslateTransform />
|
||||
</TransformGroup>
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundTintLayer"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.20" />
|
||||
<Border x:Name="BackgroundTintLayer" CornerRadius="28" ClipToBounds="True" Opacity="0.12" />
|
||||
|
||||
<Border x:Name="BackgroundLightLayer"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.66">
|
||||
<Border x:Name="BackgroundLightLayer" CornerRadius="28" ClipToBounds="True" Opacity="0.52">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="1,1">
|
||||
<GradientStop Color="#5BFFFFFF"
|
||||
Offset="0" />
|
||||
<GradientStop Color="#1FFFFFFF"
|
||||
Offset="0.30" />
|
||||
<GradientStop Color="#00000000"
|
||||
Offset="0.55" />
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#45FFFFFF" Offset="0" />
|
||||
<GradientStop Color="#16FFFFFF" Offset="0.35" />
|
||||
<GradientStop Color="#00000000" Offset="0.64" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BackgroundShadeLayer"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.78">
|
||||
<Border x:Name="BackgroundShadeLayer" CornerRadius="28" ClipToBounds="True" Opacity="0.68">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="0,1">
|
||||
<GradientStop Color="#00040A16"
|
||||
Offset="0.50" />
|
||||
<GradientStop Color="#2E0B1C34"
|
||||
Offset="1" />
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#00000000" Offset="0.42" />
|
||||
<GradientStop Color="#19000000" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Canvas x:Name="ParticleLayer"
|
||||
IsHitTestVisible="False"
|
||||
ClipToBounds="True" />
|
||||
<Canvas x:Name="ParticleLayer" IsHitTestVisible="False" ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="ContentPaddingBorder"
|
||||
Padding="16"
|
||||
Background="Transparent">
|
||||
<Border x:Name="ContentPaddingBorder" Padding="24,18" Background="Transparent">
|
||||
<Grid x:Name="LayoutRoot">
|
||||
<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">
|
||||
<Grid x:Name="ContentGrid" RowDefinitions="Auto,Auto,*" RowSpacing="8">
|
||||
<Grid x:Name="TopRowGrid" Grid.Row="0" ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
|
||||
<TextBlock x:Name="TemperatureTextBlock"
|
||||
Grid.Column="0"
|
||||
Grid.RowSpan="2"
|
||||
Text="24°"
|
||||
FontSize="98"
|
||||
Text="7°"
|
||||
FontSize="54"
|
||||
FontWeight="Light"
|
||||
FontFeatures="tnum"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,-1,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,-2,0,0"
|
||||
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="ConditionIconStack"
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2"
|
||||
Margin="2,0,0,0">
|
||||
<StackPanel x:Name="BottomInfoStack"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
Spacing="3"
|
||||
Margin="0,0,0,1"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="Clear"
|
||||
FontSize="21"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="20°/28°"
|
||||
FontSize="21"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
<Border x:Name="CityInfoBadge"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="0">
|
||||
<fi:SymbolIcon x:Name="LocationIcon"
|
||||
Symbol="Location"
|
||||
FontSize="13"
|
||||
IsVisible="False"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="CityTextBlock"
|
||||
Text="北京"
|
||||
FontSize="17"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="ConditionInfoBadge"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0"
|
||||
Margin="0">
|
||||
<StackPanel x:Name="ConditionIconStack"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="9">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="雾"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="11°/4°"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
Opacity="0.92" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Image x:Name="WeatherIconImage"
|
||||
Grid.Column="2"
|
||||
Grid.RowSpan="2"
|
||||
Width="56"
|
||||
Height="56"
|
||||
Width="66"
|
||||
Height="66"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
VerticalAlignment="Center"
|
||||
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>
|
||||
<Border Grid.Row="1"
|
||||
Height="1"
|
||||
Background="#2AFFFFFF"
|
||||
Margin="0,4,0,0" />
|
||||
|
||||
<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>
|
||||
<Border x:Name="HourlyPanelBorder"
|
||||
Grid.Row="2"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
ClipToBounds="True"
|
||||
Padding="0,2,0,0"
|
||||
VerticalAlignment="Top">
|
||||
<Grid x:Name="HourlyGrid" ColumnDefinitions="*,*,*,*,*" ColumnSpacing="5">
|
||||
<StackPanel Grid.Column="0" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp0" Text="10°/5°" FontSize="16" FontWeight="SemiBold" FontFeatures="tnum" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon0" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime0" Text="明天" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp1" Text="13°/4°" FontSize="16" FontWeight="SemiBold" FontFeatures="tnum" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon1" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime1" Text="周四" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp2" Text="12°/3°" FontSize="16" FontWeight="SemiBold" FontFeatures="tnum" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon2" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime2" Text="周五" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="3" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp3" Text="10°/2°" FontSize="16" FontWeight="SemiBold" FontFeatures="tnum" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon3" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime3" Text="周六" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="4" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock x:Name="HourlyTemp4" Text="11°/3°" FontSize="16" FontWeight="SemiBold" FontFeatures="tnum" HorizontalAlignment="Center" />
|
||||
<Image x:Name="HourlyIcon4" Width="26" Height="26" HorizontalAlignment="Center" Stretch="Uniform" />
|
||||
<TextBlock x:Name="HourlyTime4" Text="周日" FontSize="12" FontWeight="Medium" HorizontalAlignment="Center" Opacity="0.82" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
@@ -512,16 +512,44 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
BackgroundMotionLayer.Background = ResolveWeatherBackgroundBrush(kind, palette);
|
||||
BackgroundTintLayer.Background = CreateSolidBrush(palette.Tint);
|
||||
|
||||
var primary = CreateSolidBrush(palette.PrimaryText);
|
||||
var particleBrush = ResolveParticleBrush(ToThemeKind(kind), palette.ParticleColor);
|
||||
var isNightVisual = kind is WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight;
|
||||
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;
|
||||
var backgroundSamples = WeatherTypographyAccessibility.BuildBackgroundSamples(
|
||||
palette.GradientFrom,
|
||||
palette.GradientTo,
|
||||
palette.Tint,
|
||||
isNightVisual);
|
||||
var primary = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagLargeTextContrast);
|
||||
var cityBrush = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.SecondaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xE6 : (byte)0xD4);
|
||||
var conditionSecondary = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagLargeTextContrast,
|
||||
isNightVisual ? (byte)0xED : (byte)0xDF);
|
||||
var rangeSecondary = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagLargeTextContrast,
|
||||
isNightVisual ? (byte)0xE2 : (byte)0xCE);
|
||||
var forecastTimeBrush = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xE7 : (byte)0xDA);
|
||||
var forecastTempBrush = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.TertiaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xC0 : (byte)0xAC);
|
||||
HourlyPanelBorder.Background = Brushes.Transparent;
|
||||
LocationIcon.Foreground = cityBrush;
|
||||
CityTextBlock.Foreground = cityBrush;
|
||||
TemperatureTextBlock.Foreground = primary;
|
||||
ConditionTextBlock.Foreground = conditionSecondary;
|
||||
@@ -725,12 +753,12 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
{
|
||||
const int itemCount = 5;
|
||||
var today = DateOnly.FromDateTime(_timeZoneService?.GetCurrentTime() ?? DateTime.Now);
|
||||
var firstFallback = snapshot.DailyForecasts.FirstOrDefault();
|
||||
var firstFallback = snapshot.DailyForecasts.Skip(1).FirstOrDefault() ?? snapshot.DailyForecasts.FirstOrDefault();
|
||||
var items = new List<HourlyForecastItem>(itemCount);
|
||||
|
||||
for (var i = 0; i < itemCount; i++)
|
||||
{
|
||||
var date = today.AddDays(i);
|
||||
var date = today.AddDays(i + 1);
|
||||
var daily = ResolveDailyForecastForDate(snapshot, date) ?? firstFallback;
|
||||
var weatherCode = daily?.DayWeatherCode ??
|
||||
daily?.NightWeatherCode ??
|
||||
@@ -743,10 +771,11 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
"{0}/{1}",
|
||||
FormatTemperature(low),
|
||||
FormatTemperature(high));
|
||||
var label = ResolveForecastDayLabel(date, i + 1);
|
||||
|
||||
items.Add(new HourlyForecastItem(
|
||||
date.ToDateTime(TimeOnly.MinValue),
|
||||
ResolveForecastDayLabel(date, i),
|
||||
label,
|
||||
ToThemeKind(visualKind),
|
||||
rangeText));
|
||||
}
|
||||
@@ -762,10 +791,11 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
var iconKind = ToThemeKind(visualKind);
|
||||
for (var i = 0; i < itemCount; i++)
|
||||
{
|
||||
var date = start.AddDays(i);
|
||||
var date = start.AddDays(i + 1);
|
||||
var label = ResolveForecastDayLabel(date, i + 1);
|
||||
items.Add(new HourlyForecastItem(
|
||||
date.ToDateTime(TimeOnly.MinValue),
|
||||
ResolveForecastDayLabel(date, i),
|
||||
label,
|
||||
iconKind,
|
||||
"--°/--°"));
|
||||
}
|
||||
@@ -775,7 +805,6 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private void ApplyHourlyForecastItems(IReadOnlyList<HourlyForecastItem> items)
|
||||
{
|
||||
var compactRangeText = ResolveScale() <= 0.78;
|
||||
for (var i = 0; i < _hourlyTimeBlocks.Length; i++)
|
||||
{
|
||||
if (i >= items.Count)
|
||||
@@ -791,25 +820,10 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
_hourlyTimeBlocks[i].Text = item.TimeLabel;
|
||||
_hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(
|
||||
HyperOS3WeatherTheme.ResolveIconAsset(item.IconKind));
|
||||
_hourlyTempBlocks[i].Text = compactRangeText
|
||||
? CompactRangeLabel(item.TemperatureText)
|
||||
: item.TemperatureText;
|
||||
_hourlyTempBlocks[i].Text = item.TemperatureText;
|
||||
}
|
||||
}
|
||||
|
||||
private static string CompactRangeLabel(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return "--°/--°";
|
||||
}
|
||||
|
||||
return text
|
||||
.Replace(" / ", "/", StringComparison.Ordinal)
|
||||
.Replace(" /", "/", StringComparison.Ordinal)
|
||||
.Replace("/ ", "/", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static WeatherDailyForecast? ResolveDailyForecastForDate(WeatherSnapshot snapshot, DateOnly date)
|
||||
{
|
||||
foreach (var forecast in snapshot.DailyForecasts)
|
||||
@@ -1003,62 +1017,66 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
var (layoutWidth, layoutHeight) = ResolveLayoutViewport();
|
||||
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);
|
||||
var compactness = Math.Clamp((1.0 - scaleY) / 0.55, 0, 1);
|
||||
|
||||
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);
|
||||
ContentGrid.RowSpacing = Math.Clamp((4.2 - (compactness * 0.7)) * scaleY, 2, 8);
|
||||
TopRowGrid.ColumnSpacing = Math.Clamp(8 * scaleX, 6, 13);
|
||||
BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp((1.0 - (compactness * 0.4)) * scaleY, 0, 2));
|
||||
|
||||
var summaryHeight = Math.Clamp(116 * scaleY, 82, 164);
|
||||
var bodyHeight = Math.Max(52, innerHeight - summaryHeight - ContentGrid.RowSpacing);
|
||||
var separatorHeight = Math.Clamp(6 * scaleY, 2, 10);
|
||||
var contentHeight = Math.Max(60, innerHeight - ContentGrid.RowSpacing - separatorHeight);
|
||||
var topZoneRatio = Math.Clamp(0.38 + (compactness * 0.09), 0.36, 0.50);
|
||||
var topZoneHeight = Math.Clamp(contentHeight * topZoneRatio, 60, 170);
|
||||
var bottomZoneHeight = Math.Max(42, contentHeight - topZoneHeight);
|
||||
var topScaleH = Math.Clamp(topZoneHeight / 102d, 0.62, 2.0);
|
||||
var topScaleW = Math.Clamp(innerWidth / 620d, 0.62, 2.0);
|
||||
var topScale = Math.Clamp((topScaleH * 0.68) + (topScaleW * 0.32), 0.62, 2.0);
|
||||
var bottomScaleH = Math.Clamp(bottomZoneHeight / 122d, 0.56, 2.0);
|
||||
var bottomScale = Math.Clamp((bottomScaleH * 0.74) + (scaleX * 0.26), 0.56, 1.95);
|
||||
var bodyHeight = bottomZoneHeight;
|
||||
|
||||
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);
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(88 * topScale, 56, 132);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(315);
|
||||
TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-1.2 * topScale, -4, 0), 0, 0);
|
||||
TemperatureTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 88, 196);
|
||||
|
||||
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);
|
||||
CityInfoBadge.Padding = new Thickness(0);
|
||||
CityInfoBadge.CornerRadius = new CornerRadius(0);
|
||||
LocationIcon.FontSize = Math.Clamp(12 * topScale, 9, 17);
|
||||
CityTextBlock.FontSize = Math.Clamp(18 * topScale, 11, 26);
|
||||
CityTextBlock.FontWeight = ToVariableWeight(540);
|
||||
CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.36, 112, 300);
|
||||
|
||||
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);
|
||||
ConditionInfoBadge.CornerRadius = new CornerRadius(0);
|
||||
ConditionIconStack.Spacing = Math.Clamp(7 * topScale, 4, 13);
|
||||
ConditionTextBlock.FontSize = Math.Clamp(19 * topScale, 12, 27);
|
||||
RangeTextBlock.FontSize = Math.Clamp(20 * topScale, 12, 30);
|
||||
ConditionTextBlock.FontWeight = ToVariableWeight(600);
|
||||
RangeTextBlock.FontWeight = ToVariableWeight(620);
|
||||
ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.16, 46, 170);
|
||||
RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.20, 60, 200);
|
||||
ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 58, 220);
|
||||
RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.30, 88, 270);
|
||||
BottomInfoStack.Spacing = Math.Clamp(2.2 * topScale, 1, 6);
|
||||
|
||||
var iconSize = Math.Clamp(68 * uiScale, 40, 90);
|
||||
var iconSize = Math.Clamp(68 * topScale, 42, 98);
|
||||
WeatherIconImage.Width = iconSize;
|
||||
WeatherIconImage.Height = iconSize;
|
||||
|
||||
HourlyPanelBorder.Padding = new Thickness(
|
||||
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(
|
||||
HourlyPanelBorder.Padding = new Thickness(0, Math.Clamp(1 * scaleY, 0, 2), 0, 0);
|
||||
HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(1.2 * scaleY, 0, 3), 0, 0);
|
||||
HourlyPanelBorder.CornerRadius = new CornerRadius(0);
|
||||
HourlyGrid.ColumnSpacing = Math.Clamp(7 * scaleX, 4, 11);
|
||||
var hourlyColumnCount = Math.Max(1, _hourlyTimeBlocks.Length);
|
||||
var hourlyInnerWidth = Math.Max(
|
||||
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);
|
||||
innerWidth - HourlyPanelBorder.Padding.Left - HourlyPanelBorder.Padding.Right - (HourlyGrid.ColumnSpacing * (hourlyColumnCount - 1)));
|
||||
var hourlyCellWidth = Math.Max(34, hourlyInnerWidth / hourlyColumnCount);
|
||||
var stackSpacing = Math.Clamp((1.6 + (bottomScale * 0.8)) * scaleY, 1, 4);
|
||||
var forecastRangeSize = Math.Clamp(Math.Max(13, bodyHeight * 0.22) * (0.76 + (bottomScale * 0.24)), 13, 31);
|
||||
var forecastLabelSize = Math.Clamp(Math.Max(10, bodyHeight * 0.17) * (0.78 + (bottomScale * 0.22)), 10, 23);
|
||||
var forecastIconSize = Math.Clamp(Math.Max(14, bodyHeight * 0.25) * (0.78 + (bottomScale * 0.22)), 14, 35);
|
||||
|
||||
for (var i = 0; i < _hourlyTimeBlocks.Length; i++)
|
||||
{
|
||||
@@ -1066,13 +1084,15 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
_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].MaxWidth = Math.Clamp(hourlyCellWidth, 34, 112);
|
||||
_hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 34, 112);
|
||||
_hourlyTimeBlocks[i].FontWeight = ToVariableWeight(500);
|
||||
_hourlyTempBlocks[i].FontWeight = ToVariableWeight(590);
|
||||
if (_hourlyTimeBlocks[i].Parent is StackPanel forecastStack)
|
||||
_hourlyTimeBlocks[i].TextAlignment = TextAlignment.Center;
|
||||
_hourlyTempBlocks[i].TextAlignment = TextAlignment.Center;
|
||||
if (_hourlyTimeBlocks[i].Parent is StackPanel hourlyStack)
|
||||
{
|
||||
forecastStack.Spacing = stackSpacing;
|
||||
hourlyStack.Spacing = stackSpacing;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1090,8 +1110,16 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
private void SetLoadingSkeleton(bool isLoading)
|
||||
{
|
||||
CityInfoBadge.Background = isLoading ? CreateSolidBrush("#24FFFFFF") : Brushes.Transparent;
|
||||
ConditionInfoBadge.Background = isLoading ? CreateSolidBrush("#1CFFFFFF") : Brushes.Transparent;
|
||||
var opacity = isLoading ? 0.58 : 1.0;
|
||||
TemperatureTextBlock.Opacity = opacity;
|
||||
ConditionTextBlock.Opacity = opacity;
|
||||
RangeTextBlock.Opacity = opacity;
|
||||
CityTextBlock.Opacity = isLoading ? 0.50 : 0.96;
|
||||
for (var i = 0; i < _hourlyTempBlocks.Length; i++)
|
||||
{
|
||||
_hourlyTempBlocks[i].Opacity = opacity;
|
||||
_hourlyTimeBlocks[i].Opacity = isLoading ? 0.76 : 0.94;
|
||||
}
|
||||
}
|
||||
|
||||
private static FontWeight ToVariableWeight(double weight)
|
||||
@@ -1424,4 +1452,3 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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="640"
|
||||
d:DesignHeight="320"
|
||||
@@ -9,37 +10,58 @@
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Button.music-action">
|
||||
<Setter Property="Background" Value="#24FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#44FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Background" Value="#00000000" />
|
||||
<Setter Property="BorderBrush" Value="#00000000" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.music-action:pointerover">
|
||||
<Setter Property="Background" Value="#30FFFFFF" />
|
||||
<Setter Property="Background" Value="#20FFFFFF" />
|
||||
</Style>
|
||||
<Style Selector="Button.music-action:pressed">
|
||||
<Setter Property="Background" Value="#4AFFFFFF" />
|
||||
<Setter Property="Background" Value="#30FFFFFF" />
|
||||
</Style>
|
||||
<Style Selector="Button.music-action:disabled">
|
||||
<Setter Property="Opacity" Value="0.55" />
|
||||
<Setter Property="Opacity" Value="0.85" />
|
||||
</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 Selector="Button.music-action-primary">
|
||||
<Setter Property="Background" Value="#F2FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#00FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="Button.music-link:pointerover">
|
||||
<Setter Property="Background" Value="#24FFFFFF" />
|
||||
<Style Selector="Button.music-action-primary:pointerover">
|
||||
<Setter Property="Background" Value="#FFFFFFFF" />
|
||||
</Style>
|
||||
<Style Selector="Button.music-link:pressed">
|
||||
<Style Selector="Button.music-action-primary:pressed">
|
||||
<Setter Property="Background" Value="#E6FFFFFF" />
|
||||
</Style>
|
||||
<Style Selector="Button.music-source">
|
||||
<Setter Property="Background" Value="#3AFFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#46FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<Setter Property="MinWidth" Value="62" />
|
||||
<Setter Property="Height" Value="32" />
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.music-source:pointerover">
|
||||
<Setter Property="Background" Value="#46FFFFFF" />
|
||||
</Style>
|
||||
<Style Selector="Button.music-source:pressed">
|
||||
<Setter Property="Background" Value="#55FFFFFF" />
|
||||
</Style>
|
||||
<Style Selector="Border.music-progress-track">
|
||||
<Setter Property="Background" Value="#4AFFFFFF" />
|
||||
<Setter Property="CornerRadius" Value="2" />
|
||||
</Style>
|
||||
<Style Selector="Border.music-progress-fill">
|
||||
<Setter Property="Background" Value="#D8FFFFFF" />
|
||||
<Setter Property="CornerRadius" Value="2" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
@@ -47,209 +69,242 @@
|
||||
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"
|
||||
BorderBrush="#52FFFFFF"
|
||||
Padding="0"
|
||||
BoxShadow="0 12 28 #29000000">
|
||||
<Grid>
|
||||
<Grid IsHitTestVisible="False">
|
||||
<Border x:Name="DynamicBackgroundBase"
|
||||
CornerRadius="30"
|
||||
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>
|
||||
Background="#B89E7B" />
|
||||
<Border x:Name="BackdropCoverHost"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True">
|
||||
<Image x:Name="BackdropCoverImage"
|
||||
IsVisible="False"
|
||||
Opacity="0.62"
|
||||
Stretch="UniformToFill">
|
||||
<Image.Effect>
|
||||
<BlurEffect Radius="42" />
|
||||
</Image.Effect>
|
||||
</Image>
|
||||
</Border>
|
||||
<Border x:Name="DynamicGradientOverlay"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True" />
|
||||
<Border x:Name="DynamicSoftLightOverlay"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True" />
|
||||
</Grid>
|
||||
|
||||
<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"
|
||||
<Border x:Name="ContentPaddingBorder"
|
||||
Background="Transparent"
|
||||
Padding="14,11,14,11">
|
||||
<Grid x:Name="LayoutGrid"
|
||||
RowDefinitions="Auto,Auto,Auto"
|
||||
RowSpacing="9">
|
||||
<Grid x:Name="HeaderRowGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="11">
|
||||
<Border x:Name="CoverBorder"
|
||||
Width="56"
|
||||
Height="56"
|
||||
CornerRadius="12"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="1"
|
||||
BorderBrush="#77FFFFFF"
|
||||
Background="#3CFFFFFF">
|
||||
<Grid>
|
||||
<Image x:Name="CoverImage"
|
||||
IsVisible="False"
|
||||
Stretch="UniformToFill" />
|
||||
<fi:SymbolIcon x:Name="CoverFallbackGlyph"
|
||||
Symbol="Album"
|
||||
IconVariant="Regular"
|
||||
FontSize="18"
|
||||
Foreground="#F3FFFFFF"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<StackPanel x:Name="MetaStackPanel"
|
||||
Grid.Column="1"
|
||||
Spacing="3"
|
||||
VerticalAlignment="Top">
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="5">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="Music"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
Foreground="#FFFFFFFF" />
|
||||
<fi:SymbolIcon x:Name="PlaybackActivityIcon"
|
||||
Grid.Column="1"
|
||||
Symbol="DeviceEq"
|
||||
IconVariant="Regular"
|
||||
FontSize="13"
|
||||
Foreground="#E5FFFFFF"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="False" />
|
||||
</Grid>
|
||||
<TextBlock x:Name="ArtistTextBlock"
|
||||
Text="No active media session"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
Foreground="#F7FFFFFF" />
|
||||
Foreground="#E7FFFFFF" />
|
||||
</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>
|
||||
<Button x:Name="SourceAppButton"
|
||||
Grid.Column="2"
|
||||
Classes="music-source"
|
||||
Click="OnSourceAppButtonClick"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,1,0,0">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
VerticalAlignment="Center">
|
||||
<Border x:Name="SourceAppGlyphBadge"
|
||||
CornerRadius="999"
|
||||
Width="22"
|
||||
Height="22"
|
||||
Background="#33FFFFFF"
|
||||
BorderBrush="#3CFFFFFF"
|
||||
BorderThickness="1">
|
||||
<fi:SymbolIcon x:Name="SourceAppIcon"
|
||||
Symbol="MusicNote1"
|
||||
IconVariant="Filled"
|
||||
FontSize="13"
|
||||
Foreground="#F7FFFFFF" />
|
||||
</Border>
|
||||
<fi:SymbolIcon x:Name="SourceChevronIcon"
|
||||
Symbol="ChevronDown"
|
||||
IconVariant="Regular"
|
||||
FontSize="12"
|
||||
Foreground="#E9FFFFFF" />
|
||||
<TextBlock x:Name="SourceAppTextBlock"
|
||||
IsVisible="False"
|
||||
Text="Open player" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</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" />
|
||||
<Grid x:Name="TimelineRowGrid"
|
||||
Grid.Row="1"
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="9"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="PositionTextBlock"
|
||||
Text="00:00"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#E9FFFFFF"
|
||||
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" />
|
||||
<Grid x:Name="ProgressTrackHost"
|
||||
Grid.Column="1"
|
||||
MinWidth="124"
|
||||
Height="3"
|
||||
VerticalAlignment="Center">
|
||||
<Border x:Name="ProgressTrackBorder"
|
||||
Classes="music-progress-track" />
|
||||
<Border x:Name="ProgressFillBorder"
|
||||
Classes="music-progress-fill"
|
||||
HorizontalAlignment="Left"
|
||||
Width="0" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="DurationTextBlock"
|
||||
Grid.Column="2"
|
||||
Text="00:00"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#E8FFFFFF"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
<TextBlock x:Name="DurationTextBlock"
|
||||
Grid.Column="2"
|
||||
Text="00:00"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#E9FFFFFF"
|
||||
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>
|
||||
<Grid x:Name="ActionRowGrid"
|
||||
Grid.Row="2"
|
||||
ColumnDefinitions="Auto,Auto,Auto,Auto,Auto"
|
||||
ColumnSpacing="12"
|
||||
Margin="0,1,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Bottom">
|
||||
<Button x:Name="QueueButton"
|
||||
Grid.Column="0"
|
||||
Classes="music-action"
|
||||
Width="31"
|
||||
Height="31">
|
||||
<fi:SymbolIcon x:Name="QueueIcon"
|
||||
Symbol="List"
|
||||
IconVariant="Regular"
|
||||
FontSize="16"
|
||||
Foreground="#F0FFFFFF" />
|
||||
</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="PreviousButton"
|
||||
Grid.Column="1"
|
||||
Classes="music-action"
|
||||
Width="34"
|
||||
Height="34"
|
||||
Click="OnPreviousButtonClick">
|
||||
<fi:SymbolIcon x:Name="PreviousIcon"
|
||||
Symbol="ArrowPrevious"
|
||||
IconVariant="Regular"
|
||||
FontSize="18"
|
||||
Foreground="#F8FFFFFF" />
|
||||
</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="PlayPauseButton"
|
||||
Grid.Column="2"
|
||||
Classes="music-action music-action-primary"
|
||||
Width="44"
|
||||
Height="44"
|
||||
Click="OnPlayPauseButtonClick">
|
||||
<fi:SymbolIcon x:Name="PlayPauseGlyphIcon"
|
||||
Symbol="Play"
|
||||
IconVariant="Filled"
|
||||
FontSize="23"
|
||||
Foreground="#FF6A604F" />
|
||||
</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="NextButton"
|
||||
Grid.Column="3"
|
||||
Classes="music-action"
|
||||
Width="34"
|
||||
Height="34"
|
||||
Click="OnNextButtonClick">
|
||||
<fi:SymbolIcon x:Name="NextIcon"
|
||||
Symbol="ArrowNext"
|
||||
IconVariant="Regular"
|
||||
FontSize="18"
|
||||
Foreground="#F8FFFFFF" />
|
||||
</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>
|
||||
<Button x:Name="FavoriteButton"
|
||||
Grid.Column="4"
|
||||
Classes="music-action"
|
||||
Width="31"
|
||||
Height="31">
|
||||
<fi:SymbolIcon x:Name="FavoriteIcon"
|
||||
Symbol="Heart"
|
||||
IconVariant="Regular"
|
||||
FontSize="16"
|
||||
Foreground="#F0FFFFFF" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="--" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
@@ -8,15 +9,18 @@ using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using FluentIcons.Common;
|
||||
using LanMontainDesktop.Services;
|
||||
using LanMontainDesktop.Theme;
|
||||
|
||||
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 const Symbol PlaySymbol = Symbol.Play;
|
||||
private const Symbol PauseSymbol = Symbol.Pause;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
@@ -24,6 +28,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
};
|
||||
|
||||
private readonly IMusicControlService _musicControlService = MusicControlServiceFactory.CreateDefault();
|
||||
private readonly MonetColorService _monetColorService = new();
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
|
||||
@@ -35,6 +40,8 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
private bool _isExecutingCommand;
|
||||
private double _progressRatio;
|
||||
private bool _isProgressIndeterminate;
|
||||
|
||||
public MusicControlWidget()
|
||||
{
|
||||
@@ -46,6 +53,7 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyDynamicBackground(null);
|
||||
ApplyState(MusicPlaybackState.NoSession(isSupported: OperatingSystem.IsWindows()));
|
||||
}
|
||||
|
||||
@@ -54,39 +62,68 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
_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),
|
||||
var rootRadius = Math.Clamp(30 * scale, 16, 44);
|
||||
var rootCornerRadius = new CornerRadius(rootRadius);
|
||||
|
||||
RootBorder.CornerRadius = rootCornerRadius;
|
||||
ContentPaddingBorder.Padding = new Thickness(
|
||||
Math.Clamp(14 * scale, 9, 22),
|
||||
Math.Clamp(11 * scale, 7, 18),
|
||||
Math.Clamp(14 * scale, 8, 24),
|
||||
Math.Clamp(14 * scale, 9, 22),
|
||||
Math.Clamp(11 * scale, 7, 18));
|
||||
LayoutGrid.RowSpacing = Math.Clamp(9 * scale, 6, 14);
|
||||
HeaderRowGrid.ColumnSpacing = Math.Clamp(11 * scale, 8, 18);
|
||||
MetaStackPanel.Spacing = Math.Clamp(3 * scale, 1, 6);
|
||||
TimelineRowGrid.ColumnSpacing = Math.Clamp(9 * scale, 6, 14);
|
||||
ActionRowGrid.ColumnSpacing = Math.Clamp(12 * scale, 8, 20);
|
||||
ActionRowGrid.Margin = new Thickness(0, Math.Clamp(1 * scale, 0, 4), 0, 0);
|
||||
DynamicBackgroundBase.CornerRadius = rootCornerRadius;
|
||||
BackdropCoverHost.CornerRadius = rootCornerRadius;
|
||||
DynamicGradientOverlay.CornerRadius = rootCornerRadius;
|
||||
DynamicSoftLightOverlay.CornerRadius = rootCornerRadius;
|
||||
|
||||
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));
|
||||
CoverBorder.Width = Math.Clamp(56 * scale, 38, 86);
|
||||
CoverBorder.Height = Math.Clamp(56 * scale, 38, 86);
|
||||
CoverBorder.CornerRadius = new CornerRadius(Math.Clamp(12 * scale, 8, 16));
|
||||
|
||||
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(20 * scale, 12, 28);
|
||||
ArtistTextBlock.FontSize = Math.Clamp(14 * scale, 9, 18);
|
||||
PlaybackActivityIcon.FontSize = Math.Clamp(13 * scale, 9, 16);
|
||||
|
||||
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);
|
||||
Math.Clamp(9 * scale, 6, 14),
|
||||
Math.Clamp(5 * scale, 3, 8));
|
||||
SourceAppButton.Margin = new Thickness(0, Math.Clamp(1 * scale, 0, 3), 0, 0);
|
||||
var sourceButtonHeight = Math.Clamp(32 * scale, 22, 44);
|
||||
SourceAppButton.Height = sourceButtonHeight;
|
||||
SourceAppButton.MinWidth = Math.Clamp(62 * scale, 46, 94);
|
||||
SourceAppButton.CornerRadius = new CornerRadius(sourceButtonHeight / 2d);
|
||||
SourceAppGlyphBadge.Width = Math.Clamp(22 * scale, 15, 30);
|
||||
SourceAppGlyphBadge.Height = Math.Clamp(22 * scale, 15, 30);
|
||||
SourceAppIcon.FontSize = Math.Clamp(13 * scale, 9, 18);
|
||||
SourceChevronIcon.FontSize = Math.Clamp(12 * scale, 8, 16);
|
||||
|
||||
PositionTextBlock.FontSize = Math.Clamp(13 * scale, 9, 16);
|
||||
DurationTextBlock.FontSize = Math.Clamp(13 * scale, 9, 16);
|
||||
ProgressBar.Height = Math.Clamp(5 * scale, 3, 8);
|
||||
PositionTextBlock.FontSize = Math.Clamp(13 * scale, 8, 15);
|
||||
DurationTextBlock.FontSize = Math.Clamp(13 * scale, 8, 15);
|
||||
ProgressTrackHost.MinWidth = Math.Clamp(124 * scale, 88, 190);
|
||||
var progressHeight = Math.Clamp(3.2 * scale, 2, 6);
|
||||
ProgressTrackHost.Height = progressHeight;
|
||||
ProgressTrackBorder.CornerRadius = new CornerRadius(progressHeight / 2d);
|
||||
ProgressFillBorder.CornerRadius = new CornerRadius(progressHeight / 2d);
|
||||
|
||||
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);
|
||||
QueueButton.Width = QueueButton.Height = Math.Clamp(31 * scale, 23, 42);
|
||||
FavoriteButton.Width = FavoriteButton.Height = Math.Clamp(31 * scale, 23, 42);
|
||||
PreviousButton.Width = PreviousButton.Height = Math.Clamp(34 * scale, 25, 44);
|
||||
NextButton.Width = NextButton.Height = Math.Clamp(34 * scale, 25, 44);
|
||||
PlayPauseButton.Width = PlayPauseButton.Height = Math.Clamp(44 * scale, 31, 58);
|
||||
|
||||
QueueIcon.FontSize = Math.Clamp(16 * scale, 11, 21);
|
||||
PreviousIcon.FontSize = Math.Clamp(18 * scale, 13, 24);
|
||||
PlayPauseGlyphIcon.FontSize = Math.Clamp(23 * scale, 15, 32);
|
||||
NextIcon.FontSize = Math.Clamp(18 * scale, 13, 24);
|
||||
FavoriteIcon.FontSize = Math.Clamp(16 * scale, 11, 21);
|
||||
|
||||
UpdateProgressVisual(_progressRatio, _isProgressIndeterminate);
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
@@ -131,12 +168,20 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
private async void OnSourceAppButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
await ExecuteCommandAsync(token => _musicControlService.LaunchSourceAppAsync(token), refreshAfterCommand: false);
|
||||
await ExecuteCommandAsync(
|
||||
token => _musicControlService.LaunchSourceAppAsync(token),
|
||||
refreshAfterCommand: false,
|
||||
requireActiveSession: false);
|
||||
}
|
||||
|
||||
private async Task ExecuteCommandAsync(Func<CancellationToken, Task<bool>> command, bool refreshAfterCommand = true)
|
||||
private async Task ExecuteCommandAsync(
|
||||
Func<CancellationToken, Task<bool>> command,
|
||||
bool refreshAfterCommand = true,
|
||||
bool requireActiveSession = true)
|
||||
{
|
||||
if (_isExecutingCommand || !_currentState.IsSupported || !_currentState.HasSession)
|
||||
if (_isExecutingCommand
|
||||
|| !_currentState.IsSupported
|
||||
|| (requireActiveSession && !_currentState.HasSession))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -224,11 +269,13 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
StatusTextBlock.Text = "--";
|
||||
PositionTextBlock.Text = "00:00";
|
||||
DurationTextBlock.Text = "00:00";
|
||||
ProgressBar.IsIndeterminate = false;
|
||||
ProgressBar.Value = 0;
|
||||
PlayPauseGlyphPath.Data = PlayGlyph;
|
||||
PlaybackActivityIcon.IsVisible = false;
|
||||
PlayPauseGlyphIcon.Symbol = PlaySymbol;
|
||||
UpdateProgressVisual(0, false);
|
||||
SetCoverImage(null);
|
||||
ApplyNoMediaVisualTheme();
|
||||
ApplyActionButtonState(state);
|
||||
UpdateSourceAppButtonTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -240,14 +287,18 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
StatusTextBlock.Text = "--";
|
||||
PositionTextBlock.Text = "00:00";
|
||||
DurationTextBlock.Text = "00:00";
|
||||
ProgressBar.IsIndeterminate = false;
|
||||
ProgressBar.Value = 0;
|
||||
PlayPauseGlyphPath.Data = PlayGlyph;
|
||||
PlaybackActivityIcon.IsVisible = false;
|
||||
PlayPauseGlyphIcon.Symbol = PlaySymbol;
|
||||
UpdateProgressVisual(0, false);
|
||||
SetCoverImage(null);
|
||||
ApplyNoMediaVisualTheme();
|
||||
ApplyActionButtonState(state);
|
||||
UpdateSourceAppButtonTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyActiveVisualTheme();
|
||||
|
||||
var title = string.IsNullOrWhiteSpace(state.Title)
|
||||
? L("music.widget.unknown_title", "Unknown title")
|
||||
: state.Title;
|
||||
@@ -263,37 +314,119 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
? L("music.widget.open_player", "Open player")
|
||||
: state.SourceAppName;
|
||||
StatusTextBlock.Text = ResolveStatusText(state.PlaybackStatus);
|
||||
PlaybackActivityIcon.IsVisible = state.PlaybackStatus == MusicPlaybackStatus.Playing;
|
||||
|
||||
var position = ClampToNonNegative(state.Position);
|
||||
var duration = ClampToNonNegative(state.Duration);
|
||||
var progress = duration.TotalMilliseconds <= 1
|
||||
var progressRatio = duration.TotalMilliseconds <= 1
|
||||
? 0
|
||||
: Math.Clamp((position.TotalMilliseconds / duration.TotalMilliseconds) * 100d, 0, 100);
|
||||
: Math.Clamp(position.TotalMilliseconds / duration.TotalMilliseconds, 0, 1);
|
||||
|
||||
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;
|
||||
UpdateProgressVisual(progressRatio, hasMediaSession && duration.TotalMilliseconds <= 1);
|
||||
|
||||
PlayPauseGlyphPath.Data = state.PlaybackStatus == MusicPlaybackStatus.Playing
|
||||
? PauseGlyph
|
||||
: PlayGlyph;
|
||||
PlayPauseGlyphIcon.Symbol = state.PlaybackStatus == MusicPlaybackStatus.Playing
|
||||
? PauseSymbol
|
||||
: PlaySymbol;
|
||||
|
||||
SetCoverImage(state.ThumbnailBytes);
|
||||
ApplyActionButtonState(state);
|
||||
UpdateSourceAppButtonTooltip();
|
||||
}
|
||||
|
||||
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;
|
||||
var showNoSessionStyle = !_isExecutingCommand && state.IsSupported && !state.HasSession;
|
||||
|
||||
PlayPauseButton.IsEnabled = canOperate
|
||||
? state.CanPlayPause
|
||||
: showNoSessionStyle;
|
||||
PreviousButton.IsEnabled = canOperate
|
||||
? state.CanSkipPrevious
|
||||
: showNoSessionStyle;
|
||||
NextButton.IsEnabled = canOperate
|
||||
? state.CanSkipNext
|
||||
: showNoSessionStyle;
|
||||
SourceAppButton.IsEnabled = !_isExecutingCommand && state.IsSupported;
|
||||
QueueButton.IsEnabled = canOperate || showNoSessionStyle;
|
||||
FavoriteButton.IsEnabled = canOperate || showNoSessionStyle;
|
||||
}
|
||||
|
||||
private void ApplyNoMediaVisualTheme()
|
||||
{
|
||||
ArtistTextBlock.MaxLines = 2;
|
||||
|
||||
DynamicBackgroundBase.Background = new SolidColorBrush(Color.Parse("#F0635D61"));
|
||||
DynamicGradientOverlay.Background = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
[
|
||||
new GradientStop(Color.Parse("#44FFFFFF"), 0.0),
|
||||
new GradientStop(Color.Parse("#15000000"), 0.60),
|
||||
new GradientStop(Color.Parse("#30000000"), 1.0)
|
||||
]
|
||||
};
|
||||
DynamicSoftLightOverlay.Background = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
[
|
||||
new GradientStop(Color.Parse("#05000000"), 0.0),
|
||||
new GradientStop(Color.Parse("#24000000"), 1.0)
|
||||
]
|
||||
};
|
||||
|
||||
RootBorder.BorderBrush = new SolidColorBrush(Color.Parse("#58FFFFFF"));
|
||||
ProgressTrackBorder.Background = new SolidColorBrush(Color.Parse("#3DFFFFFF"));
|
||||
ProgressFillBorder.Background = new SolidColorBrush(Color.Parse("#65FFFFFF"));
|
||||
|
||||
CoverBorder.Background = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
[
|
||||
new GradientStop(Color.Parse("#FFFF4767"), 0.0),
|
||||
new GradientStop(Color.Parse("#FFFF1F56"), 0.58),
|
||||
new GradientStop(Color.Parse("#FFD60045"), 1.0)
|
||||
]
|
||||
};
|
||||
CoverBorder.BorderBrush = new SolidColorBrush(Color.Parse("#48FFFFFF"));
|
||||
CoverFallbackGlyph.Symbol = Symbol.MusicNote1;
|
||||
CoverFallbackGlyph.IconVariant = IconVariant.Filled;
|
||||
CoverFallbackGlyph.Foreground = new SolidColorBrush(Color.Parse("#F5EFF3"));
|
||||
|
||||
SourceAppButton.Background = new SolidColorBrush(Color.Parse("#2FFFFFFF"));
|
||||
SourceAppButton.BorderBrush = new SolidColorBrush(Color.Parse("#30FFFFFF"));
|
||||
SourceAppGlyphBadge.Background = new SolidColorBrush(Color.Parse("#57FFFFFF"));
|
||||
SourceAppGlyphBadge.BorderBrush = new SolidColorBrush(Color.Parse("#00FFFFFF"));
|
||||
SourceAppIcon.IconVariant = IconVariant.Filled;
|
||||
SourceAppIcon.Foreground = new SolidColorBrush(Color.Parse("#FBFFFFFF"));
|
||||
}
|
||||
|
||||
private void ApplyActiveVisualTheme()
|
||||
{
|
||||
ArtistTextBlock.MaxLines = 1;
|
||||
|
||||
CoverBorder.Background = new SolidColorBrush(Color.Parse("#3CFFFFFF"));
|
||||
CoverBorder.BorderBrush = new SolidColorBrush(Color.Parse("#77FFFFFF"));
|
||||
CoverFallbackGlyph.Symbol = Symbol.Album;
|
||||
CoverFallbackGlyph.IconVariant = IconVariant.Regular;
|
||||
CoverFallbackGlyph.Foreground = new SolidColorBrush(Color.Parse("#F3FFFFFF"));
|
||||
|
||||
SourceAppButton.Background = new SolidColorBrush(Color.Parse("#3AFFFFFF"));
|
||||
SourceAppButton.BorderBrush = new SolidColorBrush(Color.Parse("#46FFFFFF"));
|
||||
SourceAppGlyphBadge.Background = new SolidColorBrush(Color.Parse("#33FFFFFF"));
|
||||
SourceAppGlyphBadge.BorderBrush = new SolidColorBrush(Color.Parse("#3CFFFFFF"));
|
||||
SourceAppIcon.IconVariant = IconVariant.Filled;
|
||||
SourceAppIcon.Foreground = new SolidColorBrush(Color.Parse("#F7FFFFFF"));
|
||||
}
|
||||
|
||||
private void UpdateLanguageCode()
|
||||
@@ -343,12 +476,12 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
{
|
||||
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)
|
||||
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * 4), 0.58, 1.9)
|
||||
: 1;
|
||||
var heightScale = Bounds.Height > 1
|
||||
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * 2), 0.60, 1.8)
|
||||
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * 2), 0.58, 1.9)
|
||||
: 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.05), 0.58, 2.0);
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.05), 0.56, 2.0);
|
||||
}
|
||||
|
||||
private static TimeSpan ClampToNonNegative(TimeSpan value)
|
||||
@@ -373,8 +506,11 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
if (thumbnailBytes is null || thumbnailBytes.Length == 0)
|
||||
{
|
||||
CoverImage.Source = null;
|
||||
BackdropCoverImage.Source = null;
|
||||
CoverImage.IsVisible = false;
|
||||
BackdropCoverImage.IsVisible = false;
|
||||
CoverFallbackGlyph.IsVisible = true;
|
||||
ApplyDynamicBackground(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -383,14 +519,20 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
using var stream = new MemoryStream(thumbnailBytes, writable: false);
|
||||
_coverBitmap = new Bitmap(stream);
|
||||
CoverImage.Source = _coverBitmap;
|
||||
BackdropCoverImage.Source = _coverBitmap;
|
||||
CoverImage.IsVisible = true;
|
||||
BackdropCoverImage.IsVisible = true;
|
||||
CoverFallbackGlyph.IsVisible = false;
|
||||
ApplyDynamicBackground(_coverBitmap);
|
||||
}
|
||||
catch
|
||||
{
|
||||
CoverImage.Source = null;
|
||||
BackdropCoverImage.Source = null;
|
||||
CoverImage.IsVisible = false;
|
||||
BackdropCoverImage.IsVisible = false;
|
||||
CoverFallbackGlyph.IsVisible = true;
|
||||
ApplyDynamicBackground(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,7 +543,125 @@ public partial class MusicControlWidget : UserControl, IDesktopComponentWidget
|
||||
return;
|
||||
}
|
||||
|
||||
if (ReferenceEquals(CoverImage.Source, _coverBitmap))
|
||||
{
|
||||
CoverImage.Source = null;
|
||||
}
|
||||
|
||||
if (ReferenceEquals(BackdropCoverImage.Source, _coverBitmap))
|
||||
{
|
||||
BackdropCoverImage.Source = null;
|
||||
}
|
||||
|
||||
_coverBitmap.Dispose();
|
||||
_coverBitmap = null;
|
||||
}
|
||||
|
||||
private void UpdateProgressVisual(double ratio, bool indeterminate)
|
||||
{
|
||||
_progressRatio = Math.Clamp(ratio, 0, 1);
|
||||
_isProgressIndeterminate = indeterminate;
|
||||
|
||||
if (ProgressTrackHost.Bounds.Width <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var trackWidth = ProgressTrackHost.Bounds.Width;
|
||||
if (indeterminate)
|
||||
{
|
||||
ProgressFillBorder.Width = Math.Max(trackWidth * 0.24, 14);
|
||||
ProgressFillBorder.Opacity = 0.56;
|
||||
return;
|
||||
}
|
||||
|
||||
ProgressFillBorder.Width = trackWidth * _progressRatio;
|
||||
ProgressFillBorder.Opacity = 0.96;
|
||||
}
|
||||
|
||||
private void UpdateSourceAppButtonTooltip()
|
||||
{
|
||||
var sourceName = string.IsNullOrWhiteSpace(SourceAppTextBlock.Text)
|
||||
? L("music.widget.open_player", "Open player")
|
||||
: SourceAppTextBlock.Text;
|
||||
var statusText = string.IsNullOrWhiteSpace(StatusTextBlock.Text) || StatusTextBlock.Text == "--"
|
||||
? sourceName
|
||||
: string.Create(CultureInfo.InvariantCulture, $"{sourceName} ({StatusTextBlock.Text})");
|
||||
ToolTip.SetTip(SourceAppButton, statusText);
|
||||
}
|
||||
|
||||
private void ApplyDynamicBackground(Bitmap? albumBitmap)
|
||||
{
|
||||
var nightMode = ResolveIsNightMode();
|
||||
var palette = _monetColorService.BuildPalette(albumBitmap, nightMode);
|
||||
var colors = palette.MonetColors.Count > 0 ? palette.MonetColors : palette.RecommendedColors;
|
||||
|
||||
var c0 = PickPaletteColor(colors, 0, Color.Parse("#C4A983"));
|
||||
var c1 = PickPaletteColor(colors, 1, Color.Parse("#A88C6B"));
|
||||
var c2 = PickPaletteColor(colors, 2, Color.Parse("#8B7459"));
|
||||
var c3 = PickPaletteColor(colors, 4, Color.Parse("#6F5E4C"));
|
||||
|
||||
var topLeft = ColorMath.Blend(c0, Color.Parse("#FFFFFFFF"), nightMode ? 0.08 : 0.30);
|
||||
var center = ColorMath.Blend(c1, c2, 0.34);
|
||||
var bottomRight = ColorMath.Blend(c3, Color.Parse("#FF1F1A16"), nightMode ? 0.42 : 0.20);
|
||||
var glow = ColorMath.Blend(c0, Color.Parse("#FFFFFFFF"), 0.38);
|
||||
var borderColor = ColorMath.Blend(c0, Color.Parse("#FFFFFFFF"), 0.44);
|
||||
|
||||
DynamicBackgroundBase.Background = new SolidColorBrush(ColorMath.WithAlpha(center, 0xD6));
|
||||
DynamicGradientOverlay.Background = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
[
|
||||
new GradientStop(ColorMath.WithAlpha(topLeft, 0xE6), 0.0),
|
||||
new GradientStop(ColorMath.WithAlpha(center, 0xCF), 0.52),
|
||||
new GradientStop(ColorMath.WithAlpha(bottomRight, 0xDA), 1.0)
|
||||
]
|
||||
};
|
||||
|
||||
DynamicSoftLightOverlay.Background = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 0, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
[
|
||||
new GradientStop(ColorMath.WithAlpha(glow, 0x44), 0.0),
|
||||
new GradientStop(ColorMath.WithAlpha(Color.Parse("#FFFFFFFF"), 0x10), 0.45),
|
||||
new GradientStop(ColorMath.WithAlpha(Color.Parse("#FF000000"), nightMode ? (byte)0x44 : (byte)0x2B), 1.0)
|
||||
]
|
||||
};
|
||||
|
||||
RootBorder.BorderBrush = new SolidColorBrush(ColorMath.WithAlpha(borderColor, 0x7A));
|
||||
ProgressTrackBorder.Background = new SolidColorBrush(
|
||||
ColorMath.WithAlpha(ColorMath.Blend(center, Color.Parse("#FFFFFFFF"), 0.44), 0x88));
|
||||
ProgressFillBorder.Background = new SolidColorBrush(
|
||||
ColorMath.WithAlpha(ColorMath.Blend(c0, Color.Parse("#FFFFFFFF"), 0.76), 0xF2));
|
||||
}
|
||||
|
||||
private bool ResolveIsNightMode()
|
||||
{
|
||||
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ActualThemeVariant == ThemeVariant.Light)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
|
||||
}
|
||||
|
||||
private static Color PickPaletteColor(IReadOnlyList<Color> colors, int index, Color fallback)
|
||||
{
|
||||
if (colors.Count == 0)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var safeIndex = Math.Clamp(index, 0, colors.Count - 1);
|
||||
return colors[safeIndex];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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="320"
|
||||
d:DesignHeight="320"
|
||||
@@ -9,46 +10,49 @@
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
Padding="10"
|
||||
Padding="0"
|
||||
ClipToBounds="True"
|
||||
Background="#ECEFF3"
|
||||
BorderBrush="#DEE3EA"
|
||||
BorderBrush="#D9DEE7"
|
||||
BorderThickness="1">
|
||||
<Viewbox Stretch="Uniform">
|
||||
<Grid Width="300"
|
||||
Height="300">
|
||||
<Border x:Name="RecorderCardBorder"
|
||||
Width="248"
|
||||
Height="248"
|
||||
Width="300"
|
||||
Height="300"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
CornerRadius="30"
|
||||
BorderBrush="#E6EAF0"
|
||||
BorderThickness="1"
|
||||
Background="#F4F6FA">
|
||||
<Grid Margin="16,14,16,12"
|
||||
CornerRadius="34"
|
||||
BorderBrush="#00000000"
|
||||
BorderThickness="0"
|
||||
Background="Transparent">
|
||||
<Grid x:Name="RecorderContentGrid"
|
||||
Margin="24,20,24,22"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Grid.Row="0"
|
||||
Text="录音"
|
||||
Text="Recorder"
|
||||
FontSize="19"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#11151D"
|
||||
HorizontalAlignment="Center" />
|
||||
HorizontalAlignment="Center"
|
||||
IsVisible="False" />
|
||||
|
||||
<TextBlock x:Name="TimerTextBlock"
|
||||
Grid.Row="1"
|
||||
Margin="0,8,0,0"
|
||||
Margin="0,6,0,0"
|
||||
Text="00:00"
|
||||
FontSize="66"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
Foreground="#151922"
|
||||
Foreground="#A4A9B2"
|
||||
HorizontalAlignment="Center" />
|
||||
|
||||
<Grid Grid.Row="2"
|
||||
Margin="0,10,0,0"
|
||||
ColumnDefinitions="*,2,68"
|
||||
<Grid x:Name="WaveformRowGrid"
|
||||
Grid.Row="2"
|
||||
Margin="0,14,0,0"
|
||||
ColumnDefinitions="*,2,*"
|
||||
VerticalAlignment="Center">
|
||||
<StackPanel x:Name="WaveformBarsPanel"
|
||||
Grid.Column="0"
|
||||
@@ -57,8 +61,8 @@
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Border Grid.Column="1"
|
||||
Margin="0,0,0,0"
|
||||
<Border x:Name="CenterNeedle"
|
||||
Grid.Column="1"
|
||||
Width="2"
|
||||
Height="32"
|
||||
CornerRadius="1"
|
||||
@@ -68,7 +72,7 @@
|
||||
|
||||
<Border x:Name="FutureLine"
|
||||
Grid.Column="2"
|
||||
Margin="8,0,0,0"
|
||||
Margin="4,0,0,0"
|
||||
Height="2"
|
||||
CornerRadius="1"
|
||||
Background="#A3A8B3"
|
||||
@@ -77,8 +81,9 @@
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="3"
|
||||
Margin="0,16,0,0"
|
||||
<Grid x:Name="ControlButtonsGrid"
|
||||
Grid.Row="3"
|
||||
Margin="0,22,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
ColumnDefinitions="Auto,Auto,Auto"
|
||||
ColumnSpacing="16">
|
||||
@@ -92,13 +97,13 @@
|
||||
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>
|
||||
<fi:SymbolIcon x:Name="DiscardIcon"
|
||||
Symbol="Dismiss"
|
||||
IconVariant="Regular"
|
||||
FontSize="20"
|
||||
Foreground="#141922"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<Border x:Name="RecordToggleButtonBorder"
|
||||
@@ -116,20 +121,22 @@
|
||||
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" />
|
||||
<fi:SymbolIcon x:Name="PauseGlyphIcon"
|
||||
Symbol="Pause"
|
||||
IconVariant="Filled"
|
||||
FontSize="20"
|
||||
Foreground="#FFFFFF"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="False" />
|
||||
<fi:SymbolIcon x:Name="PlayGlyphIcon"
|
||||
Symbol="Play"
|
||||
IconVariant="Filled"
|
||||
FontSize="20"
|
||||
Foreground="#FFFFFF"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="False" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -143,13 +150,13 @@
|
||||
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>
|
||||
<fi:SymbolIcon x:Name="SaveIcon"
|
||||
Symbol="Checkmark"
|
||||
IconVariant="Regular"
|
||||
FontSize="22"
|
||||
Foreground="#141922"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
@@ -161,7 +168,8 @@
|
||||
FontSize="13"
|
||||
FontWeight="Medium"
|
||||
Foreground="#7A818E"
|
||||
Text="点击红色按钮开始" />
|
||||
Text="Tap red button to record"
|
||||
IsVisible="False" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
@@ -2,10 +2,12 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using LanMontainDesktop.Services;
|
||||
|
||||
@@ -49,30 +51,54 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var scale = ResolveScale();
|
||||
var rawScale = ResolveScale();
|
||||
var chromeScale = Math.Clamp(rawScale, 0.62, 2.0);
|
||||
var contentScale = Math.Clamp(rawScale, 0.74, 1.0);
|
||||
|
||||
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 rootRadius = Math.Clamp(34 * chromeScale, 16, 56);
|
||||
RootBorder.CornerRadius = new CornerRadius(rootRadius);
|
||||
RootBorder.Padding = new Thickness(0);
|
||||
RecorderCardBorder.CornerRadius = new CornerRadius(rootRadius);
|
||||
RecorderContentGrid.Margin = new Thickness(
|
||||
Math.Clamp(24 * contentScale, 14, 26),
|
||||
Math.Clamp(18 * contentScale, 10, 22),
|
||||
Math.Clamp(24 * contentScale, 14, 26),
|
||||
Math.Clamp(18 * contentScale, 10, 24));
|
||||
|
||||
var sideButtonSize = Math.Clamp(54 * scale, 38, 72);
|
||||
var sideButtonSize = Math.Clamp(54 * contentScale, 34, 58);
|
||||
DiscardButtonBorder.Width = sideButtonSize;
|
||||
DiscardButtonBorder.Height = sideButtonSize;
|
||||
DiscardButtonBorder.CornerRadius = new CornerRadius(sideButtonSize / 2d);
|
||||
DiscardIcon.FontSize = Math.Clamp(20 * contentScale, 14, 22);
|
||||
|
||||
SaveButtonBorder.Width = sideButtonSize;
|
||||
SaveButtonBorder.Height = sideButtonSize;
|
||||
SaveButtonBorder.CornerRadius = new CornerRadius(sideButtonSize / 2d);
|
||||
SaveIcon.FontSize = Math.Clamp(22 * contentScale, 15, 24);
|
||||
|
||||
var centerButtonSize = Math.Clamp(68 * scale, 48, 86);
|
||||
var centerButtonSize = Math.Clamp(68 * contentScale, 42, 72);
|
||||
RecordToggleButtonBorder.Width = centerButtonSize;
|
||||
RecordToggleButtonBorder.Height = centerButtonSize;
|
||||
RecordToggleButtonBorder.CornerRadius = new CornerRadius(centerButtonSize / 2d);
|
||||
var centerIconSize = Math.Clamp(20 * contentScale, 14, 24);
|
||||
PauseGlyphIcon.FontSize = centerIconSize;
|
||||
PlayGlyphIcon.FontSize = centerIconSize;
|
||||
var recordDotSize = Math.Clamp(15 * contentScale, 10, 16);
|
||||
RecordDot.Width = recordDotSize;
|
||||
RecordDot.Height = recordDotSize;
|
||||
|
||||
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);
|
||||
WaveformRowGrid.Margin = new Thickness(0, Math.Clamp(12 * contentScale, 6, 16), 0, 0);
|
||||
CenterNeedle.Height = Math.Clamp(32 * contentScale, 18, 34);
|
||||
FutureLine.Margin = new Thickness(Math.Clamp(4 * contentScale, 2, 6), 0, 0, 0);
|
||||
FutureLine.Height = Math.Clamp(2 * contentScale, 1, 3);
|
||||
ControlButtonsGrid.Margin = new Thickness(0, Math.Clamp(16 * contentScale, 8, 20), 0, 0);
|
||||
ControlButtonsGrid.ColumnSpacing = Math.Clamp(16 * contentScale, 8, 16);
|
||||
HintTextBlock.Margin = new Thickness(0, Math.Clamp(8 * contentScale, 4, 10), 0, 0);
|
||||
|
||||
WaveformBarsPanel.Spacing = Math.Clamp(3 * contentScale, 1.6, 3.4);
|
||||
TitleTextBlock.FontSize = Math.Clamp(19 * contentScale, 12, 20);
|
||||
TimerTextBlock.FontSize = Math.Clamp(66 * contentScale, 34, 66);
|
||||
HintTextBlock.FontSize = Math.Clamp(13 * contentScale, 9, 13);
|
||||
|
||||
UpdateWaveformVisual();
|
||||
}
|
||||
@@ -146,14 +172,35 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnSaveButtonPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
private async void OnSaveButtonPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = _audioRecorderService.StopAndSave();
|
||||
var snapshot = _audioRecorderService.GetSnapshot();
|
||||
if (!snapshot.IsSupported)
|
||||
{
|
||||
RefreshVisual();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot.State == AudioRecorderRuntimeState.Recording)
|
||||
{
|
||||
_audioRecorderService.Pause();
|
||||
}
|
||||
|
||||
var (wasCancelled, outputPath) = await PickSavePathAsync();
|
||||
if (wasCancelled)
|
||||
{
|
||||
RefreshVisual();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_ = _audioRecorderService.StopAndSave(outputPath);
|
||||
RefreshVisual();
|
||||
e.Handled = true;
|
||||
}
|
||||
@@ -165,13 +212,15 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
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;
|
||||
if (snapshot.State == AudioRecorderRuntimeState.Recording)
|
||||
{
|
||||
PushWaveLevel(snapshot.InputLevel);
|
||||
}
|
||||
else if (snapshot.State != AudioRecorderRuntimeState.Paused)
|
||||
{
|
||||
ClearWaveLevels();
|
||||
}
|
||||
|
||||
PushWaveLevel(incomingLevel);
|
||||
UpdateWaveformVisual();
|
||||
|
||||
ApplyControlState(snapshot);
|
||||
@@ -182,7 +231,11 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
var isSupported = snapshot.IsSupported;
|
||||
var canFinalize = snapshot.State == AudioRecorderRuntimeState.Recording ||
|
||||
snapshot.State == AudioRecorderRuntimeState.Paused;
|
||||
var isReady = snapshot.State == AudioRecorderRuntimeState.Ready;
|
||||
|
||||
TitleTextBlock.IsVisible = false;
|
||||
DiscardButtonBorder.IsVisible = canFinalize;
|
||||
SaveButtonBorder.IsVisible = canFinalize;
|
||||
DiscardButtonBorder.IsHitTestVisible = isSupported && canFinalize;
|
||||
SaveButtonBorder.IsHitTestVisible = isSupported && canFinalize;
|
||||
RecordToggleButtonBorder.IsHitTestVisible = isSupported;
|
||||
@@ -191,9 +244,16 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
SaveButtonBorder.Opacity = SaveButtonBorder.IsHitTestVisible ? 1 : 0.42;
|
||||
RecordToggleButtonBorder.Opacity = RecordToggleButtonBorder.IsHitTestVisible ? 1 : 0.54;
|
||||
|
||||
TimerTextBlock.Foreground = CreateBrush(!isSupported
|
||||
? "#B2B7C0"
|
||||
: isReady
|
||||
? "#A4A9B2"
|
||||
: "#151922");
|
||||
HintTextBlock.IsVisible = !isReady || !isSupported;
|
||||
|
||||
RecordDot.IsVisible = snapshot.State == AudioRecorderRuntimeState.Ready;
|
||||
PauseGlyphPath.IsVisible = snapshot.State == AudioRecorderRuntimeState.Recording;
|
||||
PlayGlyphPath.IsVisible = snapshot.State == AudioRecorderRuntimeState.Paused;
|
||||
PauseGlyphIcon.IsVisible = snapshot.State == AudioRecorderRuntimeState.Recording;
|
||||
PlayGlyphIcon.IsVisible = snapshot.State == AudioRecorderRuntimeState.Paused;
|
||||
|
||||
if (!isSupported)
|
||||
{
|
||||
@@ -276,17 +336,22 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
_waveLevels[^1] = Math.Clamp((previous * 0.35) + (target * 0.65), 0, 1);
|
||||
}
|
||||
|
||||
private void ClearWaveLevels()
|
||||
{
|
||||
Array.Fill(_waveLevels, 0);
|
||||
}
|
||||
|
||||
private void UpdateWaveformVisual()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var barWidth = Math.Clamp(3 * scale, 2, 5);
|
||||
var scale = Math.Clamp(ResolveScale(), 0.74, 1.0);
|
||||
var barWidth = Math.Clamp(3 * scale, 1.8, 3.2);
|
||||
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.Height = Math.Clamp((4 + (eased * 24)) * scale, 3, 30);
|
||||
bar.CornerRadius = new CornerRadius(Math.Clamp(barWidth / 2d, 1, 2));
|
||||
bar.Opacity = Math.Clamp(0.20 + (eased * 0.82), 0.20, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -335,4 +400,43 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget
|
||||
{
|
||||
return new SolidColorBrush(Color.Parse(colorHex));
|
||||
}
|
||||
|
||||
private async Task<(bool WasCancelled, string? OutputPath)> PickSavePathAsync()
|
||||
{
|
||||
var suggestedName = $"recording_{DateTime.Now:yyyyMMdd_HHmmss}.wav";
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
var storageProvider = topLevel?.StorageProvider;
|
||||
if (storageProvider is null)
|
||||
{
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var saveFile = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = L("recording.widget.save_picker_title", "Save recording"),
|
||||
SuggestedFileName = suggestedName,
|
||||
DefaultExtension = "wav",
|
||||
FileTypeChoices =
|
||||
[
|
||||
new FilePickerFileType(L("recording.widget.save_picker_type", "WAV audio"))
|
||||
{
|
||||
Patterns = ["*.wav"],
|
||||
MimeTypes = ["audio/wav", "audio/x-wav"]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (saveFile is null)
|
||||
{
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
var path = saveFile.Path;
|
||||
if (path is null || !path.IsFile)
|
||||
{
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
return (false, path.LocalPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,9 +408,16 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
private void ApplyModeVisual(bool isNightMode)
|
||||
{
|
||||
RootBorder.Background = isNightMode
|
||||
? CreateGradientBrush("#2A3346", "#202A3B")
|
||||
: CreateGradientBrush("#FFFFFF", "#F6F8FC");
|
||||
var gradientFrom = isNightMode ? "#2A3346" : "#FFFFFF";
|
||||
var gradientTo = isNightMode ? "#202A3B" : "#F6F8FC";
|
||||
var dialSurface = isNightMode ? "#1B2434" : "#F8FAFF";
|
||||
var backgroundSamples = WeatherTypographyAccessibility.BuildBackgroundSamples(
|
||||
gradientFrom,
|
||||
gradientTo,
|
||||
dialSurface,
|
||||
isNightMode);
|
||||
|
||||
RootBorder.Background = CreateGradientBrush(gradientFrom, gradientTo);
|
||||
RootBorder.BorderBrush = CreateBrush(isNightMode ? "#36F2F5FF" : "#14000000");
|
||||
|
||||
AnalogDialBorder.Background = isNightMode
|
||||
@@ -418,8 +425,14 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
|
||||
: CreateBrush("#F8FAFF");
|
||||
AnalogDialBorder.BorderBrush = CreateBrush(isNightMode ? "#34DDE7FF" : "#12000000");
|
||||
|
||||
TimeTextBlock.Foreground = CreateBrush(isNightMode ? "#F8FBFF" : "#10131A");
|
||||
DateTextBlock.Foreground = CreateBrush(isNightMode ? "#BCC8DD" : "#7A7E87");
|
||||
TimeTextBlock.Foreground = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
isNightMode ? "#F8FBFF" : "#10131A",
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagLargeTextContrast);
|
||||
DateTextBlock.Foreground = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
isNightMode ? "#BCC8DD" : "#7A7E87",
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast);
|
||||
|
||||
_hourHandLine.Stroke = CreateBrush(isNightMode ? "#F1F5FF" : "#232938");
|
||||
_minuteHandLine.Stroke = CreateBrush(isNightMode ? "#D6E0F2" : "#2F3749");
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Media;
|
||||
using LanMontainDesktop.Theme;
|
||||
|
||||
namespace LanMontainDesktop.Views.Components;
|
||||
|
||||
internal static class WeatherTypographyAccessibility
|
||||
{
|
||||
// WCAG-inspired targets used by the project theme system.
|
||||
public const double WcagNormalTextContrast = 4.5;
|
||||
public const double WcagLargeTextContrast = 3.0;
|
||||
private const double LightTextLuminanceFloor = 0.58;
|
||||
|
||||
public static IReadOnlyList<Color> BuildBackgroundSamples(
|
||||
string gradientFromHex,
|
||||
string gradientToHex,
|
||||
string tintHex,
|
||||
bool isNightVisual)
|
||||
{
|
||||
var from = Color.Parse(gradientFromHex);
|
||||
var to = Color.Parse(gradientToHex);
|
||||
var tint = Color.Parse(tintHex);
|
||||
var mid = ColorMath.Blend(from, to, 0.52);
|
||||
var tinted = ColorMath.Blend(mid, tint, isNightVisual ? 0.34 : 0.28);
|
||||
var shaded = ColorMath.Blend(tinted, Color.Parse("#FF0B1220"), isNightVisual ? 0.24 : 0.16);
|
||||
var lightProbe = ColorMath.Blend(mid, Color.Parse("#FFFFFFFF"), 0.12);
|
||||
|
||||
return
|
||||
[
|
||||
from,
|
||||
to,
|
||||
mid,
|
||||
tinted,
|
||||
shaded,
|
||||
lightProbe
|
||||
];
|
||||
}
|
||||
|
||||
public static IBrush CreateReadableBrush(
|
||||
string preferredHex,
|
||||
IReadOnlyList<Color> backgroundSamples,
|
||||
double minRatio,
|
||||
byte desiredAlpha = 0xFF)
|
||||
{
|
||||
var preferred = Color.Parse(preferredHex);
|
||||
return new SolidColorBrush(CreateReadableColor(preferred, backgroundSamples, minRatio, desiredAlpha));
|
||||
}
|
||||
|
||||
private static Color CreateReadableColor(
|
||||
Color preferred,
|
||||
IReadOnlyList<Color> backgroundSamples,
|
||||
double minRatio,
|
||||
byte desiredAlpha)
|
||||
{
|
||||
var lightPreferred = EnsureLightTone(Color.FromArgb(0xFF, preferred.R, preferred.G, preferred.B));
|
||||
if (backgroundSamples.Count == 0)
|
||||
{
|
||||
return desiredAlpha >= 0xFF
|
||||
? lightPreferred
|
||||
: Color.FromArgb(desiredAlpha, lightPreferred.R, lightPreferred.G, lightPreferred.B);
|
||||
}
|
||||
|
||||
var opaque = EnsureContrastPreservingTone(lightPreferred, backgroundSamples, minRatio);
|
||||
if (desiredAlpha >= 0xFF)
|
||||
{
|
||||
return Color.FromArgb(0xFF, opaque.R, opaque.G, opaque.B);
|
||||
}
|
||||
|
||||
var alpha = AdjustAlphaForContrast(opaque, backgroundSamples, minRatio, desiredAlpha);
|
||||
return Color.FromArgb(alpha, opaque.R, opaque.G, opaque.B);
|
||||
}
|
||||
|
||||
private static Color EnsureContrastPreservingTone(
|
||||
Color preferred,
|
||||
IReadOnlyList<Color> backgroundSamples,
|
||||
double minRatio)
|
||||
{
|
||||
if (MinContrastRatio(preferred, backgroundSamples) >= minRatio)
|
||||
{
|
||||
return preferred;
|
||||
}
|
||||
|
||||
var white = Color.Parse("#FFFFFFFF");
|
||||
|
||||
if (TryFindBlendRatio(preferred, white, backgroundSamples, minRatio, out var whiteDelta))
|
||||
{
|
||||
return ColorMath.Blend(preferred, white, whiteDelta);
|
||||
}
|
||||
|
||||
// Enforce light typography: never fall back to dark text.
|
||||
return white;
|
||||
}
|
||||
|
||||
private static bool TryFindBlendRatio(
|
||||
Color source,
|
||||
Color target,
|
||||
IReadOnlyList<Color> backgroundSamples,
|
||||
double minRatio,
|
||||
out double blendRatio)
|
||||
{
|
||||
if (MinContrastRatio(target, backgroundSamples) < minRatio)
|
||||
{
|
||||
blendRatio = double.PositiveInfinity;
|
||||
return false;
|
||||
}
|
||||
|
||||
var low = 0d;
|
||||
var high = 1d;
|
||||
for (var i = 0; i < 16; i++)
|
||||
{
|
||||
var mid = (low + high) / 2d;
|
||||
var candidate = ColorMath.Blend(source, target, mid);
|
||||
if (MinContrastRatio(candidate, backgroundSamples) >= minRatio)
|
||||
{
|
||||
high = mid;
|
||||
}
|
||||
else
|
||||
{
|
||||
low = mid;
|
||||
}
|
||||
}
|
||||
|
||||
blendRatio = high;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte AdjustAlphaForContrast(
|
||||
Color opaqueColor,
|
||||
IReadOnlyList<Color> backgroundSamples,
|
||||
double minRatio,
|
||||
byte desiredAlpha)
|
||||
{
|
||||
var alpha = desiredAlpha;
|
||||
while (alpha < 0xFF)
|
||||
{
|
||||
var candidate = Color.FromArgb(alpha, opaqueColor.R, opaqueColor.G, opaqueColor.B);
|
||||
if (MinContrastRatio(candidate, backgroundSamples) >= minRatio)
|
||||
{
|
||||
return alpha;
|
||||
}
|
||||
|
||||
alpha = (byte)Math.Min(0xFF, alpha + 4);
|
||||
}
|
||||
|
||||
return 0xFF;
|
||||
}
|
||||
|
||||
private static double MinContrastRatio(Color foreground, IReadOnlyList<Color> backgroundSamples)
|
||||
{
|
||||
var minimum = double.MaxValue;
|
||||
for (var i = 0; i < backgroundSamples.Count; i++)
|
||||
{
|
||||
var bg = backgroundSamples[i];
|
||||
var visibleForeground = foreground.A >= 0xFF
|
||||
? Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B)
|
||||
: CompositeOverBackground(foreground, bg);
|
||||
var ratio = ColorMath.ContrastRatio(visibleForeground, bg);
|
||||
if (ratio < minimum)
|
||||
{
|
||||
minimum = ratio;
|
||||
}
|
||||
}
|
||||
|
||||
return minimum;
|
||||
}
|
||||
|
||||
private static Color CompositeOverBackground(Color foreground, Color background)
|
||||
{
|
||||
var alpha = foreground.A / 255d;
|
||||
var red = (byte)Math.Round((foreground.R * alpha) + (background.R * (1 - alpha)));
|
||||
var green = (byte)Math.Round((foreground.G * alpha) + (background.G * (1 - alpha)));
|
||||
var blue = (byte)Math.Round((foreground.B * alpha) + (background.B * (1 - alpha)));
|
||||
return Color.FromArgb(0xFF, red, green, blue);
|
||||
}
|
||||
|
||||
private static bool IsLightText(Color color)
|
||||
{
|
||||
return RelativeLuminance(color) >= LightTextLuminanceFloor;
|
||||
}
|
||||
|
||||
private static Color EnsureLightTone(Color color)
|
||||
{
|
||||
if (IsLightText(color))
|
||||
{
|
||||
return color;
|
||||
}
|
||||
|
||||
var white = Color.Parse("#FFFFFFFF");
|
||||
var low = 0d;
|
||||
var high = 1d;
|
||||
for (var i = 0; i < 16; i++)
|
||||
{
|
||||
var mid = (low + high) / 2d;
|
||||
var candidate = ColorMath.Blend(color, white, mid);
|
||||
if (IsLightText(candidate))
|
||||
{
|
||||
high = mid;
|
||||
}
|
||||
else
|
||||
{
|
||||
low = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return ColorMath.Blend(color, white, high);
|
||||
}
|
||||
|
||||
private static double RelativeLuminance(Color color)
|
||||
{
|
||||
var red = ToLinear(color.R / 255d);
|
||||
var green = ToLinear(color.G / 255d);
|
||||
var blue = ToLinear(color.B / 255d);
|
||||
return (0.2126 * red) + (0.7152 * green) + (0.0722 * blue);
|
||||
}
|
||||
|
||||
private static double ToLinear(double channel)
|
||||
{
|
||||
return channel <= 0.03928
|
||||
? channel / 12.92
|
||||
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
<Border x:Name="BackgroundMotionLayer"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.24"
|
||||
Opacity="0.20"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<Border.RenderTransform>
|
||||
<TransformGroup>
|
||||
@@ -34,21 +34,21 @@
|
||||
<Border x:Name="BackgroundTintLayer"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.20" />
|
||||
Opacity="0.16" />
|
||||
|
||||
<Border x:Name="BackgroundLightLayer"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.66">
|
||||
Opacity="0.62">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="1,1">
|
||||
<GradientStop Color="#5BFFFFFF"
|
||||
<GradientStop Color="#52FFFFFF"
|
||||
Offset="0" />
|
||||
<GradientStop Color="#1FFFFFFF"
|
||||
<GradientStop Color="#1AFFFFFF"
|
||||
Offset="0.30" />
|
||||
<GradientStop Color="#00000000"
|
||||
Offset="0.55" />
|
||||
Offset="0.56" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
@@ -56,13 +56,13 @@
|
||||
<Border x:Name="BackgroundShadeLayer"
|
||||
CornerRadius="30"
|
||||
ClipToBounds="True"
|
||||
Opacity="0.78">
|
||||
Opacity="0.74">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0"
|
||||
EndPoint="0,1">
|
||||
<GradientStop Color="#00040A16"
|
||||
Offset="0.50" />
|
||||
<GradientStop Color="#2E0B1C34"
|
||||
<GradientStop Color="#00000000"
|
||||
Offset="0.46" />
|
||||
<GradientStop Color="#2009182D"
|
||||
Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
@@ -73,83 +73,87 @@
|
||||
ClipToBounds="True" />
|
||||
|
||||
<Border x:Name="ContentPaddingBorder"
|
||||
Padding="16"
|
||||
Padding="18,16"
|
||||
Background="Transparent">
|
||||
<Grid x:Name="LayoutRoot">
|
||||
<Grid x:Name="ContentGrid"
|
||||
RowDefinitions="Auto,*,Auto"
|
||||
RowSpacing="2">
|
||||
RowSpacing="0">
|
||||
<Grid x:Name="TopRowGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="Auto,*">
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="6">
|
||||
<TextBlock x:Name="TemperatureTextBlock"
|
||||
Grid.Column="0"
|
||||
Text="26°"
|
||||
FontSize="96"
|
||||
Text="7°"
|
||||
FontSize="88"
|
||||
FontWeight="Light"
|
||||
FontFeatures="tnum"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,-1,0,0"
|
||||
Margin="-1,-7,0,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
|
||||
<Image x:Name="WeatherIconImage"
|
||||
Grid.Column="1"
|
||||
Width="76"
|
||||
Height="76"
|
||||
Grid.Column="2"
|
||||
Width="84"
|
||||
Height="84"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,-4,0,0"
|
||||
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="44"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="20°/28°"
|
||||
FontSize="46"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel x:Name="BottomInfoStack"
|
||||
Grid.Row="2"
|
||||
VerticalAlignment="Bottom"
|
||||
HorizontalAlignment="Left"
|
||||
Spacing="0"
|
||||
Margin="0,0,0,1">
|
||||
Margin="0,0,0,2">
|
||||
<StackPanel x:Name="ConditionStack"
|
||||
Orientation="Vertical"
|
||||
Spacing="2"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,0,1">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="雾"
|
||||
FontSize="26"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Left"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="11°/4°"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
HorizontalAlignment="Left"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</StackPanel>
|
||||
<Border x:Name="CityInfoBadge"
|
||||
Background="#24FFFFFF"
|
||||
CornerRadius="13"
|
||||
Padding="10,5"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Left">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
Spacing="0"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center">
|
||||
<fi:SymbolIcon x:Name="LocationIcon"
|
||||
Symbol="Location"
|
||||
FontSize="14"
|
||||
FontSize="12"
|
||||
IsVisible="False"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="CityTextBlock"
|
||||
Text="Beijing"
|
||||
FontSize="23"
|
||||
FontWeight="Medium"
|
||||
Text="北京"
|
||||
FontSize="17"
|
||||
FontWeight="Regular"
|
||||
HorizontalAlignment="Left"
|
||||
TextAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
@@ -162,4 +166,3 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
@@ -37,6 +37,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
string Tint,
|
||||
string PrimaryText,
|
||||
string SecondaryText,
|
||||
string TertiaryText,
|
||||
string ParticleColor);
|
||||
|
||||
private readonly record struct WeatherMotionProfile(
|
||||
@@ -406,7 +407,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
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 = "--°";
|
||||
RangeTextBlock.Text = L("weather.widget.range_unknown", "-- / --");
|
||||
RangeTextBlock.Text = "--°/--°";
|
||||
ApplyAdaptiveTypography();
|
||||
_latestSnapshot = null;
|
||||
}
|
||||
@@ -423,7 +424,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
L("weather.widget.location_unknown", "Unknown location"));
|
||||
ConditionTextBlock.Text = L("weather.widget.loading", "Loading...");
|
||||
TemperatureTextBlock.Text = "--°";
|
||||
RangeTextBlock.Text = L("weather.widget.range_unknown", "-- / --");
|
||||
RangeTextBlock.Text = "--°/--°";
|
||||
ApplyAdaptiveTypography();
|
||||
}
|
||||
|
||||
@@ -438,7 +439,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
L("weather.widget.location_unknown", "Unknown location"));
|
||||
ConditionTextBlock.Text = L("weather.widget.fetch_failed", "Weather fetch failed");
|
||||
TemperatureTextBlock.Text = "--°";
|
||||
RangeTextBlock.Text = L("weather.widget.range_unknown", "-- / --");
|
||||
RangeTextBlock.Text = "--°/--°";
|
||||
ApplyAdaptiveTypography();
|
||||
_latestSnapshot = null;
|
||||
}
|
||||
@@ -452,16 +453,32 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
BackgroundMotionLayer.Background = ResolveWeatherBackgroundBrush(kind, palette);
|
||||
BackgroundTintLayer.Background = CreateSolidBrush(palette.Tint);
|
||||
|
||||
var primary = CreateSolidBrush(palette.PrimaryText);
|
||||
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 backgroundSamples = WeatherTypographyAccessibility.BuildBackgroundSamples(
|
||||
palette.GradientFrom,
|
||||
palette.GradientTo,
|
||||
palette.Tint,
|
||||
isNightVisual);
|
||||
var primary = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.PrimaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagLargeTextContrast);
|
||||
var secondary = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.SecondaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xEE : (byte)0xE0);
|
||||
var tertiary = WeatherTypographyAccessibility.CreateReadableBrush(
|
||||
palette.TertiaryText,
|
||||
backgroundSamples,
|
||||
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
||||
isNightVisual ? (byte)0xD6 : (byte)0xC2);
|
||||
var particleBrush = ResolveParticleBrush(ToThemeKind(kind), palette.ParticleColor);
|
||||
LocationIcon.Foreground = primary;
|
||||
CityTextBlock.Foreground = cityBrush;
|
||||
LocationIcon.Foreground = tertiary;
|
||||
CityTextBlock.Foreground = tertiary;
|
||||
TemperatureTextBlock.Foreground = primary;
|
||||
ConditionTextBlock.Foreground = secondary;
|
||||
RangeTextBlock.Foreground = CreateSolidBrush(palette.PrimaryText, isNightVisual ? (byte)0xE0 : (byte)0xD4);
|
||||
RangeTextBlock.Foreground = secondary;
|
||||
|
||||
foreach (var particle in _particleVisuals)
|
||||
{
|
||||
@@ -569,6 +586,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
palette.Tint,
|
||||
palette.PrimaryText,
|
||||
palette.SecondaryText,
|
||||
palette.TertiaryText,
|
||||
palette.ParticleColor);
|
||||
}
|
||||
|
||||
@@ -643,7 +661,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
{
|
||||
if (!value.HasValue || double.IsNaN(value.Value) || double.IsInfinity(value.Value))
|
||||
{
|
||||
return "--";
|
||||
return "--°";
|
||||
}
|
||||
|
||||
var rounded = (int)Math.Round(value.Value, MidpointRounding.AwayFromZero);
|
||||
@@ -799,42 +817,85 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
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);
|
||||
var scaleX = Math.Clamp(innerWidth / 288d, 0.56, 2.2);
|
||||
var scaleY = Math.Clamp(innerHeight / 288d, 0.56, 2.2);
|
||||
var compactness = Math.Clamp((1.0 - scaleY) / 0.60, 0, 1);
|
||||
|
||||
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));
|
||||
ContentGrid.RowSpacing = Math.Clamp((2.8 - (compactness * 0.5)) * scaleY, 1, 6);
|
||||
TopRowGrid.ColumnSpacing = Math.Clamp(7.5 * scaleX, 4, 13);
|
||||
|
||||
var iconSize = Math.Clamp(74 * uiScale, 46, 96);
|
||||
var availableHeight = Math.Max(80, innerHeight - (ContentGrid.RowSpacing * 2));
|
||||
var topZoneRatio = Math.Clamp(0.55 + ((1 - compactness) * 0.04), 0.52, 0.60);
|
||||
var bottomZoneRatio = Math.Clamp(0.29 - (compactness * 0.03), 0.24, 0.32);
|
||||
var topZoneHeight = Math.Clamp(availableHeight * topZoneRatio, 48, availableHeight - 28);
|
||||
var bottomZoneHeight = Math.Clamp(availableHeight * bottomZoneRatio, 26, availableHeight - topZoneHeight - 6);
|
||||
if (topZoneHeight + bottomZoneHeight > availableHeight - 6)
|
||||
{
|
||||
bottomZoneHeight = Math.Max(24, availableHeight - topZoneHeight - 6);
|
||||
topZoneHeight = Math.Max(42, availableHeight - bottomZoneHeight - 6);
|
||||
}
|
||||
|
||||
if (ContentGrid.RowDefinitions.Count >= 3)
|
||||
{
|
||||
ContentGrid.RowDefinitions[0].Height = new GridLength(topZoneHeight, GridUnitType.Pixel);
|
||||
ContentGrid.RowDefinitions[1].Height = new GridLength(1, GridUnitType.Star);
|
||||
ContentGrid.RowDefinitions[2].Height = new GridLength(bottomZoneHeight, GridUnitType.Pixel);
|
||||
}
|
||||
|
||||
var topScaleH = Math.Clamp(topZoneHeight / 112d, 0.60, 2.2);
|
||||
var topScaleW = Math.Clamp(innerWidth / 288d, 0.60, 2.2);
|
||||
var topScale = Math.Clamp((topScaleH * 0.72) + (topScaleW * 0.28), 0.60, 2.2);
|
||||
var bottomScaleH = Math.Clamp(bottomZoneHeight / 94d, 0.52, 2.1);
|
||||
var bottomScale = Math.Clamp((bottomScaleH * 0.76) + (scaleX * 0.24), 0.52, 2.1);
|
||||
|
||||
var iconSize = Math.Clamp(
|
||||
Math.Max(52, topZoneHeight * 0.50) * (0.76 + (topScale * 0.24)),
|
||||
52,
|
||||
136);
|
||||
WeatherIconImage.Width = iconSize;
|
||||
WeatherIconImage.Height = iconSize;
|
||||
WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-5 * topScale, -12, 0), 0, 0);
|
||||
|
||||
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);
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(
|
||||
Math.Max(56, topZoneHeight * 0.74) * (0.72 + (topScale * 0.28)),
|
||||
52,
|
||||
156);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(310);
|
||||
TemperatureTextBlock.Margin = new Thickness(Math.Clamp(-2 * topScale, -5, 0), Math.Clamp(-8 * topScale, -14, -3), 0, 0);
|
||||
var temperatureMaxWidthLimit = Math.Max(90, innerWidth * 0.70);
|
||||
TemperatureTextBlock.MaxWidth = Math.Clamp(
|
||||
innerWidth - iconSize - TopRowGrid.ColumnSpacing - 8,
|
||||
90,
|
||||
temperatureMaxWidthLimit);
|
||||
|
||||
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);
|
||||
BottomInfoStack.Spacing = Math.Clamp(1.0 * bottomScale, 0, 3);
|
||||
BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(1.8 * scaleY, 0, 4));
|
||||
BottomInfoStack.MaxHeight = Math.Max(24, bottomZoneHeight);
|
||||
|
||||
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);
|
||||
var bottomTextMaxWidth = Math.Min(innerWidth, Math.Max(48, innerWidth * 0.78));
|
||||
ConditionStack.Spacing = Math.Clamp(1.0 + (1.6 * bottomScale), 1, 5);
|
||||
ConditionStack.Margin = new Thickness(0);
|
||||
var infoFontSize = Math.Clamp(
|
||||
Math.Max(12, bottomZoneHeight * 0.30) * (0.78 + (bottomScale * 0.22)),
|
||||
12,
|
||||
34);
|
||||
var infoFontWeight = ToVariableWeight(560);
|
||||
ConditionTextBlock.FontSize = infoFontSize;
|
||||
ConditionTextBlock.FontWeight = infoFontWeight;
|
||||
ConditionTextBlock.MaxWidth = bottomTextMaxWidth;
|
||||
RangeTextBlock.FontSize = infoFontSize;
|
||||
RangeTextBlock.FontWeight = infoFontWeight;
|
||||
RangeTextBlock.MaxWidth = bottomTextMaxWidth;
|
||||
|
||||
CityInfoBadge.Padding = new Thickness(0);
|
||||
CityInfoBadge.CornerRadius = new CornerRadius(0);
|
||||
LocationIcon.FontSize = Math.Clamp(
|
||||
Math.Max(8, bottomZoneHeight * 0.13) * (0.74 + (bottomScale * 0.20)),
|
||||
8,
|
||||
14);
|
||||
CityTextBlock.FontSize = infoFontSize;
|
||||
CityTextBlock.FontWeight = infoFontWeight;
|
||||
CityTextBlock.MaxWidth = bottomTextMaxWidth;
|
||||
}
|
||||
|
||||
private static double Lerp(double from, double to, double t)
|
||||
@@ -850,8 +911,11 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
|
||||
private void SetLoadingSkeleton(bool isLoading)
|
||||
{
|
||||
CityInfoBadge.Background = isLoading ? CreateSolidBrush("#24FFFFFF") : Brushes.Transparent;
|
||||
ConditionInfoBadge.Background = isLoading ? CreateSolidBrush("#1FFFFFFF") : Brushes.Transparent;
|
||||
var opacity = isLoading ? 0.58 : 1.0;
|
||||
TemperatureTextBlock.Opacity = opacity;
|
||||
ConditionTextBlock.Opacity = opacity;
|
||||
RangeTextBlock.Opacity = opacity;
|
||||
CityTextBlock.Opacity = isLoading ? 0.45 : 0.96;
|
||||
}
|
||||
|
||||
private static FontWeight ToVariableWeight(double weight)
|
||||
|
||||
@@ -1155,6 +1155,14 @@ public partial class MainWindow
|
||||
new ComponentScaleRule(WidthUnit: 4, HeightUnit: 3, MinScale: 1)); // 4x3, 8x6...
|
||||
}
|
||||
|
||||
if (string.Equals(componentId, BuiltInComponentIds.DesktopDailyPoetry, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Keep recommendation card at a 2:1 ratio with a minimum footprint of 4x2.
|
||||
return SnapSpanToScaleRules(
|
||||
span,
|
||||
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
@@ -2222,6 +2230,11 @@ public partial class MainWindow
|
||||
return Symbol.Play;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Apps;
|
||||
}
|
||||
|
||||
return Symbol.Apps;
|
||||
}
|
||||
|
||||
@@ -2252,6 +2265,11 @@ public partial class MainWindow
|
||||
return L("component_category.media", "Media");
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return L("component_category.info", "Info");
|
||||
}
|
||||
|
||||
return categoryId;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ ArchitecturesInstallIn64BitMode=x64compatible
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
Name: "chinesesimp"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
||||
|
||||
Reference in New Issue
Block a user