Compare commits

...

3 Commits

Author SHA1 Message Date
lincube
3b71486423 0.4.2
修复视频壁纸恶性bug,添加央广网组件。
2026-03-05 18:46:32 +08:00
lincube
8768fa1ed2 0.4.1
修天气,时钟,每日图片....
2026-03-05 17:09:46 +08:00
lincube
24f1b896e1 0.4.0 2026-03-05 16:34:22 +08:00
44 changed files with 6380 additions and 115 deletions

View File

@@ -19,7 +19,7 @@
<Application.Styles>
<sty:FluentAvaloniaTheme />
<mi:MaterialIconStyles />
<StyleInclude Source="avares://LanMountainDesktop/Styles/MotionTokens.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/FluttermotionToken.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />

View File

@@ -110,7 +110,7 @@ public class PanelIntroAnimationBehavior
var index = 0;
var timer = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = UiMotionTokens.StaggerStepInterval
Interval = FluttermotionToken.StaggerStepInterval
};
timer.Tick += (_, _) =>
{

View File

@@ -10,7 +10,7 @@ namespace LanMountainDesktop.Behaviors;
public class PopupIntroAnimationBehavior
{
private static readonly Easing StandardEasing = Easing.Parse(UiMotionTokens.StandardBezier);
private static readonly Easing StandardEasing = Easing.Parse(FluttermotionToken.StandardBezier);
public static readonly AttachedProperty<bool> IsEnabledProperty =
AvaloniaProperty.RegisterAttached<PopupIntroAnimationBehavior, Control, bool>("IsEnabled");
@@ -97,14 +97,14 @@ public class PopupIntroAnimationBehavior
var opacityAnimation = compositor.CreateScalarKeyFrameAnimation();
opacityAnimation.Target = nameof(compositionVisual.Opacity);
opacityAnimation.Duration = UiMotionTokens.Standard;
opacityAnimation.Duration = FluttermotionToken.Standard;
opacityAnimation.InsertKeyFrame(0f, 0f);
opacityAnimation.InsertKeyFrame(1f, 1f, StandardEasing);
compositionVisual.StartAnimation(nameof(compositionVisual.Opacity), opacityAnimation);
var scaleAnimation = compositor.CreateVector3DKeyFrameAnimation();
scaleAnimation.Target = nameof(compositionVisual.Scale);
scaleAnimation.Duration = UiMotionTokens.Standard;
scaleAnimation.Duration = FluttermotionToken.Standard;
scaleAnimation.InsertKeyFrame(0f, compositionVisual.Scale with { X = 0.94, Y = 0.94 });
scaleAnimation.InsertKeyFrame(1f, compositionVisual.Scale with { X = 1, Y = 1 }, StandardEasing);
compositionVisual.StartAnimation(nameof(compositionVisual.Scale), scaleAnimation);

View File

@@ -5,6 +5,7 @@ public static class BuiltInComponentIds
public const string Clock = "Clock";
public const string DesktopClock = "DesktopClock";
public const string DesktopWeatherClock = "DesktopWeatherClock";
public const string DesktopWorldClock = "DesktopWorldClock";
public const string DesktopTimer = "DesktopTimer";
public const string DesktopWeather = "DesktopWeather";
public const string DesktopHourlyWeather = "DesktopHourlyWeather";
@@ -28,6 +29,8 @@ public static class BuiltInComponentIds
public const string HolidayCalendar = "HolidayCalendar";
public const string DesktopDailyPoetry = "DesktopDailyPoetry";
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
public const string DesktopDailyWord = "DesktopDailyWord";
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
public const string DesktopWhiteboard = "DesktopWhiteboard";
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
public const string DesktopBrowser = "DesktopBrowser";

View File

@@ -48,6 +48,15 @@ public sealed class ComponentRegistry
MinHeightCells: 1,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopWorldClock,
"World Clock",
"Clock",
"Clock",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopTimer,
"Timer",
@@ -216,6 +225,24 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopDailyWord,
"Daily Word",
"Book",
"Info",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopCnrDailyNews,
"CNR Daily News",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopWhiteboard,
"Blackboard Portrait",

View File

@@ -12,6 +12,7 @@
"settings.nav.status_bar": "Status Bar",
"settings.nav.weather": "Weather",
"settings.nav.region": "Region",
"settings.nav.update": "Update",
"settings.nav.about": "About",
"settings.wallpaper.title": "Wallpaper",
"settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.",
@@ -162,6 +163,21 @@
"schedule.settings.delete": "Delete",
"schedule.settings.picker_title": "Select ClassIsland schedule file",
"schedule.settings.picker_file_type": "ClassIsland CSES schedule",
"worldclock.settings.title": "World Clock Settings",
"worldclock.settings.desc": "Choose a time zone for each of the four clocks.",
"worldclock.settings.clock_1": "Clock 1",
"worldclock.settings.clock_2": "Clock 2",
"worldclock.settings.clock_3": "Clock 3",
"worldclock.settings.clock_4": "Clock 4",
"worldclock.settings.second_mode_label": "Second Hand",
"worldclock.widget.today": "Today",
"worldclock.widget.yesterday": "Yesterday",
"worldclock.widget.tomorrow": "Tomorrow",
"worldclock.widget.offset_same": "0h",
"worldclock.widget.offset_ahead_hours": "Ahead {0}h",
"worldclock.widget.offset_behind_hours": "Behind {0}h",
"worldclock.widget.offset_ahead_hm": "Ahead {0}h {1}m",
"worldclock.widget.offset_behind_hm": "Behind {0}h {1}m",
"weather.widget.aqi_unknown": "AQI --",
"weather.widget.aqi_format": "AQI {0}",
"weather.widget.updated_format": "Updated {0:HH:mm}",
@@ -180,6 +196,38 @@
"settings.region.timezone_header": "Time Zone",
"settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.",
"settings.region.applied_format": "Language switched to: {0}",
"settings.update.title": "Update",
"settings.update.current_version_label": "Current Version",
"settings.update.latest_version_label": "Latest Release",
"settings.update.published_at_label": "Published At",
"settings.update.options_header": "Update Options",
"settings.update.options_desc": "Configure update checks and release channel.",
"settings.update.auto_check_toggle": "Automatically check for updates on startup",
"settings.update.include_prerelease_toggle": "Include prerelease versions",
"settings.update.channel_label": "Update Channel",
"settings.update.channel_stable": "Stable",
"settings.update.channel_preview": "Preview",
"settings.update.actions_header": "Update Actions",
"settings.update.actions_desc": "Check releases, download installer, and start update.",
"settings.update.check_button": "Check for Updates",
"settings.update.download_install_button": "Download & Install",
"settings.update.download_progress_idle": "Download progress: -",
"settings.update.download_progress_format": "Download progress: {0:F0}%",
"settings.update.status_ready": "Ready to check for updates.",
"settings.update.status_channel_changed": "Update channel changed. Please check again.",
"settings.update.status_channel_changed_format": "Update channel switched to {0}. Please check again.",
"settings.update.status_windows_only": "Automatic installer update is currently available only on Windows.",
"settings.update.status_checking": "Checking GitHub releases...",
"settings.update.status_check_failed_format": "Update check failed: {0}",
"settings.update.status_up_to_date": "You are already on the latest version.",
"settings.update.status_asset_missing": "A new release is available, but no compatible installer was found.",
"settings.update.status_available_format": "New version {0} is available. Click Download & Install.",
"settings.update.status_downloading": "Downloading installer...",
"settings.update.status_download_failed_format": "Download failed: {0}",
"settings.update.status_launching_installer": "Download complete. Launching installer...",
"settings.update.status_installer_missing": "Installer file was not found after download.",
"settings.update.status_installer_started": "Installer started. The app will close for update.",
"settings.update.status_launch_failed_format": "Failed to start installer: {0}",
"settings.about.title": "About",
"settings.about.version_format": "Version: {0}",
"settings.about.codename_format": "Code Name: {0}",
@@ -195,6 +243,7 @@
"common.night": "Night",
"common.back": "Back",
"common.close": "Close",
"common.unknown": "Unknown error",
"common.recommended": "Recommended",
"common.monet": "Monet",
"desktop.page_index_format": "Desktop {0}",
@@ -222,6 +271,7 @@
"component.lunar_calendar": "Lunar Calendar",
"component.desktop_clock": "Clock",
"component.weather_clock": "Weather Clock",
"component.world_clock": "World Clock",
"component.desktop_timer": "Timer",
"component.desktop_weather": "Weather",
"component.hourly_weather": "Hourly Weather",
@@ -232,6 +282,8 @@
"component.audio_recorder": "Recorder",
"component.daily_poetry": "Daily Poetry",
"component.daily_artwork": "Daily Artwork",
"component.daily_word": "Daily Word",
"component.cnr_daily_news": "CNR Headlines",
"component.whiteboard": "Blackboard (Portrait)",
"component.blackboard_landscape": "Blackboard (Landscape)",
"component.browser": "Browser",
@@ -244,6 +296,12 @@
"component.study_score_overview": "Study Score Overview",
"component.study_deduction_reasons": "Deduction Reasons",
"component.study_interrupt_density": "Interrupt Density",
"desktop_clock.settings.title": "Clock Settings",
"desktop_clock.settings.desc": "Choose the time zone for the single clock.",
"desktop_clock.settings.timezone_label": "Time Zone",
"desktop_clock.settings.second_mode_label": "Second Hand",
"clock.second_mode.tick": "Tick",
"clock.second_mode.sweep": "Sweep",
"poetry.widget.loading_content": "Loading poetry...",
"poetry.widget.loading_author": "Loading...",
"poetry.widget.fetch_failed": "Poetry fetch failed",
@@ -258,6 +316,24 @@
"artwork.widget.fallback_artist": "Recommendation service unavailable",
"artwork.widget.fallback_year": "Try again later",
"artwork.widget.unknown_artist": "Unknown artist",
"dailyword.widget.loading": "Loading...",
"dailyword.widget.loading_word": "daily word",
"dailyword.widget.loading_pronunciation": "Fetching pronunciation...",
"dailyword.widget.loading_meaning": "Fetching meaning...",
"dailyword.widget.loading_example": "Fetching example sentence...",
"dailyword.widget.loading_example_translation": "Loading...",
"dailyword.widget.fetch_failed": "Daily word fetch failed",
"dailyword.widget.fallback_word": "daily word",
"dailyword.widget.fallback_pronunciation": "Pronunciation unavailable",
"dailyword.widget.fallback_meaning": "Youdao dictionary is temporarily unavailable.",
"dailyword.widget.fallback_example": "Tap the refresh button and try again.",
"dailyword.widget.fallback_example_translation": "It will retry when network recovers.",
"cnrnews.widget.loading": "Loading...",
"cnrnews.widget.loading_title": "Fetching CNR headlines",
"cnrnews.widget.loading_subtitle": "Please wait",
"cnrnews.widget.fetch_failed": "News fetch failed",
"cnrnews.widget.fallback_title": "CNR news is temporarily unavailable",
"cnrnews.widget.fallback_subtitle": "Tap refresh and try again",
"artwork.settings.title": "Daily Artwork Settings",
"artwork.settings.desc": "Switch the data source used by Daily Artwork.",
"artwork.settings.source_label": "Mirror Source",

View File

@@ -12,6 +12,7 @@
"settings.nav.status_bar": "状态栏",
"settings.nav.weather": "天气",
"settings.nav.region": "地区",
"settings.nav.update": "更新",
"settings.nav.about": "关于",
"settings.wallpaper.title": "壁纸",
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
@@ -162,6 +163,21 @@
"schedule.settings.delete": "删除",
"schedule.settings.picker_title": "选择 ClassIsland 课表文件",
"schedule.settings.picker_file_type": "ClassIsland CSES 课表",
"worldclock.settings.title": "世界时钟设置",
"worldclock.settings.desc": "分别为四个时钟选择时区。",
"worldclock.settings.clock_1": "时钟 1",
"worldclock.settings.clock_2": "时钟 2",
"worldclock.settings.clock_3": "时钟 3",
"worldclock.settings.clock_4": "时钟 4",
"worldclock.settings.second_mode_label": "秒针方式",
"worldclock.widget.today": "今天",
"worldclock.widget.yesterday": "昨天",
"worldclock.widget.tomorrow": "明天",
"worldclock.widget.offset_same": "0 小时",
"worldclock.widget.offset_ahead_hours": "早 {0} 小时",
"worldclock.widget.offset_behind_hours": "晚 {0} 小时",
"worldclock.widget.offset_ahead_hm": "早 {0} 小时 {1} 分",
"worldclock.widget.offset_behind_hm": "晚 {0} 小时 {1} 分",
"weather.widget.aqi_unknown": "AQI --",
"weather.widget.aqi_format": "AQI {0}",
"weather.widget.updated_format": "更新于 {0:HH:mm}",
@@ -180,6 +196,38 @@
"settings.region.timezone_header": "时区",
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
"settings.region.applied_format": "语言已切换为:{0}",
"settings.update.title": "更新",
"settings.update.current_version_label": "当前版本",
"settings.update.latest_version_label": "最新发布",
"settings.update.published_at_label": "发布时间",
"settings.update.options_header": "更新选项",
"settings.update.options_desc": "配置更新检查与发布通道。",
"settings.update.auto_check_toggle": "启动时自动检查更新",
"settings.update.include_prerelease_toggle": "包含预发布版本",
"settings.update.channel_label": "更新通道",
"settings.update.channel_stable": "正式版",
"settings.update.channel_preview": "预览版",
"settings.update.actions_header": "更新操作",
"settings.update.actions_desc": "检查发布、下载安装包并启动更新。",
"settings.update.check_button": "检查更新",
"settings.update.download_install_button": "下载并安装",
"settings.update.download_progress_idle": "下载进度:-",
"settings.update.download_progress_format": "下载进度:{0:F0}%",
"settings.update.status_ready": "可开始检查更新。",
"settings.update.status_channel_changed": "更新通道已变更,请重新检查更新。",
"settings.update.status_channel_changed_format": "更新通道已切换为 {0},请重新检查更新。",
"settings.update.status_windows_only": "自动安装包更新当前仅支持 Windows。",
"settings.update.status_checking": "正在检查 GitHub Release...",
"settings.update.status_check_failed_format": "检查更新失败:{0}",
"settings.update.status_up_to_date": "当前已是最新版本。",
"settings.update.status_asset_missing": "发现新版本,但未找到兼容的安装包。",
"settings.update.status_available_format": "发现新版本 {0},点击“下载并安装”继续。",
"settings.update.status_downloading": "正在下载安装包...",
"settings.update.status_download_failed_format": "下载失败:{0}",
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
"settings.update.status_installer_missing": "下载后未找到安装包文件。",
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
"settings.update.status_launch_failed_format": "启动安装程序失败:{0}",
"settings.about.title": "关于",
"settings.about.version_format": "版本号: {0}",
"settings.about.codename_format": "版本代号: {0}",
@@ -195,6 +243,7 @@
"common.night": "夜间",
"common.back": "返回",
"common.close": "关闭",
"common.unknown": "未知错误",
"common.recommended": "推荐",
"common.monet": "莫奈",
"desktop.page_index_format": "桌面 {0}",
@@ -222,6 +271,7 @@
"component.lunar_calendar": "农历",
"component.desktop_clock": "时钟",
"component.weather_clock": "天气时钟",
"component.world_clock": "世界时钟",
"component.desktop_timer": "计时器",
"component.desktop_weather": "天气",
"component.hourly_weather": "小时天气",
@@ -232,6 +282,8 @@
"component.audio_recorder": "录音",
"component.daily_poetry": "每日诗词",
"component.daily_artwork": "每日名画",
"component.daily_word": "每日单词",
"component.cnr_daily_news": "央广网头条",
"component.whiteboard": "竖向小黑板",
"component.blackboard_landscape": "横向小黑板",
"component.browser": "浏览器",
@@ -244,6 +296,12 @@
"component.study_score_overview": "自习评分总览",
"component.study_deduction_reasons": "扣分原因",
"component.study_interrupt_density": "打断密度",
"desktop_clock.settings.title": "时钟设置",
"desktop_clock.settings.desc": "为单时钟选择时区。",
"desktop_clock.settings.timezone_label": "时区",
"desktop_clock.settings.second_mode_label": "秒针方式",
"clock.second_mode.tick": "跳针",
"clock.second_mode.sweep": "扫针",
"poetry.widget.loading_content": "正在加载诗词",
"poetry.widget.loading_author": "加载中",
"poetry.widget.fetch_failed": "诗词获取失败",
@@ -258,6 +316,24 @@
"artwork.widget.fallback_artist": "推荐服务不可用",
"artwork.widget.fallback_year": "稍后重试",
"artwork.widget.unknown_artist": "未知作者",
"dailyword.widget.loading": "加载中...",
"dailyword.widget.loading_word": "每日单词",
"dailyword.widget.loading_pronunciation": "正在获取发音",
"dailyword.widget.loading_meaning": "正在获取释义",
"dailyword.widget.loading_example": "正在获取例句",
"dailyword.widget.loading_example_translation": "加载中",
"dailyword.widget.fetch_failed": "每日单词获取失败",
"dailyword.widget.fallback_word": "每日单词",
"dailyword.widget.fallback_pronunciation": "发音暂不可用",
"dailyword.widget.fallback_meaning": "有道词典暂不可用",
"dailyword.widget.fallback_example": "请点击右上角刷新重试",
"dailyword.widget.fallback_example_translation": "网络恢复后将自动更新",
"cnrnews.widget.loading": "加载中...",
"cnrnews.widget.loading_title": "正在获取新闻热点",
"cnrnews.widget.loading_subtitle": "请稍候",
"cnrnews.widget.fetch_failed": "新闻获取失败",
"cnrnews.widget.fallback_title": "央广网新闻暂不可用",
"cnrnews.widget.fallback_subtitle": "点击右上角稍后重试",
"artwork.settings.title": "每日图片设置",
"artwork.settings.desc": "切换每日图片的数据源。",
"artwork.settings.source_label": "镜像源",

View File

@@ -48,6 +48,12 @@ public sealed class AppSettingsSnapshot
public bool AutoStartWithWindows { get; set; }
public bool AutoCheckUpdates { get; set; } = true;
public bool IncludePrereleaseUpdates { get; set; }
public string UpdateChannel { get; set; } = string.Empty;
public List<string> TopStatusComponentIds { get; set; } = [];
public List<string> PinnedTaskbarActions { get; set; } =
@@ -80,6 +86,18 @@ public sealed class AppSettingsSnapshot
public bool StudyEnvironmentShowDbfs { get; set; }
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
public string DesktopClockSecondHandMode { get; set; } = "Tick";
public List<string> WorldClockTimeZoneIds { get; set; } =
[
"China Standard Time",
"GMT Standard Time",
"AUS Eastern Standard Time",
"Eastern Standard Time"
];
public string WorldClockSecondHandMode { get; set; } = "Tick";
public AppSettingsSnapshot Clone()
{
var clone = (AppSettingsSnapshot)MemberwiseClone();
@@ -135,6 +153,10 @@ public sealed class AppSettingsSnapshot
}
clone.ImportedClassSchedules = schedules;
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
? new List<string>(WorldClockTimeZoneIds)
: [];
return clone;
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
@@ -20,3 +21,27 @@ public sealed record DailyPoetrySnapshot(
string? Author,
string? Category,
DateTimeOffset FetchedAt);
public sealed record DailyNewsItemSnapshot(
string Title,
string? Summary,
string Url,
string? ImageUrl,
string? PublishTime);
public sealed record DailyNewsSnapshot(
string Provider,
string Source,
IReadOnlyList<DailyNewsItemSnapshot> Items,
DateTimeOffset FetchedAt);
public sealed record DailyWordSnapshot(
string Provider,
string Word,
string? UkPronunciation,
string? UsPronunciation,
string Meaning,
string? ExampleSentence,
string? ExampleTranslation,
string? SourceUrl,
DateTimeOffset FetchedAt);

View File

@@ -0,0 +1,21 @@
using System;
namespace LanMountainDesktop.Services;
public static class ClockSecondHandMode
{
public const string Tick = "Tick";
public const string Sweep = "Sweep";
public static string Normalize(string? mode)
{
return string.Equals(mode?.Trim(), Sweep, StringComparison.OrdinalIgnoreCase)
? Sweep
: Tick;
}
public static bool IsSweep(string? mode)
{
return string.Equals(Normalize(mode), Sweep, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,482 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
public sealed record GitHubReleaseAsset(
string Name,
string BrowserDownloadUrl,
long SizeBytes);
public sealed record GitHubReleaseInfo(
string TagName,
string Name,
bool IsPrerelease,
bool IsDraft,
DateTimeOffset PublishedAt,
IReadOnlyList<GitHubReleaseAsset> Assets);
public sealed record UpdateCheckResult(
bool Success,
bool IsUpdateAvailable,
string CurrentVersionText,
string LatestVersionText,
GitHubReleaseInfo? Release,
GitHubReleaseAsset? PreferredAsset,
string? ErrorMessage);
public sealed record UpdateDownloadResult(
bool Success,
string? FilePath,
string? ErrorMessage);
public sealed class GitHubReleaseUpdateService : IDisposable
{
private const string GithubApiVersion = "2022-11-28";
private readonly string _owner;
private readonly string _repo;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
public GitHubReleaseUpdateService(
string owner,
string repo,
HttpClient? httpClient = null)
{
_owner = owner?.Trim() ?? string.Empty;
_repo = repo?.Trim() ?? string.Empty;
if (httpClient is null)
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownsHttpClient = false;
}
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
}
if (!_httpClient.DefaultRequestHeaders.Accept.Any())
{
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
}
if (!_httpClient.DefaultRequestHeaders.Contains("X-GitHub-Api-Version"))
{
_httpClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", GithubApiVersion);
}
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
public async Task<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "Repository information is not configured.");
}
try
{
var release = includePrerelease
? await GetLatestReleaseIncludingPrereleaseAsync(cancellationToken)
: await GetLatestStableReleaseAsync(cancellationToken);
if (release is null)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "No release data was returned from GitHub.");
}
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
? parsedTagVersion.ToString(3)
: release.TagName;
var isUpdateAvailable = parsedTagVersion is not null && parsedTagVersion > currentVersion;
var preferredAsset = isUpdateAvailable
? SelectPreferredInstallerAsset(release.Assets)
: null;
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: isUpdateAvailable,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: ex.Message);
}
}
public async Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
if (asset is null)
{
return new UpdateDownloadResult(false, null, "Asset is null.");
}
if (string.IsNullOrWhiteSpace(asset.BrowserDownloadUrl))
{
return new UpdateDownloadResult(false, null, "Asset download url is empty.");
}
if (string.IsNullOrWhiteSpace(destinationFilePath))
{
return new UpdateDownloadResult(false, null, "Destination file path is empty.");
}
try
{
var directory = Path.GetDirectoryName(destinationFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
using var response = await _httpClient.GetAsync(
asset.BrowserDownloadUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new UpdateDownloadResult(
false,
null,
$"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}");
}
var contentLength = response.Content.Headers.ContentLength ??
(asset.SizeBytes > 0 ? asset.SizeBytes : -1);
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var destinationStream = File.Create(destinationFilePath);
var buffer = new byte[81920];
long totalRead = 0;
int read;
while ((read = await sourceStream.ReadAsync(buffer, cancellationToken)) > 0)
{
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
totalRead += read;
if (contentLength > 0)
{
progress?.Report(Math.Clamp(totalRead / (double)contentLength, 0d, 1d));
}
}
progress?.Report(1d);
return new UpdateDownloadResult(true, destinationFilePath, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new UpdateDownloadResult(false, null, ex.Message);
}
}
private async Task<GitHubReleaseInfo?> GetLatestStableReleaseAsync(CancellationToken cancellationToken)
{
var url = $"https://api.github.com/repos/{_owner}/{_repo}/releases/latest";
var responseText = await GetResponseTextAsync(url, cancellationToken);
using var document = JsonDocument.Parse(responseText);
return ParseRelease(document.RootElement);
}
private async Task<GitHubReleaseInfo?> GetLatestReleaseIncludingPrereleaseAsync(CancellationToken cancellationToken)
{
var url = $"https://api.github.com/repos/{_owner}/{_repo}/releases?per_page=20";
var responseText = await GetResponseTextAsync(url, cancellationToken);
using var document = JsonDocument.Parse(responseText);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var item in document.RootElement.EnumerateArray())
{
var release = ParseRelease(item);
if (release is null || release.IsDraft)
{
continue;
}
return release;
}
return null;
}
private async Task<string> GetResponseTextAsync(string url, CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(url, cancellationToken);
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"GitHub API request failed with HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
return responseText;
}
private static GitHubReleaseInfo? ParseRelease(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
var tagName = element.TryGetProperty("tag_name", out var tagNode)
? tagNode.GetString()?.Trim()
: null;
if (string.IsNullOrWhiteSpace(tagName))
{
return null;
}
var name = element.TryGetProperty("name", out var nameNode)
? nameNode.GetString()?.Trim() ?? string.Empty
: string.Empty;
var isPrerelease = element.TryGetProperty("prerelease", out var prereleaseNode) &&
prereleaseNode.ValueKind == JsonValueKind.True;
var isDraft = element.TryGetProperty("draft", out var draftNode) &&
draftNode.ValueKind == JsonValueKind.True;
var publishedAt = DateTimeOffset.MinValue;
if (element.TryGetProperty("published_at", out var publishedAtNode) &&
publishedAtNode.ValueKind == JsonValueKind.String)
{
var publishedAtText = publishedAtNode.GetString();
if (!string.IsNullOrWhiteSpace(publishedAtText) &&
DateTimeOffset.TryParse(
publishedAtText,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal,
out var parsedPublishedAt))
{
publishedAt = parsedPublishedAt;
}
}
var assets = new List<GitHubReleaseAsset>();
if (element.TryGetProperty("assets", out var assetsNode) && assetsNode.ValueKind == JsonValueKind.Array)
{
foreach (var assetNode in assetsNode.EnumerateArray())
{
if (assetNode.ValueKind != JsonValueKind.Object)
{
continue;
}
var assetName = assetNode.TryGetProperty("name", out var assetNameNode)
? assetNameNode.GetString()?.Trim()
: null;
var browserDownloadUrl = assetNode.TryGetProperty("browser_download_url", out var urlNode)
? urlNode.GetString()?.Trim()
: null;
var sizeBytes = assetNode.TryGetProperty("size", out var sizeNode) && sizeNode.TryGetInt64(out var size)
? size
: 0L;
if (string.IsNullOrWhiteSpace(assetName) || string.IsNullOrWhiteSpace(browserDownloadUrl))
{
continue;
}
assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes));
}
}
return new GitHubReleaseInfo(tagName, name, isPrerelease, isDraft, publishedAt, assets);
}
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
{
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
{
return null;
}
var architectureToken = RuntimeInformation.OSArchitecture switch
{
Architecture.Arm64 => "arm64",
Architecture.X86 => "x86",
_ => "x64"
};
var ranked = assets
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.ToList();
return ranked.FirstOrDefault(x => x.Score > 0).Asset;
}
private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken)
{
if (string.IsNullOrWhiteSpace(assetName))
{
return 0;
}
var score = 0;
if (assetName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
score += 200;
}
else if (assetName.EndsWith(".msi", StringComparison.OrdinalIgnoreCase))
{
score += 160;
}
else
{
return 0;
}
if (assetName.Contains("setup", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("installer", StringComparison.OrdinalIgnoreCase))
{
score += 60;
}
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("x86", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
{
score -= 30;
}
if (assetName.Contains("portable", StringComparison.OrdinalIgnoreCase))
{
score -= 40;
}
return score;
}
private static bool TryParseVersion(string? value, out Version? version)
{
version = null;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = value.Trim();
if (normalized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[1..];
}
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
if (separatorIndex > 0)
{
normalized = normalized[..separatorIndex];
}
if (!Version.TryParse(normalized, out var parsed))
{
return false;
}
version = NormalizeVersion(parsed);
return true;
}
private static Version NormalizeVersion(Version version)
{
var major = Math.Max(0, version.Major);
var minor = Math.Max(0, version.Minor);
var build = Math.Max(0, version.Build);
return new Version(major, minor, build);
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
{
return value;
}
return value[..maxLength];
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
@@ -14,6 +15,14 @@ public sealed record DailyPoetryQuery(
string? Locale = null,
bool ForceRefresh = false);
public sealed record DailyNewsQuery(
string? Locale = null,
bool ForceRefresh = false);
public sealed record DailyWordQuery(
string? Locale = null,
bool ForceRefresh = false);
public sealed record RecommendationQueryResult<T>(
bool Success,
T? Data,
@@ -46,11 +55,154 @@ public sealed record RecommendationApiOptions
public string DomesticArtworkHost { get; init; } = "https://cn.bing.com";
public string CnrDailyNewsListUrl { get; init; } = "https://www.cnr.cn/newscenter/native/gd/";
public IReadOnlyList<string> CnrDailyNewsRssFeedUrls { get; init; } =
[
"https://www.cnr.cn/rss.xml",
"https://news.cnr.cn/rss.xml",
"https://www.cnr.cn/newscenter/native/gd/rss.xml",
"https://news.cnr.cn/native/gd/rss.xml"
];
public string YoudaoDictionaryApiTemplate { get; init; } = "https://dict.youdao.com/jsonapi?q={0}";
public string YoudaoDictionaryWordPageTemplate { get; init; } = "https://dict.youdao.com/w/eng/{0}/";
public IReadOnlyList<string> YoudaoDailyWordCandidates { get; init; } =
[
"illustrate",
"resilient",
"meticulous",
"coherent",
"subtle",
"constrain",
"tangible",
"versatile",
"pragmatic",
"derive",
"intricate",
"notion",
"facilitate",
"sustain",
"clarify",
"convey",
"nuance",
"transform",
"navigate",
"align",
"elevate",
"refine",
"vivid",
"compile",
"inspect",
"aggregate",
"optimize",
"resonate",
"persist",
"adapt",
"emerge",
"concrete",
"articulate",
"validate",
"insight",
"concise",
"robust",
"reliable",
"spectrum",
"landscape",
"context",
"constraint",
"iterative",
"foundation",
"priority",
"workflow",
"synthesize",
"anchor",
"precision",
"momentum",
"integrate",
"observe",
"structure",
"essence",
"framework",
"drift",
"discern",
"compose",
"modulate",
"stability",
"trajectory",
"analyze",
"diagnose",
"mitigate",
"transparent",
"progressive",
"boundary",
"allocate",
"evaluate",
"reconcile",
"strategic",
"holistic",
"incremental",
"temporal",
"semantic",
"parallel",
"explicit",
"objective",
"capacity",
"durable",
"scalable",
"residual",
"verify",
"discover",
"curate",
"invoke",
"artistry",
"sincere",
"substantive",
"deliberate",
"dynamic",
"intentional",
"initiative",
"evidence",
"infuse",
"harmony",
"vitality",
"polish",
"portrait",
"rhythm",
"accent",
"gradient",
"palette",
"pattern",
"eclipse",
"horizon",
"luminous",
"serene",
"vantage",
"kinetic",
"refactor",
"calibrate",
"orchestrate",
"prototype",
"curiosity",
"discipline",
"inscribe",
"engage",
"spark",
"zenith",
"clarity",
"resolve",
"aptitude"
];
public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(20);
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8);
public int DefaultArtworkCandidateCount { get; init; } = 50;
public int DefaultDailyNewsCount { get; init; } = 2;
}
public interface IRecommendationInfoService
@@ -63,5 +215,13 @@ public interface IRecommendationInfoService
DailyPoetryQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<DailyNewsSnapshot>> GetDailyNewsAsync(
DailyNewsQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<DailyWordSnapshot>> GetDailyWordAsync(
DailyWordQuery query,
CancellationToken cancellationToken = default);
void ClearCache();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace LanMountainDesktop.Services;
public static class WorldClockTimeZoneCatalog
{
public const int ClockCount = 4;
private static readonly string[][] DefaultTimeZoneCandidates =
[
["China Standard Time", "Asia/Shanghai"],
["GMT Standard Time", "Europe/London", "UTC"],
["AUS Eastern Standard Time", "Australia/Sydney"],
["Eastern Standard Time", "America/New_York"]
];
private static readonly Dictionary<string, string[]> CrossPlatformAliases =
new(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = ["Asia/Shanghai"],
["Asia/Shanghai"] = ["China Standard Time"],
["GMT Standard Time"] = ["Europe/London", "UTC"],
["Europe/London"] = ["GMT Standard Time", "UTC"],
["AUS Eastern Standard Time"] = ["Australia/Sydney"],
["Australia/Sydney"] = ["AUS Eastern Standard Time"],
["Eastern Standard Time"] = ["America/New_York"],
["America/New_York"] = ["Eastern Standard Time"],
["UTC"] = ["Etc/UTC"],
["Etc/UTC"] = ["UTC"],
["Tokyo Standard Time"] = ["Asia/Tokyo"],
["Asia/Tokyo"] = ["Tokyo Standard Time"]
};
public static IReadOnlyList<string> NormalizeTimeZoneIds(IEnumerable<string>? configuredIds)
{
var available = TimeZoneInfo.GetSystemTimeZones();
return NormalizeTimeZoneIds(configuredIds, available);
}
public static IReadOnlyList<string> NormalizeTimeZoneIds(
IEnumerable<string>? configuredIds,
IReadOnlyList<TimeZoneInfo> availableTimeZones)
{
var availableById = BuildAvailableTimeZoneLookup(availableTimeZones);
var requested = configuredIds?
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.ToList() ?? [];
var normalized = new List<string>(ClockCount);
for (var index = 0; index < ClockCount; index++)
{
var requestedId = index < requested.Count ? requested[index] : null;
var resolved = ResolveAvailableId(requestedId, availableById) ??
ResolveDefaultId(index, availableById) ??
TimeZoneInfo.Local.Id;
normalized.Add(resolved);
}
return normalized;
}
public static TimeZoneInfo ResolveTimeZoneOrLocal(string? timeZoneId)
{
if (TryResolveTimeZone(timeZoneId, out var resolved))
{
return resolved;
}
return TimeZoneInfo.Local;
}
private static Dictionary<string, TimeZoneInfo> BuildAvailableTimeZoneLookup(
IReadOnlyList<TimeZoneInfo> availableTimeZones)
{
return availableTimeZones
.Where(zone => !string.IsNullOrWhiteSpace(zone.Id))
.GroupBy(zone => zone.Id, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
}
private static string? ResolveDefaultId(
int slotIndex,
IReadOnlyDictionary<string, TimeZoneInfo> availableById)
{
var clampedIndex = Math.Clamp(slotIndex, 0, ClockCount - 1);
foreach (var candidateId in DefaultTimeZoneCandidates[clampedIndex])
{
var resolved = ResolveAvailableId(candidateId, availableById);
if (!string.IsNullOrWhiteSpace(resolved))
{
return resolved;
}
}
return null;
}
private static string? ResolveAvailableId(
string? candidateId,
IReadOnlyDictionary<string, TimeZoneInfo> availableById)
{
if (string.IsNullOrWhiteSpace(candidateId))
{
return null;
}
var normalizedCandidate = candidateId.Trim();
if (availableById.TryGetValue(normalizedCandidate, out var exact))
{
return exact.Id;
}
if (TryResolveTimeZone(normalizedCandidate, out var resolvedZone) &&
availableById.TryGetValue(resolvedZone.Id, out var resolved))
{
return resolved.Id;
}
if (!CrossPlatformAliases.TryGetValue(normalizedCandidate, out var aliases))
{
return null;
}
foreach (var alias in aliases)
{
if (availableById.TryGetValue(alias, out var aliasZone))
{
return aliasZone.Id;
}
if (TryResolveTimeZone(alias, out var aliasResolvedZone) &&
availableById.TryGetValue(aliasResolvedZone.Id, out var mappedAlias))
{
return mappedAlias.Id;
}
}
return null;
}
private static bool TryResolveTimeZone(string? timeZoneId, out TimeZoneInfo timeZone)
{
timeZone = TimeZoneInfo.Local;
if (string.IsNullOrWhiteSpace(timeZoneId))
{
return false;
}
var normalizedId = timeZoneId.Trim();
if (TryFindTimeZone(normalizedId, out timeZone))
{
return true;
}
if (!CrossPlatformAliases.TryGetValue(normalizedId, out var aliases))
{
return false;
}
foreach (var alias in aliases)
{
if (TryFindTimeZone(alias, out timeZone))
{
return true;
}
}
return false;
}
private static bool TryFindTimeZone(string timeZoneId, out TimeZoneInfo timeZone)
{
timeZone = TimeZoneInfo.Local;
try
{
timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
return true;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,12 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Styles.Resources>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Fast">0:0:0.12</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Standard">0:0:0.16</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Slow">0:0:0.20</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Page">0:0:0.24</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Intro">0:0:0.32</x:TimeSpan>
<x:Double x:Key="FluttermotionToken.BackdropBlurRadiusStrong">30</x:Double>
</Styles.Resources>
</Styles>

View File

@@ -25,9 +25,9 @@
<Setter Property="Padding" Value="16,10" />
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
<DoubleTransition Property="Opacity" Duration="0:0:0.12" />
<BrushTransition Property="Background" Duration="0:0:0.12" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
</Transitions>
</Setter>
</Style>
@@ -150,7 +150,7 @@
<Setter Property="BoxShadow" Value="0 12 32 #33000000" />
<Setter Property="Transitions">
<Transitions>
<ThicknessTransition Property="Padding" Duration="0:0:0.2" Easing="QuarticEaseOut" />
<ThicknessTransition Property="Padding" Duration="{StaticResource FluttermotionToken.Duration.Slow}" Easing="QuarticEaseOut" />
</Transitions>
</Setter>
</Style>

View File

@@ -1,14 +0,0 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Styles.Resources>
<x:String x:Key="MotionEasingStandard">0.22,1,0.36,1</x:String>
<x:String x:Key="MotionDurationFast">0:0:0.12</x:String>
<x:String x:Key="MotionDurationStandard">0:0:0.16</x:String>
<x:String x:Key="MotionDurationSlow">0:0:0.20</x:String>
<x:String x:Key="MotionDurationPage">0:0:0.24</x:String>
<x:String x:Key="MotionDurationIntro">0:0:0.32</x:String>
<x:Double x:Key="MotionBackdropBlurRadiusStrong">30</x:Double>
</Styles.Resources>
</Styles>

View File

@@ -21,7 +21,7 @@
</Setter>
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
<Style.Animations>
<Animation Duration="0:0:0.32"
<Animation Duration="{StaticResource FluttermotionToken.Duration.Intro}"
FillMode="Both"
Easing="0.22,1,0.36,1">
<KeyFrame Cue="0%">
@@ -53,9 +53,9 @@
<Setter Property="MinHeight" Value="34" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
<BrushTransition Property="BorderBrush" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
</Transitions>
</Setter>
</Style>
@@ -74,8 +74,8 @@
<Style Selector="Grid.settings-scope ComboBox">
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.12" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" Easing="0.22,1,0.36,1" />
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
</Transitions>
</Setter>
</Style>
@@ -87,8 +87,8 @@
<Style Selector="Grid.settings-scope ToggleSwitch">
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="0.22,1,0.36,1" />
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
</Transitions>
</Setter>
</Style>

View File

@@ -2,7 +2,7 @@ using System;
namespace LanMountainDesktop.Theme;
public static class UiMotionTokens
public static class FluttermotionToken
{
public static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(120);
public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(160);

View File

@@ -1,5 +1,6 @@
using System;
using System.Globalization;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
@@ -12,6 +13,40 @@ namespace LanMountainDesktop.Views.Components;
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private static readonly IReadOnlyDictionary<string, string> ZhCityNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "\u5317\u4EAC",
["Asia/Shanghai"] = "\u5317\u4EAC",
["GMT Standard Time"] = "\u4F26\u6566",
["Europe/London"] = "\u4F26\u6566",
["AUS Eastern Standard Time"] = "\u6089\u5C3C",
["Australia/Sydney"] = "\u6089\u5C3C",
["Eastern Standard Time"] = "\u7EBD\u7EA6",
["America/New_York"] = "\u7EBD\u7EA6",
["Tokyo Standard Time"] = "\u4E1C\u4EAC",
["Asia/Tokyo"] = "\u4E1C\u4EAC",
["UTC"] = "\u534F\u8C03\u4E16\u754C\u65F6",
["Etc/UTC"] = "\u534F\u8C03\u4E16\u754C\u65F6"
};
private static readonly IReadOnlyDictionary<string, string> EnCityNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "Beijing",
["Asia/Shanghai"] = "Beijing",
["GMT Standard Time"] = "London",
["Europe/London"] = "London",
["AUS Eastern Standard Time"] = "Sydney",
["Australia/Sydney"] = "Sydney",
["Eastern Standard Time"] = "New York",
["America/New_York"] = "New York",
["Tokyo Standard Time"] = "Tokyo",
["Asia/Tokyo"] = "Tokyo",
["UTC"] = "UTC",
["Etc/UTC"] = "UTC"
};
private readonly DispatcherTimer _timer = new()
{
Interval = TimeSpan.FromSeconds(1)
@@ -20,11 +55,16 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
private const double DialSize = 258;
private const double Center = DialSize / 2;
private readonly AppSettingsService _settingsService = new();
private readonly LocalizationService _localizationService = new();
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 48;
private bool _dialInitialized;
private bool _handsInitialized;
private bool? _isNightModeApplied;
private TimeZoneInfo _clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal("China Standard Time");
private string _languageCode = "zh-CN";
private string _secondHandMode = ClockSecondHandMode.Tick;
private readonly Line _hourHandLine = CreateHandLine("#1A2A46", 12);
private readonly Line _minuteHandLine = CreateHandLine("#29406B", 8);
private readonly Line _secondHandLine = CreateHandLine("#1A74F2", 4);
@@ -40,6 +80,8 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
InitializeDialIfNeeded();
InitializeHandsIfNeeded();
LoadClockSettings();
ApplySecondHandTimerInterval();
UpdateClock();
}
@@ -62,10 +104,19 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
_timeZoneService = null;
}
public void RefreshFromSettings()
{
LoadClockSettings();
ApplySecondHandTimerInterval();
UpdateClock();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
InitializeDialIfNeeded();
InitializeHandsIfNeeded();
LoadClockSettings();
ApplySecondHandTimerInterval();
UpdateClock();
_timer.Start();
}
@@ -187,17 +238,22 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
{
ApplyModeVisualIfNeeded();
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
var hourAngle = (now.Hour % 12 + now.Minute / 60d + now.Second / 3600d) * 30d;
var minuteAngle = (now.Minute + now.Second / 60d) * 6d;
var secondAngle = (now.Second + now.Millisecond / 1000d) * 6d;
var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _clockTimeZone);
var secondValue = ClockSecondHandMode.IsSweep(_secondHandMode)
? now.Second + now.Millisecond / 1000d
: now.Second;
var minuteValue = now.Minute + secondValue / 60d;
var hourValue = (now.Hour % 12) + minuteValue / 60d;
var hourAngle = hourValue * 30d;
var minuteAngle = minuteValue * 6d;
var secondAngle = secondValue * 6d;
SetHandGeometry(_hourHandLine, hourAngle, forwardLength: 52, backwardLength: 6);
SetHandGeometry(_minuteHandLine, minuteAngle, forwardLength: 76, backwardLength: 8);
SetHandGeometry(_secondHandLine, secondAngle, forwardLength: 94, backwardLength: 18);
var isZh = CultureInfo.CurrentCulture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
CityTextBlock.Text = isZh ? "\u5317\u4eac" : "Beijing";
CityTextBlock.Text = ResolveCityName(_clockTimeZone);
}
private void ApplyModeVisualIfNeeded()
@@ -299,6 +355,53 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
};
}
private void LoadClockSettings()
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
var configuredTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
? "China Standard Time"
: snapshot.DesktopClockTimeZoneId.Trim();
_clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(configuredTimeZoneId);
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
}
private void ApplySecondHandTimerInterval()
{
_timer.Interval = ClockSecondHandMode.IsSweep(_secondHandMode)
? TimeSpan.FromMilliseconds(16)
: TimeSpan.FromSeconds(1);
}
private string ResolveCityName(TimeZoneInfo timeZone)
{
var cityNames = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
? ZhCityNames
: EnCityNames;
if (cityNames.TryGetValue(timeZone.Id, out var cityName))
{
return cityName;
}
var normalized = timeZone.Id;
var slashIndex = normalized.LastIndexOf('/');
if (slashIndex >= 0 && slashIndex < normalized.Length - 1)
{
normalized = normalized[(slashIndex + 1)..];
}
normalized = normalized.Replace('_', ' ').Trim();
normalized = normalized
.Replace("Standard Time", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("Daylight Time", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("Time", string.Empty, StringComparison.OrdinalIgnoreCase)
.Trim();
return string.IsNullOrWhiteSpace(normalized) ? timeZone.Id : normalized;
}
private bool ResolveIsNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)

View File

@@ -0,0 +1,73 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="560"
d:DesignHeight="300"
x:Class="LanMountainDesktop.Views.Components.AnalogClockWidgetSettingsWindow">
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
Padding="16">
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<TextBlock x:Name="TitleTextBlock"
Text="时钟设置"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="DescriptionTextBlock"
Grid.Row="1"
Text="为单时钟选择时区。"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<ScrollViewer Grid.Row="2"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="10"
Margin="0,0,6,0">
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="TimeZoneLabelTextBlock"
Text="时区"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="TimeZoneComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="SecondHandModeLabelTextBlock"
Text="秒针方式"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<StackPanel Orientation="Horizontal"
Spacing="12">
<RadioButton x:Name="SecondHandTickRadioButton"
GroupName="desktop_clock_second_mode"
Content="跳针"
Checked="OnSecondHandModeChanged" />
<RadioButton x:Name="SecondHandSweepRadioButton"
GroupName="desktop_clock_second_mode"
Content="扫针"
Checked="OnSecondHandModeChanged" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class AnalogClockWidgetSettingsWindow : UserControl
{
private static readonly IReadOnlyDictionary<string, string> ZhTimeZoneNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "中国标准时间",
["Asia/Shanghai"] = "中国标准时间",
["GMT Standard Time"] = "格林威治标准时间",
["Europe/London"] = "格林威治标准时间",
["AUS Eastern Standard Time"] = "澳大利亚东部标准时间",
["Australia/Sydney"] = "澳大利亚东部标准时间",
["Eastern Standard Time"] = "美国东部标准时间",
["America/New_York"] = "美国东部标准时间",
["Tokyo Standard Time"] = "日本标准时间",
["Asia/Tokyo"] = "日本标准时间",
["UTC"] = "协调世界时",
["Etc/UTC"] = "协调世界时"
};
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly TimeZoneService _timeZoneService = new();
private bool _suppressEvents;
private string _languageCode = "zh-CN";
private IReadOnlyList<TimeZoneInfo> _allTimeZones = Array.Empty<TimeZoneInfo>();
private string _selectedTimeZoneId = string.Empty;
private string _secondHandMode = ClockSecondHandMode.Tick;
public event EventHandler? SettingsChanged;
public AnalogClockWidgetSettingsWindow()
{
InitializeComponent();
LoadState();
ApplyLocalization();
PopulateTimeZoneComboBox();
}
private void LoadState()
{
var snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
_selectedTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
? "China Standard Time"
: snapshot.DesktopClockTimeZoneId.Trim();
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
_allTimeZones = _timeZoneService
.GetAllTimeZones()
.OrderBy(zone => zone.GetUtcOffset(DateTime.UtcNow))
.ThenBy(zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private void ApplyLocalization()
{
TitleTextBlock.Text = L("desktop_clock.settings.title", "时钟设置");
DescriptionTextBlock.Text = L("desktop_clock.settings.desc", "为单时钟选择时区。");
TimeZoneLabelTextBlock.Text = L("desktop_clock.settings.timezone_label", "时区");
SecondHandModeLabelTextBlock.Text = L("desktop_clock.settings.second_mode_label", "秒针方式");
SecondHandTickRadioButton.Content = L("clock.second_mode.tick", "跳针");
SecondHandSweepRadioButton.Content = L("clock.second_mode.sweep", "扫针");
}
private void PopulateTimeZoneComboBox()
{
_suppressEvents = true;
try
{
TimeZoneComboBox.Items.Clear();
foreach (var timeZone in _allTimeZones)
{
TimeZoneComboBox.Items.Add(new ComboBoxItem
{
Tag = timeZone.Id,
Content = GetLocalizedTimeZoneDisplayName(timeZone)
});
}
var normalizedId = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(
new[] { _selectedTimeZoneId },
_allTimeZones)[0];
_selectedTimeZoneId = normalizedId;
var selected = TimeZoneComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => string.Equals(item.Tag as string, normalizedId, StringComparison.OrdinalIgnoreCase));
TimeZoneComboBox.SelectedItem = selected ?? TimeZoneComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
var normalizedMode = ClockSecondHandMode.Normalize(_secondHandMode);
SecondHandTickRadioButton.IsChecked = string.Equals(
normalizedMode,
ClockSecondHandMode.Tick,
StringComparison.OrdinalIgnoreCase);
SecondHandSweepRadioButton.IsChecked = string.Equals(
normalizedMode,
ClockSecondHandMode.Sweep,
StringComparison.OrdinalIgnoreCase);
}
finally
{
_suppressEvents = false;
}
}
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void OnSecondHandModeChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void SaveState()
{
var selectedId = (TimeZoneComboBox.SelectedItem as ComboBoxItem)?.Tag as string;
var normalizedId = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(
new[] { selectedId ?? _selectedTimeZoneId },
_allTimeZones)[0];
_selectedTimeZoneId = normalizedId;
_secondHandMode = GetSelectedSecondHandMode();
var snapshot = _appSettingsService.Load();
snapshot.DesktopClockTimeZoneId = normalizedId;
snapshot.DesktopClockSecondHandMode = _secondHandMode;
_appSettingsService.Save(snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
private string GetSelectedSecondHandMode()
{
return SecondHandSweepRadioButton.IsChecked == true
? ClockSecondHandMode.Sweep
: ClockSecondHandMode.Tick;
}
private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone)
{
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);
var sign = offset >= TimeSpan.Zero ? "+" : "-";
var totalMinutes = Math.Abs((int)offset.TotalMinutes);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
var displayName = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
? ResolveZhDisplayName(timeZone)
: ResolveEnDisplayName(timeZone);
return $"(UTC{sign}{hours:D2}:{minutes:D2}) {displayName}";
}
private static string ResolveZhDisplayName(TimeZoneInfo timeZone)
{
if (ZhTimeZoneNames.TryGetValue(timeZone.Id, out var localizedName))
{
return localizedName;
}
return string.IsNullOrWhiteSpace(timeZone.StandardName)
? timeZone.DisplayName
: timeZone.StandardName;
}
private static string ResolveEnDisplayName(TimeZoneInfo timeZone)
{
if (!string.IsNullOrWhiteSpace(timeZone.StandardName))
{
return timeZone.StandardName;
}
return timeZone.DisplayName;
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
}

View File

@@ -0,0 +1,148 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.CnrDailyNewsWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
Background="#D5D5D5"
ClipToBounds="True"
BorderThickness="0"
Padding="16,12,16,12">
<Grid>
<Border x:Name="CardBorder"
Background="#F9F9F9"
CornerRadius="24"
Padding="16,14,16,14">
<Grid RowDefinitions="Auto,Auto,Auto"
RowSpacing="10">
<Grid Grid.Row="0"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<StackPanel Orientation="Horizontal"
Spacing="0"
VerticalAlignment="Center">
<TextBlock x:Name="BrandPrimaryTextBlock"
Text="&#22830;&#24191;&#32593;"
Foreground="#D6272E"
FontSize="28"
FontWeight="Bold"
TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="BrandSecondaryTextBlock"
Text="&#183;&#22836;&#26465;"
Foreground="#202327"
FontSize="28"
FontWeight="Bold"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<Button x:Name="RefreshButton"
Grid.Column="1"
Width="116"
Height="42"
CornerRadius="21"
Background="#F0F0F0"
BorderBrush="Transparent"
BorderThickness="0"
Padding="10,0"
Focusable="False">
<StackPanel Orientation="Horizontal"
Spacing="4"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock x:Name="RefreshGlyphTextBlock"
Text="&#8635;"
Foreground="#52575F"
FontSize="19"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBlock x:Name="RefreshLabelTextBlock"
Text="&#25442;&#19968;&#25442;"
Foreground="#202327"
FontSize="25"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
<Grid x:Name="NewsItem1Grid"
Grid.Row="1"
ColumnDefinitions="*,Auto"
ColumnSpacing="12"
PointerPressed="OnNewsItem1PointerPressed">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="4"
VerticalAlignment="Center">
<TextBlock x:Name="News1PrefixTextBlock"
Text="&#28909;&#28857; |"
Foreground="#D6272E"
FontSize="25"
FontWeight="SemiBold"
VerticalAlignment="Top" />
<TextBlock x:Name="News1TitleTextBlock"
Grid.Column="1"
Text="Headline"
Foreground="#202327"
FontSize="25"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2" />
</Grid>
<Border x:Name="News1ImageHost"
Grid.Column="1"
Width="160"
Height="90"
CornerRadius="16"
ClipToBounds="True"
Background="#E6E6E6">
<Image x:Name="News1Image"
Stretch="UniformToFill" />
</Border>
</Grid>
<Grid x:Name="NewsItem2Grid"
Grid.Row="2"
ColumnDefinitions="*,Auto"
ColumnSpacing="12"
PointerPressed="OnNewsItem2PointerPressed">
<TextBlock x:Name="News2TitleTextBlock"
Text="Headline"
Foreground="#202327"
FontSize="25"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Center" />
<Border x:Name="News2ImageHost"
Grid.Column="1"
Width="160"
Height="90"
CornerRadius="16"
ClipToBounds="True"
Background="#E6E6E6">
<Image x:Name="News2Image"
Stretch="UniformToFill" />
</Border>
</Grid>
</Grid>
</Border>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="Loading"
Foreground="#6A6F77"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,534 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
{
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
private static readonly HttpClient ImageHttpClient = new()
{
Timeout = TimeSpan.FromSeconds(8)
};
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 readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromMinutes(30)
};
private readonly AppSettingsService _settingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly Bitmap?[] _newsBitmaps = new Bitmap?[2];
private readonly string?[] _newsUrls = new string?[2];
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
private CancellationTokenSource? _refreshCts;
private string _languageCode = "zh-CN";
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
public CnrDailyNewsWidget()
{
InitializeComponent();
BrandPrimaryTextBlock.FontFamily = MiSansFontFamily;
BrandSecondaryTextBlock.FontFamily = MiSansFontFamily;
RefreshGlyphTextBlock.FontFamily = MiSansFontFamily;
RefreshLabelTextBlock.FontFamily = MiSansFontFamily;
News1PrefixTextBlock.FontFamily = MiSansFontFamily;
News1TitleTextBlock.FontFamily = MiSansFontFamily;
News2TitleTextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
_refreshTimer.Tick += OnRefreshTimerTick;
RefreshButton.Click += OnRefreshButtonClick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
ApplyLoadingState();
UpdateRefreshButtonState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateAdaptiveLayout();
}
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
{
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
if (_isAttached)
{
_ = RefreshNewsAsync(forceRefresh: false);
}
}
public void RefreshFromSettings()
{
_recommendationService.ClearCache();
if (_isAttached)
{
_ = RefreshNewsAsync(forceRefresh: true);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
UpdateRefreshButtonState();
_refreshTimer.Start();
_ = RefreshNewsAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_refreshTimer.Stop();
CancelRefreshRequest();
DisposeNewsBitmaps();
UpdateRefreshButtonState();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
{
if (_isRefreshing)
{
return;
}
await RefreshNewsAsync(forceRefresh: true);
e.Handled = true;
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshNewsAsync(forceRefresh: false);
}
private void OnNewsItem1PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
TryOpenNewsUrl(0);
e.Handled = true;
}
private void OnNewsItem2PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
TryOpenNewsUrl(1);
e.Handled = true;
}
private async Task RefreshNewsAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateRefreshButtonState();
UpdateLanguageCode();
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new DailyNewsQuery(
Locale: _languageCode,
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetDailyNewsAsync(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;
UpdateRefreshButtonState();
}
}
private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken)
{
var items = snapshot.Items is null
? []
: snapshot.Items.Take(2).ToArray();
var item1 = items.Length > 0 ? items[0] : null;
var item2 = items.Length > 1 ? items[1] : null;
News1PrefixTextBlock.IsVisible = item1 is not null;
News1TitleTextBlock.Text = NormalizeCompactText(item1?.Title);
News2TitleTextBlock.Text = NormalizeCompactText(item2?.Title);
_newsUrls[0] = NormalizeHttpUrl(item1?.Url);
_newsUrls[1] = NormalizeHttpUrl(item2?.Url);
UpdateNewsInteractionState();
StatusTextBlock.IsVisible = false;
UpdateAdaptiveLayout();
var loadTasks = new[]
{
TryDownloadBitmapAsync(item1?.ImageUrl, cancellationToken),
TryDownloadBitmapAsync(item2?.ImageUrl, cancellationToken)
};
var bitmaps = await Task.WhenAll(loadTasks);
if (cancellationToken.IsCancellationRequested || !_isAttached)
{
bitmaps[0]?.Dispose();
bitmaps[1]?.Dispose();
return;
}
SetNewsBitmap(0, bitmaps[0]);
SetNewsBitmap(1, bitmaps[1]);
}
private void ApplyLoadingState()
{
_newsUrls[0] = null;
_newsUrls[1] = null;
News1PrefixTextBlock.IsVisible = true;
News1TitleTextBlock.Text = L("cnrnews.widget.loading_title", "正在获取新闻热点");
News2TitleTextBlock.Text = L("cnrnews.widget.loading_subtitle", "请稍候");
StatusTextBlock.Text = L("cnrnews.widget.loading", "加载中...");
StatusTextBlock.IsVisible = true;
UpdateNewsInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
_newsUrls[0] = null;
_newsUrls[1] = null;
News1PrefixTextBlock.IsVisible = false;
News1TitleTextBlock.Text = L("cnrnews.widget.fallback_title", "央广网新闻暂不可用");
News2TitleTextBlock.Text = L("cnrnews.widget.fallback_subtitle", "点击右上角稍后重试");
StatusTextBlock.Text = L("cnrnews.widget.fetch_failed", "新闻获取失败");
StatusTextBlock.IsVisible = true;
SetNewsBitmap(0, null);
SetNewsBitmap(1, null);
UpdateNewsInteractionState();
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;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
RootBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 8, 28),
Math.Clamp(12 * scale, 6, 20),
Math.Clamp(16 * scale, 8, 28),
Math.Clamp(12 * scale, 6, 20));
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 12, 36));
CardBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 8, 24),
Math.Clamp(14 * scale, 7, 22),
Math.Clamp(16 * scale, 8, 24),
Math.Clamp(14 * scale, 7, 22));
var headlineFont = Math.Clamp(28 * scale, 13, 36);
BrandPrimaryTextBlock.FontSize = headlineFont;
BrandSecondaryTextBlock.FontSize = headlineFont;
var refreshHeight = Math.Clamp(42 * scale, 24, 52);
var refreshWidth = Math.Clamp(116 * scale, 76, 152);
RefreshButton.Height = refreshHeight;
RefreshButton.Width = refreshWidth;
RefreshButton.CornerRadius = new CornerRadius(refreshHeight / 2d);
RefreshGlyphTextBlock.FontSize = Math.Clamp(19 * scale, 11, 26);
RefreshLabelTextBlock.FontSize = Math.Clamp(25 * scale, 12, 32);
var imageWidth = Math.Clamp(totalWidth * 0.23, 68, 170);
var imageHeight = Math.Clamp(imageWidth * 0.56, 38, 94);
News1ImageHost.Width = imageWidth;
News1ImageHost.Height = imageHeight;
News2ImageHost.Width = imageWidth;
News2ImageHost.Height = imageHeight;
News1ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
News2ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
var columnGap = Math.Clamp(12 * scale, 6, 18);
NewsItem1Grid.ColumnSpacing = columnGap;
NewsItem2Grid.ColumnSpacing = columnGap;
NewsItem1Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
NewsItem2Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
var availableTextWidth = Math.Max(72, totalWidth - RootBorder.Padding.Left - RootBorder.Padding.Right - imageWidth - columnGap - Math.Clamp(24 * scale, 12, 36));
News1TitleTextBlock.MaxWidth = availableTextWidth;
News2TitleTextBlock.MaxWidth = availableTextWidth;
var newsFont = Math.Clamp(25 * scale, 11, 32);
News1PrefixTextBlock.FontSize = newsFont;
News1TitleTextBlock.FontSize = newsFont;
News2TitleTextBlock.FontSize = newsFont;
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
var compactLayout = totalHeight < _currentCellSize * 1.7;
News1TitleTextBlock.MaxLines = compactLayout ? 1 : 2;
News2TitleTextBlock.MaxLines = compactLayout ? 1 : 2;
}
private void UpdateRefreshButtonState()
{
RefreshButton.IsEnabled = !_isRefreshing;
RefreshButton.Opacity = _isAttached ? 1.0 : 0.85;
RefreshGlyphTextBlock.Opacity = _isRefreshing ? 0.56 : 1.0;
RefreshLabelTextBlock.Opacity = _isRefreshing ? 0.56 : 1.0;
}
private void UpdateNewsInteractionState()
{
var item1Enabled = !string.IsNullOrWhiteSpace(_newsUrls[0]);
var item2Enabled = !string.IsNullOrWhiteSpace(_newsUrls[1]);
NewsItem1Grid.IsHitTestVisible = item1Enabled;
NewsItem2Grid.IsHitTestVisible = item2Enabled;
NewsItem1Grid.Opacity = item1Enabled ? 1.0 : 0.72;
NewsItem2Grid.Opacity = item2Enabled ? 1.0 : 0.72;
}
private static async Task<Bitmap?> TryDownloadBitmapAsync(string? imageUrl, CancellationToken cancellationToken)
{
var normalizedUrl = NormalizeHttpUrl(imageUrl);
if (string.IsNullOrWhiteSpace(normalizedUrl))
{
return null;
}
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, normalizedUrl);
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
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);
}
catch (OperationCanceledException)
{
throw;
}
catch
{
return null;
}
}
private void TryOpenNewsUrl(int index)
{
if (index < 0 || index >= _newsUrls.Length)
{
return;
}
var normalizedUrl = NormalizeHttpUrl(_newsUrls[index]);
if (string.IsNullOrWhiteSpace(normalizedUrl))
{
return;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = normalizedUrl,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch
{
// Ignore malformed URLs or shell launch failures.
}
}
private static string? NormalizeHttpUrl(string? rawUrl)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return null;
}
var candidate = rawUrl.Trim();
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
return null;
}
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return uri.ToString();
}
private void SetNewsBitmap(int index, Bitmap? bitmap)
{
if (index < 0 || index >= _newsBitmaps.Length)
{
bitmap?.Dispose();
return;
}
var imageControl = index == 0 ? News1Image : News2Image;
var oldBitmap = _newsBitmaps[index];
if (ReferenceEquals(imageControl.Source, oldBitmap))
{
imageControl.Source = null;
}
oldBitmap?.Dispose();
_newsBitmaps[index] = bitmap;
imageControl.Source = bitmap;
}
private void DisposeNewsBitmaps()
{
SetNewsBitmap(0, null);
SetNewsBitmap(1, null);
}
private void UpdateLanguageCode()
{
try
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
if (cts is null)
{
return;
}
cts.Cancel();
cts.Dispose();
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 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(), " ");
}
}

