mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b71486423 | ||
|
|
8768fa1ed2 | ||
|
|
24f1b896e1 |
@@ -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" />
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ public class PanelIntroAnimationBehavior
|
||||
var index = 0;
|
||||
var timer = new DispatcherTimer(DispatcherPriority.Background)
|
||||
{
|
||||
Interval = UiMotionTokens.StaggerStepInterval
|
||||
Interval = FluttermotionToken.StaggerStepInterval
|
||||
};
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "镜像源",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
21
LanMountainDesktop/Services/ClockSecondHandMode.cs
Normal file
21
LanMountainDesktop/Services/ClockSecondHandMode.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
482
LanMountainDesktop/Services/GitHubReleaseUpdateService.cs
Normal file
482
LanMountainDesktop/Services/GitHubReleaseUpdateService.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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
187
LanMountainDesktop/Services/WorldClockTimeZoneCatalog.cs
Normal file
187
LanMountainDesktop/Services/WorldClockTimeZoneCatalog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
LanMountainDesktop/Styles/FluttermotionToken.axaml
Normal file
12
LanMountainDesktop/Styles/FluttermotionToken.axaml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
148
LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml
Normal file
148
LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml
Normal 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="央广网"
|
||||
Foreground="#D6272E"
|
||||
FontSize="28"
|
||||
FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="BrandSecondaryTextBlock"
|
||||
Text="·头条"
|
||||
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="↻"
|
||||
Foreground="#52575F"
|
||||
FontSize="19"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="RefreshLabelTextBlock"
|
||||
Text="换一换"
|
||||
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="热点 |"
|
||||
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>
|
||||
534
LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs
Normal file
534
LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs
Normal 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(), " ");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
127
LanMountainDesktop/Views/Components/DailyWordWidget.axaml
Normal file
127
LanMountainDesktop/Views/Components/DailyWordWidget.axaml
Normal 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>
|
||||
502
LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs
Normal file
502
LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
@@ -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++)
|
||||
{
|
||||
|
||||
@@ -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++)
|
||||
{
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
Opacity="0.62"
|
||||
Stretch="UniformToFill">
|
||||
<Image.Effect>
|
||||
<BlurEffect Radius="{DynamicResource MotionBackdropBlurRadiusStrong}" />
|
||||
<BlurEffect Radius="{DynamicResource FluttermotionToken.BackdropBlurRadiusStrong}" />
|
||||
</Image.Effect>
|
||||
</Image>
|
||||
</Border>
|
||||
|
||||
@@ -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();
|
||||
|
||||
21
LanMountainDesktop/Views/Components/WorldClockWidget.axaml
Normal file
21
LanMountainDesktop/Views/Components/WorldClockWidget.axaml
Normal 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>
|
||||
671
LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs
Normal file
671
LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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...
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
482
LanMountainDesktop/Views/MainWindow.Update.cs
Normal file
482
LanMountainDesktop/Views/MainWindow.Update.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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="地区" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</ListBoxItem>
|
||||
<ListBoxItem x:Name="SettingsNavUpdateItem" ToolTip.Tip="更新">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<fi:SymbolIcon x:Name="SettingsNavUpdateIcon" Symbol="ArrowSync" IconVariant="Regular" />
|
||||
<TextBlock x:Name="SettingsNavUpdateTextBlock" Text="更新" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</ListBoxItem>
|
||||
<ListBoxItem x:Name="SettingsNavAboutItem" ToolTip.Tip="关于">
|
||||
<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 & 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user