View File

@@ -18,7 +18,8 @@
<Border x:Name="ArtworkPanel"
Grid.Column="0"
ClipToBounds="True"
Background="#B8AE9A">
Background="#B8AE9A"
PointerPressed="OnArtworkPanelPointerPressed">
<Grid>
<Image x:Name="ArtworkImage"
Stretch="UniformToFill" />
@@ -34,12 +35,14 @@
FontSize="44"
FontWeight="Bold"
FontFeatures="tnum"
TextTrimming="CharacterEllipsis"
LineHeight="46" />
<TextBlock x:Name="WeekdayTextBlock"
Text="星期二"
Foreground="#F9F9F9"
FontSize="44"
FontWeight="Bold"
TextTrimming="CharacterEllipsis"
LineHeight="46" />
</StackPanel>
</Grid>
@@ -48,7 +51,8 @@
<Border Grid.Column="1"
x:Name="InfoPanel"
Background="#111418"
Padding="18,14,18,14">
Padding="18,14,18,14"
PointerPressed="OnInfoPanelPointerPressed">
<Grid>
<Canvas x:Name="BrickPatternCanvas"
IsHitTestVisible="False"
@@ -76,7 +80,8 @@
FontSize="44"
FontWeight="Bold"
TextWrapping="Wrap"
MaxLines="2"
TextTrimming="CharacterEllipsis"
MaxLines="4"
Margin="0,0,0,8" />
<Border x:Name="RightPanelSeparator"
@@ -96,15 +101,17 @@
FontSize="26"
FontWeight="SemiBold"
TextWrapping="Wrap"
MaxLines="2" />
TextTrimming="CharacterEllipsis"
MaxLines="3" />
<TextBlock x:Name="YearTextBlock"
Text="1754"
Foreground="#D7DCE3"
FontSize="22"
FontWeight="Medium"
FontFeatures="tnum"
TextWrapping="NoWrap"
MaxLines="1" />
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2" />
</StackPanel>
</Grid>
</Grid>

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Net.Http;
@@ -8,6 +9,7 @@ using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
@@ -32,6 +34,9 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly FontWeight[] TitleWeightCandidates = new[] { FontWeight.Bold, FontWeight.SemiBold, FontWeight.Medium, FontWeight.Normal };
private static readonly FontWeight[] ArtistWeightCandidates = new[] { FontWeight.SemiBold, FontWeight.Medium, FontWeight.Normal };
private static readonly FontWeight[] SecondaryWeightCandidates = new[] { FontWeight.Medium, FontWeight.Normal, FontWeight.Light };
private static readonly HttpClient ImageHttpClient = new()
{
@@ -62,6 +67,8 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
private string? _currentArtworkSourceUrl;
private string? _currentArtworkImageUrl;
public DailyArtworkWidget()
{
@@ -102,7 +109,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
0,
0,
Math.Clamp(16 * scale, 8, 26));
DateInfoStack.Spacing = Math.Clamp(2 * scale, 1, 6);
DateInfoStack.Spacing = Math.Clamp(4 * scale, 2, 10);
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 10, 24);
@@ -154,6 +161,28 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
await RefreshArtworkAsync(forceRefresh: false);
}
private void OnArtworkPanelPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
_ = RefreshArtworkAsync(forceRefresh: true);
e.Handled = true;
}
private void OnInfoPanelPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
TryOpenArtworkSourceUrl();
e.Handled = true;
}
private async Task RefreshArtworkAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
@@ -222,6 +251,8 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
ArtistTextBlock.Text = NormalizeCompactText(artist);
YearTextBlock.Text = ResolveYearText(snapshot);
_currentArtworkSourceUrl = snapshot.ArtworkUrl;
_currentArtworkImageUrl = snapshot.ImageUrl;
StatusTextBlock.IsVisible = false;
UpdateAdaptiveLayout();
@@ -352,6 +383,8 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
private void ApplyLoadingState()
{
_currentArtworkSourceUrl = null;
_currentArtworkImageUrl = null;
StatusTextBlock.IsVisible = true;
StatusTextBlock.Text = L("artwork.widget.loading", "Loading...");
PaintingTitleTextBlock.Text = BuildQuotedTitle(L("artwork.widget.loading_title", "Daily Artwork"));
@@ -362,6 +395,8 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
private void ApplyFailedState()
{
_currentArtworkSourceUrl = null;
_currentArtworkImageUrl = null;
StatusTextBlock.IsVisible = true;
StatusTextBlock.Text = L("artwork.widget.fetch_failed", "Artwork fetch failed");
PaintingTitleTextBlock.Text = BuildQuotedTitle(L("artwork.widget.fallback_title", "Daily Artwork"));
@@ -384,71 +419,137 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
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 leftContentHeight = Math.Max(30, totalHeight - DateInfoStack.Margin.Bottom - 10);
var dateStackSpacing = Math.Clamp(4 * scale, 2, 10);
DateInfoStack.Spacing = dateStackSpacing;
DateInfoStack.MaxWidth = leftContentWidth;
var leftSingleLineHeight = Math.Max(12, (leftContentHeight - dateStackSpacing) / 2d);
var dateBase = Math.Clamp(44 * scale, 16, 62);
DateTextBlock.FontSize = FitFontSize(
DateTextBlock.Text,
leftContentWidth,
Math.Max(18, totalHeight * 0.20),
leftSingleLineHeight,
maxLines: 1,
minFontSize: Math.Max(12, dateBase * 0.68),
maxFontSize: dateBase,
weight: FontWeight.Bold,
lineHeightFactor: 1.00);
DateTextBlock.LineHeight = DateTextBlock.FontSize * 1.00;
lineHeightFactor: 1.10);
DateTextBlock.LineHeight = DateTextBlock.FontSize * 1.10;
WeekdayTextBlock.FontSize = FitFontSize(
WeekdayTextBlock.Text,
leftContentWidth,
Math.Max(18, totalHeight * 0.21),
leftSingleLineHeight,
maxLines: 1,
minFontSize: Math.Max(12, dateBase * 0.68),
maxFontSize: dateBase,
weight: FontWeight.Bold,
lineHeightFactor: 1.00);
WeekdayTextBlock.LineHeight = WeekdayTextBlock.FontSize * 1.00;
lineHeightFactor: 1.10);
WeekdayTextBlock.LineHeight = WeekdayTextBlock.FontSize * 1.10;
var rightContentHeight = Math.Max(42, totalHeight - InfoPanel.Padding.Top - InfoPanel.Padding.Bottom);
var titleBottomMargin = Math.Clamp(8 * scale, 4, 14);
var separatorBottomMargin = Math.Clamp(10 * scale, 4, 14);
var bottomStackSpacing = Math.Clamp(3 * scale, 2, 8);
var reservedHeight = titleBottomMargin + separatorBottomMargin + bottomStackSpacing + 3;
var textHeightBudget = Math.Max(24, rightContentHeight - reservedHeight);
var titleBase = Math.Clamp(44 * scale, 16, 58);
PaintingTitleTextBlock.MaxWidth = rightContentWidth;
PaintingTitleTextBlock.FontSize = FitFontSize(
var artistBase = Math.Clamp(26 * scale, 11, 34);
var yearBase = Math.Clamp(22 * scale, 10, 30);
var titleMin = Math.Max(9.2, titleBase * 0.42);
var artistMin = Math.Max(8.4, artistBase * 0.50);
var yearMin = Math.Max(8.0, yearBase * 0.54);
var titleDemand = Math.Clamp(NormalizeCompactText(PaintingTitleTextBlock.Text).Length, 6, 96);
var artistDemand = Math.Clamp(NormalizeCompactText(ArtistTextBlock.Text).Length, 4, 72);
var yearDemand = Math.Clamp(NormalizeCompactText(YearTextBlock.Text).Length, 2, 48);
var minTitleHeight = Math.Max(10, titleMin * 1.10 * 2);
var minArtistHeight = Math.Max(8, artistMin * 1.14);
var minYearHeight = Math.Max(8, yearMin * 1.08);
var minTextHeightTotal = minTitleHeight + minArtistHeight + minYearHeight;
double titleHeightBudget;
double artistHeightBudget;
double yearHeightBudget;
if (textHeightBudget <= minTextHeightTotal + 0.6)
{
var compression = textHeightBudget / Math.Max(1, minTextHeightTotal);
titleHeightBudget = Math.Max(9, minTitleHeight * compression);
artistHeightBudget = Math.Max(7, minArtistHeight * compression);
yearHeightBudget = Math.Max(7, minYearHeight * compression);
}
else
{
var extraHeight = textHeightBudget - minTextHeightTotal;
var titleWeight = titleDemand + 8d;
var artistWeight = artistDemand + 4d;
var yearWeight = yearDemand + 2d;
var weightSum = Math.Max(1d, titleWeight + artistWeight + yearWeight);
titleHeightBudget = minTitleHeight + extraHeight * (titleWeight / weightSum);
artistHeightBudget = minArtistHeight + extraHeight * (artistWeight / weightSum);
yearHeightBudget = minYearHeight + extraHeight * (yearWeight / weightSum);
}
var titleLayout = FitAdaptiveTextLayout(
PaintingTitleTextBlock.Text,
rightContentWidth,
Math.Max(20, totalHeight * 0.34),
maxLines: 2,
minFontSize: Math.Max(12, titleBase * 0.62),
titleHeightBudget,
minLines: 2,
maxLines: 5,
minFontSize: titleMin,
maxFontSize: titleBase,
weight: FontWeight.Bold,
lineHeightFactor: 1.08);
PaintingTitleTextBlock.LineHeight = PaintingTitleTextBlock.FontSize * 1.08;
weightCandidates: TitleWeightCandidates,
lineHeightFactor: 1.10);
PaintingTitleTextBlock.MaxWidth = rightContentWidth;
PaintingTitleTextBlock.Margin = new Thickness(0, 0, 0, titleBottomMargin);
PaintingTitleTextBlock.MaxLines = titleLayout.MaxLines;
PaintingTitleTextBlock.FontWeight = titleLayout.Weight;
PaintingTitleTextBlock.FontSize = titleLayout.FontSize;
PaintingTitleTextBlock.LineHeight = titleLayout.LineHeight;
var artistBase = Math.Clamp(26 * scale, 11, 34);
ArtistTextBlock.MaxWidth = rightContentWidth;
ArtistTextBlock.FontSize = FitFontSize(
if (ArtistTextBlock.Parent is StackPanel artistInfoStack)
{
artistInfoStack.Spacing = bottomStackSpacing;
}
var artistLayout = FitAdaptiveTextLayout(
ArtistTextBlock.Text,
rightContentWidth,
Math.Max(18, totalHeight * 0.24),
maxLines: 2,
minFontSize: Math.Max(10, artistBase * 0.72),
artistHeightBudget,
minLines: 1,
maxLines: 4,
minFontSize: artistMin,
maxFontSize: artistBase,
weight: FontWeight.SemiBold,
lineHeightFactor: 1.12);
ArtistTextBlock.LineHeight = ArtistTextBlock.FontSize * 1.12;
weightCandidates: ArtistWeightCandidates,
lineHeightFactor: 1.14);
ArtistTextBlock.MaxWidth = rightContentWidth;
ArtistTextBlock.MaxLines = artistLayout.MaxLines;
ArtistTextBlock.FontWeight = artistLayout.Weight;
ArtistTextBlock.FontSize = artistLayout.FontSize;
ArtistTextBlock.LineHeight = artistLayout.LineHeight;
var yearBase = Math.Clamp(22 * scale, 10, 30);
YearTextBlock.MaxWidth = rightContentWidth;
YearTextBlock.FontSize = FitFontSize(
var yearLayout = FitAdaptiveTextLayout(
YearTextBlock.Text,
rightContentWidth,
Math.Max(14, totalHeight * 0.12),
maxLines: 1,
minFontSize: Math.Max(9.5, yearBase * 0.78),
yearHeightBudget,
minLines: 1,
maxLines: 3,
minFontSize: yearMin,
maxFontSize: yearBase,
weight: FontWeight.Medium,
lineHeightFactor: 1.04);
YearTextBlock.LineHeight = YearTextBlock.FontSize * 1.04;
weightCandidates: SecondaryWeightCandidates,
lineHeightFactor: 1.08);
YearTextBlock.MaxWidth = rightContentWidth;
YearTextBlock.MaxLines = yearLayout.MaxLines;
YearTextBlock.FontWeight = yearLayout.Weight;
YearTextBlock.FontSize = yearLayout.FontSize;
YearTextBlock.LineHeight = yearLayout.LineHeight;
RightPanelSeparator.Width = Math.Clamp(rightContentWidth * 0.58, 42, 136);
RightPanelSeparator.Margin = new Thickness(0, 0, 0, Math.Clamp(10 * scale, 4, 14));
RightPanelSeparator.Margin = new Thickness(0, 0, 0, separatorBottomMargin);
BrickPatternCanvas.Opacity = totalWidth < _currentCellSize * 4.2
? 0.34
@@ -478,6 +579,54 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
_currentArtworkBitmap = null;
}
private void TryOpenArtworkSourceUrl()
{
var candidate = _currentArtworkSourceUrl;
if (!TryNormalizeHttpUrl(candidate, out var normalizedUrl) &&
!TryNormalizeHttpUrl(_currentArtworkImageUrl, out normalizedUrl))
{
return;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = normalizedUrl,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch
{
// Ignore malformed URLs or shell launch failures.
}
}
private static bool TryNormalizeHttpUrl(string? rawUrl, out string normalizedUrl)
{
normalizedUrl = string.Empty;
if (string.IsNullOrWhiteSpace(rawUrl))
{
return false;
}
var candidate = rawUrl.Trim();
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
return false;
}
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return false;
}
normalizedUrl = uri.ToString();
return true;
}
private void UpdateLanguageCode()
{
try
@@ -623,6 +772,170 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
return best;
}
private static AdaptiveTextLayout FitAdaptiveTextLayout(
string? text,
double maxWidth,
double maxHeight,
int minLines,
int maxLines,
double minFontSize,
double maxFontSize,
FontWeight[] weightCandidates,
double lineHeightFactor)
{
var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
var safeMinLines = Math.Max(1, minLines);
var safeMaxLines = Math.Max(safeMinLines, maxLines);
var linesByHeight = ResolveMaxLinesByHeight(maxHeight, minFontSize, lineHeightFactor, safeMinLines, safeMaxLines);
var candidates = weightCandidates is { Length: > 0 }
? weightCandidates
: new[] { FontWeight.Normal };
AdaptiveTextLayout? best = null;
foreach (var weight in candidates)
{
for (var lineLimit = linesByHeight; lineLimit >= safeMinLines; lineLimit--)
{
var fontSize = FitFontSize(
content,
maxWidth,
maxHeight,
lineLimit,
minFontSize,
maxFontSize,
weight,
lineHeightFactor);
var lineHeight = fontSize * lineHeightFactor;
var measuredSize = MeasureTextSize(content, fontSize, weight, Math.Max(1, maxWidth), lineHeight);
var measuredLineCount = ResolveLineCount(measuredSize.Height, lineHeight);
var overflowLines = Math.Max(0, measuredLineCount - lineLimit);
var overflowHeight = Math.Max(0, measuredSize.Height - maxHeight);
var overflowScore = overflowLines * 1000d + overflowHeight;
var fitsCompletely = overflowLines == 0 && overflowHeight <= 0.6;
var candidate = new AdaptiveTextLayout(fontSize, weight, lineLimit, lineHeight, overflowScore, fitsCompletely);
if (best is null || IsBetterAdaptiveTextCandidate(candidate, best.Value))
{
best = candidate;
}
}
}
if (best is not null)
{
return best.Value;
}
var fallbackFontSize = Math.Max(6, minFontSize);
return new AdaptiveTextLayout(
fallbackFontSize,
FontWeight.Normal,
safeMinLines,
fallbackFontSize * lineHeightFactor,
double.MaxValue,
fitsCompletely: false);
}
private static bool IsBetterAdaptiveTextCandidate(AdaptiveTextLayout candidate, AdaptiveTextLayout best)
{
if (candidate.FitsCompletely && !best.FitsCompletely)
{
return true;
}
if (!candidate.FitsCompletely && best.FitsCompletely)
{
return false;
}
if (candidate.FitsCompletely && best.FitsCompletely)
{
if (candidate.FontSize > best.FontSize + 0.12)
{
return true;
}
if (Math.Abs(candidate.FontSize - best.FontSize) <= 0.12 && candidate.MaxLines < best.MaxLines)
{
return true;
}
return false;
}
if (candidate.OverflowScore < best.OverflowScore - 0.2)
{
return true;
}
if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2 &&
candidate.FontSize > best.FontSize + 0.12)
{
return true;
}
if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2 &&
Math.Abs(candidate.FontSize - best.FontSize) <= 0.12 &&
candidate.MaxLines > best.MaxLines)
{
return true;
}
return false;
}
private static int ResolveMaxLinesByHeight(
double maxHeight,
double minFontSize,
double lineHeightFactor,
int minLines,
int maxLines)
{
var safeMinLines = Math.Max(1, minLines);
var safeMaxLines = Math.Max(safeMinLines, maxLines);
var lineHeight = Math.Max(1, Math.Max(6, minFontSize) * lineHeightFactor);
var maxHeightWithTolerance = Math.Max(1, maxHeight + 0.6);
var linesByHeight = (int)Math.Floor(maxHeightWithTolerance / lineHeight);
return Math.Clamp(linesByHeight, safeMinLines, safeMaxLines);
}
private static int ResolveLineCount(double measuredHeight, double lineHeight)
{
return Math.Max(1, (int)Math.Ceiling(measuredHeight / Math.Max(1, lineHeight)));
}
private readonly struct AdaptiveTextLayout
{
public AdaptiveTextLayout(
double fontSize,
FontWeight weight,
int maxLines,
double lineHeight,
double overflowScore,
bool fitsCompletely)
{
FontSize = fontSize;
Weight = weight;
MaxLines = Math.Max(1, maxLines);
LineHeight = lineHeight;
OverflowScore = overflowScore;
FitsCompletely = fitsCompletely;
}
public double FontSize { get; }
public FontWeight Weight { get; }
public int MaxLines { get; }
public double LineHeight { get; }
public double OverflowScore { get; }
public bool FitsCompletely { get; }
}
private static Size MeasureTextSize(string text, double fontSize, FontWeight weight, double maxWidth, double lineHeight)
{
var probe = new TextBlock

View File

@@ -0,0 +1,127 @@
<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:fi="using:FluentIcons.Avalonia"
xmlns:shapes="clr-namespace:Avalonia.Controls.Shapes;assembly=Avalonia.Controls"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.DailyWordWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
Background="#D5D5D5"
ClipToBounds="True"
BorderThickness="0"
Padding="16,12,16,12">
<Grid>
<Border x:Name="CardBorder"
Background="#FBFAF8"
CornerRadius="24"
Padding="16,14,16,14">
<Grid>
<Grid IsHitTestVisible="False">
<shapes:Ellipse x:Name="HaloEllipse"
Width="290"
Height="290"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,-106,-52,0"
Fill="#14F3C9B4" />
<Border x:Name="AccentCorner"
Width="116"
Height="116"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="0,0,-34,-34"
CornerRadius="58"
Background="#23F29A7A" />
</Grid>
<Grid RowDefinitions="Auto,Auto,*,Auto"
RowSpacing="7">
<Grid Grid.Row="0"
ColumnDefinitions="*,Auto"
ColumnSpacing="8">
<TextBlock x:Name="WordTextBlock"
Text="illustrate"
Foreground="#F07541"
FontSize="56"
FontWeight="Bold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
<Button x:Name="RefreshButton"
Grid.Column="1"
Width="38"
Height="38"
CornerRadius="19"
Background="#14A0A6AF"
BorderBrush="Transparent"
BorderThickness="0"
Padding="0"
Focusable="False">
<fi:SymbolIcon x:Name="RefreshIcon"
Symbol="ArrowClockwise"
IconVariant="Regular"
FontSize="19"
Foreground="#626870" />
</Button>
</Grid>
<TextBlock x:Name="PronunciationTextBlock"
Grid.Row="1"
Text="英 /ˈɪləstreɪt/ · 美 /ˈɪləstreɪt/"
Foreground="#6B7078"
FontSize="27"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="MeaningTextBlock"
Grid.Row="2"
Text="vt. 说明;阐明;举例证明;加插图"
Foreground="#2B2F35"
FontSize="25"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top" />
<StackPanel Grid.Row="3"
Spacing="2">
<TextBlock x:Name="ExampleTextBlock"
Text="One example will suffice to illustrate the point."
Foreground="#2B2F35"
FontSize="22"
FontWeight="Medium"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2" />
<TextBlock x:Name="ExampleTranslationTextBlock"
Text="一个例子就足以说明这个观点。"
Foreground="#7A8088"
FontSize="20"
FontWeight="Medium"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</StackPanel>
</Grid>
</Grid>
</Border>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="Loading"
Foreground="#6A6F77"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,502 @@
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
{
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
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 string _languageCode = "zh-CN";
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
public DailyWordWidget()
{
InitializeComponent();
WordTextBlock.FontFamily = MiSansFontFamily;
PronunciationTextBlock.FontFamily = MiSansFontFamily;
MeaningTextBlock.FontFamily = MiSansFontFamily;
ExampleTextBlock.FontFamily = MiSansFontFamily;
ExampleTranslationTextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
_refreshTimer.Tick += OnRefreshTimerTick;
RefreshButton.Click += OnRefreshButtonClick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
ApplyLoadingState();
UpdateRefreshButtonState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateAdaptiveLayout();
}
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
{
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
if (_isAttached)
{
_ = RefreshWordAsync(forceRefresh: false);
}
}
public void RefreshFromSettings()
{
_recommendationService.ClearCache();
if (_isAttached)
{
_ = RefreshWordAsync(forceRefresh: true);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
UpdateRefreshButtonState();
_refreshTimer.Start();
_ = RefreshWordAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_refreshTimer.Stop();
CancelRefreshRequest();
UpdateRefreshButtonState();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
{
if (_isRefreshing)
{
return;
}
await RefreshWordAsync(forceRefresh: true);
e.Handled = true;
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshWordAsync(forceRefresh: false);
}
private async Task RefreshWordAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateRefreshButtonState();
UpdateLanguageCode();
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new DailyWordQuery(
Locale: _languageCode,
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetDailyWordAsync(query, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
{
return;
}
if (!result.Success || result.Data is null)
{
ApplyFailedState();
return;
}
ApplySnapshot(result.Data);
}
catch (OperationCanceledException)
{
// Ignore canceled requests.
}
catch
{
if (_isAttached && !cts.IsCancellationRequested)
{
ApplyFailedState();
}
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
UpdateRefreshButtonState();
}
}
private void ApplySnapshot(DailyWordSnapshot snapshot)
{
WordTextBlock.Text = NormalizeCompactText(snapshot.Word);
PronunciationTextBlock.Text = BuildPronunciationText(snapshot);
MeaningTextBlock.Text = BuildMeaningText(snapshot.Meaning);
ExampleTextBlock.Text = BuildExampleText(snapshot.ExampleSentence);
ExampleTranslationTextBlock.Text = BuildExampleTranslation(snapshot.ExampleTranslation);
StatusTextBlock.IsVisible = false;
UpdateAdaptiveLayout();
}
private void ApplyLoadingState()
{
WordTextBlock.Text = L("dailyword.widget.loading_word", "daily word");
PronunciationTextBlock.Text = L("dailyword.widget.loading_pronunciation", "Fetching pronunciation...");
MeaningTextBlock.Text = L("dailyword.widget.loading_meaning", "Fetching meaning...");
ExampleTextBlock.Text = L("dailyword.widget.loading_example", "Fetching example sentence...");
ExampleTranslationTextBlock.Text = L("dailyword.widget.loading_example_translation", "Loading...");
StatusTextBlock.Text = L("dailyword.widget.loading", "Loading...");
StatusTextBlock.IsVisible = true;
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
WordTextBlock.Text = L("dailyword.widget.fallback_word", "daily word");
PronunciationTextBlock.Text = L("dailyword.widget.fallback_pronunciation", "Pronunciation unavailable");
MeaningTextBlock.Text = L("dailyword.widget.fallback_meaning", "Youdao dictionary is temporarily unavailable.");
ExampleTextBlock.Text = L("dailyword.widget.fallback_example", "Tap the refresh button and try again.");
ExampleTranslationTextBlock.Text = L("dailyword.widget.fallback_example_translation", "It will retry when network recovers.");
StatusTextBlock.Text = L("dailyword.widget.fetch_failed", "Daily word fetch failed");
StatusTextBlock.IsVisible = true;
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;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
RootBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 8, 26),
Math.Clamp(12 * scale, 6, 20),
Math.Clamp(16 * scale, 8, 26),
Math.Clamp(12 * scale, 6, 20));
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 12, 36));
CardBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 8, 24),
Math.Clamp(14 * scale, 7, 22),
Math.Clamp(16 * scale, 8, 24),
Math.Clamp(14 * scale, 7, 22));
var refreshSize = Math.Clamp(38 * scale, 22, 48);
RefreshButton.Width = refreshSize;
RefreshButton.Height = refreshSize;
RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d);
RefreshIcon.FontSize = Math.Clamp(19 * scale, 12, 26);
HaloEllipse.Width = Math.Clamp(totalWidth * 0.52, 120, 340);
HaloEllipse.Height = HaloEllipse.Width;
AccentCorner.Width = Math.Clamp(totalWidth * 0.20, 66, 132);
AccentCorner.Height = AccentCorner.Width;
AccentCorner.CornerRadius = new CornerRadius(AccentCorner.Width / 2d);
var horizontalPadding = RootBorder.Padding.Left + RootBorder.Padding.Right + CardBorder.Padding.Left + CardBorder.Padding.Right;
var contentWidth = Math.Max(98, totalWidth - horizontalPadding);
var wordWidth = Math.Max(70, contentWidth - refreshSize - Math.Clamp(8 * scale, 5, 14));
WordTextBlock.MaxWidth = wordWidth;
PronunciationTextBlock.MaxWidth = contentWidth;
MeaningTextBlock.MaxWidth = contentWidth;
ExampleTextBlock.MaxWidth = contentWidth;
ExampleTranslationTextBlock.MaxWidth = contentWidth;
var compactLayout = totalHeight < _currentCellSize * 1.72;
MeaningTextBlock.MaxLines = compactLayout ? 1 : 2;
ExampleTextBlock.MaxLines = compactLayout ? 1 : 2;
ExampleTranslationTextBlock.IsVisible = !compactLayout;
ExampleTranslationTextBlock.MaxLines = 1;
var contentHeight = Math.Max(52, totalHeight - RootBorder.Padding.Top - RootBorder.Padding.Bottom - CardBorder.Padding.Top - CardBorder.Padding.Bottom);
var wordHeightBudget = Math.Max(18, contentHeight * 0.24);
var pronunciationHeightBudget = Math.Max(14, contentHeight * 0.16);
var meaningHeightBudget = Math.Max(16, contentHeight * (compactLayout ? 0.26 : 0.30));
var exampleHeightBudget = Math.Max(16, contentHeight - wordHeightBudget - pronunciationHeightBudget - meaningHeightBudget - Math.Clamp(16 * scale, 8, 24));
if (!ExampleTranslationTextBlock.IsVisible)
{
exampleHeightBudget += Math.Clamp(11 * scale, 5, 18);
}
var wordBase = Math.Clamp(56 * scale, 18, 72);
WordTextBlock.FontSize = FitFontSize(
WordTextBlock.Text,
wordWidth,
wordHeightBudget,
maxLines: 1,
minFontSize: Math.Max(14, wordBase * 0.56),
maxFontSize: wordBase,
weight: FontWeight.Bold,
lineHeightFactor: 1.04);
WordTextBlock.LineHeight = WordTextBlock.FontSize * 1.04;
var pronunciationBase = Math.Clamp(27 * scale, 10, 36);
PronunciationTextBlock.FontSize = FitFontSize(
PronunciationTextBlock.Text,
contentWidth,
pronunciationHeightBudget,
maxLines: 1,
minFontSize: Math.Max(8.6, pronunciationBase * 0.62),
maxFontSize: pronunciationBase,
weight: FontWeight.SemiBold,
lineHeightFactor: 1.08);
PronunciationTextBlock.LineHeight = PronunciationTextBlock.FontSize * 1.08;
var meaningBase = Math.Clamp(25 * scale, 10, 34);
MeaningTextBlock.FontSize = FitFontSize(
MeaningTextBlock.Text,
contentWidth,
meaningHeightBudget,
maxLines: Math.Max(1, MeaningTextBlock.MaxLines),
minFontSize: Math.Max(9.2, meaningBase * 0.60),
maxFontSize: meaningBase,
weight: FontWeight.SemiBold,
lineHeightFactor: 1.10);
MeaningTextBlock.LineHeight = MeaningTextBlock.FontSize * 1.10;
var exampleBase = Math.Clamp(22 * scale, 9, 30);
ExampleTextBlock.FontSize = FitFontSize(
ExampleTextBlock.Text,
contentWidth,
exampleHeightBudget,
maxLines: Math.Max(1, ExampleTextBlock.MaxLines),
minFontSize: Math.Max(8.8, exampleBase * 0.58),
maxFontSize: exampleBase,
weight: FontWeight.Medium,
lineHeightFactor: 1.08);
ExampleTextBlock.LineHeight = ExampleTextBlock.FontSize * 1.08;
var translationBase = Math.Clamp(20 * scale, 8, 28);
ExampleTranslationTextBlock.FontSize = FitFontSize(
ExampleTranslationTextBlock.Text,
contentWidth,
Math.Max(10, exampleHeightBudget * 0.44),
maxLines: 1,
minFontSize: Math.Max(7.8, translationBase * 0.62),
maxFontSize: translationBase,
weight: FontWeight.Medium,
lineHeightFactor: 1.06);
ExampleTranslationTextBlock.LineHeight = ExampleTranslationTextBlock.FontSize * 1.06;
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
}
private void UpdateRefreshButtonState()
{
RefreshButton.IsEnabled = !_isRefreshing;
RefreshButton.Opacity = _isAttached ? 1.0 : 0.85;
RefreshIcon.Opacity = _isRefreshing ? 0.56 : 1.0;
}
private void UpdateLanguageCode()
{
try
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
if (cts is null)
{
return;
}
cts.Cancel();
cts.Dispose();
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 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 string BuildPronunciationText(DailyWordSnapshot snapshot)
{
var uk = NormalizeCompactText(snapshot.UkPronunciation);
var us = NormalizeCompactText(snapshot.UsPronunciation);
var isZh = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(uk) && !string.IsNullOrWhiteSpace(us))
{
return isZh
? $"英 /{uk}/ · 美 /{us}/"
: $"UK /{uk}/ · US /{us}/";
}
if (!string.IsNullOrWhiteSpace(uk))
{
return isZh ? $"英 /{uk}/" : $"UK /{uk}/";
}
if (!string.IsNullOrWhiteSpace(us))
{
return isZh ? $"美 /{us}/" : $"US /{us}/";
}
return isZh ? "英/美 发音暂无" : "Pronunciation unavailable";
}
private static string BuildMeaningText(string? rawMeaning)
{
var normalized = NormalizeCompactText(rawMeaning);
return string.IsNullOrWhiteSpace(normalized)
? "Meaning unavailable"
: normalized;
}
private static string BuildExampleText(string? sentence)
{
var normalized = NormalizeCompactText(sentence);
return string.IsNullOrWhiteSpace(normalized)
? "No example sentence."
: normalized;
}
private static string BuildExampleTranslation(string? translation)
{
var normalized = NormalizeCompactText(translation);
return string.IsNullOrWhiteSpace(normalized)
? string.Empty
: normalized;
}
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;
}
}

View File

@@ -134,6 +134,11 @@ public sealed class DesktopComponentRuntimeRegistry
"component.weather_clock",
() => new WeatherClockWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopWorldClock,
"component.world_clock",
() => new WorldClockWidget(),
cellSize => Math.Clamp(cellSize * 0.30, 10, 24)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopTimer,
"component.desktop_timer",
@@ -224,6 +229,16 @@ public sealed class DesktopComponentRuntimeRegistry
"component.daily_artwork",
() => new DailyArtworkWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopDailyWord,
"component.daily_word",
() => new DailyWordWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopCnrDailyNews,
"component.cnr_daily_news",
() => new CnrDailyNewsWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopWhiteboard,
"component.whiteboard",

View File

@@ -20,7 +20,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromMinutes(12) };
private readonly DispatcherTimer _animationTimer = new() { Interval = UiMotionTokens.WeatherAnimationFrameInterval };
private readonly DispatcherTimer _animationTimer = new() { Interval = FluttermotionToken.WeatherAnimationFrameInterval };
private readonly ScaleTransform _backgroundMotionScaleTransform = new(1, 1);
private readonly TranslateTransform _backgroundMotionTranslateTransform = new();
private readonly AppSettingsService _settingsService = new();
@@ -43,6 +43,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
private readonly TextBlock[] _dailyHighBlocks;
private readonly TextBlock[] _dailyLowBlocks;
private readonly Image[] _dailyIconBlocks;
private readonly HyperOS3WeatherVisualKind[] _dailyIconKinds;
public ExtendedWeatherWidget()
{
@@ -76,6 +77,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
[
DailyIcon0, DailyIcon1, DailyIcon2, DailyIcon3, DailyIcon4
];
_dailyIconKinds = Enumerable.Repeat(HyperOS3WeatherVisualKind.CloudyDay, _dailyIconBlocks.Length).ToArray();
ConfigureTextOverflowGuards();
_refreshTimer.Tick += OnRefreshTimerTick;
_animationTimer.Tick += OnAnimationTick;
@@ -344,6 +346,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
_dailyLabelBlocks[i].Text = $"{ResolveDayLabel(date, i + 1)}·{dayText}";
_dailyHighBlocks[i].Text = FormatTemperatureValue(daily?.HighTemperatureC);
_dailyLowBlocks[i].Text = FormatTemperatureValue(daily?.LowTemperatureC);
_dailyIconKinds[i] = dayKind;
_dailyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveMiniIconAsset(dayKind));
}
}
@@ -371,6 +374,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
_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 = "--";
_dailyIconKinds[i] = HyperOS3WeatherVisualKind.CloudyDay;
_dailyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveMiniIconAsset(HyperOS3WeatherVisualKind.CloudyDay));
}
}
@@ -523,7 +527,9 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
3.80);
var hourlyTempSize = Math.Clamp(19 * hourlyCellScale, 6, 72);
var hourlyTimeSize = Math.Clamp(14 * hourlyCellScale, 6, 52);
var hourlyIconSize = Math.Clamp(34 * hourlyCellScale, 8, 114);
var hourlyIconSize = Math.Clamp(42 * hourlyCellScale, 9, 140);
hourlyIconSize = Math.Min(hourlyIconSize, Math.Max(10, hourlyCellWidth * 0.86));
hourlyIconSize = Math.Min(hourlyIconSize, Math.Max(10, hourlyHeight * 0.56));
var hourlyStackSpacing = Math.Clamp(2 * hourlyCellScale, 0.2, 10);
for (var i = 0; i < _hourlyTempBlocks.Length; i++)
{
@@ -548,7 +554,9 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
var dailyLabelSize = Math.Clamp(18.5 * dailyRowScale, 6, 70);
var dailyTempSize = Math.Clamp(19 * dailyRowScale, 6, 72);
var dailyIconSize = Math.Clamp(30 * dailyRowScale, 8, 102);
var dailyIconSize = Math.Clamp(43 * dailyRowScale, 9, 132);
dailyIconSize = Math.Min(dailyIconSize, Math.Max(10, dailyRowHeight * 0.92));
dailyIconSize = Math.Min(dailyIconSize, Math.Max(10, innerWidth * 0.14));
var dailyLabelMaxWidth = Math.Clamp(innerWidth * 0.52, 28, 460);
var dailyHighWidth = Math.Clamp(innerWidth * 0.14, 14, 140);
var dailyLowWidth = Math.Clamp(innerWidth * 0.11, 12, 120);
@@ -570,8 +578,21 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
_dailyLowBlocks[i].HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right;
_dailyHighBlocks[i].TextAlignment = TextAlignment.Right;
_dailyLowBlocks[i].TextAlignment = TextAlignment.Right;
_dailyIconBlocks[i].Width = dailyIconSize;
_dailyIconBlocks[i].Height = dailyIconSize;
if (_dailyIconBlocks[i].Parent is Grid dailyRowGrid)
{
dailyRowGrid.ColumnSpacing = Math.Clamp(9 * dailyRowScale, 4, 18);
}
var dailyKind = i < _dailyIconKinds.Length
? _dailyIconKinds[i]
: HyperOS3WeatherVisualKind.CloudyDay;
var dailyIconVisualSize = Math.Clamp(
dailyIconSize * ResolveDailyMiniIconScaleBoost(dailyKind),
8,
148);
dailyIconVisualSize = Math.Min(dailyIconVisualSize, Math.Max(10, dailyRowHeight * 0.94));
_dailyIconBlocks[i].Width = dailyIconVisualSize;
_dailyIconBlocks[i].Height = dailyIconVisualSize;
}
}
@@ -905,6 +926,21 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
HyperOS3WeatherVisualKind.ClearNight or HyperOS3WeatherVisualKind.CloudyNight => 1.08,
_ => 1.0
};
private static double ResolveDailyMiniIconScaleBoost(HyperOS3WeatherVisualKind kind) =>
kind switch
{
HyperOS3WeatherVisualKind.CloudyDay => 1.30,
HyperOS3WeatherVisualKind.CloudyNight => 1.28,
HyperOS3WeatherVisualKind.ClearDay => 1.26,
HyperOS3WeatherVisualKind.ClearNight => 1.24,
HyperOS3WeatherVisualKind.Fog => 1.18,
HyperOS3WeatherVisualKind.RainLight => 1.14,
HyperOS3WeatherVisualKind.RainHeavy => 1.12,
HyperOS3WeatherVisualKind.Snow => 1.12,
HyperOS3WeatherVisualKind.Storm => 1.08,
_ => 1.18
};
private static FontWeight ToVariableWeight(double weight) => (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
private static IBrush CreateSolidBrush(string colorHex) => new SolidColorBrush(Color.Parse(colorHex));
private static IBrush CreateSolidBrush(string colorHex, byte alpha) { var c = Color.Parse(colorHex); return new SolidColorBrush(Color.FromArgb(alpha, c.R, c.G, c.B)); }

View File

@@ -90,7 +90,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
private readonly DispatcherTimer _backgroundAnimationTimer = new()
{
Interval = UiMotionTokens.WeatherAnimationFrameInterval
Interval = FluttermotionToken.WeatherAnimationFrameInterval
};
private readonly AppSettingsService _settingsService = new();
@@ -1264,7 +1264,9 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
var stackSpacing = Math.Clamp(2 * hourlyCellScale, 0.2, 10);
var hourlyTempSize = Math.Clamp(19.5 * hourlyCellScale, 6, 72);
var hourlyTimeSize = Math.Clamp(14.5 * hourlyCellScale, 6, 50);
var hourlyIconSize = Math.Clamp(34 * hourlyCellScale, 8, 108);
var hourlyIconSize = Math.Clamp(42 * hourlyCellScale, 9, 136);
hourlyIconSize = Math.Min(hourlyIconSize, Math.Max(10, hourlyCellWidth * 0.86));
hourlyIconSize = Math.Min(hourlyIconSize, Math.Max(10, bottomZoneHeight * 0.52));
for (var i = 0; i < _hourlyTimeBlocks.Length; i++)
{

View File

@@ -88,7 +88,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
private readonly DispatcherTimer _backgroundAnimationTimer = new()
{
Interval = UiMotionTokens.WeatherAnimationFrameInterval
Interval = FluttermotionToken.WeatherAnimationFrameInterval
};
private readonly AppSettingsService _settingsService = new();
@@ -1112,7 +1112,9 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
var stackSpacing = Math.Clamp(2 * hourlyCellScale, 0.2, 10);
var forecastRangeSize = Math.Clamp(18.0 * hourlyCellScale, 6, 62);
var forecastLabelSize = Math.Clamp(13.8 * hourlyCellScale, 6, 48);
var forecastIconSize = Math.Clamp(32 * hourlyCellScale, 8, 100);
var forecastIconSize = Math.Clamp(40 * hourlyCellScale, 9, 124);
forecastIconSize = Math.Min(forecastIconSize, Math.Max(10, hourlyCellWidth * 0.88));
forecastIconSize = Math.Min(forecastIconSize, Math.Max(10, bottomZoneHeight * 0.50));
for (var i = 0; i < _hourlyTimeBlocks.Length; i++)
{

View File

@@ -86,7 +86,7 @@
Opacity="0.62"
Stretch="UniformToFill">
<Image.Effect>
<BlurEffect Radius="{DynamicResource MotionBackdropBlurRadiusStrong}" />
<BlurEffect Radius="{DynamicResource FluttermotionToken.BackdropBlurRadiusStrong}" />
</Image.Effect>
</Image>
</Border>

View File

@@ -84,7 +84,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk
private readonly DispatcherTimer _backgroundAnimationTimer = new()
{
Interval = UiMotionTokens.WeatherAnimationFrameInterval
Interval = FluttermotionToken.WeatherAnimationFrameInterval
};
private readonly AppSettingsService _settingsService = new();

View File

@@ -0,0 +1,21 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="420"
d:DesignHeight="210"
x:Class="LanMountainDesktop.Views.Components.WorldClockWidget">
<Border x:Name="RootBorder"
Background="#F4F5F7"
BorderBrush="#16000000"
BorderThickness="1"
CornerRadius="26"
ClipToBounds="True"
Padding="10,8">
<Grid x:Name="ClockHostGrid"
ColumnDefinitions="*,*,*,*"
ColumnSpacing="8" />
</Border>
</UserControl>

View File

@@ -0,0 +1,671 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private const double BaseCellSize = 48;
private const double DialDesignSize = 100;
private const double DialCenter = DialDesignSize / 2d;
private static readonly FontFamily MiSansFontFamily =
new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly IReadOnlyDictionary<string, string> ZhCityNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "北京",
["Asia/Shanghai"] = "北京",
["GMT Standard Time"] = "伦敦",
["Europe/London"] = "伦敦",
["AUS Eastern Standard Time"] = "悉尼",
["Australia/Sydney"] = "悉尼",
["Eastern Standard Time"] = "纽约",
["America/New_York"] = "纽约",
["Tokyo Standard Time"] = "东京",
["Asia/Tokyo"] = "东京",
["UTC"] = "协调世界时",
["Etc/UTC"] = "协调世界时"
};
private static readonly IReadOnlyDictionary<string, string> EnCityNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "Beijing",
["Asia/Shanghai"] = "Beijing",
["GMT Standard Time"] = "London",
["Europe/London"] = "London",
["AUS Eastern Standard Time"] = "Sydney",
["Australia/Sydney"] = "Sydney",
["Eastern Standard Time"] = "New York",
["America/New_York"] = "New York",
["Tokyo Standard Time"] = "Tokyo",
["Asia/Tokyo"] = "Tokyo",
["UTC"] = "UTC",
["Etc/UTC"] = "UTC"
};
private sealed class ClockEntryVisual
{
public required StackPanel Host { get; init; }
public required Border DialBorder { get; init; }
public required Canvas TickCanvas { get; init; }
public required Canvas NumberCanvas { get; init; }
public required Line HourHand { get; init; }
public required Line MinuteHand { get; init; }
public required Line SecondHand { get; init; }
public required Ellipse CenterOuter { get; init; }
public required TextBlock CityTextBlock { get; init; }
public required TextBlock DayTextBlock { get; init; }
public required TextBlock OffsetTextBlock { get; init; }
public bool? IsNightApplied { get; set; }
}
private readonly DispatcherTimer _clockTimer = new()
{
Interval = TimeSpan.FromSeconds(1)
};
private readonly AppSettingsService _settingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly ClockEntryVisual[] _entryVisuals = new ClockEntryVisual[WorldClockTimeZoneCatalog.ClockCount];
private readonly TimeZoneInfo[] _entryTimeZones = new TimeZoneInfo[WorldClockTimeZoneCatalog.ClockCount];
private TimeZoneService? _timeZoneService;
private string _languageCode = "zh-CN";
private double _currentCellSize = BaseCellSize;
private DateTime _nextLanguageProbeUtc = DateTime.MinValue;
private string _secondHandMode = ClockSecondHandMode.Tick;
public WorldClockWidget()
{
InitializeComponent();
BuildClockEntryVisuals();
LoadFromSettings();
ApplySecondHandTimerInterval();
ApplyCellSize(_currentCellSize);
UpdateClockVisuals();
_clockTimer.Tick += OnClockTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
}
public void SetTimeZoneService(TimeZoneService timeZoneService)
{
ClearTimeZoneService();
_timeZoneService = timeZoneService;
_timeZoneService.TimeZoneChanged += OnTimeZoneChanged;
UpdateClockVisuals();
}
public void ClearTimeZoneService()
{
if (_timeZoneService is null)
{
return;
}
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
_timeZoneService = null;
}
public void RefreshFromSettings()
{
LoadFromSettings();
ApplySecondHandTimerInterval();
UpdateClockVisuals();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var scale = ResolveScale();
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
var horizontalPadding = Math.Clamp(10 * scale, 4, 26);
var verticalPadding = Math.Clamp(8 * scale, 3, 22);
RootBorder.Padding = new Thickness(horizontalPadding, verticalPadding);
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 10, 46));
var usableWidth = Math.Max(48, totalWidth - horizontalPadding * 2);
var usableHeight = Math.Max(28, totalHeight - verticalPadding * 2);
var columnSpacing = Math.Clamp(usableWidth * 0.015, 2, 14);
ClockHostGrid.ColumnSpacing = columnSpacing;
var widthPerClock = Math.Max(18, (usableWidth - columnSpacing * 3) / WorldClockTimeZoneCatalog.ClockCount);
var secondaryFont = Math.Clamp(10.5 * scale * (widthPerClock / 46d), 7, 18);
var cityFont = Math.Clamp(secondaryFont * 1.42, 9, 24);
var textSpacing = Math.Clamp(2.8 * scale, 1, 7);
var estimatedTextHeight = cityFont * 1.2 + secondaryFont * 2.35 + textSpacing * 3;
var dialSize = Math.Clamp(Math.Min(widthPerClock, usableHeight - estimatedTextHeight), 18, 108);
if (dialSize < 18)
{
dialSize = Math.Clamp(Math.Min(widthPerClock, usableHeight * 0.56), 16, 108);
}
foreach (var entry in _entryVisuals)
{
entry.Host.Spacing = textSpacing;
entry.DialBorder.Width = dialSize;
entry.DialBorder.Height = dialSize;
entry.DialBorder.CornerRadius = new CornerRadius(dialSize / 2d);
entry.CityTextBlock.FontSize = cityFont;
entry.DayTextBlock.FontSize = secondaryFont;
entry.OffsetTextBlock.FontSize = secondaryFont;
var maxTextWidth = Math.Max(16, widthPerClock + 10);
entry.CityTextBlock.MaxWidth = maxTextWidth;
entry.DayTextBlock.MaxWidth = maxTextWidth;
entry.OffsetTextBlock.MaxWidth = maxTextWidth;
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
LoadFromSettings();
ApplySecondHandTimerInterval();
UpdateClockVisuals();
_clockTimer.Start();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_clockTimer.Stop();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
_ = sender;
_ = e;
ApplyCellSize(_currentCellSize);
}
private void OnTimeZoneChanged(object? sender, EventArgs e)
{
_ = sender;
_ = e;
UpdateClockVisuals();
}
private void OnClockTimerTick(object? sender, EventArgs e)
{
_ = sender;
_ = e;
UpdateClockVisuals();
}
private void BuildClockEntryVisuals()
{
ClockHostGrid.Children.Clear();
for (var index = 0; index < WorldClockTimeZoneCatalog.ClockCount; index++)
{
var entry = CreateClockEntryVisual();
_entryVisuals[index] = entry;
ClockHostGrid.Children.Add(entry.Host);
Grid.SetColumn(entry.Host, index);
Grid.SetRow(entry.Host, 0);
}
}
private ClockEntryVisual CreateClockEntryVisual()
{
var tickCanvas = new Canvas
{
Width = DialDesignSize,
Height = DialDesignSize,
IsHitTestVisible = false
};
var numberCanvas = new Canvas
{
Width = DialDesignSize,
Height = DialDesignSize,
IsHitTestVisible = false
};
var handsCanvas = new Canvas
{
Width = DialDesignSize,
Height = DialDesignSize,
IsHitTestVisible = false
};
var hourHand = CreateHandLine("#2B3242", 5.0);
var minuteHand = CreateHandLine("#40495E", 3.2);
var secondHand = CreateHandLine("#1A74F2", 2.2);
handsCanvas.Children.Add(hourHand);
handsCanvas.Children.Add(minuteHand);
handsCanvas.Children.Add(secondHand);
var centerOuter = new Ellipse
{
Width = 11,
Height = 11,
Fill = CreateBrush("#4F7BC0"),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
var centerInner = new Ellipse
{
Width = 4.5,
Height = 4.5,
Fill = CreateBrush("#1A74F2"),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
var dialRoot = new Grid
{
Width = DialDesignSize,
Height = DialDesignSize
};
dialRoot.Children.Add(tickCanvas);
dialRoot.Children.Add(numberCanvas);
dialRoot.Children.Add(handsCanvas);
dialRoot.Children.Add(centerOuter);
dialRoot.Children.Add(centerInner);
var dialBorder = new Border
{
Width = 56,
Height = 56,
CornerRadius = new CornerRadius(28),
BorderThickness = new Thickness(1),
Background = CreateBrush("#FAFBFD"),
BorderBrush = CreateBrush("#DADFE8"),
ClipToBounds = true,
Child = new Viewbox
{
Stretch = Stretch.Uniform,
Child = dialRoot
}
};
var cityTextBlock = new TextBlock
{
Text = string.Empty,
FontFamily = MiSansFontFamily,
FontSize = 13,
FontWeight = FontWeight.SemiBold,
Foreground = CreateBrush("#20232A"),
TextAlignment = TextAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
TextWrapping = TextWrapping.NoWrap,
HorizontalAlignment = HorizontalAlignment.Center
};
var dayTextBlock = new TextBlock
{
Text = string.Empty,
FontFamily = MiSansFontFamily,
FontSize = 10.5,
FontWeight = FontWeight.Medium,
Foreground = CreateBrush("#646C79"),
TextAlignment = TextAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
TextWrapping = TextWrapping.NoWrap,
HorizontalAlignment = HorizontalAlignment.Center
};
var offsetTextBlock = new TextBlock
{
Text = string.Empty,
FontFamily = MiSansFontFamily,
FontSize = 10.5,
FontWeight = FontWeight.Medium,
Foreground = CreateBrush("#7A7F89"),
TextAlignment = TextAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
TextWrapping = TextWrapping.NoWrap,
HorizontalAlignment = HorizontalAlignment.Center
};
var host = new StackPanel
{
Orientation = Orientation.Vertical,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Spacing = 3,
Children =
{
dialBorder,
cityTextBlock,
dayTextBlock,
offsetTextBlock
}
};
var entry = new ClockEntryVisual
{
Host = host,
DialBorder = dialBorder,
TickCanvas = tickCanvas,
NumberCanvas = numberCanvas,
HourHand = hourHand,
MinuteHand = minuteHand,
SecondHand = secondHand,
CenterOuter = centerOuter,
CityTextBlock = cityTextBlock,
DayTextBlock = dayTextBlock,
OffsetTextBlock = offsetTextBlock
};
ApplyDialTheme(entry, isNight: false);
return entry;
}
private static void BuildDialTicks(ClockEntryVisual entry, bool isNight)
{
entry.TickCanvas.Children.Clear();
var majorColor = isNight ? "#E3E7F2" : "#2D3341";
var minorColor = isNight ? "#9EA7B8" : "#9AA4B3";
for (var i = 0; i < 60; i++)
{
var isMajor = i % 5 == 0;
var angle = (i * 6 - 90) * Math.PI / 180d;
var outerRadius = DialCenter - 6.5;
var innerRadius = outerRadius - (isMajor ? 9 : 4.5);
var x1 = DialCenter + Math.Cos(angle) * innerRadius;
var y1 = DialCenter + Math.Sin(angle) * innerRadius;
var x2 = DialCenter + Math.Cos(angle) * outerRadius;
var y2 = DialCenter + Math.Sin(angle) * outerRadius;
entry.TickCanvas.Children.Add(new Line
{
StartPoint = new Point(x1, y1),
EndPoint = new Point(x2, y2),
Stroke = CreateBrush(isMajor ? majorColor : minorColor),
StrokeThickness = isMajor ? 1.9 : 0.8,
StrokeLineCap = PenLineCap.Round
});
}
}
private static void BuildDialNumbers(ClockEntryVisual entry, bool isNight)
{
entry.NumberCanvas.Children.Clear();
var numberColor = isNight ? "#F2F5FB" : "#1B202A";
var radius = 36;
for (var number = 1; number <= 12; number++)
{
var angle = (number * 30 - 90) * Math.PI / 180d;
var x = DialCenter + Math.Cos(angle) * radius;
var y = DialCenter + Math.Sin(angle) * radius;
var text = number.ToString(CultureInfo.InvariantCulture);
var isDoubleDigit = number >= 10;
var width = isDoubleDigit ? 14 : 10;
var height = 12;
var numberText = new TextBlock
{
Text = text,
Width = width,
Height = height,
FontFamily = MiSansFontFamily,
FontSize = 9,
FontWeight = FontWeight.SemiBold,
Foreground = CreateBrush(numberColor),
TextAlignment = TextAlignment.Center
};
Canvas.SetLeft(numberText, x - width / 2d);
Canvas.SetTop(numberText, y - height / 2d);
entry.NumberCanvas.Children.Add(numberText);
}
}
private void LoadFromSettings()
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
var ids = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(snapshot.WorldClockTimeZoneIds);
for (var index = 0; index < WorldClockTimeZoneCatalog.ClockCount; index++)
{
var resolvedId = ids[index];
_entryTimeZones[index] = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(resolvedId);
}
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.WorldClockSecondHandMode);
}
private void ApplySecondHandTimerInterval()
{
_clockTimer.Interval = ClockSecondHandMode.IsSweep(_secondHandMode)
? TimeSpan.FromMilliseconds(16)
: TimeSpan.FromSeconds(1);
}
private void UpdateClockVisuals()
{
var utcNow = DateTime.UtcNow;
ProbeLanguageCodeIfNeeded(utcNow);
var baseZone = _timeZoneService?.CurrentTimeZone ?? TimeZoneInfo.Local;
var baseNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, baseZone);
var baseOffset = baseZone.GetUtcOffset(utcNow);
for (var index = 0; index < WorldClockTimeZoneCatalog.ClockCount; index++)
{
var entry = _entryVisuals[index];
var zone = _entryTimeZones[index] ?? TimeZoneInfo.Local;
var zonedNow = TimeZoneInfo.ConvertTimeFromUtc(utcNow, zone);
var isNight = IsNightForLocalTime(zonedNow);
ApplyDialTheme(entry, isNight);
var secondValue = ClockSecondHandMode.IsSweep(_secondHandMode)
? zonedNow.Second + zonedNow.Millisecond / 1000d
: zonedNow.Second;
var minuteValue = zonedNow.Minute + secondValue / 60d;
var hourValue = (zonedNow.Hour % 12) + minuteValue / 60d;
var hourAngle = hourValue * 30d;
var minuteAngle = minuteValue * 6d;
var secondAngle = secondValue * 6d;
SetHandGeometry(entry.HourHand, hourAngle, forwardLength: 24, backwardLength: 4.8);
SetHandGeometry(entry.MinuteHand, minuteAngle, forwardLength: 33, backwardLength: 6);
SetHandGeometry(entry.SecondHand, secondAngle, forwardLength: 37, backwardLength: 8.5);
entry.CityTextBlock.Text = ResolveCityName(zone);
entry.DayTextBlock.Text = ResolveRelativeDayLabel((zonedNow.Date - baseNow.Date).Days);
var offsetDelta = zone.GetUtcOffset(utcNow) - baseOffset;
entry.OffsetTextBlock.Text = ResolveOffsetLabel(offsetDelta);
}
}
private static void ApplyDialTheme(ClockEntryVisual entry, bool isNight)
{
if (entry.IsNightApplied.HasValue && entry.IsNightApplied.Value == isNight)
{
return;
}
entry.IsNightApplied = isNight;
entry.DialBorder.Background = CreateBrush(isNight ? "#2D313A" : "#FAFBFD");
entry.DialBorder.BorderBrush = CreateBrush(isNight ? "#262A33" : "#DADFE8");
entry.HourHand.Stroke = CreateBrush(isNight ? "#F5F8FF" : "#2B3242");
entry.MinuteHand.Stroke = CreateBrush(isNight ? "#DDE4F0" : "#40495E");
entry.SecondHand.Stroke = CreateBrush("#1A74F2");
entry.CenterOuter.Fill = CreateBrush(isNight ? "#97B4EA" : "#4F7BC0");
BuildDialTicks(entry, isNight);
BuildDialNumbers(entry, isNight);
}
private void ProbeLanguageCodeIfNeeded(DateTime utcNow)
{
if (utcNow < _nextLanguageProbeUtc)
{
return;
}
_nextLanguageProbeUtc = utcNow.AddSeconds(25);
try
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
private string ResolveCityName(TimeZoneInfo timeZone)
{
var cityNames = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
? ZhCityNames
: EnCityNames;
if (cityNames.TryGetValue(timeZone.Id, out var cityName))
{
return cityName;
}
var normalized = timeZone.Id;
var slashIndex = normalized.LastIndexOf('/');
if (slashIndex >= 0 && slashIndex < normalized.Length - 1)
{
normalized = normalized[(slashIndex + 1)..];
}
normalized = normalized.Replace('_', ' ').Trim();
normalized = normalized
.Replace("Standard Time", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("Daylight Time", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("Time", string.Empty, StringComparison.OrdinalIgnoreCase)
.Trim();
return string.IsNullOrWhiteSpace(normalized) ? timeZone.Id : normalized;
}
private string ResolveRelativeDayLabel(int dayDelta)
{
if (dayDelta < 0)
{
return L("worldclock.widget.yesterday", "昨天");
}
if (dayDelta > 0)
{
return L("worldclock.widget.tomorrow", "明天");
}
return L("worldclock.widget.today", "今天");
}
private string ResolveOffsetLabel(TimeSpan delta)
{
var totalMinutes = (int)Math.Round(delta.TotalMinutes);
if (totalMinutes == 0)
{
return L("worldclock.widget.offset_same", "0 小时");
}
var absMinutes = Math.Abs(totalMinutes);
var hours = absMinutes / 60;
var minutes = absMinutes % 60;
var isAhead = totalMinutes > 0;
if (minutes == 0)
{
return isAhead
? Lf("worldclock.widget.offset_ahead_hours", "早 {0} 小时", hours)
: Lf("worldclock.widget.offset_behind_hours", "晚 {0} 小时", hours);
}
return isAhead
? Lf("worldclock.widget.offset_ahead_hm", "早 {0} 小时 {1} 分", hours, minutes)
: Lf("worldclock.widget.offset_behind_hm", "晚 {0} 小时 {1} 分", hours, minutes);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private string Lf(string key, string fallback, params object[] args)
{
var template = L(key, fallback);
return string.Format(template, args);
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.5);
var widthScale = Bounds.Width > 1
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.52, 2.4)
: 1;
var heightScale = Bounds.Height > 1
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.52, 2.4)
: 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.50, 2.4);
}
private static bool IsNightForLocalTime(DateTime localTime)
{
var hour = localTime.Hour + localTime.Minute / 60d;
return hour < 6 || hour >= 18;
}
private static void SetHandGeometry(Line hand, double angleDeg, double forwardLength, double backwardLength)
{
var radians = (angleDeg - 90) * Math.PI / 180d;
var cos = Math.Cos(radians);
var sin = Math.Sin(radians);
hand.StartPoint = new Point(
DialCenter - cos * backwardLength,
DialCenter - sin * backwardLength);
hand.EndPoint = new Point(
DialCenter + cos * forwardLength,
DialCenter + sin * forwardLength);
}
private static Line CreateHandLine(string colorHex, double thickness)
{
return new Line
{
StartPoint = new Point(DialCenter, DialCenter),
EndPoint = new Point(DialCenter, DialCenter - 32),
Stroke = CreateBrush(colorHex),
StrokeThickness = thickness,
StrokeLineCap = PenLineCap.Round
};
}
private static IBrush CreateBrush(string colorHex)
{
return new SolidColorBrush(Color.Parse(colorHex));
}
}

View File

@@ -0,0 +1,121 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="560"
d:DesignHeight="380"
x:Class="LanMountainDesktop.Views.Components.WorldClockWidgetSettingsWindow">
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
Padding="16">
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<TextBlock x:Name="TitleTextBlock"
Text="世界时钟设置"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="DescriptionTextBlock"
Grid.Row="1"
Text="分别为四个时钟选择时区。"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<ScrollViewer Grid.Row="2"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="10"
Margin="0,0,6,0">
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="SecondHandModeLabelTextBlock"
Text="秒针方式"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<StackPanel Orientation="Horizontal"
Spacing="12">
<RadioButton x:Name="SecondHandTickRadioButton"
GroupName="world_clock_second_mode"
Content="跳针"
Checked="OnSecondHandModeChanged" />
<RadioButton x:Name="SecondHandSweepRadioButton"
GroupName="world_clock_second_mode"
Content="扫针"
Checked="OnSecondHandModeChanged" />
</StackPanel>
</StackPanel>
</Border>
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="ClockOneLabelTextBlock"
Text="时钟 1"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="ClockOneTimeZoneComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="ClockTwoLabelTextBlock"
Text="时钟 2"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="ClockTwoTimeZoneComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="ClockThreeLabelTextBlock"
Text="时钟 3"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="ClockThreeTimeZoneComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="ClockFourLabelTextBlock"
Text="时钟 4"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="ClockFourTimeZoneComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnTimeZoneSelectionChanged" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,244 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class WorldClockWidgetSettingsWindow : UserControl
{
private static readonly IReadOnlyDictionary<string, string> ZhTimeZoneNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["China Standard Time"] = "中国标准时间",
["Asia/Shanghai"] = "中国标准时间",
["GMT Standard Time"] = "格林威治标准时间",
["Europe/London"] = "格林威治标准时间",
["AUS Eastern Standard Time"] = "澳大利亚东部标准时间",
["Australia/Sydney"] = "澳大利亚东部标准时间",
["Eastern Standard Time"] = "美国东部标准时间",
["America/New_York"] = "美国东部标准时间",
["Tokyo Standard Time"] = "日本标准时间",
["Asia/Tokyo"] = "日本标准时间",
["UTC"] = "协调世界时",
["Etc/UTC"] = "协调世界时"
};
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly TimeZoneService _timeZoneService = new();
private readonly ComboBox[] _timeZoneComboBoxes;
private bool _suppressEvents;
private string _languageCode = "zh-CN";
private IReadOnlyList<TimeZoneInfo> _allTimeZones = Array.Empty<TimeZoneInfo>();
private IReadOnlyList<string> _selectedTimeZoneIds = Array.Empty<string>();
private string _secondHandMode = ClockSecondHandMode.Tick;
public event EventHandler? SettingsChanged;
public WorldClockWidgetSettingsWindow()
{
InitializeComponent();
_timeZoneComboBoxes =
[
ClockOneTimeZoneComboBox,
ClockTwoTimeZoneComboBox,
ClockThreeTimeZoneComboBox,
ClockFourTimeZoneComboBox
];
LoadState();
ApplyLocalization();
PopulateTimeZoneComboBoxes();
}
private void LoadState()
{
var snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
_allTimeZones = _timeZoneService
.GetAllTimeZones()
.OrderBy(zone => zone.GetUtcOffset(DateTime.UtcNow))
.ThenBy(zone => zone.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
_selectedTimeZoneIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(
snapshot.WorldClockTimeZoneIds,
_allTimeZones);
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.WorldClockSecondHandMode);
}
private void ApplyLocalization()
{
TitleTextBlock.Text = L("worldclock.settings.title", "世界时钟设置");
DescriptionTextBlock.Text = L("worldclock.settings.desc", "分别为四个时钟选择时区。");
ClockOneLabelTextBlock.Text = L("worldclock.settings.clock_1", "时钟 1");
ClockTwoLabelTextBlock.Text = L("worldclock.settings.clock_2", "时钟 2");
ClockThreeLabelTextBlock.Text = L("worldclock.settings.clock_3", "时钟 3");
ClockFourLabelTextBlock.Text = L("worldclock.settings.clock_4", "时钟 4");
SecondHandModeLabelTextBlock.Text = L("worldclock.settings.second_mode_label", "秒针方式");
SecondHandTickRadioButton.Content = L("clock.second_mode.tick", "跳针");
SecondHandSweepRadioButton.Content = L("clock.second_mode.sweep", "扫针");
}
private void PopulateTimeZoneComboBoxes()
{
_suppressEvents = true;
try
{
foreach (var comboBox in _timeZoneComboBoxes)
{
comboBox.Items.Clear();
foreach (var timeZone in _allTimeZones)
{
comboBox.Items.Add(new ComboBoxItem
{
Tag = timeZone.Id,
Content = GetLocalizedTimeZoneDisplayName(timeZone)
});
}
}
for (var index = 0; index < _timeZoneComboBoxes.Length; index++)
{
var comboBox = _timeZoneComboBoxes[index];
var targetId = index < _selectedTimeZoneIds.Count
? _selectedTimeZoneIds[index]
: TimeZoneInfo.Local.Id;
var selected = comboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => string.Equals(item.Tag as string, targetId, StringComparison.OrdinalIgnoreCase));
comboBox.SelectedItem = selected ?? comboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
var normalizedMode = ClockSecondHandMode.Normalize(_secondHandMode);
SecondHandTickRadioButton.IsChecked = string.Equals(
normalizedMode,
ClockSecondHandMode.Tick,
StringComparison.OrdinalIgnoreCase);
SecondHandSweepRadioButton.IsChecked = string.Equals(
normalizedMode,
ClockSecondHandMode.Sweep,
StringComparison.OrdinalIgnoreCase);
}
finally
{
_suppressEvents = false;
}
}
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void OnSecondHandModeChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void SaveState()
{
var selectedIds = GetSelectedTimeZoneIds();
var normalizedIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(selectedIds, _allTimeZones);
_secondHandMode = GetSelectedSecondHandMode();
var snapshot = _appSettingsService.Load();
snapshot.WorldClockTimeZoneIds = normalizedIds.ToList();
snapshot.WorldClockSecondHandMode = _secondHandMode;
_appSettingsService.Save(snapshot);
_selectedTimeZoneIds = normalizedIds;
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
private string GetSelectedSecondHandMode()
{
return SecondHandSweepRadioButton.IsChecked == true
? ClockSecondHandMode.Sweep
: ClockSecondHandMode.Tick;
}
private List<string> GetSelectedTimeZoneIds()
{
var selectedIds = new List<string>(_timeZoneComboBoxes.Length);
foreach (var comboBox in _timeZoneComboBoxes)
{
if (comboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string timeZoneId &&
!string.IsNullOrWhiteSpace(timeZoneId))
{
selectedIds.Add(timeZoneId.Trim());
continue;
}
selectedIds.Add(TimeZoneInfo.Local.Id);
}
return selectedIds;
}
private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone)
{
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);
var sign = offset >= TimeSpan.Zero ? "+" : "-";
var totalMinutes = Math.Abs((int)offset.TotalMinutes);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
var displayName = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
? ResolveZhDisplayName(timeZone)
: ResolveEnDisplayName(timeZone);
return $"(UTC{sign}{hours:D2}:{minutes:D2}) {displayName}";
}
private static string ResolveZhDisplayName(TimeZoneInfo timeZone)
{
if (ZhTimeZoneNames.TryGetValue(timeZone.Id, out var localizedName))
{
return localizedName;
}
return string.IsNullOrWhiteSpace(timeZone.StandardName)
? timeZone.DisplayName
: timeZone.StandardName;
}
private static string ResolveEnDisplayName(TimeZoneInfo timeZone)
{
if (!string.IsNullOrWhiteSpace(timeZone.StandardName))
{
return timeZone.StandardName;
}
return timeZone.DisplayName;
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
}

View File

@@ -409,7 +409,7 @@ public partial class MainWindow
{
OpenSettingsPage();
}
}, UiMotionTokens.Slow);
}, FluttermotionToken.Slow);
}
private void InitializeDesktopComponentDragHandlers()
@@ -701,12 +701,24 @@ public partial class MainWindow
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopClock)
{
OpenDesktopClockComponentSettings();
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopClassSchedule)
{
OpenClassScheduleComponentSettings();
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopWorldClock)
{
OpenWorldClockComponentSettings();
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopDailyArtwork)
{
OpenDailyArtworkComponentSettings();
@@ -751,6 +763,38 @@ public partial class MainWindow
ComponentSettingsWindow.Opacity = 1;
}
private void OpenDesktopClockComponentSettings()
{
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
{
return;
}
var settingsContent = new AnalogClockWidgetSettingsWindow();
settingsContent.SettingsChanged += OnDesktopClockSettingsChanged;
ComponentSettingsContentHost.Content = settingsContent;
ComponentSettingsWindow.IsVisible = true;
ComponentSettingsWindow.Opacity = 0;
ComponentSettingsWindow.Opacity = 1;
}
private void OpenWorldClockComponentSettings()
{
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
{
return;
}
var settingsContent = new WorldClockWidgetSettingsWindow();
settingsContent.SettingsChanged += OnWorldClockSettingsChanged;
ComponentSettingsContentHost.Content = settingsContent;
ComponentSettingsWindow.IsVisible = true;
ComponentSettingsWindow.Opacity = 0;
ComponentSettingsWindow.Opacity = 1;
}
private void OpenStudyEnvironmentComponentSettings()
{
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
@@ -796,6 +840,30 @@ public partial class MainWindow
}
}
private void OnDesktopClockSettingsChanged(object? sender, EventArgs e)
{
_ = sender;
_ = e;
foreach (var pageGrid in _desktopPageComponentGrids.Values)
{
foreach (var host in pageGrid.Children.OfType<Border>())
{
if (!host.Classes.Contains(DesktopComponentHostClass))
{
continue;
}
if (TryGetContentHost(host)?.Child is AnalogClockWidget widget)
{
widget.RefreshFromSettings();
}
}
}
PersistSettings();
}
private void OnStudyEnvironmentSettingsChanged(object? sender, EventArgs e)
{
_ = sender;
@@ -839,6 +907,30 @@ public partial class MainWindow
PersistSettings();
}
private void OnWorldClockSettingsChanged(object? sender, EventArgs e)
{
_ = sender;
_ = e;
foreach (var pageGrid in _desktopPageComponentGrids.Values)
{
foreach (var host in pageGrid.Children.OfType<Border>())
{
if (!host.Classes.Contains(DesktopComponentHostClass))
{
continue;
}
if (TryGetContentHost(host)?.Child is WorldClockWidget widget)
{
widget.RefreshFromSettings();
}
}
}
PersistSettings();
}
private void CloseComponentSettingsWindow()
{
if (ComponentSettingsWindow is null)
@@ -851,6 +943,11 @@ public partial class MainWindow
classScheduleSettingsWindow.SettingsChanged -= OnClassScheduleSettingsChanged;
}
if (ComponentSettingsContentHost?.Content is AnalogClockWidgetSettingsWindow analogClockSettingsWindow)
{
analogClockSettingsWindow.SettingsChanged -= OnDesktopClockSettingsChanged;
}
if (ComponentSettingsContentHost?.Content is StudyEnvironmentWidgetSettingsWindow studyEnvironmentSettingsWindow)
{
studyEnvironmentSettingsWindow.SettingsChanged -= OnStudyEnvironmentSettingsChanged;
@@ -861,6 +958,11 @@ public partial class MainWindow
dailyArtworkSettingsWindow.SettingsChanged -= OnDailyArtworkSettingsChanged;
}
if (ComponentSettingsContentHost?.Content is WorldClockWidgetSettingsWindow worldClockSettingsWindow)
{
worldClockSettingsWindow.SettingsChanged -= OnWorldClockSettingsChanged;
}
ComponentSettingsWindow.Opacity = 0;
DispatcherTimer.RunOnce(() =>
@@ -873,7 +975,7 @@ public partial class MainWindow
{
ComponentSettingsContentHost.Content = null;
}
}, UiMotionTokens.Slow);
}, FluttermotionToken.Slow);
}
private void AddDesktopPage()
@@ -1272,6 +1374,14 @@ public partial class MainWindow
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopWorldClock, StringComparison.OrdinalIgnoreCase))
{
// Keep world clock widget at 2:1 ratio: 4x2, 6x3, 8x4...
return SnapSpanToScaleRules(
span,
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyScoreOverview, StringComparison.OrdinalIgnoreCase))
{
// Keep score overview widget square: 4x4, 5x5, 6x6...

View File

@@ -110,6 +110,7 @@ public partial class MainWindow
SettingsNavStatusBarTextBlock.Text = L("settings.nav.status_bar", "Status Bar");
SettingsNavWeatherTextBlock.Text = L("settings.nav.weather", "Weather");
SettingsNavRegionTextBlock.Text = L("settings.nav.region", "Region");
SettingsNavUpdateTextBlock.Text = L("settings.nav.update", "Update");
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
@@ -248,6 +249,8 @@ public partial class MainWindow
"settings.region.timezone_desc",
"Select a time zone. Clock and calendar widgets will follow this zone.");
ApplyUpdateLocalization();
SettingsNavAboutTextBlock.Text = L("settings.nav.about", "About");
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
VersionTextBlock.Text = Lf(

View File

@@ -8,6 +8,7 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
@@ -64,6 +65,7 @@ public partial class MainWindow
StatusBarSettingsPanel is null ||
WeatherSettingsPanel is null ||
RegionSettingsPanel is null ||
UpdateSettingsPanel is null ||
AboutSettingsPanel is null)
{
return;
@@ -76,7 +78,8 @@ public partial class MainWindow
StatusBarSettingsPanel.IsVisible = selectedIndex == 3;
WeatherSettingsPanel.IsVisible = selectedIndex == 4;
RegionSettingsPanel.IsVisible = selectedIndex == 5;
AboutSettingsPanel.IsVisible = selectedIndex == 6;
UpdateSettingsPanel.IsVisible = selectedIndex == 6;
AboutSettingsPanel.IsVisible = selectedIndex == 7;
if (selectedIndex == 1)
{
@@ -547,10 +550,12 @@ public partial class MainWindow
Core.Initialize();
_libVlc ??= new LibVLC("--quiet");
if (_videoWallpaperPlayer is null && DesktopVideoWallpaperView is not null)
if (_videoWallpaperPlayer is null)
{
_videoWallpaperPlayer = new MediaPlayer(_libVlc);
DesktopVideoWallpaperView.MediaPlayer = _videoWallpaperPlayer;
_videoWallpaperPlayer = new MediaPlayer(_libVlc)
{
EnableHardwareDecoding = false
};
}
if (_previewVideoWallpaperPlayer is null && WallpaperPreviewVideoView is not null)
@@ -560,6 +565,212 @@ public partial class MainWindow
}
}
private bool ConfigureDesktopVideoRenderer()
{
if (_videoWallpaperPlayer is null || DesktopVideoWallpaperImage is null)
{
return false;
}
var (targetWidth, targetHeight) = GetDesktopVideoRenderSize();
var targetPitch = targetWidth * 4;
var targetBufferSize = targetPitch * targetHeight;
if (targetBufferSize <= 0)
{
return false;
}
if (targetWidth == _desktopVideoFrameWidth &&
targetHeight == _desktopVideoFrameHeight &&
_desktopVideoFrameBufferPtr != IntPtr.Zero &&
_desktopVideoBitmap is not null)
{
return true;
}
ReleaseDesktopVideoRendererResources();
try
{
_desktopVideoFrameWidth = targetWidth;
_desktopVideoFrameHeight = targetHeight;
_desktopVideoFramePitch = targetPitch;
_desktopVideoFrameBufferSize = targetBufferSize;
_desktopVideoFrameBufferPtr = Marshal.AllocHGlobal(_desktopVideoFrameBufferSize);
_desktopVideoStagingBuffer = new byte[_desktopVideoFrameBufferSize];
_desktopVideoBitmap = new WriteableBitmap(
new PixelSize(_desktopVideoFrameWidth, _desktopVideoFrameHeight),
new Vector(96, 96),
PixelFormat.Bgra8888,
AlphaFormat.Opaque);
EnsureDesktopVideoCallbacks();
_videoWallpaperPlayer.SetVideoCallbacks(
_desktopVideoLockCallback!,
_desktopVideoUnlockCallback!,
_desktopVideoDisplayCallback!);
_videoWallpaperPlayer.SetVideoFormat(
"RV32",
(uint)_desktopVideoFrameWidth,
(uint)_desktopVideoFrameHeight,
(uint)_desktopVideoFramePitch);
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
return true;
}
catch
{
ReleaseDesktopVideoRendererResources();
return false;
}
}
private (int Width, int Height) GetDesktopVideoRenderSize()
{
var hostWidth = DesktopHost?.Bounds.Width ?? Bounds.Width;
var hostHeight = DesktopHost?.Bounds.Height ?? Bounds.Height;
var pixelWidth = Math.Max(1, (int)Math.Round(hostWidth * RenderScaling));
var pixelHeight = Math.Max(1, (int)Math.Round(hostHeight * RenderScaling));
const int maxPixelCount = 1920 * 1080;
var pixelCount = (long)pixelWidth * pixelHeight;
if (pixelCount > maxPixelCount)
{
var scale = Math.Sqrt((double)maxPixelCount / pixelCount);
pixelWidth = Math.Max(1, (int)Math.Round(pixelWidth * scale));
pixelHeight = Math.Max(1, (int)Math.Round(pixelHeight * scale));
}
return (pixelWidth, pixelHeight);
}
private void EnsureDesktopVideoCallbacks()
{
_desktopVideoLockCallback ??= OnDesktopVideoFrameLock;
_desktopVideoUnlockCallback ??= OnDesktopVideoFrameUnlock;
_desktopVideoDisplayCallback ??= OnDesktopVideoFrameDisplay;
}
private IntPtr OnDesktopVideoFrameLock(IntPtr opaque, IntPtr planes)
{
Monitor.Enter(_desktopVideoFrameSync);
if (_desktopVideoFrameBufferPtr == IntPtr.Zero)
{
Marshal.WriteIntPtr(planes, IntPtr.Zero);
Monitor.Exit(_desktopVideoFrameSync);
return IntPtr.Zero;
}
Marshal.WriteIntPtr(planes, _desktopVideoFrameBufferPtr);
return IntPtr.Zero;
}
private void OnDesktopVideoFrameUnlock(IntPtr opaque, IntPtr picture, IntPtr planes)
{
if (Monitor.IsEntered(_desktopVideoFrameSync))
{
Monitor.Exit(_desktopVideoFrameSync);
}
}
private void OnDesktopVideoFrameDisplay(IntPtr opaque, IntPtr picture)
{
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 1);
ScheduleDesktopVideoFrameUiRefresh();
}
private void ScheduleDesktopVideoFrameUiRefresh()
{
if (Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 1) == 1)
{
return;
}
Dispatcher.UIThread.Post(() =>
{
try
{
PushDesktopVideoFrameToWallpaperImage();
}
finally
{
Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 0);
if (Volatile.Read(ref _desktopVideoFrameDirtyFlag) == 1)
{
ScheduleDesktopVideoFrameUiRefresh();
}
}
}, DispatcherPriority.Render);
}
private void PushDesktopVideoFrameToWallpaperImage()
{
if (Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 0) == 0)
{
return;
}
if (_desktopVideoBitmap is null ||
_desktopVideoStagingBuffer is null ||
_desktopVideoFrameBufferPtr == IntPtr.Zero ||
_desktopVideoFrameBufferSize <= 0)
{
return;
}
lock (_desktopVideoFrameSync)
{
if (_desktopVideoFrameBufferPtr == IntPtr.Zero)
{
return;
}
Marshal.Copy(_desktopVideoFrameBufferPtr, _desktopVideoStagingBuffer, 0, _desktopVideoFrameBufferSize);
}
using var framebuffer = _desktopVideoBitmap.Lock();
var rows = Math.Min(framebuffer.Size.Height, _desktopVideoFrameHeight);
var bytesPerRow = Math.Min(framebuffer.RowBytes, _desktopVideoFramePitch);
for (var row = 0; row < rows; row++)
{
var sourceOffset = row * _desktopVideoFramePitch;
var destinationPtr = IntPtr.Add(framebuffer.Address, row * framebuffer.RowBytes);
Marshal.Copy(_desktopVideoStagingBuffer, sourceOffset, destinationPtr, bytesPerRow);
}
if (DesktopVideoWallpaperImage is not null &&
!ReferenceEquals(DesktopVideoWallpaperImage.Source, _desktopVideoBitmap))
{
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
}
}
private void ReleaseDesktopVideoRendererResources()
{
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 0);
Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 0);
if (DesktopVideoWallpaperImage is not null)
{
DesktopVideoWallpaperImage.Source = null;
}
_desktopVideoBitmap?.Dispose();
_desktopVideoBitmap = null;
_desktopVideoStagingBuffer = null;
_desktopVideoFrameWidth = 0;
_desktopVideoFrameHeight = 0;
_desktopVideoFramePitch = 0;
_desktopVideoFrameBufferSize = 0;
lock (_desktopVideoFrameSync)
{
if (_desktopVideoFrameBufferPtr != IntPtr.Zero)
{
Marshal.FreeHGlobal(_desktopVideoFrameBufferPtr);
_desktopVideoFrameBufferPtr = IntPtr.Zero;
}
}
}
private void PlayVideoWallpaper(string videoPath)
{
if (!File.Exists(videoPath))
@@ -575,7 +786,7 @@ public partial class MainWindow
if (_videoWallpaperPlayer is null ||
_previewVideoWallpaperPlayer is null ||
_libVlc is null ||
DesktopVideoWallpaperView is null ||
DesktopVideoWallpaperImage is null ||
WallpaperPreviewVideoView is null)
{
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
@@ -583,6 +794,13 @@ public partial class MainWindow
return;
}
if (!ConfigureDesktopVideoRenderer())
{
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
StopVideoWallpaper();
return;
}
_videoWallpaperMedia?.Dispose();
_previewVideoWallpaperMedia?.Dispose();
_videoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
@@ -591,7 +809,7 @@ public partial class MainWindow
_previewVideoWallpaperMedia.AddOption(":input-repeat=65535");
_videoWallpaperPlayer.Play(_videoWallpaperMedia);
_previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia);
DesktopVideoWallpaperView.IsVisible = true;
DesktopVideoWallpaperImage.IsVisible = true;
WallpaperPreviewVideoView.IsVisible = true;
}
catch (Exception ex)
@@ -603,9 +821,9 @@ public partial class MainWindow
private void StopVideoWallpaper()
{
if (DesktopVideoWallpaperView is not null)
if (DesktopVideoWallpaperImage is not null)
{
DesktopVideoWallpaperView.IsVisible = false;
DesktopVideoWallpaperImage.IsVisible = false;
}
if (WallpaperPreviewVideoView is not null)
@@ -613,16 +831,17 @@ public partial class MainWindow
WallpaperPreviewVideoView.IsVisible = false;
}
if (_videoWallpaperPlayer?.IsPlaying == true)
if (_videoWallpaperPlayer is not null)
{
_videoWallpaperPlayer.Stop();
}
if (_previewVideoWallpaperPlayer?.IsPlaying == true)
if (_previewVideoWallpaperPlayer is not null)
{
_previewVideoWallpaperPlayer.Stop();
}
ReleaseDesktopVideoRendererResources();
_videoWallpaperMedia?.Dispose();
_videoWallpaperMedia = null;
_previewVideoWallpaperMedia?.Dispose();
@@ -660,6 +879,9 @@ public partial class MainWindow
WeatherNoTlsRequests = _weatherNoTlsRequests,
DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(_dailyArtworkMirrorSource),
AutoStartWithWindows = _autoStartWithWindows,
AutoCheckUpdates = _autoCheckUpdates,
IncludePrereleaseUpdates = IncludePrereleaseUpdates,
UpdateChannel = IncludePrereleaseUpdates ? "Preview" : "Stable",
TopStatusComponentIds = _topStatusComponentIds.ToList(),
PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(),
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
@@ -2012,6 +2234,24 @@ public partial class MainWindow
};
}
if (UpdateOptionsSettingsExpander is not null)
{
UpdateOptionsSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
{
Symbol = Symbol.ArrowClockwiseDashesSettings,
IconVariant = variant
};
}
if (UpdateActionsSettingsExpander is not null)
{
UpdateActionsSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
{
Symbol = Symbol.ArrowDownload,
IconVariant = variant
};
}
if (AboutStartupSettingsExpander is not null)
{
AboutStartupSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource

View File

@@ -0,0 +1,482 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
public partial class MainWindow
{
private const string UpdateChannelStable = "Stable";
private const string UpdateChannelPreview = "Preview";
private bool _autoCheckUpdates = true;
private string _updateChannel = UpdateChannelStable;
private bool _suppressUpdateOptionEvents;
private bool _isCheckingUpdates;
private bool _isDownloadingUpdate;
private string _latestReleaseVersionText = "-";
private DateTimeOffset? _latestReleasePublishedAt;
private string _updateStatusText = string.Empty;
private string _updateDownloadProgressText = string.Empty;
private double _updateDownloadProgressPercent;
private GitHubReleaseAsset? _latestReleaseInstallerAsset;
private string? _downloadedUpdateInstallerPath;
private bool IncludePrereleaseUpdates => string.Equals(
_updateChannel,
UpdateChannelPreview,
StringComparison.OrdinalIgnoreCase);
private void InitializeUpdateSettings(AppSettingsSnapshot snapshot)
{
_autoCheckUpdates = snapshot.AutoCheckUpdates;
_updateChannel = NormalizeUpdateChannel(snapshot.UpdateChannel, snapshot.IncludePrereleaseUpdates);
_latestReleaseVersionText = "-";
_latestReleasePublishedAt = null;
_updateDownloadProgressPercent = 0;
_updateDownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
_updateStatusText = L("settings.update.status_ready", "Ready to check for updates.");
_latestReleaseInstallerAsset = null;
_downloadedUpdateInstallerPath = null;
_suppressUpdateOptionEvents = true;
try
{
if (AutoCheckUpdatesToggleSwitch is not null)
{
AutoCheckUpdatesToggleSwitch.IsChecked = _autoCheckUpdates;
}
if (UpdateChannelChipListBox is not null)
{
UpdateChannelChipListBox.SelectedIndex = IncludePrereleaseUpdates ? 1 : 0;
}
}
finally
{
_suppressUpdateOptionEvents = false;
}
UpdateUpdatePanelState();
}
private void TriggerAutoUpdateCheckIfEnabled()
{
if (!_autoCheckUpdates)
{
return;
}
_ = CheckForUpdatesAsync(silentWhenNoUpdate: true);
}
private void ApplyUpdateLocalization()
{
SettingsNavUpdateTextBlock.Text = L("settings.nav.update", "Update");
UpdatePanelTitleTextBlock.Text = L("settings.update.title", "Update");
UpdateCurrentVersionLabelTextBlock.Text = L("settings.update.current_version_label", "Current Version");
UpdateLatestVersionLabelTextBlock.Text = L("settings.update.latest_version_label", "Latest Release");
UpdatePublishedAtLabelTextBlock.Text = L("settings.update.published_at_label", "Published At");
UpdateOptionsSettingsExpander.Header = L("settings.update.options_header", "Update Options");
UpdateOptionsSettingsExpander.Description = L(
"settings.update.options_desc",
"Configure update checks and release channel.");
AutoCheckUpdatesToggleSwitch.Content = L(
"settings.update.auto_check_toggle",
"Automatically check for updates on startup");
UpdateChannelLabelTextBlock.Text = L(
"settings.update.channel_label",
"Update Channel");
UpdateChannelStableChipItem.Content = L(
"settings.update.channel_stable",
"Stable");
UpdateChannelPreviewChipItem.Content = L(
"settings.update.channel_preview",
"Preview");
UpdateActionsSettingsExpander.Header = L("settings.update.actions_header", "Update Actions");
UpdateActionsSettingsExpander.Description = L(
"settings.update.actions_desc",
"Check releases, download installer, and start update.");
CheckForUpdatesButton.Content = L("settings.update.check_button", "Check for Updates");
DownloadAndInstallUpdateButton.Content = L("settings.update.download_install_button", "Download & Install");
if (string.IsNullOrWhiteSpace(_updateDownloadProgressText))
{
_updateDownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
}
if (string.IsNullOrWhiteSpace(_updateStatusText))
{
_updateStatusText = L("settings.update.status_ready", "Ready to check for updates.");
}
UpdateUpdatePanelState();
}
private async void OnCheckForUpdatesClick(object? sender, RoutedEventArgs e)
{
await CheckForUpdatesAsync(silentWhenNoUpdate: false);
}
private async void OnDownloadAndInstallUpdateClick(object? sender, RoutedEventArgs e)
{
if (_isCheckingUpdates || _isDownloadingUpdate)
{
return;
}
if (_latestReleaseInstallerAsset is null)
{
await CheckForUpdatesAsync(silentWhenNoUpdate: false);
}
if (_latestReleaseInstallerAsset is null)
{
return;
}
await DownloadAndInstallUpdateAsync(_latestReleaseInstallerAsset);
}
private void OnAutoCheckUpdatesToggled(object? sender, RoutedEventArgs e)
{
if (_suppressUpdateOptionEvents || AutoCheckUpdatesToggleSwitch is null)
{
return;
}
_autoCheckUpdates = AutoCheckUpdatesToggleSwitch.IsChecked == true;
PersistSettings();
}
private void OnUpdateChannelSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressUpdateOptionEvents || UpdateChannelChipListBox is null)
{
return;
}
var selectedChannel = UpdateChannelChipListBox.SelectedIndex == 1
? UpdateChannelPreview
: UpdateChannelStable;
if (string.Equals(_updateChannel, selectedChannel, StringComparison.OrdinalIgnoreCase))
{
return;
}
_updateChannel = selectedChannel;
_latestReleaseInstallerAsset = null;
_latestReleaseVersionText = "-";
_latestReleasePublishedAt = null;
_downloadedUpdateInstallerPath = null;
_updateStatusText = Lf(
"settings.update.status_channel_changed_format",
"Update channel switched to {0}. Please check again.",
GetLocalizedUpdateChannelName(_updateChannel));
PersistSettings();
UpdateUpdatePanelState();
}
private async Task CheckForUpdatesAsync(bool silentWhenNoUpdate)
{
if (_isCheckingUpdates || _isDownloadingUpdate)
{
return;
}
if (!OperatingSystem.IsWindows())
{
_updateStatusText = L(
"settings.update.status_windows_only",
"Automatic installer update is currently available only on Windows.");
UpdateUpdatePanelState();
return;
}
_isCheckingUpdates = true;
_updateStatusText = L("settings.update.status_checking", "Checking GitHub releases...");
_updateDownloadProgressPercent = 0;
_updateDownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
UpdateUpdatePanelState();
try
{
if (!Version.TryParse(GetAppVersionText(), out var currentVersion))
{
currentVersion = new Version(0, 0, 0);
}
var result = await _releaseUpdateService.CheckForUpdatesAsync(
currentVersion,
IncludePrereleaseUpdates);
if (!result.Success)
{
_latestReleaseInstallerAsset = null;
_latestReleaseVersionText = "-";
_latestReleasePublishedAt = null;
_downloadedUpdateInstallerPath = null;
_updateStatusText = Lf(
"settings.update.status_check_failed_format",
"Update check failed: {0}",
result.ErrorMessage ?? L("common.unknown", "Unknown error"));
return;
}
_latestReleaseInstallerAsset = result.PreferredAsset;
_latestReleaseVersionText = result.LatestVersionText;
_latestReleasePublishedAt = result.Release?.PublishedAt;
_downloadedUpdateInstallerPath = null;
if (!result.IsUpdateAvailable)
{
_latestReleaseInstallerAsset = null;
_updateStatusText = silentWhenNoUpdate
? L("settings.update.status_up_to_date", "You are already on the latest version.")
: L("settings.update.status_up_to_date", "You are already on the latest version.");
return;
}
if (_latestReleaseInstallerAsset is null)
{
_updateStatusText = L(
"settings.update.status_asset_missing",
"A new release is available, but no compatible installer was found.");
return;
}
_updateStatusText = Lf(
"settings.update.status_available_format",
"New version {0} is available. Click Download & Install.",
_latestReleaseVersionText);
}
catch (Exception ex)
{
_updateStatusText = Lf(
"settings.update.status_check_failed_format",
"Update check failed: {0}",
ex.Message);
}
finally
{
_isCheckingUpdates = false;
UpdateUpdatePanelState();
}
}
private async Task DownloadAndInstallUpdateAsync(GitHubReleaseAsset asset)
{
if (_isCheckingUpdates || _isDownloadingUpdate)
{
return;
}
_isDownloadingUpdate = true;
_updateStatusText = L("settings.update.status_downloading", "Downloading installer...");
_updateDownloadProgressPercent = 0;
_updateDownloadProgressText = Lf(
"settings.update.download_progress_format",
"Download progress: {0:F0}%",
_updateDownloadProgressPercent);
UpdateUpdatePanelState();
try
{
var destinationPath = BuildUpdateInstallerPath(asset.Name);
var progress = new Progress<double>(value =>
{
_updateDownloadProgressPercent = Math.Clamp(value * 100d, 0d, 100d);
_updateDownloadProgressText = Lf(
"settings.update.download_progress_format",
"Download progress: {0:F0}%",
_updateDownloadProgressPercent);
UpdateUpdatePanelState();
});
var result = await _releaseUpdateService.DownloadAssetAsync(asset, destinationPath, progress);
if (!result.Success || string.IsNullOrWhiteSpace(result.FilePath))
{
_updateStatusText = Lf(
"settings.update.status_download_failed_format",
"Download failed: {0}",
result.ErrorMessage ?? L("common.unknown", "Unknown error"));
return;
}
_downloadedUpdateInstallerPath = result.FilePath;
_updateDownloadProgressPercent = 100;
_updateDownloadProgressText = Lf(
"settings.update.download_progress_format",
"Download progress: {0:F0}%",
_updateDownloadProgressPercent);
_updateStatusText = L("settings.update.status_launching_installer", "Download complete. Launching installer...");
UpdateUpdatePanelState();
LaunchInstallerAndExit(_downloadedUpdateInstallerPath);
}
catch (Exception ex)
{
_updateStatusText = Lf(
"settings.update.status_download_failed_format",
"Download failed: {0}",
ex.Message);
}
finally
{
_isDownloadingUpdate = false;
UpdateUpdatePanelState();
}
}
private void LaunchInstallerAndExit(string installerPath)
{
if (string.IsNullOrWhiteSpace(installerPath) || !File.Exists(installerPath))
{
_updateStatusText = L(
"settings.update.status_installer_missing",
"Installer file was not found after download.");
UpdateUpdatePanelState();
return;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = installerPath,
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory,
UseShellExecute = true
});
_updateStatusText = L(
"settings.update.status_installer_started",
"Installer started. The app will close for update.");
UpdateUpdatePanelState();
Dispatcher.UIThread.Post(Close, DispatcherPriority.Background);
}
catch (Exception ex)
{
_updateStatusText = Lf(
"settings.update.status_launch_failed_format",
"Failed to start installer: {0}",
ex.Message);
UpdateUpdatePanelState();
}
}
private void UpdateUpdatePanelState()
{
if (UpdateCurrentVersionValueTextBlock is not null)
{
UpdateCurrentVersionValueTextBlock.Text = GetAppVersionText();
}
if (UpdateLatestVersionValueTextBlock is not null)
{
UpdateLatestVersionValueTextBlock.Text = string.IsNullOrWhiteSpace(_latestReleaseVersionText)
? "-"
: _latestReleaseVersionText;
}
if (UpdatePublishedAtValueTextBlock is not null)
{
UpdatePublishedAtValueTextBlock.Text = _latestReleasePublishedAt.HasValue &&
_latestReleasePublishedAt.Value != DateTimeOffset.MinValue
? _latestReleasePublishedAt.Value.LocalDateTime.ToString("yyyy-MM-dd HH:mm")
: "-";
}
if (UpdateStatusTextBlock is not null)
{
UpdateStatusTextBlock.Text = string.IsNullOrWhiteSpace(_updateStatusText)
? L("settings.update.status_ready", "Ready to check for updates.")
: _updateStatusText;
}
if (UpdateDownloadProgressTextBlock is not null)
{
UpdateDownloadProgressTextBlock.Text = string.IsNullOrWhiteSpace(_updateDownloadProgressText)
? L("settings.update.download_progress_idle", "Download progress: -")
: _updateDownloadProgressText;
}
if (UpdateDownloadProgressBar is not null)
{
UpdateDownloadProgressBar.IsVisible = _isDownloadingUpdate;
UpdateDownloadProgressBar.Value = Math.Clamp(_updateDownloadProgressPercent, 0d, 100d);
}
if (CheckForUpdatesButton is not null)
{
CheckForUpdatesButton.IsEnabled = !_isCheckingUpdates && !_isDownloadingUpdate;
}
if (DownloadAndInstallUpdateButton is not null)
{
DownloadAndInstallUpdateButton.IsEnabled = !_isCheckingUpdates &&
!_isDownloadingUpdate &&
_latestReleaseInstallerAsset is not null;
}
}
private static string NormalizeUpdateChannel(string? channel, bool includePrereleaseFallback)
{
if (string.Equals(channel, UpdateChannelPreview, StringComparison.OrdinalIgnoreCase))
{
return UpdateChannelPreview;
}
if (string.Equals(channel, UpdateChannelStable, StringComparison.OrdinalIgnoreCase))
{
return UpdateChannelStable;
}
return includePrereleaseFallback ? UpdateChannelPreview : UpdateChannelStable;
}
private string GetLocalizedUpdateChannelName(string channel)
{
return string.Equals(channel, UpdateChannelPreview, StringComparison.OrdinalIgnoreCase)
? L("settings.update.channel_preview", "Preview")
: L("settings.update.channel_stable", "Stable");
}
private static string BuildUpdateInstallerPath(string assetName)
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var updatesDirectory = Path.Combine(appData, "LanMountainDesktop", "Updates");
Directory.CreateDirectory(updatesDirectory);
var safeName = SanitizeFileName(assetName);
return Path.Combine(updatesDirectory, safeName);
}
private static string SanitizeFileName(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return $"LanMountainDesktop-Update-{DateTime.Now:yyyyMMddHHmmss}.exe";
}
var sanitized = fileName;
foreach (var c in Path.GetInvalidFileNameChars())
{
sanitized = sanitized.Replace(c, '_');
}
return sanitized;
}
}

View File

@@ -67,7 +67,7 @@
VerticalAlignment="Stretch">
<Grid.Transitions>
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.24" />
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
</Transitions>
</Grid.Transitions>
@@ -83,11 +83,12 @@
VerticalAlignment="Stretch"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}" />
<vlc:VideoView x:Name="DesktopVideoWallpaperView"
IsVisible="False"
IsHitTestVisible="False"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
<Image x:Name="DesktopVideoWallpaperImage"
IsVisible="False"
IsHitTestVisible="False"
Stretch="UniformToFill"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
<Grid x:Name="DesktopGrid"
HorizontalAlignment="Center"
@@ -109,7 +110,7 @@
<TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="X" Duration="0:0:0.24" />
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>
@@ -349,7 +350,7 @@
VerticalAlignment="Stretch">
<Grid.Transitions>
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.24" />
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
</Transitions>
</Grid.Transitions>
@@ -365,7 +366,7 @@
<TranslateTransform Y="30">
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="Y" Duration="0:0:0.24" />
<DoubleTransition Property="Y" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>
@@ -447,6 +448,12 @@
<TextBlock x:Name="SettingsNavRegionTextBlock" Text="&#22320;&#21306;" VerticalAlignment="Center" />
</StackPanel>
</ListBoxItem>
<ListBoxItem x:Name="SettingsNavUpdateItem" ToolTip.Tip="&#26356;&#26032;">
<StackPanel Orientation="Horizontal" Spacing="12">
<fi:SymbolIcon x:Name="SettingsNavUpdateIcon" Symbol="ArrowSync" IconVariant="Regular" />
<TextBlock x:Name="SettingsNavUpdateTextBlock" Text="&#26356;&#26032;" VerticalAlignment="Center" />
</StackPanel>
</ListBoxItem>
<ListBoxItem x:Name="SettingsNavAboutItem" ToolTip.Tip="&#20851;&#20110;">
<StackPanel Orientation="Horizontal" Spacing="12">
<fi:SymbolIcon x:Name="SettingsNavAboutIcon" Symbol="Info" IconVariant="Regular" />
@@ -1378,6 +1385,114 @@
</Border>
</StackPanel>
<StackPanel x:Name="UpdateSettingsPanel" IsVisible="False" Spacing="16">
<TextBlock x:Name="UpdatePanelTitleTextBlock"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="Update" />
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}" CornerRadius="{DynamicResource DesignCornerRadiusMd}" Padding="20">
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto" ColumnSpacing="12" RowSpacing="8">
<TextBlock x:Name="UpdateCurrentVersionLabelTextBlock"
Text="Current Version"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="UpdateCurrentVersionValueTextBlock"
Grid.Column="1"
Text="-"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="UpdateLatestVersionLabelTextBlock"
Grid.Row="1"
Text="Latest Release"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="UpdateLatestVersionValueTextBlock"
Grid.Row="1" Grid.Column="1"
Text="-"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="UpdatePublishedAtLabelTextBlock"
Grid.Row="2"
Text="Published At"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="UpdatePublishedAtValueTextBlock"
Grid.Row="2" Grid.Column="1"
Text="-"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</Grid>
</Border>
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="UpdateOptionsSettingsExpander"
Header="Update Options"
Description="Configure update checks and release channel."
IsExpanded="True">
<ui:SettingsExpander.Footer>
<StackPanel Spacing="10">
<ToggleSwitch x:Name="AutoCheckUpdatesToggleSwitch"
Checked="OnAutoCheckUpdatesToggled"
Unchecked="OnAutoCheckUpdatesToggled"
Content="Automatically check for updates on startup" />
<TextBlock x:Name="UpdateChannelLabelTextBlock"
Text="Update Channel"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<ListBox x:Name="UpdateChannelChipListBox"
SelectionChanged="OnUpdateChannelSelectionChanged">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="8" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBoxItem x:Name="UpdateChannelStableChipItem"
Tag="Stable"
Content="Stable" />
<ListBoxItem x:Name="UpdateChannelPreviewChipItem"
Tag="Preview"
Content="Preview" />
</ListBox>
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="UpdateActionsSettingsExpander"
Header="Update Actions"
Description="Check releases, download installer, and start update."
IsExpanded="True">
<ui:SettingsExpander.Footer>
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="10">
<Button x:Name="CheckForUpdatesButton"
MinWidth="140"
Click="OnCheckForUpdatesClick"
Content="Check for Updates" />
<Button x:Name="DownloadAndInstallUpdateButton"
MinWidth="180"
Click="OnDownloadAndInstallUpdateClick"
Content="Download &amp; Install" />
</StackPanel>
<ProgressBar x:Name="UpdateDownloadProgressBar"
Minimum="0"
Maximum="100"
Height="6"
IsVisible="False" />
<TextBlock x:Name="UpdateDownloadProgressTextBlock"
Text="Download progress: -"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="UpdateStatusTextBlock"
Text="Ready to check for updates."
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
</StackPanel>
<StackPanel x:Name="AboutSettingsPanel" IsVisible="False" Spacing="20">
<TextBlock x:Name="AboutPanelTitleTextBlock" FontSize="24" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" Text="About" />
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}" CornerRadius="{DynamicResource DesignCornerRadiusMd}" Padding="20">
@@ -1471,7 +1586,7 @@
PointerReleased="OnComponentLibraryWindowPointerReleased">
<Border.Transitions>
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.2" />
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Slow}" />
</Transitions>
</Border.Transitions>
@@ -1582,7 +1697,7 @@
<TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="X" Duration="0:0:0.22" />
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>

View File

@@ -58,7 +58,7 @@ public partial class MainWindow : Window
private const int MinEdgeInsetPercent = 0;
private const int MaxEdgeInsetPercent = 30;
private const int DefaultEdgeInsetPercent = 18;
private static readonly int SettingsTransitionDurationMs = (int)UiMotionTokens.Page.TotalMilliseconds;
private static readonly int SettingsTransitionDurationMs = (int)FluttermotionToken.Page.TotalMilliseconds;
private const double WallpaperPreviewMaxWidth = 520;
private const double LightBackgroundLuminanceThreshold = 0.57;
private const string TaskbarLayoutBottomFullRowMacStyle = "BottomFullRowMacStyle";
@@ -91,6 +91,7 @@ public partial class MainWindow : Window
private readonly LocalizationService _localizationService = new();
private readonly TimeZoneService _timeZoneService = new();
private readonly WindowsStartupService _windowsStartupService = new();
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
private readonly ComponentRegistry _componentRegistry = ComponentRegistry
@@ -126,6 +127,19 @@ public partial class MainWindow : Window
private Media? _videoWallpaperMedia;
private MediaPlayer? _previewVideoWallpaperPlayer;
private Media? _previewVideoWallpaperMedia;
private readonly object _desktopVideoFrameSync = new();
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
private IntPtr _desktopVideoFrameBufferPtr;
private byte[]? _desktopVideoStagingBuffer;
private WriteableBitmap? _desktopVideoBitmap;
private int _desktopVideoFrameWidth;
private int _desktopVideoFrameHeight;
private int _desktopVideoFramePitch;
private int _desktopVideoFrameBufferSize;
private int _desktopVideoFrameDirtyFlag;
private int _desktopVideoFrameUiRefreshScheduledFlag;
private string? _wallpaperPath;
private string _wallpaperStatus = "Current background uses solid color.";
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
@@ -221,7 +235,7 @@ public partial class MainWindow : Window
GridSizeSlider.ValueChanged += OnGridSizeSliderChanged;
GridSizeNumberBox.ValueChanged += OnGridSizeNumberBoxChanged;
SettingsNavListBox.SelectedIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, 6);
SettingsNavListBox.SelectedIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, 7);
UpdateSettingsTabContent();
WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement);
@@ -231,6 +245,7 @@ public partial class MainWindow : Window
InitializeWeatherSettings(snapshot);
_dailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
InitializeAutoStartWithWindowsSetting(snapshot);
InitializeUpdateSettings(snapshot);
InitializeDesktopSurfaceState(snapshot);
InitializeDesktopComponentPlacements(snapshot);
InitializeSettingsIcons();
@@ -262,6 +277,8 @@ public partial class MainWindow : Window
_suppressSettingsPersistence = false;
PersistSettings();
TriggerAutoUpdateCheckIfEnabled();
}
protected override void OnClosed(EventArgs e)
@@ -287,6 +304,7 @@ public partial class MainWindow : Window
{
recommendationServiceDisposable.Dispose();
}
_releaseUpdateService.Dispose();
_wallpaperBitmap?.Dispose();
_wallpaperBitmap = null;
PropertyChanged -= OnWindowPropertyChanged;