mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 08:04:26 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72a0be16b3 | ||
|
|
de40471af6 | ||
|
|
5d35e0d21c | ||
|
|
e917a1e4af | ||
|
|
b8643a2959 | ||
|
|
3b71486423 |
@@ -15,6 +15,21 @@
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
<TrayIcon.Icons>
|
||||
<TrayIcons>
|
||||
<TrayIcon Icon="/Assets/avalonia-logo.ico"
|
||||
ToolTipText="LanMountainDesktop">
|
||||
<TrayIcon.Menu>
|
||||
<NativeMenu>
|
||||
<NativeMenuItem Header="重启应用" Click="OnTrayRestartClick" />
|
||||
<NativeMenuItemSeparator />
|
||||
<NativeMenuItem Header="退出应用" Click="OnTrayExitClick" />
|
||||
</NativeMenu>
|
||||
</TrayIcon.Menu>
|
||||
</TrayIcon>
|
||||
</TrayIcons>
|
||||
</TrayIcon.Icons>
|
||||
|
||||
<Application.Styles>
|
||||
<sty:FluentAvaloniaTheme />
|
||||
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -37,6 +38,57 @@ public partial class App : Application
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTrayRestartClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryStartCurrentProcess())
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryStartCurrentProcess()
|
||||
{
|
||||
try
|
||||
{
|
||||
var args = Environment.GetCommandLineArgs();
|
||||
if (args.Length == 0 || string.IsNullOrWhiteSpace(args[0]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = args[0],
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
for (var i = 1; i < args.Length; i++)
|
||||
{
|
||||
startInfo.ArgumentList.Add(args[i]);
|
||||
}
|
||||
|
||||
Process.Start(startInfo);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
{
|
||||
// Get an array of plugins to remove
|
||||
|
||||
@@ -29,6 +29,11 @@ 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 DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
|
||||
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
|
||||
public const string DesktopExchangeRateCalculator = "DesktopExchangeRateCalculator";
|
||||
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||
public const string DesktopBrowser = "DesktopBrowser";
|
||||
|
||||
@@ -225,6 +225,51 @@ 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.DesktopBilibiliHotSearch,
|
||||
"Bilibili Hot Search",
|
||||
"News",
|
||||
"Info",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopStcn24Forum,
|
||||
"STCN 24",
|
||||
"News",
|
||||
"Info",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopExchangeRateCalculator,
|
||||
"Exchange Rate Converter",
|
||||
"Calculator",
|
||||
"Calculator",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopWhiteboard,
|
||||
"Blackboard Portrait",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"settings.nav.status_bar": "Status Bar",
|
||||
"settings.nav.weather": "Weather",
|
||||
"settings.nav.region": "Region",
|
||||
"settings.nav.update": "Update",
|
||||
"settings.nav.launcher": "App Launcher",
|
||||
"settings.nav.about": "About",
|
||||
"settings.wallpaper.title": "Wallpaper",
|
||||
"settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.",
|
||||
@@ -195,6 +197,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}",
|
||||
@@ -210,6 +244,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}",
|
||||
@@ -218,6 +253,16 @@
|
||||
"launcher.empty": "No Start Menu entries found.",
|
||||
"launcher.empty_folder": "This folder is empty.",
|
||||
"launcher.folder_items_format": "{0} apps",
|
||||
"launcher.context.hide_icon": "Hide Icon",
|
||||
"launcher.action.hide": "Hide",
|
||||
"settings.launcher.title": "App Launcher",
|
||||
"settings.launcher.hidden_header": "Hidden Items",
|
||||
"settings.launcher.hidden_desc": "Review hidden launcher entries and show them again.",
|
||||
"settings.launcher.hidden_hint": "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.",
|
||||
"settings.launcher.hidden_empty": "No hidden items.",
|
||||
"settings.launcher.hidden_type_folder": "Folder",
|
||||
"settings.launcher.hidden_type_shortcut": "Shortcut",
|
||||
"settings.launcher.restore_button": "Show Again",
|
||||
"button.component_library": "Edit Desktop",
|
||||
"tooltip.component_library": "Edit Desktop",
|
||||
"component_library.title": "Widgets",
|
||||
@@ -231,6 +276,7 @@
|
||||
"component_category.board": "Board",
|
||||
"component_category.media": "Media",
|
||||
"component_category.info": "Info",
|
||||
"component_category.calculator": "Calculator",
|
||||
"component_category.study": "Study",
|
||||
"component.date": "Calendar",
|
||||
"component.month_calendar": "Month Calendar",
|
||||
@@ -248,6 +294,11 @@
|
||||
"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.bilibili_hot_search": "Bilibili Hot Search",
|
||||
"component.stcn24_forum": "STCN 24",
|
||||
"component.exchange_rate_converter": "Exchange Rate Converter",
|
||||
"component.whiteboard": "Blackboard (Portrait)",
|
||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||
"component.browser": "Browser",
|
||||
@@ -280,6 +331,104 @@
|
||||
"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",
|
||||
"cnrnews.widget.hot_label": "Hot",
|
||||
"bilihot.widget.brand": "bilibili hot search",
|
||||
"bilihot.widget.top_right_label": "bilibili热搜",
|
||||
"bilihot.widget.search_entry": "Search",
|
||||
"bilihot.widget.search_placeholder": "Search trending topics",
|
||||
"bilihot.widget.loading": "Loading...",
|
||||
"bilihot.widget.loading_item": "Loading...",
|
||||
"bilihot.widget.fetch_failed": "Hot search fetch failed",
|
||||
"bilihot.widget.fallback_item": "No hot search data",
|
||||
"bilihot.widget.more_hot": "More hot search",
|
||||
"dailyword.settings.title": "Daily word settings",
|
||||
"dailyword.settings.desc": "Configure auto refresh and refresh interval.",
|
||||
"dailyword.settings.auto_refresh_label": "Auto refresh",
|
||||
"dailyword.settings.auto_refresh_enabled": "Enable auto refresh",
|
||||
"dailyword.settings.frequency_label": "Refresh interval",
|
||||
"bilihot.settings.title": "Bilibili hot search settings",
|
||||
"bilihot.settings.desc": "Configure auto refresh and refresh interval.",
|
||||
"bilihot.settings.auto_refresh_label": "Auto refresh",
|
||||
"bilihot.settings.auto_refresh_enabled": "Enable auto refresh",
|
||||
"bilihot.settings.frequency_label": "Refresh interval",
|
||||
"refresh.frequency.5m": "5 minutes",
|
||||
"refresh.frequency.10m": "10 minutes",
|
||||
"refresh.frequency.12m": "12 minutes",
|
||||
"refresh.frequency.15m": "15 minutes",
|
||||
"refresh.frequency.20m": "20 minutes",
|
||||
"refresh.frequency.30m": "30 minutes",
|
||||
"refresh.frequency.40m": "40 minutes",
|
||||
"refresh.frequency.1h": "1 hour",
|
||||
"refresh.frequency.3h": "3 hours",
|
||||
"refresh.frequency.6h": "6 hours",
|
||||
"refresh.frequency.12h": "12 hours",
|
||||
"refresh.frequency.24h": "24 hours",
|
||||
"weather.widget.settings.title": "Weather widget settings",
|
||||
"weather.widget.settings.desc": "Configure auto refresh and refresh interval for all weather widgets.",
|
||||
"weather.widget.settings.auto_refresh_label": "Auto refresh",
|
||||
"weather.widget.settings.auto_refresh_enabled": "Enable auto refresh",
|
||||
"weather.widget.settings.frequency_label": "Refresh interval",
|
||||
"weather.widget.settings.frequency_10m": "10 minutes",
|
||||
"weather.widget.settings.frequency_12m": "12 minutes",
|
||||
"weather.widget.settings.frequency_15m": "15 minutes",
|
||||
"weather.widget.settings.frequency_30m": "30 minutes",
|
||||
"weather.widget.settings.frequency_1h": "1 hour",
|
||||
"weather.widget.settings.frequency_3h": "3 hours",
|
||||
"stcn24.widget.loading": "Loading...",
|
||||
"stcn24.widget.loading_item": "Loading...",
|
||||
"stcn24.widget.fetch_failed": "Forum posts fetch failed",
|
||||
"stcn24.widget.fallback_item": "No posts",
|
||||
"stcn24.settings.title": "STCN 24 settings",
|
||||
"stcn24.settings.desc": "Configure information source, auto refresh and refresh interval.",
|
||||
"stcn24.settings.source_label": "Information source",
|
||||
"stcn24.settings.source_latest_created": "Latest posts",
|
||||
"stcn24.settings.source_latest_activity": "Latest activity",
|
||||
"stcn24.settings.source_most_replies": "Most replies",
|
||||
"stcn24.settings.source_earliest_created": "Earliest posts",
|
||||
"stcn24.settings.source_earliest_activity": "Earliest activity",
|
||||
"stcn24.settings.source_least_replies": "Least replies",
|
||||
"stcn24.settings.source_frontpage_latest": "Frontpage latest",
|
||||
"stcn24.settings.source_frontpage_earliest": "Frontpage earliest",
|
||||
"stcn24.settings.auto_refresh_label": "Auto refresh",
|
||||
"stcn24.settings.auto_refresh_enabled": "Enable auto refresh",
|
||||
"stcn24.settings.frequency_label": "Refresh interval",
|
||||
"stcn24.settings.frequency_5m": "5 minutes",
|
||||
"stcn24.settings.frequency_10m": "10 minutes",
|
||||
"stcn24.settings.frequency_20m": "20 minutes",
|
||||
"stcn24.settings.frequency_30m": "30 minutes",
|
||||
"stcn24.settings.frequency_1h": "1 hour",
|
||||
"stcn24.settings.frequency_3h": "3 hours",
|
||||
"exchange.widget.loading": "Loading exchange rates...",
|
||||
"exchange.widget.fetch_failed": "Exchange rate fetch failed",
|
||||
"cnrnews.settings.title": "CNR Settings",
|
||||
"cnrnews.settings.desc": "Configure auto-rotation and refresh interval.",
|
||||
"cnrnews.settings.auto_rotate_label": "Auto-rotation",
|
||||
"cnrnews.settings.auto_rotate_enabled": "Enable auto-rotation",
|
||||
"cnrnews.settings.frequency_label": "Rotation interval",
|
||||
"cnrnews.settings.frequency_5m": "5 minutes",
|
||||
"cnrnews.settings.frequency_10m": "10 minutes",
|
||||
"cnrnews.settings.frequency_40m": "40 minutes",
|
||||
"cnrnews.settings.frequency_1h": "1 hour",
|
||||
"cnrnews.settings.frequency_12h": "12 hours",
|
||||
"cnrnews.settings.frequency_24h": "24 hours",
|
||||
"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,8 @@
|
||||
"settings.nav.status_bar": "状态栏",
|
||||
"settings.nav.weather": "天气",
|
||||
"settings.nav.region": "地区",
|
||||
"settings.nav.update": "更新",
|
||||
"settings.nav.launcher": "应用启动台",
|
||||
"settings.nav.about": "关于",
|
||||
"settings.wallpaper.title": "壁纸",
|
||||
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
|
||||
@@ -195,6 +197,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}",
|
||||
@@ -210,6 +244,7 @@
|
||||
"common.night": "夜间",
|
||||
"common.back": "返回",
|
||||
"common.close": "关闭",
|
||||
"common.unknown": "未知错误",
|
||||
"common.recommended": "推荐",
|
||||
"common.monet": "莫奈",
|
||||
"desktop.page_index_format": "桌面 {0}",
|
||||
@@ -218,6 +253,16 @@
|
||||
"launcher.empty": "未找到开始菜单条目。",
|
||||
"launcher.empty_folder": "此文件夹为空。",
|
||||
"launcher.folder_items_format": "{0} 个应用",
|
||||
"launcher.context.hide_icon": "隐藏图标",
|
||||
"launcher.action.hide": "隐藏",
|
||||
"settings.launcher.title": "应用启动台",
|
||||
"settings.launcher.hidden_header": "已隐藏项目",
|
||||
"settings.launcher.hidden_desc": "查看已隐藏的启动台项目并重新显示。",
|
||||
"settings.launcher.hidden_hint": "进入桌面编辑模式后,在启动台选中图标并点击“隐藏”,隐藏后的项目会显示在这里。",
|
||||
"settings.launcher.hidden_empty": "暂无隐藏项目。",
|
||||
"settings.launcher.hidden_type_folder": "文件夹",
|
||||
"settings.launcher.hidden_type_shortcut": "快捷方式",
|
||||
"settings.launcher.restore_button": "重新显示",
|
||||
"button.component_library": "桌面编辑",
|
||||
"tooltip.component_library": "桌面编辑",
|
||||
"component_library.title": "桌面编辑",
|
||||
@@ -231,6 +276,7 @@
|
||||
"component_category.board": "白板",
|
||||
"component_category.media": "媒体",
|
||||
"component_category.info": "信息推荐",
|
||||
"component_category.calculator": "计算器",
|
||||
"component_category.study": "自习",
|
||||
"component.date": "日历",
|
||||
"component.month_calendar": "月历",
|
||||
@@ -248,6 +294,11 @@
|
||||
"component.audio_recorder": "录音",
|
||||
"component.daily_poetry": "每日诗词",
|
||||
"component.daily_artwork": "每日名画",
|
||||
"component.daily_word": "每日单词",
|
||||
"component.cnr_daily_news": "央广网头条",
|
||||
"component.bilibili_hot_search": "B站热搜",
|
||||
"component.stcn24_forum": "STCN 24",
|
||||
"component.exchange_rate_converter": "汇率换算",
|
||||
"component.whiteboard": "竖向小黑板",
|
||||
"component.blackboard_landscape": "横向小黑板",
|
||||
"component.browser": "浏览器",
|
||||
@@ -280,6 +331,104 @@
|
||||
"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": "点击右上角稍后重试",
|
||||
"cnrnews.widget.hot_label": "热点",
|
||||
"bilihot.widget.brand": "bilibili 热搜",
|
||||
"bilihot.widget.top_right_label": "bilibili热搜",
|
||||
"bilihot.widget.search_entry": "搜索",
|
||||
"bilihot.widget.search_placeholder": "搜索热词",
|
||||
"bilihot.widget.loading": "加载中...",
|
||||
"bilihot.widget.loading_item": "加载中...",
|
||||
"bilihot.widget.fetch_failed": "热搜获取失败",
|
||||
"bilihot.widget.fallback_item": "暂无热搜",
|
||||
"bilihot.widget.more_hot": "更多热搜",
|
||||
"dailyword.settings.title": "每日单词设置",
|
||||
"dailyword.settings.desc": "配置自动刷新开关与刷新频率。",
|
||||
"dailyword.settings.auto_refresh_label": "自动刷新",
|
||||
"dailyword.settings.auto_refresh_enabled": "启用自动刷新",
|
||||
"dailyword.settings.frequency_label": "刷新频率",
|
||||
"bilihot.settings.title": "B站热搜设置",
|
||||
"bilihot.settings.desc": "配置自动刷新开关与刷新频率。",
|
||||
"bilihot.settings.auto_refresh_label": "自动刷新",
|
||||
"bilihot.settings.auto_refresh_enabled": "启用自动刷新",
|
||||
"bilihot.settings.frequency_label": "刷新频率",
|
||||
"refresh.frequency.5m": "5 分钟",
|
||||
"refresh.frequency.10m": "10 分钟",
|
||||
"refresh.frequency.12m": "12 分钟",
|
||||
"refresh.frequency.15m": "15 分钟",
|
||||
"refresh.frequency.20m": "20 分钟",
|
||||
"refresh.frequency.30m": "30 分钟",
|
||||
"refresh.frequency.40m": "40 分钟",
|
||||
"refresh.frequency.1h": "1 小时",
|
||||
"refresh.frequency.3h": "3 小时",
|
||||
"refresh.frequency.6h": "6 小时",
|
||||
"refresh.frequency.12h": "12 小时",
|
||||
"refresh.frequency.24h": "24 小时",
|
||||
"weather.widget.settings.title": "天气组件设置",
|
||||
"weather.widget.settings.desc": "配置全部天气组件的自动刷新开关与刷新频率。",
|
||||
"weather.widget.settings.auto_refresh_label": "自动刷新",
|
||||
"weather.widget.settings.auto_refresh_enabled": "启用自动刷新",
|
||||
"weather.widget.settings.frequency_label": "刷新频率",
|
||||
"weather.widget.settings.frequency_10m": "10 分钟",
|
||||
"weather.widget.settings.frequency_12m": "12 分钟",
|
||||
"weather.widget.settings.frequency_15m": "15 分钟",
|
||||
"weather.widget.settings.frequency_30m": "30 分钟",
|
||||
"weather.widget.settings.frequency_1h": "1 小时",
|
||||
"weather.widget.settings.frequency_3h": "3 小时",
|
||||
"stcn24.widget.loading": "加载中...",
|
||||
"stcn24.widget.loading_item": "加载中...",
|
||||
"stcn24.widget.fetch_failed": "帖子获取失败",
|
||||
"stcn24.widget.fallback_item": "暂无帖子",
|
||||
"stcn24.settings.title": "STCN 24 设置",
|
||||
"stcn24.settings.desc": "配置信息源、自动刷新开关与刷新频率。",
|
||||
"stcn24.settings.source_label": "信息源",
|
||||
"stcn24.settings.source_latest_created": "最新发布",
|
||||
"stcn24.settings.source_latest_activity": "最新回复",
|
||||
"stcn24.settings.source_most_replies": "回复最多",
|
||||
"stcn24.settings.source_earliest_created": "最早发布",
|
||||
"stcn24.settings.source_earliest_activity": "最早回复",
|
||||
"stcn24.settings.source_least_replies": "回复最少",
|
||||
"stcn24.settings.source_frontpage_latest": "前台推荐(新)",
|
||||
"stcn24.settings.source_frontpage_earliest": "前台推荐(旧)",
|
||||
"stcn24.settings.auto_refresh_label": "自动刷新",
|
||||
"stcn24.settings.auto_refresh_enabled": "启用自动刷新",
|
||||
"stcn24.settings.frequency_label": "刷新频率",
|
||||
"stcn24.settings.frequency_5m": "5 分钟",
|
||||
"stcn24.settings.frequency_10m": "10 分钟",
|
||||
"stcn24.settings.frequency_20m": "20 分钟",
|
||||
"stcn24.settings.frequency_30m": "30 分钟",
|
||||
"stcn24.settings.frequency_1h": "1 小时",
|
||||
"stcn24.settings.frequency_3h": "3 小时",
|
||||
"exchange.widget.loading": "正在加载汇率...",
|
||||
"exchange.widget.fetch_failed": "汇率获取失败",
|
||||
"cnrnews.settings.title": "央广网设置",
|
||||
"cnrnews.settings.desc": "配置新闻自动轮换与刷新频率。",
|
||||
"cnrnews.settings.auto_rotate_label": "自动轮换",
|
||||
"cnrnews.settings.auto_rotate_enabled": "启用自动轮换",
|
||||
"cnrnews.settings.frequency_label": "轮换频率",
|
||||
"cnrnews.settings.frequency_5m": "5 分钟",
|
||||
"cnrnews.settings.frequency_10m": "10 分钟",
|
||||
"cnrnews.settings.frequency_40m": "40 分钟",
|
||||
"cnrnews.settings.frequency_1h": "1 小时",
|
||||
"cnrnews.settings.frequency_12h": "12 小时",
|
||||
"cnrnews.settings.frequency_24h": "24 小时",
|
||||
"artwork.settings.title": "每日图片设置",
|
||||
"artwork.settings.desc": "切换每日图片的数据源。",
|
||||
"artwork.settings.source_label": "镜像源",
|
||||
|
||||
@@ -44,10 +44,14 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool WeatherNoTlsRequests { get; set; }
|
||||
|
||||
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
||||
|
||||
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; } =
|
||||
@@ -72,25 +76,9 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public List<DesktopComponentPlacementSnapshot> DesktopComponentPlacements { get; set; } = [];
|
||||
|
||||
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
|
||||
public List<string> HiddenLauncherFolderPaths { get; set; } = [];
|
||||
|
||||
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
|
||||
|
||||
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
|
||||
|
||||
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 List<string> HiddenLauncherAppPaths { get; set; } = [];
|
||||
|
||||
public AppSettingsSnapshot Clone()
|
||||
{
|
||||
@@ -126,29 +114,11 @@ public sealed class AppSettingsSnapshot
|
||||
}
|
||||
}
|
||||
clone.DesktopComponentPlacements = placements;
|
||||
|
||||
var schedules = new List<ImportedClassScheduleSnapshot>(ImportedClassSchedules?.Count ?? 0);
|
||||
if (ImportedClassSchedules is not null)
|
||||
{
|
||||
foreach (var schedule in ImportedClassSchedules)
|
||||
{
|
||||
if (schedule is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
schedules.Add(new ImportedClassScheduleSnapshot
|
||||
{
|
||||
Id = schedule.Id,
|
||||
DisplayName = schedule.DisplayName,
|
||||
FilePath = schedule.FilePath
|
||||
});
|
||||
}
|
||||
}
|
||||
clone.ImportedClassSchedules = schedules;
|
||||
|
||||
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
|
||||
? new List<string>(WorldClockTimeZoneIds)
|
||||
clone.HiddenLauncherFolderPaths = HiddenLauncherFolderPaths is { Count: > 0 }
|
||||
? new List<string>(HiddenLauncherFolderPaths)
|
||||
: [];
|
||||
clone.HiddenLauncherAppPaths = HiddenLauncherAppPaths is { Count: > 0 }
|
||||
? new List<string>(HiddenLauncherAppPaths)
|
||||
: [];
|
||||
|
||||
return clone;
|
||||
|
||||
83
LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
Normal file
83
LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public sealed class ComponentSettingsSnapshot
|
||||
{
|
||||
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
||||
|
||||
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
|
||||
|
||||
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
|
||||
|
||||
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
|
||||
|
||||
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 bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
|
||||
|
||||
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
|
||||
|
||||
public bool DailyWordAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360;
|
||||
|
||||
public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
|
||||
|
||||
public bool WeatherAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
|
||||
|
||||
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
|
||||
|
||||
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
|
||||
|
||||
public ComponentSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||
|
||||
var schedules = new List<ImportedClassScheduleSnapshot>(ImportedClassSchedules?.Count ?? 0);
|
||||
if (ImportedClassSchedules is not null)
|
||||
{
|
||||
foreach (var schedule in ImportedClassSchedules)
|
||||
{
|
||||
if (schedule is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
schedules.Add(new ImportedClassScheduleSnapshot
|
||||
{
|
||||
Id = schedule.Id,
|
||||
DisplayName = schedule.DisplayName,
|
||||
FilePath = schedule.FilePath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,65 @@ 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 BilibiliHotSearchItemSnapshot(
|
||||
string Title,
|
||||
string Keyword,
|
||||
string Url,
|
||||
long? HeatScore,
|
||||
bool HasHotTag,
|
||||
string? IconUrl);
|
||||
|
||||
public sealed record BilibiliHotSearchSnapshot(
|
||||
string Provider,
|
||||
string Source,
|
||||
string SearchPlaceholder,
|
||||
string SearchUrl,
|
||||
string MoreHotUrl,
|
||||
IReadOnlyList<BilibiliHotSearchItemSnapshot> 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);
|
||||
|
||||
public sealed record ExchangeRateSnapshot(
|
||||
string Provider,
|
||||
string Source,
|
||||
string BaseCurrency,
|
||||
string TargetCurrency,
|
||||
decimal Rate,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
public sealed record Stcn24ForumPostItemSnapshot(
|
||||
string Title,
|
||||
string Url,
|
||||
string? AuthorDisplayName,
|
||||
string? AuthorAvatarUrl,
|
||||
DateTimeOffset? CreatedAt);
|
||||
|
||||
public sealed record Stcn24ForumPostsSnapshot(
|
||||
string Provider,
|
||||
string Source,
|
||||
IReadOnlyList<Stcn24ForumPostItemSnapshot> Items,
|
||||
DateTimeOffset FetchedAt);
|
||||
|
||||
74
LanMountainDesktop/Models/RefreshIntervalCatalog.cs
Normal file
74
LanMountainDesktop/Models/RefreshIntervalCatalog.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public static class RefreshIntervalCatalog
|
||||
{
|
||||
public static IReadOnlyList<int> SupportedIntervalsMinutes { get; } =
|
||||
[
|
||||
5,
|
||||
10,
|
||||
12,
|
||||
15,
|
||||
20,
|
||||
30,
|
||||
40,
|
||||
60,
|
||||
180,
|
||||
360,
|
||||
720,
|
||||
1440
|
||||
];
|
||||
|
||||
public static int Normalize(int minutes, int fallbackMinutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return fallbackMinutes;
|
||||
}
|
||||
|
||||
if (SupportedIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(fallbackMinutes);
|
||||
}
|
||||
|
||||
public static string ToLocalizationKeySuffix(int minutes)
|
||||
{
|
||||
return minutes switch
|
||||
{
|
||||
5 => "5m",
|
||||
10 => "10m",
|
||||
12 => "12m",
|
||||
15 => "15m",
|
||||
20 => "20m",
|
||||
30 => "30m",
|
||||
40 => "40m",
|
||||
60 => "1h",
|
||||
180 => "3h",
|
||||
360 => "6h",
|
||||
720 => "12h",
|
||||
1440 => "24h",
|
||||
_ => $"{minutes}m"
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToEnglishFallbackLabel(int minutes)
|
||||
{
|
||||
return minutes switch
|
||||
{
|
||||
60 => "1 hour",
|
||||
180 => "3 hours",
|
||||
360 => "6 hours",
|
||||
720 => "12 hours",
|
||||
1440 => "24 hours",
|
||||
_ => $"{minutes} min"
|
||||
};
|
||||
}
|
||||
}
|
||||
42
LanMountainDesktop/Models/Stcn24ForumSourceTypes.cs
Normal file
42
LanMountainDesktop/Models/Stcn24ForumSourceTypes.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public static class Stcn24ForumSourceTypes
|
||||
{
|
||||
public const string LatestCreated = "LatestCreated";
|
||||
public const string LatestActivity = "LatestActivity";
|
||||
public const string MostReplies = "MostReplies";
|
||||
public const string EarliestCreated = "EarliestCreated";
|
||||
public const string EarliestActivity = "EarliestActivity";
|
||||
public const string LeastReplies = "LeastReplies";
|
||||
public const string FrontpageLatest = "FrontpageLatest";
|
||||
public const string FrontpageEarliest = "FrontpageEarliest";
|
||||
|
||||
public static IReadOnlyList<string> SupportedValues { get; } =
|
||||
[
|
||||
LatestCreated,
|
||||
LatestActivity,
|
||||
MostReplies,
|
||||
EarliestCreated,
|
||||
EarliestActivity,
|
||||
LeastReplies,
|
||||
FrontpageLatest,
|
||||
FrontpageEarliest
|
||||
];
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
var candidate = value?.Trim() ?? string.Empty;
|
||||
foreach (var supported in SupportedValues)
|
||||
{
|
||||
if (string.Equals(candidate, supported, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return supported;
|
||||
}
|
||||
}
|
||||
|
||||
return LatestCreated;
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,6 @@ public enum TaskbarActionId
|
||||
AddDesktopPage,
|
||||
DeleteDesktopPage,
|
||||
DeleteComponent,
|
||||
EditComponent
|
||||
EditComponent,
|
||||
HideLauncherEntry
|
||||
}
|
||||
|
||||
123
LanMountainDesktop/Services/CalculatorDataService.cs
Normal file
123
LanMountainDesktop/Services/CalculatorDataService.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class CalculatorDataService : ICalculatorDataService
|
||||
{
|
||||
private const int MaxInputLength = 18;
|
||||
|
||||
public string ApplyInputToken(string currentInput, string token)
|
||||
{
|
||||
var normalized = NormalizeInput(currentInput);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (string.Equals(token, CalculatorInputTokens.Clear, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (string.Equals(token, CalculatorInputTokens.Backspace, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (normalized.Length <= 1)
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
var trimmed = normalized[..^1];
|
||||
if (trimmed is "-" or "" or "-0")
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (string.Equals(token, CalculatorInputTokens.DecimalPoint, StringComparison.Ordinal))
|
||||
{
|
||||
if (normalized.Contains('.', StringComparison.Ordinal))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (normalized.Length >= MaxInputLength)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return $"{normalized}.";
|
||||
}
|
||||
|
||||
if (token is "00")
|
||||
{
|
||||
if (normalized == "0")
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (normalized.Length + 2 > MaxInputLength)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized + "00";
|
||||
}
|
||||
|
||||
if (token.Length == 1 && char.IsDigit(token[0]))
|
||||
{
|
||||
if (normalized == "0")
|
||||
{
|
||||
return token;
|
||||
}
|
||||
|
||||
if (normalized.Length >= MaxInputLength)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized + token;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public decimal ParseAmountOrZero(string? inputText)
|
||||
{
|
||||
var normalized = NormalizeInput(inputText);
|
||||
if (decimal.TryParse(
|
||||
normalized,
|
||||
NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var amount))
|
||||
{
|
||||
return amount;
|
||||
}
|
||||
|
||||
return 0m;
|
||||
}
|
||||
|
||||
public string FormatAmount(decimal amount, int maxFractionDigits = 4)
|
||||
{
|
||||
var safeDigits = Math.Clamp(maxFractionDigits, 0, 8);
|
||||
var pattern = safeDigits == 0 ? "0" : $"0.{new string('#', safeDigits)}";
|
||||
return amount.ToString(pattern, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string NormalizeInput(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
var trimmed = input.Trim();
|
||||
return trimmed switch
|
||||
{
|
||||
"-" or "-0" => "0",
|
||||
_ => trimmed
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(inputPath))
|
||||
{
|
||||
inputPath = ResolveImportedSchedulePathFromAppSettings();
|
||||
inputPath = ResolveImportedSchedulePathFromComponentSettings();
|
||||
}
|
||||
|
||||
var source = ResolveSource(inputPath, profileFileName, warnings);
|
||||
@@ -180,11 +180,11 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? ResolveImportedSchedulePathFromAppSettings()
|
||||
private static string? ResolveImportedSchedulePathFromComponentSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = new AppSettingsService().Load();
|
||||
var snapshot = new ComponentSettingsService().Load();
|
||||
if (snapshot.ImportedClassSchedules.Count == 0)
|
||||
{
|
||||
return null;
|
||||
|
||||
392
LanMountainDesktop/Services/ComponentSettingsService.cs
Normal file
392
LanMountainDesktop/Services/ComponentSettingsService.cs
Normal file
@@ -0,0 +1,392 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class ComponentSettingsService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static readonly object CacheGate = new();
|
||||
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private static string? _cachedPath;
|
||||
private static ComponentSettingsSnapshot? _cachedSnapshot;
|
||||
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
|
||||
private static DateTime _lastProbeUtc = DateTime.MinValue;
|
||||
|
||||
private readonly string _settingsPath;
|
||||
private readonly string _legacyAppSettingsPath;
|
||||
|
||||
public ComponentSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
_settingsPath = Path.Combine(settingsDirectory, "component-settings.json");
|
||||
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
public ComponentSettingsSnapshot Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var hasFile = File.Exists(_settingsPath);
|
||||
var writeTimeUtc = hasFile
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.MinValue;
|
||||
|
||||
_lastProbeUtc = nowUtc;
|
||||
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
ComponentSettingsSnapshot loadedSnapshot;
|
||||
var loadedFromLegacy = false;
|
||||
if (hasFile)
|
||||
{
|
||||
loadedSnapshot = LoadSnapshotFromDisk();
|
||||
}
|
||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||
{
|
||||
loadedSnapshot = migratedSnapshot;
|
||||
loadedFromLegacy = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedSnapshot = new ComponentSettingsSnapshot();
|
||||
}
|
||||
|
||||
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
|
||||
if (loadedFromLegacy)
|
||||
{
|
||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||
}
|
||||
|
||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ComponentSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
var snapshotToPersist = NormalizeSnapshot(snapshot);
|
||||
|
||||
try
|
||||
{
|
||||
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
|
||||
|
||||
lock (CacheGate)
|
||||
{
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
nowUtc - _lastProbeUtc < CacheProbeInterval)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
writeTimeUtc == _cachedWriteTimeUtc)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private ComponentSettingsSnapshot LoadSnapshotFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
var snapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
|
||||
return NormalizeSnapshot(snapshot);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ComponentSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadLegacySnapshot(out ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
snapshot = new ComponentSettingsSnapshot();
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_legacyAppSettingsPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
|
||||
var legacy = JsonSerializer.Deserialize<LegacyComponentSettingsSnapshot>(legacyJson, SerializerOptions);
|
||||
if (legacy is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = new ComponentSettingsSnapshot
|
||||
{
|
||||
DailyArtworkMirrorSource = legacy.DailyArtworkMirrorSource,
|
||||
ImportedClassSchedules = legacy.ImportedClassSchedules ?? [],
|
||||
ActiveImportedClassScheduleId = legacy.ActiveImportedClassScheduleId ?? string.Empty,
|
||||
StudyEnvironmentShowDisplayDb = legacy.StudyEnvironmentShowDisplayDb,
|
||||
StudyEnvironmentShowDbfs = legacy.StudyEnvironmentShowDbfs,
|
||||
DesktopClockTimeZoneId = legacy.DesktopClockTimeZoneId,
|
||||
DesktopClockSecondHandMode = legacy.DesktopClockSecondHandMode,
|
||||
WorldClockTimeZoneIds = legacy.WorldClockTimeZoneIds ?? [],
|
||||
WorldClockSecondHandMode = legacy.WorldClockSecondHandMode,
|
||||
CnrDailyNewsAutoRotateEnabled = legacy.CnrDailyNewsAutoRotateEnabled,
|
||||
CnrDailyNewsAutoRotateIntervalMinutes = legacy.CnrDailyNewsAutoRotateIntervalMinutes,
|
||||
DailyWordAutoRefreshEnabled = legacy.DailyWordAutoRefreshEnabled,
|
||||
DailyWordAutoRefreshIntervalMinutes = legacy.DailyWordAutoRefreshIntervalMinutes,
|
||||
BilibiliHotSearchAutoRefreshEnabled = legacy.BilibiliHotSearchAutoRefreshEnabled,
|
||||
BilibiliHotSearchAutoRefreshIntervalMinutes = legacy.BilibiliHotSearchAutoRefreshIntervalMinutes,
|
||||
WeatherAutoRefreshEnabled = legacy.WeatherAutoRefreshEnabled,
|
||||
WeatherAutoRefreshIntervalMinutes = legacy.WeatherAutoRefreshIntervalMinutes,
|
||||
Stcn24ForumAutoRefreshEnabled = legacy.Stcn24ForumAutoRefreshEnabled,
|
||||
Stcn24ForumAutoRefreshIntervalMinutes = legacy.Stcn24ForumAutoRefreshIntervalMinutes,
|
||||
Stcn24ForumSourceType = legacy.Stcn24ForumSourceType
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime PersistSnapshotToDisk(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
|
||||
File.WriteAllText(_settingsPath, json);
|
||||
|
||||
return File.Exists(_settingsPath)
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static ComponentSettingsSnapshot NormalizeSnapshot(ComponentSettingsSnapshot? snapshot)
|
||||
{
|
||||
var normalized = snapshot?.Clone() ?? new ComponentSettingsSnapshot();
|
||||
|
||||
normalized.DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(normalized.DailyArtworkMirrorSource);
|
||||
normalized.ImportedClassSchedules = NormalizeImportedSchedules(normalized.ImportedClassSchedules);
|
||||
normalized.ActiveImportedClassScheduleId = NormalizeActiveScheduleId(
|
||||
normalized.ActiveImportedClassScheduleId,
|
||||
normalized.ImportedClassSchedules);
|
||||
|
||||
if (!normalized.StudyEnvironmentShowDisplayDb && !normalized.StudyEnvironmentShowDbfs)
|
||||
{
|
||||
normalized.StudyEnvironmentShowDisplayDb = true;
|
||||
}
|
||||
|
||||
normalized.DesktopClockTimeZoneId = NormalizeDesktopClockTimeZoneId(normalized.DesktopClockTimeZoneId);
|
||||
normalized.DesktopClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.DesktopClockSecondHandMode);
|
||||
normalized.WorldClockTimeZoneIds = WorldClockTimeZoneCatalog
|
||||
.NormalizeTimeZoneIds(normalized.WorldClockTimeZoneIds)
|
||||
.ToList();
|
||||
normalized.WorldClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.WorldClockSecondHandMode);
|
||||
normalized.CnrDailyNewsAutoRotateIntervalMinutes = NormalizeCnrInterval(normalized.CnrDailyNewsAutoRotateIntervalMinutes);
|
||||
normalized.DailyWordAutoRefreshIntervalMinutes = NormalizeDailyWordInterval(normalized.DailyWordAutoRefreshIntervalMinutes);
|
||||
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes = NormalizeBilibiliHotSearchInterval(
|
||||
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes);
|
||||
normalized.WeatherAutoRefreshIntervalMinutes = NormalizeWeatherInterval(normalized.WeatherAutoRefreshIntervalMinutes);
|
||||
normalized.Stcn24ForumAutoRefreshIntervalMinutes = NormalizeStcn24ForumInterval(normalized.Stcn24ForumAutoRefreshIntervalMinutes);
|
||||
normalized.Stcn24ForumSourceType = Stcn24ForumSourceTypes.Normalize(normalized.Stcn24ForumSourceType);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static List<ImportedClassScheduleSnapshot> NormalizeImportedSchedules(
|
||||
IReadOnlyList<ImportedClassScheduleSnapshot>? schedules)
|
||||
{
|
||||
if (schedules is null || schedules.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new List<ImportedClassScheduleSnapshot>(schedules.Count);
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var schedule in schedules)
|
||||
{
|
||||
if (schedule is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = schedule.Id?.Trim() ?? string.Empty;
|
||||
var filePath = schedule.FilePath?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seenIds.Add(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new ImportedClassScheduleSnapshot
|
||||
{
|
||||
Id = id,
|
||||
DisplayName = schedule.DisplayName?.Trim() ?? string.Empty,
|
||||
FilePath = filePath
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string NormalizeActiveScheduleId(
|
||||
string? activeScheduleId,
|
||||
IReadOnlyList<ImportedClassScheduleSnapshot> schedules)
|
||||
{
|
||||
var activeId = activeScheduleId?.Trim() ?? string.Empty;
|
||||
if (schedules.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(activeId))
|
||||
{
|
||||
return schedules[0].Id;
|
||||
}
|
||||
|
||||
return schedules.Any(item => string.Equals(item.Id, activeId, StringComparison.OrdinalIgnoreCase))
|
||||
? activeId
|
||||
: schedules[0].Id;
|
||||
}
|
||||
|
||||
private static string NormalizeDesktopClockTimeZoneId(string? timeZoneId)
|
||||
{
|
||||
var normalizedId = string.IsNullOrWhiteSpace(timeZoneId)
|
||||
? "China Standard Time"
|
||||
: timeZoneId.Trim();
|
||||
return WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(normalizedId).Id;
|
||||
}
|
||||
|
||||
private static int NormalizeCnrInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 60);
|
||||
}
|
||||
|
||||
private static int NormalizeDailyWordInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 360);
|
||||
}
|
||||
|
||||
private static int NormalizeBilibiliHotSearchInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 15);
|
||||
}
|
||||
|
||||
private static int NormalizeWeatherInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 12);
|
||||
}
|
||||
|
||||
private static int NormalizeStcn24ForumInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 20);
|
||||
}
|
||||
|
||||
private void UpdateCache(ComponentSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
|
||||
{
|
||||
_cachedPath = _settingsPath;
|
||||
_cachedSnapshot = snapshot.Clone();
|
||||
_cachedWriteTimeUtc = writeTimeUtc;
|
||||
_lastProbeUtc = probeTimeUtc;
|
||||
}
|
||||
|
||||
private sealed class LegacyComponentSettingsSnapshot
|
||||
{
|
||||
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
||||
|
||||
public List<ImportedClassScheduleSnapshot>? ImportedClassSchedules { get; set; }
|
||||
|
||||
public string? ActiveImportedClassScheduleId { get; set; }
|
||||
|
||||
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
|
||||
|
||||
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; }
|
||||
|
||||
public string WorldClockSecondHandMode { get; set; } = "Tick";
|
||||
|
||||
public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
|
||||
|
||||
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
|
||||
|
||||
public bool DailyWordAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360;
|
||||
|
||||
public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
|
||||
|
||||
public bool WeatherAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
|
||||
|
||||
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
|
||||
|
||||
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
|
||||
|
||||
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
|
||||
}
|
||||
}
|
||||
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];
|
||||
}
|
||||
}
|
||||
17
LanMountainDesktop/Services/ICalculatorDataService.cs
Normal file
17
LanMountainDesktop/Services/ICalculatorDataService.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface ICalculatorDataService
|
||||
{
|
||||
string ApplyInputToken(string currentInput, string token);
|
||||
|
||||
decimal ParseAmountOrZero(string? inputText);
|
||||
|
||||
string FormatAmount(decimal amount, int maxFractionDigits = 4);
|
||||
}
|
||||
|
||||
public static class CalculatorInputTokens
|
||||
{
|
||||
public const string Clear = "AC";
|
||||
public const string Backspace = "BACK";
|
||||
public const string DecimalPoint = ".";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Models;
|
||||
@@ -14,6 +15,31 @@ public sealed record DailyPoetryQuery(
|
||||
string? Locale = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record DailyNewsQuery(
|
||||
string? Locale = null,
|
||||
int? ItemCount = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record BilibiliHotSearchQuery(
|
||||
string? Locale = null,
|
||||
int? ItemCount = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record DailyWordQuery(
|
||||
string? Locale = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record Stcn24ForumPostsQuery(
|
||||
string? Locale = null,
|
||||
int? ItemCount = null,
|
||||
string? SourceType = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record ExchangeRateQuery(
|
||||
string? BaseCurrency = null,
|
||||
string? TargetCurrency = null,
|
||||
bool ForceRefresh = false);
|
||||
|
||||
public sealed record RecommendationQueryResult<T>(
|
||||
bool Success,
|
||||
T? Data,
|
||||
@@ -46,11 +72,175 @@ 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 BilibiliHotSearchApiTemplate { get; init; } =
|
||||
"https://api.bilibili.com/x/web-interface/search/square?limit={0}";
|
||||
|
||||
public string BilibiliSearchDefaultApiUrl { get; init; } =
|
||||
"https://api.bilibili.com/x/web-interface/search/default";
|
||||
|
||||
public string BilibiliSearchPageUrl { get; init; } = "https://search.bilibili.com/all";
|
||||
|
||||
public string SmartTeachForumApiTemplate { get; init; } =
|
||||
"https://forum.smart-teach.cn/api/discussions?filter[q]={0}&sort=-createdAt&page[limit]={1}&include=user";
|
||||
|
||||
public string SmartTeachForumBaseUrl { get; init; } = "https://forum.smart-teach.cn";
|
||||
|
||||
public string SmartTeachStcnKeyword { get; init; } = "STCN";
|
||||
|
||||
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 string ExchangeRateApiTemplate { get; init; } = "https://open.er-api.com/v6/latest/{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 int DefaultBilibiliHotSearchCount { get; init; } = 5;
|
||||
|
||||
public int DefaultStcn24ForumPostCount { get; init; } = 4;
|
||||
}
|
||||
|
||||
public interface IRecommendationInfoService
|
||||
@@ -63,5 +253,25 @@ public interface IRecommendationInfoService
|
||||
DailyPoetryQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<DailyNewsSnapshot>> GetDailyNewsAsync(
|
||||
DailyNewsQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<BilibiliHotSearchSnapshot>> GetBilibiliHotSearchAsync(
|
||||
BilibiliHotSearchQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<DailyWordSnapshot>> GetDailyWordAsync(
|
||||
DailyWordQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<Stcn24ForumPostsSnapshot>> GetStcn24ForumPostsAsync(
|
||||
Stcn24ForumPostsQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecommendationQueryResult<ExchangeRateSnapshot>> GetExchangeRateAsync(
|
||||
ExchangeRateQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
void ClearCache();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,7 +55,8 @@ 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 AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private TimeZoneService? _timeZoneService;
|
||||
private double _currentCellSize = 48;
|
||||
@@ -357,15 +358,16 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
|
||||
private void LoadClockSettings()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var configuredTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
|
||||
var configuredTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
|
||||
? "China Standard Time"
|
||||
: snapshot.DesktopClockTimeZoneId.Trim();
|
||||
: componentSnapshot.DesktopClockTimeZoneId.Trim();
|
||||
|
||||
_clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(configuredTimeZoneId);
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.DesktopClockSecondHandMode);
|
||||
}
|
||||
|
||||
private void ApplySecondHandTimerInterval()
|
||||
|
||||
@@ -28,6 +28,7 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly TimeZoneService _timeZoneService = new();
|
||||
private bool _suppressEvents;
|
||||
@@ -48,12 +49,13 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
_selectedTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
_selectedTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
|
||||
? "China Standard Time"
|
||||
: snapshot.DesktopClockTimeZoneId.Trim();
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
|
||||
: componentSnapshot.DesktopClockTimeZoneId.Trim();
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.DesktopClockSecondHandMode);
|
||||
|
||||
_allTimeZones = _timeZoneService
|
||||
.GetAllTimeZones()
|
||||
@@ -147,10 +149,10 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
|
||||
_selectedTimeZoneId = normalizedId;
|
||||
_secondHandMode = GetSelectedSecondHandMode();
|
||||
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.DesktopClockTimeZoneId = normalizedId;
|
||||
snapshot.DesktopClockSecondHandMode = _secondHandMode;
|
||||
_appSettingsService.Save(snapshot);
|
||||
_componentSettingsService.Save(snapshot);
|
||||
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<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="300"
|
||||
x:Class="LanMountainDesktop.Views.Components.BilibiliHotSearchSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="Bilibili hot search settings"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="Configure auto refresh and refresh interval."
|
||||
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="AutoRefreshLabelTextBlock"
|
||||
Text="Auto refresh"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<CheckBox x:Name="AutoRefreshCheckBox"
|
||||
Content="Enable auto refresh"
|
||||
Checked="OnAutoRefreshChanged"
|
||||
Unchecked="OnAutoRefreshChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="FrequencyCardBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="FrequencyLabelTextBlock"
|
||||
Text="Refresh interval"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="FrequencyComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnFrequencySelectionChanged">
|
||||
<ComboBoxItem x:Name="Frequency5mItem"
|
||||
Tag="5"
|
||||
Content="5 min" />
|
||||
<ComboBoxItem x:Name="Frequency10mItem"
|
||||
Tag="10"
|
||||
Content="10 min" />
|
||||
<ComboBoxItem x:Name="Frequency15mItem"
|
||||
Tag="15"
|
||||
Content="15 min" />
|
||||
<ComboBoxItem x:Name="Frequency30mItem"
|
||||
Tag="30"
|
||||
Content="30 min" />
|
||||
<ComboBoxItem x:Name="Frequency1hItem"
|
||||
Tag="60"
|
||||
Content="1 hour" />
|
||||
<ComboBoxItem x:Name="Frequency3hItem"
|
||||
Tag="180"
|
||||
Content="3 hours" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class BilibiliHotSearchSettingsWindow : UserControl
|
||||
{
|
||||
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public BilibiliHotSearchSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeFrequencyOptions();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var enabled = componentSnapshot.BilibiliHotSearchAutoRefreshEnabled;
|
||||
var interval = NormalizeInterval(componentSnapshot.BilibiliHotSearchAutoRefreshIntervalMinutes);
|
||||
|
||||
_suppressEvents = true;
|
||||
AutoRefreshCheckBox.IsChecked = enabled;
|
||||
SelectInterval(interval);
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
TitleTextBlock.Text = L("bilihot.settings.title", "Bilibili hot search settings");
|
||||
DescriptionTextBlock.Text = L("bilihot.settings.desc", "Configure auto refresh and refresh interval.");
|
||||
AutoRefreshLabelTextBlock.Text = L("bilihot.settings.auto_refresh_label", "Auto refresh");
|
||||
AutoRefreshCheckBox.Content = L("bilihot.settings.auto_refresh_enabled", "Enable auto refresh");
|
||||
FrequencyLabelTextBlock.Text = L("bilihot.settings.frequency_label", "Refresh interval");
|
||||
ApplyFrequencyLocalization();
|
||||
}
|
||||
|
||||
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var enabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.BilibiliHotSearchAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes = GetSelectedInterval();
|
||||
_componentSettingsService.Save(snapshot);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private int GetSelectedInterval()
|
||||
{
|
||||
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
return NormalizeInterval(minutes);
|
||||
}
|
||||
|
||||
return 15;
|
||||
}
|
||||
|
||||
private void SelectInterval(int intervalMinutes)
|
||||
{
|
||||
var selected = FrequencyComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes) &&
|
||||
minutes == intervalMinutes);
|
||||
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int NormalizeInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 15);
|
||||
}
|
||||
|
||||
private void InitializeFrequencyOptions()
|
||||
{
|
||||
FrequencyComboBox.Items.Clear();
|
||||
foreach (var minutes in SupportedIntervals)
|
||||
{
|
||||
FrequencyComboBox.Items.Add(new ComboBoxItem
|
||||
{
|
||||
Tag = minutes.ToString(),
|
||||
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFrequencyLocalization()
|
||||
{
|
||||
foreach (var item in FrequencyComboBox.Items.OfType<ComboBoxItem>())
|
||||
{
|
||||
if (item.Tag is not string tagText ||
|
||||
!int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}";
|
||||
item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes));
|
||||
}
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<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"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="640"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.BilibiliHotSearchWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFCFD"
|
||||
CornerRadius="34"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="16,14,16,14">
|
||||
<Grid x:Name="ContentGrid"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
||||
RowSpacing="6">
|
||||
<Grid x:Name="HeaderGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="10">
|
||||
<Border x:Name="SearchBoxBorder"
|
||||
Height="38"
|
||||
CornerRadius="19"
|
||||
Background="#F1F2F4"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="10,0"
|
||||
HorizontalAlignment="Left"
|
||||
PointerPressed="OnSearchBoxPointerPressed">
|
||||
<Grid ColumnDefinitions="Auto,Auto"
|
||||
ColumnSpacing="6"
|
||||
VerticalAlignment="Center">
|
||||
<fi:SymbolIcon x:Name="SearchGlyphIcon"
|
||||
Symbol="Search"
|
||||
IconVariant="Regular"
|
||||
Foreground="#7A8088"
|
||||
FontSize="17"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="SearchEntryTextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Search"
|
||||
Foreground="#7A8088"
|
||||
FontSize="18"
|
||||
FontWeight="Medium"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="TopRightTitleTextBlock"
|
||||
Grid.Column="1"
|
||||
Text="bilibili热搜"
|
||||
Foreground="#F44C9F"
|
||||
FontSize="24"
|
||||
FontWeight="Bold"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="HotItem1Host"
|
||||
Grid.Row="1"
|
||||
Tag="0"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem1Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem1IndexTextBlock"
|
||||
Text="1"
|
||||
Foreground="#F44C9F"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left" />
|
||||
<TextBlock x:Name="HotItem1TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Trending Topic"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="HotItem2Host"
|
||||
Grid.Row="2"
|
||||
Tag="1"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem2Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem2IndexTextBlock"
|
||||
Text="2"
|
||||
Foreground="#F44C9F"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left" />
|
||||
<TextBlock x:Name="HotItem2TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Trending Topic"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="HotItem3Host"
|
||||
Grid.Row="3"
|
||||
Tag="2"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem3Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem3IndexTextBlock"
|
||||
Text="3"
|
||||
Foreground="#F44C9F"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left" />
|
||||
<TextBlock x:Name="HotItem3TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Trending Topic"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="HotItem4Host"
|
||||
Grid.Row="4"
|
||||
Tag="3"
|
||||
Background="Transparent"
|
||||
Padding="0,2"
|
||||
PointerPressed="OnHotItemPointerPressed">
|
||||
<Grid x:Name="HotItem4Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="HotItem4IndexTextBlock"
|
||||
Text="4"
|
||||
Foreground="#F44C9F"
|
||||
FontSize="18"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left" />
|
||||
<TextBlock x:Name="HotItem4TextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Trending Topic"
|
||||
Foreground="#202327"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="Loading"
|
||||
Foreground="#6A6F77"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,581 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class BilibiliHotSearchWidget : 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 const int MaxDisplayItemCount = 4;
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMinutes(15)
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly List<BilibiliHotSearchItemSnapshot> _activeItems = [];
|
||||
private readonly List<HotItemVisual> _hotItemVisuals = [];
|
||||
|
||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private string _languageCode = "zh-CN";
|
||||
private string? _searchPageUrl;
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
|
||||
private sealed record HotItemVisual(
|
||||
Border Host,
|
||||
Grid RowGrid,
|
||||
TextBlock IndexTextBlock,
|
||||
TextBlock TitleTextBlock);
|
||||
|
||||
public BilibiliHotSearchWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
SearchEntryTextBlock.FontFamily = MiSansFontFamily;
|
||||
TopRightTitleTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem1IndexTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem2IndexTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem3IndexTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem4IndexTextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem1TextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem2TextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem3TextBlock.FontFamily = MiSansFontFamily;
|
||||
HotItem4TextBlock.FontFamily = MiSansFontFamily;
|
||||
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||
|
||||
_hotItemVisuals.Add(new HotItemVisual(HotItem1Host, HotItem1Grid, HotItem1IndexTextBlock, HotItem1TextBlock));
|
||||
_hotItemVisuals.Add(new HotItemVisual(HotItem2Host, HotItem2Grid, HotItem2IndexTextBlock, HotItem2TextBlock));
|
||||
_hotItemVisuals.Add(new HotItemVisual(HotItem3Host, HotItem3Grid, HotItem3IndexTextBlock, HotItem3TextBlock));
|
||||
_hotItemVisuals.Add(new HotItemVisual(HotItem4Host, HotItem4Grid, HotItem4IndexTextBlock, HotItem4TextBlock));
|
||||
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
UpdateLanguageCode();
|
||||
ApplyAutoRefreshSettings();
|
||||
ApplyLoadingState();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||
{
|
||||
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshHotSearchAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
_recommendationService.ClearCache();
|
||||
ApplyAutoRefreshSettings();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshHotSearchAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRefreshSettings();
|
||||
_ = RefreshHotSearchAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
CancelRefreshRequest();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
await RefreshHotSearchAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private async Task RefreshHotSearchAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
UpdateLanguageCode();
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
||||
previous?.Cancel();
|
||||
previous?.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
var query = new BilibiliHotSearchQuery(
|
||||
Locale: _languageCode,
|
||||
ItemCount: MaxDisplayItemCount,
|
||||
ForceRefresh: forceRefresh);
|
||||
var result = await _recommendationService.GetBilibiliHotSearchAsync(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;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplySnapshot(BilibiliHotSearchSnapshot snapshot)
|
||||
{
|
||||
SearchEntryTextBlock.Text = ResolveSearchEntryText(snapshot.SearchPlaceholder);
|
||||
TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜");
|
||||
|
||||
_searchPageUrl = NormalizeHttpUrl(snapshot.SearchUrl) ?? BuildDefaultSearchPageUrl();
|
||||
|
||||
_activeItems.Clear();
|
||||
foreach (var item in snapshot.Items)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Title) || string.IsNullOrWhiteSpace(item.Url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_activeItems.Add(item);
|
||||
if (_activeItems.Count >= MaxDisplayItemCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackText = L("bilihot.widget.fallback_item", "暂无热搜");
|
||||
for (var i = 0; i < _hotItemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _hotItemVisuals[i];
|
||||
visual.Host.IsVisible = true;
|
||||
visual.IndexTextBlock.Text = (i + 1).ToString();
|
||||
visual.TitleTextBlock.Text = i < _activeItems.Count
|
||||
? NormalizeCompactText(_activeItems[i].Title)
|
||||
: fallbackText;
|
||||
}
|
||||
|
||||
StatusTextBlock.IsVisible = false;
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
SearchEntryTextBlock.Text = L("bilihot.widget.search_entry", "搜索");
|
||||
TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜");
|
||||
_searchPageUrl = BuildDefaultSearchPageUrl();
|
||||
_activeItems.Clear();
|
||||
|
||||
var loadingText = L("bilihot.widget.loading_item", "加载中...");
|
||||
for (var i = 0; i < _hotItemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _hotItemVisuals[i];
|
||||
visual.Host.IsVisible = true;
|
||||
visual.IndexTextBlock.Text = (i + 1).ToString();
|
||||
visual.TitleTextBlock.Text = loadingText;
|
||||
}
|
||||
|
||||
StatusTextBlock.Text = L("bilihot.widget.loading", "加载中...");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyFailedState()
|
||||
{
|
||||
SearchEntryTextBlock.Text = L("bilihot.widget.search_entry", "搜索");
|
||||
TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜");
|
||||
_searchPageUrl = BuildDefaultSearchPageUrl();
|
||||
_activeItems.Clear();
|
||||
|
||||
var fallbackText = L("bilihot.widget.fallback_item", "暂无热搜");
|
||||
for (var i = 0; i < _hotItemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _hotItemVisuals[i];
|
||||
visual.Host.IsVisible = true;
|
||||
visual.IndexTextBlock.Text = (i + 1).ToString();
|
||||
visual.TitleTextBlock.Text = fallbackText;
|
||||
}
|
||||
|
||||
StatusTextBlock.Text = L("bilihot.widget.fetch_failed", "热搜获取失败");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private string ResolveSearchEntryText(string? placeholder)
|
||||
{
|
||||
var compact = NormalizeCompactText(placeholder);
|
||||
if (string.IsNullOrWhiteSpace(compact))
|
||||
{
|
||||
return L("bilihot.widget.search_entry", "搜索");
|
||||
}
|
||||
|
||||
return compact;
|
||||
}
|
||||
|
||||
private void OnSearchBoxPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryOpenUrl(_searchPageUrl);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnHotItemPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
|
||||
sender is not Border host ||
|
||||
host.Tag is null ||
|
||||
!int.TryParse(host.Tag.ToString(), out var index) ||
|
||||
index < 0 ||
|
||||
index >= _activeItems.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryOpenUrl(_activeItems[index].Url);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var softScale = Math.Clamp(scale, 0.84, 1.26);
|
||||
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 * softScale, 16, 52));
|
||||
RootBorder.Padding = new Thickness(0);
|
||||
|
||||
var horizontalPadding = Math.Clamp(16 * softScale, 8, 24);
|
||||
var verticalPadding = Math.Clamp(14 * softScale, 7, 20);
|
||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
|
||||
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
||||
|
||||
var innerWidth = Math.Max(120, totalWidth - (horizontalPadding * 2d));
|
||||
var innerHeight = Math.Max(72, totalHeight - (verticalPadding * 2d));
|
||||
var rowSpacing = Math.Clamp(6 * softScale, 2, 9);
|
||||
ContentGrid.RowSpacing = rowSpacing;
|
||||
HeaderGrid.ColumnSpacing = Math.Clamp(10 * softScale, 6, 16);
|
||||
|
||||
var availableRowsHeight = Math.Max(40, innerHeight - rowSpacing * 4d);
|
||||
var minTopRowHeight = Math.Clamp(20 * softScale, 18, 34);
|
||||
var topRowHeight = Math.Clamp(availableRowsHeight * 0.27, minTopRowHeight, 52);
|
||||
var lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
|
||||
var minLineRowHeight = Math.Clamp(13 * softScale, 11, 24);
|
||||
if (lineRowHeight < minLineRowHeight)
|
||||
{
|
||||
lineRowHeight = minLineRowHeight;
|
||||
topRowHeight = Math.Max(minTopRowHeight, availableRowsHeight - lineRowHeight * 4d);
|
||||
lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
|
||||
}
|
||||
|
||||
if (ContentGrid.RowDefinitions.Count >= 5)
|
||||
{
|
||||
ContentGrid.RowDefinitions[0].Height = new GridLength(topRowHeight);
|
||||
for (var i = 1; i <= 4; i++)
|
||||
{
|
||||
ContentGrid.RowDefinitions[i].Height = new GridLength(lineRowHeight);
|
||||
}
|
||||
}
|
||||
|
||||
var searchBoxHeight = Math.Clamp(topRowHeight * 0.84, 20, 46);
|
||||
SearchBoxBorder.Height = searchBoxHeight;
|
||||
SearchBoxBorder.Width = Math.Clamp(innerWidth * 0.30, 80, 180);
|
||||
SearchBoxBorder.CornerRadius = new CornerRadius(searchBoxHeight / 2d);
|
||||
SearchBoxBorder.Padding = new Thickness(
|
||||
Math.Clamp(searchBoxHeight * 0.24, 5, 10),
|
||||
0,
|
||||
Math.Clamp(searchBoxHeight * 0.24, 5, 10),
|
||||
0);
|
||||
SearchGlyphIcon.FontSize = Math.Clamp(searchBoxHeight * 0.45, 10, 20);
|
||||
SearchEntryTextBlock.FontSize = Math.Clamp(searchBoxHeight * 0.44, 10, 18);
|
||||
|
||||
TopRightTitleTextBlock.MaxWidth = Math.Max(80, innerWidth - SearchBoxBorder.Width - HeaderGrid.ColumnSpacing);
|
||||
TopRightTitleTextBlock.FontSize = Math.Clamp(topRowHeight * 0.46, 11, 22);
|
||||
|
||||
var lineColumnGap = Math.Clamp(lineRowHeight * 0.34, 5, 12);
|
||||
var indexWidth = Math.Clamp(lineRowHeight * 1.02, 16, 28);
|
||||
var indexFont = Math.Clamp(lineRowHeight * 0.50, 10, 16);
|
||||
var itemFont = Math.Clamp(lineRowHeight * 0.62, 12, 24);
|
||||
var rowPadding = Math.Clamp(lineRowHeight * 0.08, 1, 4);
|
||||
var itemTextWidth = Math.Max(56, innerWidth - indexWidth - lineColumnGap);
|
||||
|
||||
foreach (var visual in _hotItemVisuals)
|
||||
{
|
||||
visual.RowGrid.ColumnSpacing = lineColumnGap;
|
||||
if (visual.RowGrid.ColumnDefinitions.Count > 0)
|
||||
{
|
||||
visual.RowGrid.ColumnDefinitions[0].Width = new GridLength(indexWidth, GridUnitType.Pixel);
|
||||
}
|
||||
|
||||
visual.Host.Padding = new Thickness(0, rowPadding, 0, rowPadding);
|
||||
visual.IndexTextBlock.FontSize = indexFont;
|
||||
visual.IndexTextBlock.MaxWidth = indexWidth;
|
||||
visual.IndexTextBlock.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right;
|
||||
visual.IndexTextBlock.TextAlignment = TextAlignment.Right;
|
||||
visual.TitleTextBlock.FontSize = itemFont;
|
||||
visual.TitleTextBlock.MaxWidth = itemTextWidth;
|
||||
visual.TitleTextBlock.TextAlignment = TextAlignment.Left;
|
||||
}
|
||||
|
||||
StatusTextBlock.FontSize = Math.Clamp(itemFont, 10, 20);
|
||||
}
|
||||
|
||||
private void UpdateInteractionState()
|
||||
{
|
||||
for (var i = 0; i < _hotItemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _hotItemVisuals[i];
|
||||
var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url);
|
||||
visual.Host.IsHitTestVisible = enabled;
|
||||
visual.Host.Opacity = enabled ? 1.0 : 0.68;
|
||||
visual.Host.Cursor = enabled
|
||||
? new Cursor(StandardCursorType.Hand)
|
||||
: new Cursor(StandardCursorType.Arrow);
|
||||
}
|
||||
|
||||
var searchEnabled = !string.IsNullOrWhiteSpace(_searchPageUrl);
|
||||
SearchBoxBorder.IsHitTestVisible = searchEnabled;
|
||||
SearchBoxBorder.Opacity = searchEnabled ? 1.0 : 0.72;
|
||||
SearchBoxBorder.Cursor = searchEnabled
|
||||
? new Cursor(StandardCursorType.Hand)
|
||||
: new Cursor(StandardCursorType.Arrow);
|
||||
}
|
||||
|
||||
private void UpdateLanguageCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAutoRefreshSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 15;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
enabled = snapshot.BilibiliHotSearchAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_autoRefreshEnabled = enabled;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
|
||||
if (!_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_autoRefreshEnabled)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
}
|
||||
else if (_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 15;
|
||||
}
|
||||
|
||||
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRefreshIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(15);
|
||||
}
|
||||
|
||||
private static string NormalizeCompactText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
|
||||
}
|
||||
|
||||
private static string BuildDefaultSearchPageUrl()
|
||||
{
|
||||
return "https://search.bilibili.com/all";
|
||||
}
|
||||
|
||||
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 TryOpenUrl(string? rawUrl)
|
||||
{
|
||||
var normalizedUrl = NormalizeHttpUrl(rawUrl);
|
||||
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 double ResolveScale()
|
||||
{
|
||||
var expectedWidth = _currentCellSize * BaseWidthCells;
|
||||
var expectedHeight = _currentCellSize * BaseHeightCells;
|
||||
if (expectedWidth <= 0 || expectedHeight <= 0)
|
||||
{
|
||||
return 1d;
|
||||
}
|
||||
|
||||
var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth;
|
||||
var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight;
|
||||
var scaleX = actualWidth / expectedWidth;
|
||||
var scaleY = actualHeight / expectedHeight;
|
||||
return Math.Clamp(Math.Min(scaleX, scaleY), 0.72, 2.8);
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private void CancelRefreshRequest()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
if (cts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ namespace LanMountainDesktop.Views.Components;
|
||||
public partial class ClassScheduleSettingsWindow : UserControl
|
||||
{
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
|
||||
private string _activeScheduleId = string.Empty;
|
||||
@@ -35,11 +36,12 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
_importedSchedules.Clear();
|
||||
foreach (var item in snapshot.ImportedClassSchedules)
|
||||
foreach (var item in componentSnapshot.ImportedClassSchedules)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Id) ||
|
||||
string.IsNullOrWhiteSpace(item.FilePath))
|
||||
@@ -55,7 +57,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
||||
});
|
||||
}
|
||||
|
||||
_activeScheduleId = snapshot.ActiveImportedClassScheduleId?.Trim() ?? string.Empty;
|
||||
_activeScheduleId = componentSnapshot.ActiveImportedClassScheduleId?.Trim() ?? string.Empty;
|
||||
if (_importedSchedules.Count > 0 &&
|
||||
!_importedSchedules.Any(item => string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
@@ -297,7 +299,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.ImportedClassSchedules = _importedSchedules
|
||||
.Select(item => new ImportedClassScheduleSnapshot
|
||||
{
|
||||
@@ -307,7 +309,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
||||
})
|
||||
.ToList();
|
||||
snapshot.ActiveImportedClassScheduleId = _activeScheduleId ?? string.Empty;
|
||||
_appSettingsService.Save(snapshot);
|
||||
_componentSettingsService.Save(snapshot);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService();
|
||||
|
||||
@@ -115,11 +116,12 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
private void RefreshSchedule()
|
||||
{
|
||||
var appSettings = _appSettingsService.Load();
|
||||
var componentSettings = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
|
||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||
UpdateHeader(now);
|
||||
|
||||
var importedSchedulePath = ResolveImportedSchedulePath(appSettings);
|
||||
var importedSchedulePath = ResolveImportedSchedulePath(componentSettings);
|
||||
var readResult = _scheduleService.Load(importedSchedulePath);
|
||||
if (!readResult.Success || readResult.Snapshot is null)
|
||||
{
|
||||
@@ -273,7 +275,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
return dayOfWeek.ToString()[..3];
|
||||
}
|
||||
|
||||
private static string? ResolveImportedSchedulePath(AppSettingsSnapshot snapshot)
|
||||
private static string? ResolveImportedSchedulePath(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
if (snapshot.ImportedClassSchedules.Count == 0)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<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="300"
|
||||
x:Class="LanMountainDesktop.Views.Components.CnrDailyNewsSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="CNR news settings"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="Configure auto-rotation and refresh interval."
|
||||
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="AutoRotateLabelTextBlock"
|
||||
Text="Auto-rotation"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<CheckBox x:Name="AutoRotateCheckBox"
|
||||
Content="Enable auto-rotation"
|
||||
Checked="OnAutoRotateChanged"
|
||||
Unchecked="OnAutoRotateChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="FrequencyCardBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="FrequencyLabelTextBlock"
|
||||
Text="Rotation interval"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="FrequencyComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnFrequencySelectionChanged">
|
||||
<ComboBoxItem x:Name="Frequency5mItem"
|
||||
Tag="5"
|
||||
Content="5 min" />
|
||||
<ComboBoxItem x:Name="Frequency10mItem"
|
||||
Tag="10"
|
||||
Content="10 min" />
|
||||
<ComboBoxItem x:Name="Frequency40mItem"
|
||||
Tag="40"
|
||||
Content="40 min" />
|
||||
<ComboBoxItem x:Name="Frequency1hItem"
|
||||
Tag="60"
|
||||
Content="1 hour" />
|
||||
<ComboBoxItem x:Name="Frequency12hItem"
|
||||
Tag="720"
|
||||
Content="12 hours" />
|
||||
<ComboBoxItem x:Name="Frequency24hItem"
|
||||
Tag="1440"
|
||||
Content="24 hours" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class CnrDailyNewsSettingsWindow : UserControl
|
||||
{
|
||||
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public CnrDailyNewsSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeFrequencyOptions();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var enabled = componentSnapshot.CnrDailyNewsAutoRotateEnabled;
|
||||
var interval = NormalizeInterval(componentSnapshot.CnrDailyNewsAutoRotateIntervalMinutes);
|
||||
|
||||
_suppressEvents = true;
|
||||
AutoRotateCheckBox.IsChecked = enabled;
|
||||
SelectInterval(interval);
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
TitleTextBlock.Text = L("cnrnews.settings.title", "CNR news settings");
|
||||
DescriptionTextBlock.Text = L("cnrnews.settings.desc", "Configure auto-rotation and refresh interval.");
|
||||
AutoRotateLabelTextBlock.Text = L("cnrnews.settings.auto_rotate_label", "Auto-rotation");
|
||||
AutoRotateCheckBox.Content = L("cnrnews.settings.auto_rotate_enabled", "Enable auto-rotation");
|
||||
FrequencyLabelTextBlock.Text = L("cnrnews.settings.frequency_label", "Rotation interval");
|
||||
ApplyFrequencyLocalization();
|
||||
}
|
||||
|
||||
private void OnAutoRotateChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var enabled = AutoRotateCheckBox.IsChecked == true;
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.CnrDailyNewsAutoRotateEnabled = AutoRotateCheckBox.IsChecked == true;
|
||||
snapshot.CnrDailyNewsAutoRotateIntervalMinutes = GetSelectedInterval();
|
||||
_componentSettingsService.Save(snapshot);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private int GetSelectedInterval()
|
||||
{
|
||||
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
return NormalizeInterval(minutes);
|
||||
}
|
||||
|
||||
return 60;
|
||||
}
|
||||
|
||||
private void SelectInterval(int intervalMinutes)
|
||||
{
|
||||
var selected = FrequencyComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes) &&
|
||||
minutes == intervalMinutes);
|
||||
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int NormalizeInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 60);
|
||||
}
|
||||
|
||||
private void InitializeFrequencyOptions()
|
||||
{
|
||||
FrequencyComboBox.Items.Clear();
|
||||
foreach (var minutes in SupportedIntervals)
|
||||
{
|
||||
FrequencyComboBox.Items.Add(new ComboBoxItem
|
||||
{
|
||||
Tag = minutes.ToString(),
|
||||
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFrequencyLocalization()
|
||||
{
|
||||
foreach (var item in FrequencyComboBox.Items.OfType<ComboBoxItem>())
|
||||
{
|
||||
if (item.Tag is not string tagText ||
|
||||
!int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}";
|
||||
item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes));
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="640"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.CnrDailyNewsWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFCFD"
|
||||
CornerRadius="34"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="16,14,16,14">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto"
|
||||
RowSpacing="8">
|
||||
<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">
|
||||
<fi:SymbolIcon x:Name="RefreshGlyphIcon"
|
||||
Symbol="ArrowClockwise"
|
||||
IconVariant="Regular"
|
||||
Foreground="#52575F"
|
||||
FontSize="19"
|
||||
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">
|
||||
<TextBlock x:Name="News1TitleTextBlock"
|
||||
Text="Headline"
|
||||
Foreground="#202327"
|
||||
FontSize="21"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top"
|
||||
LineHeight="24" />
|
||||
|
||||
<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="21"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
VerticalAlignment="Top"
|
||||
LineHeight="24" />
|
||||
|
||||
<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>
|
||||
|
||||
<StackPanel x:Name="ExtraNewsItemsPanel"
|
||||
Grid.Row="3"
|
||||
Spacing="6"
|
||||
IsVisible="False" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="Loading"
|
||||
Foreground="#6A6F77"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
811
LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs
Normal file
811
LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs
Normal file
@@ -0,0 +1,811 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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.Controls.Documents;
|
||||
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 static readonly IReadOnlyList<int> SupportedAutoRotateIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMinutes(30)
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly Bitmap?[] _newsBitmaps = new Bitmap?[2];
|
||||
private readonly List<string?> _newsUrls = [];
|
||||
private readonly List<ExtraNewsRowVisual> _extraNewsRows = [];
|
||||
private IReadOnlyList<DailyNewsItemSnapshot> _activeNewsItems = [];
|
||||
private int _renderedNewsCount = 2;
|
||||
|
||||
private sealed class ExtraNewsRowVisual
|
||||
{
|
||||
public ExtraNewsRowVisual(
|
||||
Grid rootGrid,
|
||||
TextBlock titleTextBlock,
|
||||
Border imageHost,
|
||||
Image imageControl,
|
||||
int newsIndex)
|
||||
{
|
||||
RootGrid = rootGrid;
|
||||
TitleTextBlock = titleTextBlock;
|
||||
ImageHost = imageHost;
|
||||
ImageControl = imageControl;
|
||||
NewsIndex = newsIndex;
|
||||
}
|
||||
|
||||
public Grid RootGrid { get; }
|
||||
|
||||
public TextBlock TitleTextBlock { get; }
|
||||
|
||||
public Border ImageHost { get; }
|
||||
|
||||
public Image ImageControl { get; }
|
||||
|
||||
public int NewsIndex { get; }
|
||||
|
||||
public Bitmap? Bitmap { get; set; }
|
||||
}
|
||||
|
||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private string _languageCode = "zh-CN";
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRotateEnabled = true;
|
||||
|
||||
public CnrDailyNewsWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
BrandPrimaryTextBlock.FontFamily = MiSansFontFamily;
|
||||
BrandSecondaryTextBlock.FontFamily = MiSansFontFamily;
|
||||
RefreshLabelTextBlock.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();
|
||||
ApplyAutoRotateSettings();
|
||||
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();
|
||||
ApplyAutoRotateSettings();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshNewsAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRotateSettings();
|
||||
UpdateRefreshButtonState();
|
||||
_ = RefreshNewsAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
CancelRefreshRequest();
|
||||
DisposeNewsBitmaps();
|
||||
ClearExtraNewsRows();
|
||||
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: true);
|
||||
}
|
||||
|
||||
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 void OnExtraNewsItemPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
|
||||
sender is not Control control ||
|
||||
control.Tag is not int index)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryOpenNewsUrl(index);
|
||||
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,
|
||||
ItemCount: ResolveDesiredNewsItemCount(),
|
||||
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();
|
||||
_activeNewsItems = items;
|
||||
|
||||
var item1 = items.Length > 0 ? items[0] : null;
|
||||
var item2 = items.Length > 1 ? items[1] : null;
|
||||
|
||||
UpdateHotHeadlineText(item1?.Title);
|
||||
News2TitleTextBlock.Text = NormalizeCompactText(item2?.Title);
|
||||
|
||||
_newsUrls.Clear();
|
||||
foreach (var item in items)
|
||||
{
|
||||
_newsUrls.Add(NormalizeHttpUrl(item.Url));
|
||||
}
|
||||
|
||||
RenderExtraNewsRows([]);
|
||||
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()
|
||||
{
|
||||
_activeNewsItems = [];
|
||||
_newsUrls.Clear();
|
||||
UpdateHotHeadlineText(L("cnrnews.widget.loading_title", "Loading headlines"));
|
||||
News2TitleTextBlock.Text = L("cnrnews.widget.loading_subtitle", "Please wait");
|
||||
StatusTextBlock.Text = L("cnrnews.widget.loading", "Loading...");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
SetNewsBitmap(0, null);
|
||||
SetNewsBitmap(1, null);
|
||||
RenderExtraNewsRows([]);
|
||||
UpdateNewsInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyFailedState()
|
||||
{
|
||||
_activeNewsItems = [];
|
||||
_newsUrls.Clear();
|
||||
News1TitleTextBlock.Inlines = null;
|
||||
News1TitleTextBlock.Text = L("cnrnews.widget.fallback_title", "CNR news is temporarily unavailable");
|
||||
News2TitleTextBlock.Text = L("cnrnews.widget.fallback_subtitle", "Tap refresh and try again");
|
||||
StatusTextBlock.Text = L("cnrnews.widget.fetch_failed", "News fetch failed");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
SetNewsBitmap(0, null);
|
||||
SetNewsBitmap(1, null);
|
||||
RenderExtraNewsRows([]);
|
||||
UpdateNewsInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private int ResolveDesiredNewsItemCount()
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
private void UpdateHotHeadlineText(string? title)
|
||||
{
|
||||
var normalizedTitle = NormalizeCompactText(title);
|
||||
var hotLabel = L("cnrnews.widget.hot_label", "Hot");
|
||||
if (News1TitleTextBlock.Inlines is null)
|
||||
{
|
||||
News1TitleTextBlock.Text = $"{hotLabel} | {normalizedTitle}";
|
||||
return;
|
||||
}
|
||||
|
||||
News1TitleTextBlock.Inlines.Clear();
|
||||
News1TitleTextBlock.Inlines.Add(new Run($"{hotLabel} | ")
|
||||
{
|
||||
Foreground = new SolidColorBrush(Color.Parse("#D6272E")),
|
||||
FontWeight = FontWeight.SemiBold
|
||||
});
|
||||
News1TitleTextBlock.Inlines.Add(new Run(normalizedTitle)
|
||||
{
|
||||
Foreground = new SolidColorBrush(Color.Parse("#202327")),
|
||||
FontWeight = FontWeight.SemiBold
|
||||
});
|
||||
}
|
||||
|
||||
private void RenderExtraNewsRows(IReadOnlyList<DailyNewsItemSnapshot> extraItems)
|
||||
{
|
||||
ClearExtraNewsRows();
|
||||
if (extraItems.Count == 0)
|
||||
{
|
||||
ExtraNewsItemsPanel.IsVisible = false;
|
||||
_renderedNewsCount = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < extraItems.Count; i++)
|
||||
{
|
||||
var item = extraItems[i];
|
||||
var itemIndex = i + 2;
|
||||
var rowGrid = new Grid
|
||||
{
|
||||
ColumnSpacing = 12,
|
||||
Tag = itemIndex,
|
||||
Cursor = new Cursor(StandardCursorType.Hand),
|
||||
IsHitTestVisible = true
|
||||
};
|
||||
rowGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star)));
|
||||
rowGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
|
||||
rowGrid.PointerPressed += OnExtraNewsItemPointerPressed;
|
||||
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = NormalizeCompactText(item.Title),
|
||||
Foreground = new SolidColorBrush(Color.Parse("#202327")),
|
||||
FontFamily = MiSansFontFamily,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
|
||||
var imageHost = new Border
|
||||
{
|
||||
Width = 160,
|
||||
Height = 90,
|
||||
CornerRadius = new CornerRadius(16),
|
||||
ClipToBounds = true,
|
||||
Background = new SolidColorBrush(Color.Parse("#E6E6E6")),
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
var image = new Image
|
||||
{
|
||||
Stretch = Stretch.UniformToFill,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
imageHost.Child = image;
|
||||
Grid.SetColumn(imageHost, 1);
|
||||
|
||||
rowGrid.Children.Add(textBlock);
|
||||
rowGrid.Children.Add(imageHost);
|
||||
ExtraNewsItemsPanel.Children.Add(rowGrid);
|
||||
_extraNewsRows.Add(new ExtraNewsRowVisual(rowGrid, textBlock, imageHost, image, itemIndex));
|
||||
}
|
||||
|
||||
ExtraNewsItemsPanel.IsVisible = true;
|
||||
_renderedNewsCount = 2 + extraItems.Count;
|
||||
}
|
||||
|
||||
private void ClearExtraNewsRows()
|
||||
{
|
||||
foreach (var row in _extraNewsRows)
|
||||
{
|
||||
row.RootGrid.PointerPressed -= OnExtraNewsItemPointerPressed;
|
||||
if (ReferenceEquals(row.ImageControl.Source, row.Bitmap))
|
||||
{
|
||||
row.ImageControl.Source = null;
|
||||
}
|
||||
|
||||
row.Bitmap?.Dispose();
|
||||
row.Bitmap = null;
|
||||
}
|
||||
|
||||
_extraNewsRows.Clear();
|
||||
ExtraNewsItemsPanel.Children.Clear();
|
||||
}
|
||||
|
||||
private void SetExtraNewsBitmap(int rowIndex, Bitmap? bitmap)
|
||||
{
|
||||
if (rowIndex < 0 || rowIndex >= _extraNewsRows.Count)
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
var row = _extraNewsRows[rowIndex];
|
||||
if (ReferenceEquals(row.ImageControl.Source, row.Bitmap))
|
||||
{
|
||||
row.ImageControl.Source = null;
|
||||
}
|
||||
|
||||
row.Bitmap?.Dispose();
|
||||
row.Bitmap = bitmap;
|
||||
row.ImageControl.Source = bitmap;
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
RootBorder.Padding = new Thickness(0);
|
||||
|
||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
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(24 * scale, 12, 34);
|
||||
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);
|
||||
RefreshGlyphIcon.FontSize = Math.Clamp(19 * scale, 11, 24);
|
||||
RefreshLabelTextBlock.FontSize = Math.Clamp(22 * scale, 11, 29);
|
||||
|
||||
var imageWidth = Math.Clamp(totalWidth * 0.20, 60, 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(
|
||||
84,
|
||||
totalWidth - imageWidth - columnGap - Math.Clamp(20 * scale, 10, 32));
|
||||
News1TitleTextBlock.MaxWidth = availableTextWidth;
|
||||
News2TitleTextBlock.MaxWidth = availableTextWidth;
|
||||
|
||||
var newsFont = Math.Clamp(21 * scale, 10.5, 28);
|
||||
News1TitleTextBlock.FontSize = newsFont;
|
||||
News2TitleTextBlock.FontSize = newsFont;
|
||||
var mainNewsLineHeight = newsFont * 1.14;
|
||||
News1TitleTextBlock.LineHeight = mainNewsLineHeight;
|
||||
News2TitleTextBlock.LineHeight = mainNewsLineHeight;
|
||||
var mainNewsMinHeight = mainNewsLineHeight * 2;
|
||||
News1TitleTextBlock.MinHeight = mainNewsMinHeight;
|
||||
News2TitleTextBlock.MinHeight = mainNewsMinHeight;
|
||||
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
|
||||
News1TitleTextBlock.MaxLines = 2;
|
||||
News2TitleTextBlock.MaxLines = 2;
|
||||
|
||||
foreach (var row in _extraNewsRows)
|
||||
{
|
||||
row.RootGrid.ColumnSpacing = columnGap;
|
||||
if (row.RootGrid.ColumnDefinitions.Count > 1)
|
||||
{
|
||||
row.RootGrid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
||||
}
|
||||
|
||||
row.ImageHost.Width = imageWidth;
|
||||
row.ImageHost.Height = imageHeight;
|
||||
row.ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
|
||||
|
||||
row.TitleTextBlock.MaxWidth = availableTextWidth;
|
||||
row.TitleTextBlock.FontSize = Math.Clamp(19 * scale, 10, 25);
|
||||
row.TitleTextBlock.LineHeight = row.TitleTextBlock.FontSize * 1.12;
|
||||
row.TitleTextBlock.MinHeight = row.TitleTextBlock.LineHeight * 2;
|
||||
row.TitleTextBlock.MaxLines = 2;
|
||||
}
|
||||
|
||||
ExtraNewsItemsPanel.Spacing = Math.Clamp(6 * scale, 3, 10);
|
||||
}
|
||||
|
||||
private void UpdateRefreshButtonState()
|
||||
{
|
||||
RefreshButton.IsEnabled = !_isRefreshing;
|
||||
RefreshButton.Opacity = _isAttached ? 1.0 : 0.85;
|
||||
RefreshGlyphIcon.Opacity = _isRefreshing ? 0.56 : 1.0;
|
||||
RefreshLabelTextBlock.Opacity = _isRefreshing ? 0.56 : 1.0;
|
||||
}
|
||||
|
||||
private void UpdateNewsInteractionState()
|
||||
{
|
||||
var item1Enabled = _newsUrls.Count > 0 && !string.IsNullOrWhiteSpace(_newsUrls[0]);
|
||||
var item2Enabled = _newsUrls.Count > 1 && !string.IsNullOrWhiteSpace(_newsUrls[1]);
|
||||
|
||||
NewsItem1Grid.IsHitTestVisible = item1Enabled;
|
||||
NewsItem2Grid.IsHitTestVisible = item2Enabled;
|
||||
NewsItem1Grid.Opacity = item1Enabled ? 1.0 : 0.72;
|
||||
NewsItem2Grid.Opacity = item2Enabled ? 1.0 : 0.72;
|
||||
|
||||
foreach (var row in _extraNewsRows)
|
||||
{
|
||||
var index = row.NewsIndex;
|
||||
var enabled = index >= 0 && index < _newsUrls.Count && !string.IsNullOrWhiteSpace(_newsUrls[index]);
|
||||
row.RootGrid.IsHitTestVisible = enabled;
|
||||
row.RootGrid.Opacity = enabled ? 1.0 : 0.72;
|
||||
row.RootGrid.Cursor = enabled
|
||||
? new Cursor(StandardCursorType.Hand)
|
||||
: new Cursor(StandardCursorType.Arrow);
|
||||
}
|
||||
}
|
||||
|
||||
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.Count)
|
||||
{
|
||||
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 = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAutoRotateSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 60;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
enabled = snapshot.CnrDailyNewsAutoRotateEnabled;
|
||||
intervalMinutes = NormalizeAutoRotateIntervalMinutes(snapshot.CnrDailyNewsAutoRotateIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_autoRotateEnabled = enabled;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
|
||||
if (!_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_autoRotateEnabled)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
}
|
||||
else if (_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRotateIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 60;
|
||||
}
|
||||
|
||||
if (SupportedAutoRotateIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRotateIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(60);
|
||||
}
|
||||
|
||||
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(), " ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,13 @@ namespace LanMountainDesktop.Views.Components;
|
||||
public partial class DailyArtworkSettingsWindow : UserControl
|
||||
{
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private string _languageCode = "zh-CN";
|
||||
private bool _suppressEvents;
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public string CurrentSource => GetSelectedSource();
|
||||
|
||||
public DailyArtworkSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -27,10 +26,11 @@ public partial class DailyArtworkSettingsWindow : UserControl
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var source = DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
||||
var source = DailyArtworkMirrorSources.Normalize(componentSnapshot.DailyArtworkMirrorSource);
|
||||
_suppressEvents = true;
|
||||
MirrorSourceComboBox.SelectedIndex = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
|
||||
? 0
|
||||
@@ -59,9 +59,9 @@ public partial class DailyArtworkSettingsWindow : UserControl
|
||||
}
|
||||
|
||||
var source = GetSelectedSource();
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.DailyArtworkMirrorSource = source;
|
||||
_appSettingsService.Save(snapshot);
|
||||
_componentSettingsService.Save(snapshot);
|
||||
|
||||
UpdateSourceStatus(source);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
FontWeight="Bold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
MaxLines="4"
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<Border x:Name="RightPanelSeparator"
|
||||
@@ -102,16 +102,16 @@
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2" />
|
||||
MaxLines="3" />
|
||||
<TextBlock x:Name="YearTextBlock"
|
||||
Text="1754"
|
||||
Foreground="#D7DCE3"
|
||||
FontSize="22"
|
||||
FontWeight="Medium"
|
||||
FontFeatures="tnum"
|
||||
TextWrapping="NoWrap"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
MaxLines="2" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -34,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()
|
||||
{
|
||||
@@ -452,55 +455,98 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
var bottomStackSpacing = Math.Clamp(3 * scale, 2, 8);
|
||||
var reservedHeight = titleBottomMargin + separatorBottomMargin + bottomStackSpacing + 3;
|
||||
var textHeightBudget = Math.Max(24, rightContentHeight - reservedHeight);
|
||||
var titleHeightBudget = Math.Max(16, textHeightBudget * 0.54);
|
||||
var bottomTextBudget = Math.Max(10, textHeightBudget - titleHeightBudget);
|
||||
var artistHeightBudget = Math.Max(8, bottomTextBudget * 0.66);
|
||||
var yearHeightBudget = Math.Max(8, bottomTextBudget - artistHeightBudget);
|
||||
|
||||
var titleBase = Math.Clamp(44 * scale, 16, 58);
|
||||
PaintingTitleTextBlock.MaxWidth = rightContentWidth;
|
||||
PaintingTitleTextBlock.Margin = new Thickness(0, 0, 0, titleBottomMargin);
|
||||
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,
|
||||
titleHeightBudget,
|
||||
maxLines: 2,
|
||||
minFontSize: Math.Max(12, titleBase * 0.62),
|
||||
minLines: 2,
|
||||
maxLines: 5,
|
||||
minFontSize: titleMin,
|
||||
maxFontSize: titleBase,
|
||||
weight: FontWeight.Bold,
|
||||
lineHeightFactor: 1.12);
|
||||
PaintingTitleTextBlock.LineHeight = PaintingTitleTextBlock.FontSize * 1.12;
|
||||
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);
|
||||
if (ArtistTextBlock.Parent is StackPanel artistInfoStack)
|
||||
{
|
||||
artistInfoStack.Spacing = bottomStackSpacing;
|
||||
}
|
||||
|
||||
ArtistTextBlock.MaxWidth = rightContentWidth;
|
||||
ArtistTextBlock.FontSize = FitFontSize(
|
||||
var artistLayout = FitAdaptiveTextLayout(
|
||||
ArtistTextBlock.Text,
|
||||
rightContentWidth,
|
||||
artistHeightBudget,
|
||||
maxLines: 2,
|
||||
minFontSize: Math.Max(10, artistBase * 0.72),
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
minFontSize: artistMin,
|
||||
maxFontSize: artistBase,
|
||||
weight: FontWeight.SemiBold,
|
||||
weightCandidates: ArtistWeightCandidates,
|
||||
lineHeightFactor: 1.14);
|
||||
ArtistTextBlock.LineHeight = ArtistTextBlock.FontSize * 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,
|
||||
yearHeightBudget,
|
||||
maxLines: 1,
|
||||
minFontSize: Math.Max(9.5, yearBase * 0.78),
|
||||
minLines: 1,
|
||||
maxLines: 3,
|
||||
minFontSize: yearMin,
|
||||
maxFontSize: yearBase,
|
||||
weight: FontWeight.Medium,
|
||||
weightCandidates: SecondaryWeightCandidates,
|
||||
lineHeightFactor: 1.08);
|
||||
YearTextBlock.LineHeight = YearTextBlock.FontSize * 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, separatorBottomMargin);
|
||||
@@ -726,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
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<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="300"
|
||||
x:Class="LanMountainDesktop.Views.Components.DailyWordSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="Daily word settings"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="Configure auto refresh and refresh interval."
|
||||
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="AutoRefreshLabelTextBlock"
|
||||
Text="Auto refresh"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<CheckBox x:Name="AutoRefreshCheckBox"
|
||||
Content="Enable auto refresh"
|
||||
Checked="OnAutoRefreshChanged"
|
||||
Unchecked="OnAutoRefreshChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="FrequencyCardBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="FrequencyLabelTextBlock"
|
||||
Text="Refresh interval"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="FrequencyComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnFrequencySelectionChanged">
|
||||
<ComboBoxItem x:Name="Frequency30mItem"
|
||||
Tag="30"
|
||||
Content="30 min" />
|
||||
<ComboBoxItem x:Name="Frequency1hItem"
|
||||
Tag="60"
|
||||
Content="1 hour" />
|
||||
<ComboBoxItem x:Name="Frequency3hItem"
|
||||
Tag="180"
|
||||
Content="3 hours" />
|
||||
<ComboBoxItem x:Name="Frequency6hItem"
|
||||
Tag="360"
|
||||
Content="6 hours" />
|
||||
<ComboBoxItem x:Name="Frequency12hItem"
|
||||
Tag="720"
|
||||
Content="12 hours" />
|
||||
<ComboBoxItem x:Name="Frequency24hItem"
|
||||
Tag="1440"
|
||||
Content="24 hours" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class DailyWordSettingsWindow : UserControl
|
||||
{
|
||||
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public DailyWordSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeFrequencyOptions();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var enabled = componentSnapshot.DailyWordAutoRefreshEnabled;
|
||||
var interval = NormalizeInterval(componentSnapshot.DailyWordAutoRefreshIntervalMinutes);
|
||||
|
||||
_suppressEvents = true;
|
||||
AutoRefreshCheckBox.IsChecked = enabled;
|
||||
SelectInterval(interval);
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
TitleTextBlock.Text = L("dailyword.settings.title", "Daily word settings");
|
||||
DescriptionTextBlock.Text = L("dailyword.settings.desc", "Configure auto refresh and refresh interval.");
|
||||
AutoRefreshLabelTextBlock.Text = L("dailyword.settings.auto_refresh_label", "Auto refresh");
|
||||
AutoRefreshCheckBox.Content = L("dailyword.settings.auto_refresh_enabled", "Enable auto refresh");
|
||||
FrequencyLabelTextBlock.Text = L("dailyword.settings.frequency_label", "Refresh interval");
|
||||
ApplyFrequencyLocalization();
|
||||
}
|
||||
|
||||
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var enabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.DailyWordAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
snapshot.DailyWordAutoRefreshIntervalMinutes = GetSelectedInterval();
|
||||
_componentSettingsService.Save(snapshot);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private int GetSelectedInterval()
|
||||
{
|
||||
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
return NormalizeInterval(minutes);
|
||||
}
|
||||
|
||||
return 360;
|
||||
}
|
||||
|
||||
private void SelectInterval(int intervalMinutes)
|
||||
{
|
||||
var selected = FrequencyComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes) &&
|
||||
minutes == intervalMinutes);
|
||||
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int NormalizeInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 360);
|
||||
}
|
||||
|
||||
private void InitializeFrequencyOptions()
|
||||
{
|
||||
FrequencyComboBox.Items.Clear();
|
||||
foreach (var minutes in SupportedIntervals)
|
||||
{
|
||||
FrequencyComboBox.Items.Add(new ComboBoxItem
|
||||
{
|
||||
Tag = minutes.ToString(),
|
||||
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFrequencyLocalization()
|
||||
{
|
||||
foreach (var item in FrequencyComboBox.Items.OfType<ComboBoxItem>())
|
||||
{
|
||||
if (item.Tag is not string tagText ||
|
||||
!int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}";
|
||||
item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes));
|
||||
}
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
129
LanMountainDesktop/Views/Components/DailyWordWidget.axaml
Normal file
129
LanMountainDesktop/Views/Components/DailyWordWidget.axaml
Normal file
@@ -0,0 +1,129 @@
|
||||
<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="Transparent"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFBFA"
|
||||
CornerRadius="34"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
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>
|
||||
559
LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs
Normal file
559
LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs
Normal file
@@ -0,0 +1,559 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
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 static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromHours(6)
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = 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;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
|
||||
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();
|
||||
ApplyAutoRefreshSettings();
|
||||
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();
|
||||
ApplyAutoRefreshSettings();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshWordAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRefreshSettings();
|
||||
UpdateRefreshButtonState();
|
||||
_ = 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(0);
|
||||
|
||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||
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 = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAutoRefreshSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 360;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
enabled = snapshot.DailyWordAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.DailyWordAutoRefreshIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_autoRefreshEnabled = enabled;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
|
||||
if (!_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_autoRefreshEnabled)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
}
|
||||
else if (_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 360;
|
||||
}
|
||||
|
||||
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRefreshIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(360);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,8 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
double cellSize,
|
||||
TimeZoneService timeZoneService,
|
||||
IWeatherInfoService weatherInfoService,
|
||||
IRecommendationInfoService recommendationInfoService)
|
||||
IRecommendationInfoService recommendationInfoService,
|
||||
ICalculatorDataService calculatorDataService)
|
||||
{
|
||||
var control = _controlFactory();
|
||||
if (control is IDesktopComponentWidget sizedComponent)
|
||||
@@ -64,6 +65,11 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
recommendationInfoAwareComponent.SetRecommendationInfoService(recommendationInfoService);
|
||||
}
|
||||
|
||||
if (control is ICalculatorInfoAwareComponentWidget calculatorInfoAwareComponent)
|
||||
{
|
||||
calculatorInfoAwareComponent.SetCalculatorDataService(calculatorDataService);
|
||||
}
|
||||
|
||||
return control;
|
||||
}
|
||||
|
||||
@@ -229,6 +235,31 @@ 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.DesktopBilibiliHotSearch,
|
||||
"component.bilibili_hot_search",
|
||||
() => new BilibiliHotSearchWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopStcn24Forum,
|
||||
"component.stcn24_forum",
|
||||
() => new Stcn24ForumWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.28, 12, 24)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopExchangeRateCalculator,
|
||||
"component.exchange_rate_converter",
|
||||
() => new ExchangeRateCalculatorWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.28, 12, 26)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopWhiteboard,
|
||||
"component.whiteboard",
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="320"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.ExchangeRateCalculatorWidget">
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Button">
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
<Setter Property="Background" Value="#F8F9FB" />
|
||||
<Setter Property="BorderBrush" Value="#00000000" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="FontSize" Value="26" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="#111723" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
ClipToBounds="True"
|
||||
Padding="12"
|
||||
Background="#ECEDEF">
|
||||
<Viewbox Stretch="Uniform">
|
||||
<Grid x:Name="LayoutRoot"
|
||||
Width="304"
|
||||
Height="304"
|
||||
RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="8">
|
||||
<Grid Grid.Row="0"
|
||||
ColumnDefinitions="*,62"
|
||||
RowDefinitions="Auto,Auto"
|
||||
RowSpacing="8"
|
||||
ColumnSpacing="8">
|
||||
<Border x:Name="FromCurrencyRowBorder"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
CornerRadius="16"
|
||||
Background="#F8F9FB"
|
||||
Padding="12,8"
|
||||
PointerPressed="OnFromCurrencyRowPointerPressed">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<StackPanel Orientation="Vertical"
|
||||
Spacing="1"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="FromCurrencyCodeTextBlock"
|
||||
Text="USD"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#121722" />
|
||||
<TextBlock x:Name="FromCurrencyNameTextBlock"
|
||||
Text="美元"
|
||||
FontSize="13"
|
||||
Foreground="#6C7382" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1"
|
||||
Text=">"
|
||||
FontSize="18"
|
||||
Foreground="#A3A9B6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="InputAmountTextBlock"
|
||||
Grid.Column="2"
|
||||
Text="100"
|
||||
FontSize="42"
|
||||
FontWeight="Bold"
|
||||
Foreground="#F08D20"
|
||||
TextAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Right"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="ToCurrencyRowBorder"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
CornerRadius="16"
|
||||
Background="#F8F9FB"
|
||||
Padding="12,8"
|
||||
PointerPressed="OnToCurrencyRowPointerPressed">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<StackPanel Orientation="Vertical"
|
||||
Spacing="1"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="ToCurrencyCodeTextBlock"
|
||||
Text="CNY"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#121722" />
|
||||
<TextBlock x:Name="ToCurrencyNameTextBlock"
|
||||
Text="人民币"
|
||||
FontSize="13"
|
||||
Foreground="#6C7382" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1"
|
||||
Text=">"
|
||||
FontSize="18"
|
||||
Foreground="#A3A9B6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="ConvertedAmountTextBlock"
|
||||
Grid.Column="2"
|
||||
Text="0"
|
||||
FontSize="42"
|
||||
FontWeight="Bold"
|
||||
Foreground="#0F1622"
|
||||
TextAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Right"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Button x:Name="SwapCurrencyButton"
|
||||
Grid.Row="0"
|
||||
Grid.RowSpan="2"
|
||||
Grid.Column="1"
|
||||
CornerRadius="16"
|
||||
Background="#F8F9FB"
|
||||
BorderBrush="#00000000"
|
||||
BorderThickness="0"
|
||||
FontSize="30"
|
||||
Content="⇅"
|
||||
Click="OnSwapCurrencyButtonClick" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="RateTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="1 USD = 0 CNY"
|
||||
FontSize="14"
|
||||
Foreground="#646D7D"
|
||||
Margin="4,0,0,0"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Grid Grid.Row="2"
|
||||
ColumnDefinitions="*,*,*,84"
|
||||
RowDefinitions="*,*,*,*"
|
||||
RowSpacing="8"
|
||||
ColumnSpacing="8">
|
||||
<Button Grid.Row="0" Grid.Column="0" Content="7" Tag="7" Click="OnInputButtonClick" />
|
||||
<Button Grid.Row="0" Grid.Column="1" Content="8" Tag="8" Click="OnInputButtonClick" />
|
||||
<Button Grid.Row="0" Grid.Column="2" Content="9" Tag="9" Click="OnInputButtonClick" />
|
||||
|
||||
<Button Grid.Row="1" Grid.Column="0" Content="4" Tag="4" Click="OnInputButtonClick" />
|
||||
<Button Grid.Row="1" Grid.Column="1" Content="5" Tag="5" Click="OnInputButtonClick" />
|
||||
<Button Grid.Row="1" Grid.Column="2" Content="6" Tag="6" Click="OnInputButtonClick" />
|
||||
|
||||
<Button Grid.Row="2" Grid.Column="0" Content="1" Tag="1" Click="OnInputButtonClick" />
|
||||
<Button Grid.Row="2" Grid.Column="1" Content="2" Tag="2" Click="OnInputButtonClick" />
|
||||
<Button Grid.Row="2" Grid.Column="2" Content="3" Tag="3" Click="OnInputButtonClick" />
|
||||
|
||||
<Button Grid.Row="3" Grid.Column="0" Content="00" Tag="00" Click="OnInputButtonClick" />
|
||||
<Button Grid.Row="3" Grid.Column="1" Content="0" Tag="0" Click="OnInputButtonClick" />
|
||||
<Button Grid.Row="3" Grid.Column="2" Content="." Tag="." Click="OnInputButtonClick" />
|
||||
|
||||
<Button x:Name="ClearButton"
|
||||
Grid.Row="0"
|
||||
Grid.RowSpan="2"
|
||||
Grid.Column="3"
|
||||
Content="AC"
|
||||
Background="#D9DDE4"
|
||||
Tag="AC"
|
||||
Click="OnInputButtonClick" />
|
||||
|
||||
<Button x:Name="BackspaceButton"
|
||||
Grid.Row="2"
|
||||
Grid.RowSpan="2"
|
||||
Grid.Column="3"
|
||||
Content="⌫"
|
||||
Background="#D9DDE4"
|
||||
Tag="BACK"
|
||||
Click="OnInputButtonClick" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
Grid.RowSpan="3"
|
||||
IsVisible="False"
|
||||
Text="Loading"
|
||||
Foreground="#5E6677"
|
||||
FontSize="15"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Viewbox>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,347 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class ExchangeRateCalculatorWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget, ICalculatorInfoAwareComponentWidget
|
||||
{
|
||||
private sealed record CurrencyItem(string Code, string ZhName, string EnName);
|
||||
|
||||
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
||||
private static readonly CurrencyItem[] CurrencyItems =
|
||||
[
|
||||
new("USD", "美元", "US Dollar"),
|
||||
new("CNY", "人民币", "Chinese Yuan"),
|
||||
new("EUR", "欧元", "Euro"),
|
||||
new("JPY", "日元", "Japanese Yen"),
|
||||
new("HKD", "港币", "Hong Kong Dollar"),
|
||||
new("GBP", "英镑", "British Pound")
|
||||
];
|
||||
|
||||
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
|
||||
private static readonly ICalculatorDataService DefaultCalculatorService = new CalculatorDataService();
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMinutes(30)
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||
private ICalculatorDataService _calculatorDataService = DefaultCalculatorService;
|
||||
|
||||
private string _languageCode = "zh-CN";
|
||||
private string _fromCurrency = "USD";
|
||||
private string _toCurrency = "CNY";
|
||||
private string _inputText = "100";
|
||||
private decimal _currentRate = 0m;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private double _currentCellSize = 48d;
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
|
||||
public ExchangeRateCalculatorWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
FromCurrencyCodeTextBlock.FontFamily = MiSansFontFamily;
|
||||
FromCurrencyNameTextBlock.FontFamily = MiSansFontFamily;
|
||||
ToCurrencyCodeTextBlock.FontFamily = MiSansFontFamily;
|
||||
ToCurrencyNameTextBlock.FontFamily = MiSansFontFamily;
|
||||
InputAmountTextBlock.FontFamily = MiSansFontFamily;
|
||||
ConvertedAmountTextBlock.FontFamily = MiSansFontFamily;
|
||||
RateTextBlock.FontFamily = MiSansFontFamily;
|
||||
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
UpdateLanguageCode();
|
||||
UpdateCurrencyLabels();
|
||||
UpdateAmounts();
|
||||
ApplyLoadingState();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var scale = ResolveScale();
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 14, 48));
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(12 * scale, 6, 18));
|
||||
}
|
||||
|
||||
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||
{
|
||||
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshExchangeRateAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetCalculatorDataService(ICalculatorDataService calculatorDataService)
|
||||
{
|
||||
_calculatorDataService = calculatorDataService ?? DefaultCalculatorService;
|
||||
UpdateAmounts();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
_refreshTimer.Start();
|
||||
_ = RefreshExchangeRateAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
CancelRefreshRequest();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
await RefreshExchangeRateAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private async void OnSwapCurrencyButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
var from = _fromCurrency;
|
||||
_fromCurrency = _toCurrency;
|
||||
_toCurrency = from;
|
||||
UpdateCurrencyLabels();
|
||||
await RefreshExchangeRateAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private async void OnFromCurrencyRowPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_fromCurrency = GetNextCurrencyCode(_fromCurrency, _toCurrency);
|
||||
UpdateCurrencyLabels();
|
||||
await RefreshExchangeRateAsync(forceRefresh: false);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async void OnToCurrencyRowPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_toCurrency = GetNextCurrencyCode(_toCurrency, _fromCurrency);
|
||||
UpdateCurrencyLabels();
|
||||
await RefreshExchangeRateAsync(forceRefresh: false);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnInputButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button button || button.Tag is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var token = button.Tag.ToString() ?? string.Empty;
|
||||
_inputText = _calculatorDataService.ApplyInputToken(_inputText, token);
|
||||
UpdateAmounts();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async Task RefreshExchangeRateAsync(bool forceRefresh)
|
||||
{
|
||||
if (!_isAttached || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
UpdateLanguageCode();
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
||||
previous?.Cancel();
|
||||
previous?.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
var query = new ExchangeRateQuery(
|
||||
BaseCurrency: _fromCurrency,
|
||||
TargetCurrency: _toCurrency,
|
||||
ForceRefresh: forceRefresh);
|
||||
var result = await _recommendationService.GetExchangeRateAsync(query, cts.Token);
|
||||
if (!_isAttached || cts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success || result.Data is null)
|
||||
{
|
||||
ApplyFailedState();
|
||||
return;
|
||||
}
|
||||
|
||||
_currentRate = result.Data.Rate;
|
||||
StatusTextBlock.IsVisible = false;
|
||||
UpdateAmounts();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ignore canceled requests.
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (_isAttached && !cts.IsCancellationRequested)
|
||||
{
|
||||
ApplyFailedState();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_refreshCts, cts))
|
||||
{
|
||||
_refreshCts = null;
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
_isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCurrencyLabels()
|
||||
{
|
||||
var from = ResolveCurrency(_fromCurrency);
|
||||
var to = ResolveCurrency(_toCurrency);
|
||||
|
||||
FromCurrencyCodeTextBlock.Text = from.Code;
|
||||
FromCurrencyNameTextBlock.Text = IsZh() ? from.ZhName : from.EnName;
|
||||
ToCurrencyCodeTextBlock.Text = to.Code;
|
||||
ToCurrencyNameTextBlock.Text = IsZh() ? to.ZhName : to.EnName;
|
||||
}
|
||||
|
||||
private void UpdateAmounts()
|
||||
{
|
||||
var amount = _calculatorDataService.ParseAmountOrZero(_inputText);
|
||||
var converted = amount * Math.Max(0m, _currentRate);
|
||||
|
||||
InputAmountTextBlock.Text = _inputText;
|
||||
ConvertedAmountTextBlock.Text = _calculatorDataService.FormatAmount(converted, maxFractionDigits: 4);
|
||||
RateTextBlock.Text = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"1 {0} = {1} {2}",
|
||||
_fromCurrency,
|
||||
_calculatorDataService.FormatAmount(_currentRate, maxFractionDigits: 6),
|
||||
_toCurrency);
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
StatusTextBlock.Text = L("exchange.widget.loading", "正在加载汇率...");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
}
|
||||
|
||||
private void ApplyFailedState()
|
||||
{
|
||||
StatusTextBlock.Text = L("exchange.widget.fetch_failed", "汇率获取失败");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
UpdateAmounts();
|
||||
}
|
||||
|
||||
private void UpdateLanguageCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private string GetNextCurrencyCode(string current, string avoid)
|
||||
{
|
||||
var currentIndex = Array.FindIndex(
|
||||
CurrencyItems,
|
||||
item => string.Equals(item.Code, current, StringComparison.OrdinalIgnoreCase));
|
||||
if (currentIndex < 0)
|
||||
{
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
for (var step = 1; step <= CurrencyItems.Length; step++)
|
||||
{
|
||||
var next = CurrencyItems[(currentIndex + step) % CurrencyItems.Length].Code;
|
||||
if (!string.Equals(next, avoid, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private static CurrencyItem ResolveCurrency(string code)
|
||||
{
|
||||
return CurrencyItems.FirstOrDefault(item =>
|
||||
string.Equals(item.Code, code, StringComparison.OrdinalIgnoreCase))
|
||||
?? CurrencyItems[0];
|
||||
}
|
||||
|
||||
private bool IsZh()
|
||||
{
|
||||
return string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 1.8);
|
||||
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 304d, 0.72, 2.0) : 1;
|
||||
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 304d, 0.72, 2.0) : 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 1.95);
|
||||
}
|
||||
|
||||
private void CancelRefreshRequest()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
if (cts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,14 @@ namespace LanMountainDesktop.Views.Components;
|
||||
public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
{
|
||||
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromMinutes(12) };
|
||||
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();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
|
||||
private IWeatherInfoService _weatherInfoService = DefaultWeatherInfoService;
|
||||
@@ -34,6 +36,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private string _languageCode = "zh-CN";
|
||||
private HyperOS3WeatherVisualKind _activeVisualKind = HyperOS3WeatherVisualKind.ClearDay;
|
||||
private readonly TextBlock[] _hourlyTempBlocks;
|
||||
@@ -87,6 +90,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyVisualTheme(_activeVisualKind);
|
||||
ApplyFallback();
|
||||
ApplyAutoRefreshSettings();
|
||||
}
|
||||
|
||||
private void ConfigureTextOverflowGuards()
|
||||
@@ -160,6 +164,15 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
ApplyAutoRefreshSettings();
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
@@ -184,6 +197,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRefreshSettings();
|
||||
UpdateTimerState();
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
@@ -893,10 +907,14 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
if (_autoRefreshEnabled && !_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
else if (!_autoRefreshEnabled && _refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
|
||||
if (!_animationTimer.IsEnabled)
|
||||
{
|
||||
@@ -910,6 +928,48 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
_animationTimer.Stop();
|
||||
}
|
||||
|
||||
private void ApplyAutoRefreshSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 12;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
enabled = snapshot.WeatherAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_autoRefreshEnabled = enabled;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
|
||||
if (_isAttached)
|
||||
{
|
||||
UpdateTimerState();
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 12;
|
||||
}
|
||||
|
||||
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRefreshIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(12);
|
||||
}
|
||||
|
||||
private void CancelRefresh()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
|
||||
@@ -82,6 +82,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
string TemperatureText);
|
||||
|
||||
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
@@ -94,6 +95,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly Dictionary<WeatherVisualKind, IBrush> _backgroundBrushCache = new();
|
||||
private readonly Dictionary<HyperOS3WeatherVisualKind, IBrush> _particleBrushCache = new();
|
||||
@@ -115,6 +117,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private readonly TextBlock[] _hourlyTimeBlocks;
|
||||
private readonly Image[] _hourlyIconBlocks;
|
||||
private readonly TextBlock[] _hourlyTempBlocks;
|
||||
@@ -147,6 +150,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
ApplyVisualTheme(WeatherVisualKind.ClearDay);
|
||||
ApplyNotConfiguredState();
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyAutoRefreshSettings();
|
||||
}
|
||||
|
||||
private void ConfigureTextOverflowGuards()
|
||||
@@ -211,6 +215,15 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
ApplyAutoRefreshSettings();
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
@@ -249,6 +262,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRefreshSettings();
|
||||
UpdateTimerState();
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
@@ -1382,10 +1396,14 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
if (_autoRefreshEnabled && !_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
else if (!_autoRefreshEnabled && _refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
|
||||
if (!_backgroundAnimationTimer.IsEnabled)
|
||||
{
|
||||
@@ -1399,6 +1417,48 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
_backgroundAnimationTimer.Stop();
|
||||
}
|
||||
|
||||
private void ApplyAutoRefreshSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 12;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
enabled = snapshot.WeatherAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_autoRefreshEnabled = enabled;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
|
||||
if (_isAttached)
|
||||
{
|
||||
UpdateTimerState();
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 12;
|
||||
}
|
||||
|
||||
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRefreshIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(12);
|
||||
}
|
||||
|
||||
private void InitializeParticleVisuals()
|
||||
{
|
||||
if (_particleVisuals.Count > 0)
|
||||
|
||||
@@ -23,6 +23,11 @@ public interface IRecommendationInfoAwareComponentWidget
|
||||
void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService);
|
||||
}
|
||||
|
||||
public interface ICalculatorInfoAwareComponentWidget
|
||||
{
|
||||
void SetCalculatorDataService(ICalculatorDataService calculatorDataService);
|
||||
}
|
||||
|
||||
public interface IDesktopPageVisibilityAwareComponentWidget
|
||||
{
|
||||
void SetDesktopPageContext(bool isOnActivePage, bool isEditMode);
|
||||
|
||||
@@ -80,6 +80,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
string TemperatureText);
|
||||
|
||||
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
@@ -92,6 +93,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly Dictionary<WeatherVisualKind, IBrush> _backgroundBrushCache = new();
|
||||
private readonly Dictionary<HyperOS3WeatherVisualKind, IBrush> _particleBrushCache = new();
|
||||
@@ -113,6 +115,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private readonly TextBlock[] _hourlyTimeBlocks;
|
||||
private readonly Image[] _hourlyIconBlocks;
|
||||
private readonly TextBlock[] _hourlyTempBlocks;
|
||||
@@ -145,6 +148,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
ApplyVisualTheme(WeatherVisualKind.ClearDay);
|
||||
ApplyNotConfiguredState();
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyAutoRefreshSettings();
|
||||
}
|
||||
|
||||
private void ConfigureTextOverflowGuards()
|
||||
@@ -209,6 +213,15 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
ApplyAutoRefreshSettings();
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
@@ -247,6 +260,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRefreshSettings();
|
||||
UpdateTimerState();
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
@@ -1232,10 +1246,14 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
if (_autoRefreshEnabled && !_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
else if (!_autoRefreshEnabled && _refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
|
||||
if (!_backgroundAnimationTimer.IsEnabled)
|
||||
{
|
||||
@@ -1249,6 +1267,48 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
_backgroundAnimationTimer.Stop();
|
||||
}
|
||||
|
||||
private void ApplyAutoRefreshSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 12;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
enabled = snapshot.WeatherAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_autoRefreshEnabled = enabled;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
|
||||
if (_isAttached)
|
||||
{
|
||||
UpdateTimerState();
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 12;
|
||||
}
|
||||
|
||||
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRefreshIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(12);
|
||||
}
|
||||
|
||||
private void InitializeParticleVisuals()
|
||||
{
|
||||
if (_particleVisuals.Count > 0)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<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="300"
|
||||
x:Class="LanMountainDesktop.Views.Components.Stcn24ForumSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="STCN 24 settings"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="Configure information source, auto refresh and refresh interval."
|
||||
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="SourceLabelTextBlock"
|
||||
Text="Information source"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="SourceComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnSourceSelectionChanged">
|
||||
<ComboBoxItem x:Name="SourceLatestCreatedItem"
|
||||
Tag="LatestCreated"
|
||||
Content="Latest posts" />
|
||||
<ComboBoxItem x:Name="SourceLatestActivityItem"
|
||||
Tag="LatestActivity"
|
||||
Content="Latest activity" />
|
||||
<ComboBoxItem x:Name="SourceMostRepliesItem"
|
||||
Tag="MostReplies"
|
||||
Content="Most replies" />
|
||||
<ComboBoxItem x:Name="SourceEarliestCreatedItem"
|
||||
Tag="EarliestCreated"
|
||||
Content="Earliest posts" />
|
||||
<ComboBoxItem x:Name="SourceEarliestActivityItem"
|
||||
Tag="EarliestActivity"
|
||||
Content="Earliest activity" />
|
||||
<ComboBoxItem x:Name="SourceLeastRepliesItem"
|
||||
Tag="LeastReplies"
|
||||
Content="Least replies" />
|
||||
<ComboBoxItem x:Name="SourceFrontpageLatestItem"
|
||||
Tag="FrontpageLatest"
|
||||
Content="Frontpage latest" />
|
||||
<ComboBoxItem x:Name="SourceFrontpageEarliestItem"
|
||||
Tag="FrontpageEarliest"
|
||||
Content="Frontpage earliest" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="AutoRefreshLabelTextBlock"
|
||||
Text="Auto refresh"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<CheckBox x:Name="AutoRefreshCheckBox"
|
||||
Content="Enable auto refresh"
|
||||
Checked="OnAutoRefreshChanged"
|
||||
Unchecked="OnAutoRefreshChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="FrequencyCardBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="FrequencyLabelTextBlock"
|
||||
Text="Refresh interval"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="FrequencyComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnFrequencySelectionChanged">
|
||||
<ComboBoxItem x:Name="Frequency5mItem"
|
||||
Tag="5"
|
||||
Content="5 min" />
|
||||
<ComboBoxItem x:Name="Frequency10mItem"
|
||||
Tag="10"
|
||||
Content="10 min" />
|
||||
<ComboBoxItem x:Name="Frequency20mItem"
|
||||
Tag="20"
|
||||
Content="20 min" />
|
||||
<ComboBoxItem x:Name="Frequency30mItem"
|
||||
Tag="30"
|
||||
Content="30 min" />
|
||||
<ComboBoxItem x:Name="Frequency1hItem"
|
||||
Tag="60"
|
||||
Content="1 hour" />
|
||||
<ComboBoxItem x:Name="Frequency3hItem"
|
||||
Tag="180"
|
||||
Content="3 hours" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class Stcn24ForumSettingsWindow : UserControl
|
||||
{
|
||||
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public Stcn24ForumSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeFrequencyOptions();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var enabled = componentSnapshot.Stcn24ForumAutoRefreshEnabled;
|
||||
var interval = NormalizeInterval(componentSnapshot.Stcn24ForumAutoRefreshIntervalMinutes);
|
||||
var sourceType = Stcn24ForumSourceTypes.Normalize(componentSnapshot.Stcn24ForumSourceType);
|
||||
|
||||
_suppressEvents = true;
|
||||
AutoRefreshCheckBox.IsChecked = enabled;
|
||||
SelectSourceType(sourceType);
|
||||
SelectInterval(interval);
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
TitleTextBlock.Text = L("stcn24.settings.title", "STCN 24 settings");
|
||||
DescriptionTextBlock.Text = L("stcn24.settings.desc", "Configure information source, auto refresh and refresh interval.");
|
||||
SourceLabelTextBlock.Text = L("stcn24.settings.source_label", "Information source");
|
||||
SourceLatestCreatedItem.Content = L("stcn24.settings.source_latest_created", "Latest posts");
|
||||
SourceLatestActivityItem.Content = L("stcn24.settings.source_latest_activity", "Latest activity");
|
||||
SourceMostRepliesItem.Content = L("stcn24.settings.source_most_replies", "Most replies");
|
||||
SourceEarliestCreatedItem.Content = L("stcn24.settings.source_earliest_created", "Earliest posts");
|
||||
SourceEarliestActivityItem.Content = L("stcn24.settings.source_earliest_activity", "Earliest activity");
|
||||
SourceLeastRepliesItem.Content = L("stcn24.settings.source_least_replies", "Least replies");
|
||||
SourceFrontpageLatestItem.Content = L("stcn24.settings.source_frontpage_latest", "Frontpage latest");
|
||||
SourceFrontpageEarliestItem.Content = L("stcn24.settings.source_frontpage_earliest", "Frontpage earliest");
|
||||
AutoRefreshLabelTextBlock.Text = L("stcn24.settings.auto_refresh_label", "Auto refresh");
|
||||
AutoRefreshCheckBox.Content = L("stcn24.settings.auto_refresh_enabled", "Enable auto refresh");
|
||||
FrequencyLabelTextBlock.Text = L("stcn24.settings.frequency_label", "Refresh interval");
|
||||
ApplyFrequencyLocalization();
|
||||
}
|
||||
|
||||
private void OnSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var enabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.Stcn24ForumSourceType = GetSelectedSourceType();
|
||||
snapshot.Stcn24ForumAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
snapshot.Stcn24ForumAutoRefreshIntervalMinutes = GetSelectedInterval();
|
||||
_componentSettingsService.Save(snapshot);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private string GetSelectedSourceType()
|
||||
{
|
||||
if (SourceComboBox.SelectedItem is ComboBoxItem item &&
|
||||
item.Tag is string sourceTag)
|
||||
{
|
||||
return Stcn24ForumSourceTypes.Normalize(sourceTag);
|
||||
}
|
||||
|
||||
return Stcn24ForumSourceTypes.LatestCreated;
|
||||
}
|
||||
|
||||
private int GetSelectedInterval()
|
||||
{
|
||||
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
return NormalizeInterval(minutes);
|
||||
}
|
||||
|
||||
return 20;
|
||||
}
|
||||
|
||||
private void SelectInterval(int intervalMinutes)
|
||||
{
|
||||
var selected = FrequencyComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes) &&
|
||||
minutes == intervalMinutes);
|
||||
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private void SelectSourceType(string sourceType)
|
||||
{
|
||||
var normalizedSourceType = Stcn24ForumSourceTypes.Normalize(sourceType);
|
||||
var selected = SourceComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string sourceTag &&
|
||||
string.Equals(Stcn24ForumSourceTypes.Normalize(sourceTag), normalizedSourceType, StringComparison.OrdinalIgnoreCase));
|
||||
SourceComboBox.SelectedItem = selected ?? SourceComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int NormalizeInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 20);
|
||||
}
|
||||
|
||||
private void InitializeFrequencyOptions()
|
||||
{
|
||||
FrequencyComboBox.Items.Clear();
|
||||
foreach (var minutes in SupportedIntervals)
|
||||
{
|
||||
FrequencyComboBox.Items.Add(new ComboBoxItem
|
||||
{
|
||||
Tag = minutes.ToString(),
|
||||
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFrequencyLocalization()
|
||||
{
|
||||
foreach (var item in FrequencyComboBox.Items.OfType<ComboBoxItem>())
|
||||
{
|
||||
if (item.Tag is not string tagText ||
|
||||
!int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}";
|
||||
item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes));
|
||||
}
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
246
LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml
Normal file
246
LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml
Normal file
@@ -0,0 +1,246 @@
|
||||
<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"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="320"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.Stcn24ForumWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="28"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFCFD"
|
||||
CornerRadius="28"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="12,12,12,12">
|
||||
<Grid x:Name="ContentGrid"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
||||
RowSpacing="6">
|
||||
<Grid x:Name="HeaderGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="8">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Border x:Name="HeaderDot"
|
||||
Width="8"
|
||||
Height="8"
|
||||
CornerRadius="4"
|
||||
Background="#FF4D4F"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="HeaderTitleTextBlock"
|
||||
Text="STCN 24"
|
||||
Foreground="#202327"
|
||||
FontSize="20"
|
||||
FontWeight="Bold"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="1"
|
||||
Width="34"
|
||||
Height="34"
|
||||
CornerRadius="17"
|
||||
Background="#EFF1F5"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
Focusable="False"
|
||||
Click="OnRefreshButtonClick">
|
||||
<fi:SymbolIcon x:Name="RefreshGlyphIcon"
|
||||
Symbol="ArrowClockwise"
|
||||
IconVariant="Regular"
|
||||
Foreground="#5E6671"
|
||||
FontSize="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="PostItem1Host"
|
||||
Grid.Row="1"
|
||||
Tag="0"
|
||||
Background="#F7F8FA"
|
||||
CornerRadius="10"
|
||||
Padding="8,6"
|
||||
PointerPressed="OnPostItemPointerPressed">
|
||||
<Grid x:Name="PostItem1Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<Border x:Name="PostItem1AvatarHost"
|
||||
Width="30"
|
||||
Height="30"
|
||||
CornerRadius="15"
|
||||
Background="#E7EBF4"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Image x:Name="PostItem1AvatarImage"
|
||||
Stretch="UniformToFill" />
|
||||
<TextBlock x:Name="PostItem1AvatarFallbackText"
|
||||
Text="?"
|
||||
Foreground="#4A5466"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="PostItem1TitleTextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Loading..."
|
||||
Foreground="#202327"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="PostItem2Host"
|
||||
Grid.Row="2"
|
||||
Tag="1"
|
||||
Background="#F7F8FA"
|
||||
CornerRadius="10"
|
||||
Padding="8,6"
|
||||
PointerPressed="OnPostItemPointerPressed">
|
||||
<Grid x:Name="PostItem2Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<Border x:Name="PostItem2AvatarHost"
|
||||
Width="30"
|
||||
Height="30"
|
||||
CornerRadius="15"
|
||||
Background="#E7EBF4"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Image x:Name="PostItem2AvatarImage"
|
||||
Stretch="UniformToFill" />
|
||||
<TextBlock x:Name="PostItem2AvatarFallbackText"
|
||||
Text="?"
|
||||
Foreground="#4A5466"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="PostItem2TitleTextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Loading..."
|
||||
Foreground="#202327"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="PostItem3Host"
|
||||
Grid.Row="3"
|
||||
Tag="2"
|
||||
Background="#F7F8FA"
|
||||
CornerRadius="10"
|
||||
Padding="8,6"
|
||||
PointerPressed="OnPostItemPointerPressed">
|
||||
<Grid x:Name="PostItem3Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<Border x:Name="PostItem3AvatarHost"
|
||||
Width="30"
|
||||
Height="30"
|
||||
CornerRadius="15"
|
||||
Background="#E7EBF4"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Image x:Name="PostItem3AvatarImage"
|
||||
Stretch="UniformToFill" />
|
||||
<TextBlock x:Name="PostItem3AvatarFallbackText"
|
||||
Text="?"
|
||||
Foreground="#4A5466"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="PostItem3TitleTextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Loading..."
|
||||
Foreground="#202327"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="PostItem4Host"
|
||||
Grid.Row="4"
|
||||
Tag="3"
|
||||
Background="#F7F8FA"
|
||||
CornerRadius="10"
|
||||
Padding="8,6"
|
||||
PointerPressed="OnPostItemPointerPressed">
|
||||
<Grid x:Name="PostItem4Grid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<Border x:Name="PostItem4AvatarHost"
|
||||
Width="30"
|
||||
Height="30"
|
||||
CornerRadius="15"
|
||||
Background="#E7EBF4"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Image x:Name="PostItem4AvatarImage"
|
||||
Stretch="UniformToFill" />
|
||||
<TextBlock x:Name="PostItem4AvatarFallbackText"
|
||||
Text="?"
|
||||
Foreground="#4A5466"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="PostItem4TitleTextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Loading..."
|
||||
Foreground="#202327"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="Loading..."
|
||||
Foreground="#6A6F77"
|
||||
FontSize="14"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
687
LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml.cs
Normal file
687
LanMountainDesktop/Views/Components/Stcn24ForumWidget.axaml.cs
Normal file
@@ -0,0 +1,687 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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 Stcn24ForumWidget : 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 AvatarHttpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(8)
|
||||
};
|
||||
|
||||
private const string AvatarRequestUserAgent =
|
||||
"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 = 4;
|
||||
private const int MaxDisplayItemCount = 4;
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMinutes(20)
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly List<Stcn24ForumPostItemSnapshot> _activeItems = [];
|
||||
private readonly List<ForumItemVisual> _itemVisuals = [];
|
||||
private readonly Bitmap?[] _avatarBitmaps = new Bitmap?[MaxDisplayItemCount];
|
||||
|
||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private string _languageCode = "zh-CN";
|
||||
private string _sourceType = Stcn24ForumSourceTypes.LatestCreated;
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
|
||||
private sealed record ForumItemVisual(
|
||||
Border Host,
|
||||
Grid RowGrid,
|
||||
Border AvatarHost,
|
||||
Image AvatarImage,
|
||||
TextBlock AvatarFallbackText,
|
||||
TextBlock TitleTextBlock);
|
||||
|
||||
public Stcn24ForumWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
HeaderTitleTextBlock.FontFamily = MiSansFontFamily;
|
||||
PostItem1TitleTextBlock.FontFamily = MiSansFontFamily;
|
||||
PostItem2TitleTextBlock.FontFamily = MiSansFontFamily;
|
||||
PostItem3TitleTextBlock.FontFamily = MiSansFontFamily;
|
||||
PostItem4TitleTextBlock.FontFamily = MiSansFontFamily;
|
||||
PostItem1AvatarFallbackText.FontFamily = MiSansFontFamily;
|
||||
PostItem2AvatarFallbackText.FontFamily = MiSansFontFamily;
|
||||
PostItem3AvatarFallbackText.FontFamily = MiSansFontFamily;
|
||||
PostItem4AvatarFallbackText.FontFamily = MiSansFontFamily;
|
||||
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||
|
||||
_itemVisuals.Add(new ForumItemVisual(
|
||||
PostItem1Host,
|
||||
PostItem1Grid,
|
||||
PostItem1AvatarHost,
|
||||
PostItem1AvatarImage,
|
||||
PostItem1AvatarFallbackText,
|
||||
PostItem1TitleTextBlock));
|
||||
_itemVisuals.Add(new ForumItemVisual(
|
||||
PostItem2Host,
|
||||
PostItem2Grid,
|
||||
PostItem2AvatarHost,
|
||||
PostItem2AvatarImage,
|
||||
PostItem2AvatarFallbackText,
|
||||
PostItem2TitleTextBlock));
|
||||
_itemVisuals.Add(new ForumItemVisual(
|
||||
PostItem3Host,
|
||||
PostItem3Grid,
|
||||
PostItem3AvatarHost,
|
||||
PostItem3AvatarImage,
|
||||
PostItem3AvatarFallbackText,
|
||||
PostItem3TitleTextBlock));
|
||||
_itemVisuals.Add(new ForumItemVisual(
|
||||
PostItem4Host,
|
||||
PostItem4Grid,
|
||||
PostItem4AvatarHost,
|
||||
PostItem4AvatarImage,
|
||||
PostItem4AvatarFallbackText,
|
||||
PostItem4TitleTextBlock));
|
||||
|
||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
UpdateLanguageCode();
|
||||
ApplyAutoRefreshSettings();
|
||||
ApplyLoadingState();
|
||||
UpdateInteractionState();
|
||||
UpdateRefreshButtonState();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
||||
{
|
||||
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshPostsAsync(forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
_recommendationService.ClearCache();
|
||||
ApplyAutoRefreshSettings();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshPostsAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRefreshSettings();
|
||||
|
||||
_ = RefreshPostsAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_refreshTimer.Stop();
|
||||
CancelRefreshRequest();
|
||||
DisposeAvatarBitmaps();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RefreshPostsAsync(forceRefresh: true);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
await RefreshPostsAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void OnPostItemPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
|
||||
sender is not Border host ||
|
||||
host.Tag is null ||
|
||||
!int.TryParse(host.Tag.ToString(), out var index) ||
|
||||
index < 0 ||
|
||||
index >= _activeItems.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryOpenUrl(_activeItems[index].Url);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async Task RefreshPostsAsync(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 Stcn24ForumPostsQuery(
|
||||
Locale: _languageCode,
|
||||
ItemCount: MaxDisplayItemCount,
|
||||
SourceType: _sourceType,
|
||||
ForceRefresh: forceRefresh);
|
||||
var result = await _recommendationService.GetStcn24ForumPostsAsync(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(Stcn24ForumPostsSnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
_activeItems.Clear();
|
||||
foreach (var item in snapshot.Items)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Title) || string.IsNullOrWhiteSpace(item.Url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_activeItems.Add(item);
|
||||
if (_activeItems.Count >= MaxDisplayItemCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackItemText = L("stcn24.widget.fallback_item", "暂无帖子");
|
||||
for (var i = 0; i < _itemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _itemVisuals[i];
|
||||
if (i < _activeItems.Count)
|
||||
{
|
||||
var item = _activeItems[i];
|
||||
visual.TitleTextBlock.Text = NormalizeCompactText(item.Title);
|
||||
visual.AvatarFallbackText.Text = ResolveAvatarFallbackText(item.AuthorDisplayName);
|
||||
}
|
||||
else
|
||||
{
|
||||
visual.TitleTextBlock.Text = fallbackItemText;
|
||||
visual.AvatarFallbackText.Text = "?";
|
||||
}
|
||||
|
||||
SetAvatarBitmap(i, null);
|
||||
}
|
||||
|
||||
StatusTextBlock.IsVisible = false;
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
|
||||
var tasks = _activeItems
|
||||
.Select(item => TryDownloadAvatarBitmapAsync(item.AuthorAvatarUrl, cancellationToken))
|
||||
.ToArray();
|
||||
if (tasks.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bitmaps = await Task.WhenAll(tasks);
|
||||
if (cancellationToken.IsCancellationRequested || !_isAttached)
|
||||
{
|
||||
foreach (var bitmap in bitmaps)
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < bitmaps.Length && i < _itemVisuals.Count; i++)
|
||||
{
|
||||
SetAvatarBitmap(i, bitmaps[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyLoadingState()
|
||||
{
|
||||
_activeItems.Clear();
|
||||
StatusTextBlock.Text = L("stcn24.widget.loading", "加载中...");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
|
||||
var loadingText = L("stcn24.widget.loading_item", "加载中...");
|
||||
for (var i = 0; i < _itemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _itemVisuals[i];
|
||||
visual.TitleTextBlock.Text = loadingText;
|
||||
visual.AvatarFallbackText.Text = "?";
|
||||
SetAvatarBitmap(i, null);
|
||||
}
|
||||
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void ApplyFailedState()
|
||||
{
|
||||
_activeItems.Clear();
|
||||
StatusTextBlock.Text = L("stcn24.widget.fetch_failed", "帖子获取失败");
|
||||
StatusTextBlock.IsVisible = true;
|
||||
|
||||
var fallbackText = L("stcn24.widget.fallback_item", "暂无帖子");
|
||||
for (var i = 0; i < _itemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _itemVisuals[i];
|
||||
visual.TitleTextBlock.Text = fallbackText;
|
||||
visual.AvatarFallbackText.Text = "?";
|
||||
SetAvatarBitmap(i, null);
|
||||
}
|
||||
|
||||
UpdateInteractionState();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void UpdateInteractionState()
|
||||
{
|
||||
var enabledBackground = new SolidColorBrush(Color.Parse("#F7F8FA"));
|
||||
var disabledBackground = new SolidColorBrush(Color.Parse("#F2F3F5"));
|
||||
|
||||
for (var i = 0; i < _itemVisuals.Count; i++)
|
||||
{
|
||||
var visual = _itemVisuals[i];
|
||||
var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url);
|
||||
visual.Host.IsHitTestVisible = enabled;
|
||||
visual.Host.Opacity = enabled ? 1.0 : 0.72;
|
||||
visual.Host.Cursor = enabled
|
||||
? new Cursor(StandardCursorType.Hand)
|
||||
: new Cursor(StandardCursorType.Arrow);
|
||||
visual.Host.Background = enabled
|
||||
? enabledBackground
|
||||
: disabledBackground;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateRefreshButtonState()
|
||||
{
|
||||
RefreshButton.IsEnabled = !_isRefreshing;
|
||||
RefreshButton.Opacity = _isRefreshing ? 0.58 : 1.0;
|
||||
}
|
||||
|
||||
private void UpdateLanguageCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyAutoRefreshSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 20;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
_sourceType = Stcn24ForumSourceTypes.Normalize(snapshot.Stcn24ForumSourceType);
|
||||
enabled = snapshot.Stcn24ForumAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.Stcn24ForumAutoRefreshIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
_sourceType = Stcn24ForumSourceTypes.LatestCreated;
|
||||
}
|
||||
|
||||
_autoRefreshEnabled = enabled;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
|
||||
if (!_isAttached)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_autoRefreshEnabled)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
}
|
||||
else if (_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 20;
|
||||
}
|
||||
|
||||
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRefreshIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(20);
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var softScale = Math.Clamp(scale, 0.80, 1.40);
|
||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(30 * softScale, 14, 44));
|
||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(30 * softScale, 14, 44));
|
||||
CardBorder.Padding = new Thickness(
|
||||
Math.Clamp(12 * softScale, 8, 18),
|
||||
Math.Clamp(12 * softScale, 8, 18),
|
||||
Math.Clamp(12 * softScale, 8, 18),
|
||||
Math.Clamp(12 * softScale, 8, 18));
|
||||
|
||||
var rowSpacing = Math.Clamp(6 * softScale, 3, 10);
|
||||
ContentGrid.RowSpacing = rowSpacing;
|
||||
HeaderGrid.ColumnSpacing = Math.Clamp(8 * softScale, 5, 12);
|
||||
|
||||
HeaderDot.Width = Math.Clamp(8 * softScale, 5, 12);
|
||||
HeaderDot.Height = HeaderDot.Width;
|
||||
HeaderDot.CornerRadius = new CornerRadius(HeaderDot.Width / 2d);
|
||||
HeaderTitleTextBlock.FontSize = Math.Clamp(20 * softScale, 12, 28);
|
||||
|
||||
var refreshSize = Math.Clamp(34 * softScale, 22, 42);
|
||||
RefreshButton.Width = refreshSize;
|
||||
RefreshButton.Height = refreshSize;
|
||||
RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d);
|
||||
RefreshGlyphIcon.FontSize = Math.Clamp(16 * softScale, 10, 20);
|
||||
|
||||
var innerWidth = Math.Max(100, totalWidth - CardBorder.Padding.Left - CardBorder.Padding.Right);
|
||||
var rowPaddingHorizontal = Math.Clamp(8 * softScale, 5, 14);
|
||||
var rowPaddingVertical = Math.Clamp(6 * softScale, 3, 10);
|
||||
var itemCornerRadius = Math.Clamp(10 * softScale, 6, 14);
|
||||
var avatarSize = Math.Clamp(30 * softScale, 20, 40);
|
||||
var avatarFont = Math.Clamp(13 * softScale, 9, 18);
|
||||
var titleFont = Math.Clamp(14 * softScale, 10, 19);
|
||||
var titleMaxWidth = Math.Max(60, innerWidth - avatarSize - (rowPaddingHorizontal * 2d) - 18);
|
||||
|
||||
foreach (var visual in _itemVisuals)
|
||||
{
|
||||
visual.Host.CornerRadius = new CornerRadius(itemCornerRadius);
|
||||
visual.Host.Padding = new Thickness(rowPaddingHorizontal, rowPaddingVertical);
|
||||
visual.RowGrid.ColumnSpacing = Math.Clamp(8 * softScale, 4, 12);
|
||||
|
||||
visual.AvatarHost.Width = avatarSize;
|
||||
visual.AvatarHost.Height = avatarSize;
|
||||
visual.AvatarHost.CornerRadius = new CornerRadius(avatarSize / 2d);
|
||||
|
||||
visual.AvatarFallbackText.FontSize = avatarFont;
|
||||
visual.TitleTextBlock.FontSize = titleFont;
|
||||
visual.TitleTextBlock.MaxWidth = titleMaxWidth;
|
||||
}
|
||||
|
||||
StatusTextBlock.FontSize = Math.Clamp(14 * softScale, 10, 18);
|
||||
}
|
||||
|
||||
private static string NormalizeCompactText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
|
||||
}
|
||||
|
||||
private static string ResolveAvatarFallbackText(string? displayName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
return "?";
|
||||
}
|
||||
|
||||
var compact = displayName.Trim();
|
||||
var first = compact[0];
|
||||
return first.ToString().ToUpperInvariant();
|
||||
}
|
||||
|
||||
private static async Task<Bitmap?> TryDownloadAvatarBitmapAsync(string? avatarUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedUrl = NormalizeHttpUrl(avatarUrl);
|
||||
if (string.IsNullOrWhiteSpace(normalizedUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, normalizedUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", AvatarRequestUserAgent);
|
||||
request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
|
||||
using var response = await AvatarHttpClient.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 SetAvatarBitmap(int index, Bitmap? bitmap)
|
||||
{
|
||||
if (index < 0 || index >= _avatarBitmaps.Length || index >= _itemVisuals.Count)
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
var visual = _itemVisuals[index];
|
||||
var oldBitmap = _avatarBitmaps[index];
|
||||
if (ReferenceEquals(visual.AvatarImage.Source, oldBitmap))
|
||||
{
|
||||
visual.AvatarImage.Source = null;
|
||||
}
|
||||
|
||||
oldBitmap?.Dispose();
|
||||
_avatarBitmaps[index] = bitmap;
|
||||
visual.AvatarImage.Source = bitmap;
|
||||
visual.AvatarFallbackText.IsVisible = bitmap is null;
|
||||
}
|
||||
|
||||
private void DisposeAvatarBitmaps()
|
||||
{
|
||||
for (var i = 0; i < _avatarBitmaps.Length; i++)
|
||||
{
|
||||
SetAvatarBitmap(i, null);
|
||||
}
|
||||
}
|
||||
|
||||
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 TryOpenUrl(string? rawUrl)
|
||||
{
|
||||
var normalizedUrl = NormalizeHttpUrl(rawUrl);
|
||||
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 double ResolveScale()
|
||||
{
|
||||
var expectedWidth = _currentCellSize * BaseWidthCells;
|
||||
var expectedHeight = _currentCellSize * BaseHeightCells;
|
||||
if (expectedWidth <= 0 || expectedHeight <= 0)
|
||||
{
|
||||
return 1d;
|
||||
}
|
||||
|
||||
var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth;
|
||||
var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight;
|
||||
var scaleX = actualWidth / expectedWidth;
|
||||
var scaleY = actualHeight / expectedHeight;
|
||||
return Math.Clamp(Math.Min(scaleX, scaleY), 0.62, 2.6);
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private void CancelRefreshRequest()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
if (cts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
{
|
||||
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
||||
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly DispatcherTimer _uiTimer = new()
|
||||
{
|
||||
@@ -127,10 +128,11 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
|
||||
private void ReloadDisplaySettings()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
_showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
|
||||
_showDbfs = snapshot.StudyEnvironmentShowDbfs;
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
_showDisplayDb = componentSnapshot.StudyEnvironmentShowDisplayDb;
|
||||
_showDbfs = componentSnapshot.StudyEnvironmentShowDbfs;
|
||||
if (!_showDisplayDb && !_showDbfs)
|
||||
{
|
||||
_showDisplayDb = true;
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace LanMountainDesktop.Views.Components;
|
||||
public partial class StudyEnvironmentWidgetSettingsWindow : UserControl
|
||||
{
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private string _languageCode = "zh-CN";
|
||||
private bool _suppressEvents;
|
||||
@@ -23,11 +24,12 @@ public partial class StudyEnvironmentWidgetSettingsWindow : UserControl
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
|
||||
var showDbfs = snapshot.StudyEnvironmentShowDbfs;
|
||||
var showDisplayDb = componentSnapshot.StudyEnvironmentShowDisplayDb;
|
||||
var showDbfs = componentSnapshot.StudyEnvironmentShowDbfs;
|
||||
if (!showDisplayDb && !showDbfs)
|
||||
{
|
||||
showDisplayDb = true;
|
||||
@@ -75,10 +77,10 @@ public partial class StudyEnvironmentWidgetSettingsWindow : UserControl
|
||||
showDisplayDb = true;
|
||||
}
|
||||
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.StudyEnvironmentShowDisplayDb = showDisplayDb;
|
||||
snapshot.StudyEnvironmentShowDbfs = showDbfs;
|
||||
_appSettingsService.Save(snapshot);
|
||||
_componentSettingsService.Save(snapshot);
|
||||
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
@@ -26,6 +28,7 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
|
||||
private const double DialCenter = DialDesignSize / 2d;
|
||||
|
||||
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _clockTimer = new()
|
||||
{
|
||||
@@ -38,6 +41,7 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly Line _hourHandLine = CreateHandLine("#232938", 4.0);
|
||||
private readonly Line _minuteHandLine = CreateHandLine("#2F3749", 2.8);
|
||||
@@ -51,6 +55,7 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
|
||||
private bool _dialInitialized;
|
||||
private bool _handsInitialized;
|
||||
private bool _isRefreshing;
|
||||
private bool _weatherAutoRefreshEnabled = true;
|
||||
private bool? _isNightModeApplied;
|
||||
private string _languageCode = "zh-CN";
|
||||
private HyperOS3WeatherVisualKind _activeVisualKind = HyperOS3WeatherVisualKind.CloudyDay;
|
||||
@@ -70,6 +75,7 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyDefaultWeatherIcon();
|
||||
UpdateClockVisual();
|
||||
ApplyAutoRefreshSettings();
|
||||
}
|
||||
|
||||
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
||||
@@ -100,6 +106,15 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
ApplyAutoRefreshSettings();
|
||||
if (_isAttached)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
@@ -203,9 +218,10 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRefreshSettings();
|
||||
UpdateClockVisual();
|
||||
_clockTimer.Start();
|
||||
_weatherRefreshTimer.Start();
|
||||
UpdateWeatherRefreshTimerState();
|
||||
_ = RefreshWeatherAsync(forceRefresh: false);
|
||||
}
|
||||
|
||||
@@ -629,6 +645,59 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
|
||||
return Math.Clamp(value, -180, 180);
|
||||
}
|
||||
|
||||
private void ApplyAutoRefreshSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 12;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
enabled = snapshot.WeatherAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_weatherAutoRefreshEnabled = enabled;
|
||||
_weatherRefreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
UpdateWeatherRefreshTimerState();
|
||||
}
|
||||
|
||||
private void UpdateWeatherRefreshTimerState()
|
||||
{
|
||||
if (_isAttached && _weatherAutoRefreshEnabled)
|
||||
{
|
||||
if (!_weatherRefreshTimer.IsEnabled)
|
||||
{
|
||||
_weatherRefreshTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_weatherRefreshTimer.Stop();
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 12;
|
||||
}
|
||||
|
||||
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRefreshIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(12);
|
||||
}
|
||||
|
||||
private void CancelRefreshRequest()
|
||||
{
|
||||
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
||||
|
||||
@@ -76,6 +76,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk
|
||||
double Longitude);
|
||||
|
||||
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
@@ -88,6 +89,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly Dictionary<WeatherVisualKind, IBrush> _backgroundBrushCache = new();
|
||||
private readonly Dictionary<HyperOS3WeatherVisualKind, IBrush> _particleBrushCache = new();
|
||||
@@ -109,6 +111,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
|
||||
public WeatherWidget()
|
||||
{
|
||||
@@ -125,6 +128,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk
|
||||
ApplyVisualTheme(WeatherVisualKind.ClearDay);
|
||||
ApplyNotConfiguredState();
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyAutoRefreshSettings();
|
||||
}
|
||||
|
||||
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
||||
@@ -154,6 +158,15 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
ApplyAutoRefreshSettings();
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
_ = RefreshWeatherAsync(forceRefresh: true);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
@@ -194,6 +207,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ApplyAutoRefreshSettings();
|
||||
UpdateTimerState();
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
@@ -1021,10 +1035,14 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_refreshTimer.IsEnabled)
|
||||
if (_autoRefreshEnabled && !_refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
else if (!_autoRefreshEnabled && _refreshTimer.IsEnabled)
|
||||
{
|
||||
_refreshTimer.Stop();
|
||||
}
|
||||
|
||||
if (!_backgroundAnimationTimer.IsEnabled)
|
||||
{
|
||||
@@ -1038,6 +1056,48 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk
|
||||
_backgroundAnimationTimer.Stop();
|
||||
}
|
||||
|
||||
private void ApplyAutoRefreshSettings()
|
||||
{
|
||||
var enabled = true;
|
||||
var intervalMinutes = 12;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
enabled = snapshot.WeatherAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
}
|
||||
|
||||
_autoRefreshEnabled = enabled;
|
||||
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
||||
|
||||
if (_isAttached)
|
||||
{
|
||||
UpdateTimerState();
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
||||
{
|
||||
if (minutes <= 0)
|
||||
{
|
||||
return 12;
|
||||
}
|
||||
|
||||
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
||||
{
|
||||
return minutes;
|
||||
}
|
||||
|
||||
return SupportedAutoRefreshIntervalsMinutes
|
||||
.OrderBy(value => Math.Abs(value - minutes))
|
||||
.FirstOrDefault(12);
|
||||
}
|
||||
|
||||
private void InitializeParticleVisuals()
|
||||
{
|
||||
if (_particleVisuals.Count > 0)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<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="300"
|
||||
x:Class="LanMountainDesktop.Views.Components.WeatherWidgetSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto,*"
|
||||
RowSpacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="Weather widget settings"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="Configure auto refresh and refresh interval for all weather widgets."
|
||||
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="AutoRefreshLabelTextBlock"
|
||||
Text="Auto refresh"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<CheckBox x:Name="AutoRefreshCheckBox"
|
||||
Content="Enable auto refresh"
|
||||
Checked="OnAutoRefreshChanged"
|
||||
Unchecked="OnAutoRefreshChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="FrequencyCardBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="12"
|
||||
IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="FrequencyLabelTextBlock"
|
||||
Text="Refresh interval"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<ComboBox x:Name="FrequencyComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="0"
|
||||
SelectionChanged="OnFrequencySelectionChanged">
|
||||
<ComboBoxItem x:Name="Frequency10mItem"
|
||||
Tag="10"
|
||||
Content="10 min" />
|
||||
<ComboBoxItem x:Name="Frequency12mItem"
|
||||
Tag="12"
|
||||
Content="12 min" />
|
||||
<ComboBoxItem x:Name="Frequency15mItem"
|
||||
Tag="15"
|
||||
Content="15 min" />
|
||||
<ComboBoxItem x:Name="Frequency30mItem"
|
||||
Tag="30"
|
||||
Content="30 min" />
|
||||
<ComboBoxItem x:Name="Frequency1hItem"
|
||||
Tag="60"
|
||||
Content="1 hour" />
|
||||
<ComboBoxItem x:Name="Frequency3hItem"
|
||||
Tag="180"
|
||||
Content="3 hours" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class WeatherWidgetSettingsWindow : UserControl
|
||||
{
|
||||
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
public WeatherWidgetSettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeFrequencyOptions();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var enabled = componentSnapshot.WeatherAutoRefreshEnabled;
|
||||
var interval = NormalizeInterval(componentSnapshot.WeatherAutoRefreshIntervalMinutes);
|
||||
|
||||
_suppressEvents = true;
|
||||
AutoRefreshCheckBox.IsChecked = enabled;
|
||||
SelectInterval(interval);
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
TitleTextBlock.Text = L("weather.widget.settings.title", "Weather widget settings");
|
||||
DescriptionTextBlock.Text = L("weather.widget.settings.desc", "Configure auto refresh and refresh interval for all weather widgets.");
|
||||
AutoRefreshLabelTextBlock.Text = L("weather.widget.settings.auto_refresh_label", "Auto refresh");
|
||||
AutoRefreshCheckBox.Content = L("weather.widget.settings.auto_refresh_enabled", "Enable auto refresh");
|
||||
FrequencyLabelTextBlock.Text = L("weather.widget.settings.frequency_label", "Refresh interval");
|
||||
ApplyFrequencyLocalization();
|
||||
}
|
||||
|
||||
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var enabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
FrequencyCardBorder.IsVisible = enabled;
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveState();
|
||||
}
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.WeatherAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
snapshot.WeatherAutoRefreshIntervalMinutes = GetSelectedInterval();
|
||||
_componentSettingsService.Save(snapshot);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private int GetSelectedInterval()
|
||||
{
|
||||
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
return NormalizeInterval(minutes);
|
||||
}
|
||||
|
||||
return 12;
|
||||
}
|
||||
|
||||
private void SelectInterval(int intervalMinutes)
|
||||
{
|
||||
var selected = FrequencyComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
item.Tag is string tagText &&
|
||||
int.TryParse(tagText, out var minutes) &&
|
||||
minutes == intervalMinutes);
|
||||
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int NormalizeInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 12);
|
||||
}
|
||||
|
||||
private void InitializeFrequencyOptions()
|
||||
{
|
||||
FrequencyComboBox.Items.Clear();
|
||||
foreach (var minutes in SupportedIntervals)
|
||||
{
|
||||
FrequencyComboBox.Items.Add(new ComboBoxItem
|
||||
{
|
||||
Tag = minutes.ToString(),
|
||||
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFrequencyLocalization()
|
||||
{
|
||||
foreach (var item in FrequencyComboBox.Items.OfType<ComboBoxItem>())
|
||||
{
|
||||
if (item.Tag is not string tagText ||
|
||||
!int.TryParse(tagText, out var minutes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}";
|
||||
item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes));
|
||||
}
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,8 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly ClockEntryVisual[] _entryVisuals = new ClockEntryVisual[WorldClockTimeZoneCatalog.ClockCount];
|
||||
private readonly TimeZoneInfo[] _entryTimeZones = new TimeZoneInfo[WorldClockTimeZoneCatalog.ClockCount];
|
||||
@@ -445,17 +446,18 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
|
||||
|
||||
private void LoadFromSettings()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var ids = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(snapshot.WorldClockTimeZoneIds);
|
||||
var ids = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(componentSnapshot.WorldClockTimeZoneIds);
|
||||
for (var index = 0; index < WorldClockTimeZoneCatalog.ClockCount; index++)
|
||||
{
|
||||
var resolvedId = ids[index];
|
||||
_entryTimeZones[index] = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(resolvedId);
|
||||
}
|
||||
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.WorldClockSecondHandMode);
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.WorldClockSecondHandMode);
|
||||
}
|
||||
|
||||
private void ApplySecondHandTimerInterval()
|
||||
@@ -533,7 +535,7 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
|
||||
_nextLanguageProbeUtc = utcNow.AddSeconds(25);
|
||||
try
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -28,6 +28,7 @@ public partial class WorldClockWidgetSettingsWindow : UserControl
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly TimeZoneService _timeZoneService = new();
|
||||
private readonly ComboBox[] _timeZoneComboBoxes;
|
||||
@@ -58,8 +59,9 @@ public partial class WorldClockWidgetSettingsWindow : UserControl
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
_allTimeZones = _timeZoneService
|
||||
.GetAllTimeZones()
|
||||
@@ -68,9 +70,9 @@ public partial class WorldClockWidgetSettingsWindow : UserControl
|
||||
.ToList();
|
||||
|
||||
_selectedTimeZoneIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(
|
||||
snapshot.WorldClockTimeZoneIds,
|
||||
componentSnapshot.WorldClockTimeZoneIds,
|
||||
_allTimeZones);
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.WorldClockSecondHandMode);
|
||||
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.WorldClockSecondHandMode);
|
||||
}
|
||||
|
||||
private void ApplyLocalization()
|
||||
@@ -165,10 +167,10 @@ public partial class WorldClockWidgetSettingsWindow : UserControl
|
||||
var normalizedIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(selectedIds, _allTimeZones);
|
||||
_secondHandMode = GetSelectedSecondHandMode();
|
||||
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
snapshot.WorldClockTimeZoneIds = normalizedIds.ToList();
|
||||
snapshot.WorldClockSecondHandMode = _secondHandMode;
|
||||
_appSettingsService.Save(snapshot);
|
||||
_componentSettingsService.Save(snapshot);
|
||||
|
||||
_selectedTimeZoneIds = normalizedIds;
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
@@ -389,6 +389,7 @@ public partial class MainWindow
|
||||
CancelDesktopComponentDrag();
|
||||
CancelDesktopComponentResize(restoreOriginalSpan: true);
|
||||
ClearDesktopComponentSelection();
|
||||
ClearSelectedLauncherTile(refreshTaskbar: false);
|
||||
UpdateDesktopComponentHostEditState();
|
||||
ComponentLibraryWindow.Opacity = 0;
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
@@ -425,6 +426,18 @@ public partial class MainWindow
|
||||
if (context == TaskbarContext.Desktop && _isComponentLibraryOpen)
|
||||
{
|
||||
var actions = new List<TaskbarActionItem>();
|
||||
var isLauncherSurface = _currentDesktopSurfaceIndex == LauncherSurfaceIndex;
|
||||
if (isLauncherSurface && IsLauncherTileSelected())
|
||||
{
|
||||
actions.Add(new TaskbarActionItem(
|
||||
TaskbarActionId.HideLauncherEntry,
|
||||
L("launcher.action.hide", "Hide"),
|
||||
"Hide",
|
||||
IsVisible: true,
|
||||
CommandKey: "launcher.hide"));
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (_selectedDesktopComponentHost is not null)
|
||||
{
|
||||
actions.Add(new TaskbarActionItem(
|
||||
@@ -537,10 +550,11 @@ public partial class MainWindow
|
||||
|
||||
var isDeleteAction = action.Id == TaskbarActionId.DeleteDesktopPage ||
|
||||
action.Id == TaskbarActionId.DeleteComponent;
|
||||
var isHideAction = action.Id == TaskbarActionId.HideLauncherEntry;
|
||||
var isEditAction = action.Id == TaskbarActionId.EditComponent;
|
||||
|
||||
Symbol iconSymbol;
|
||||
if (isDeleteAction)
|
||||
if (isDeleteAction || isHideAction)
|
||||
{
|
||||
iconSymbol = Symbol.Delete;
|
||||
}
|
||||
@@ -582,7 +596,7 @@ public partial class MainWindow
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
Padding = new Thickness(padding),
|
||||
Foreground = isDeleteAction
|
||||
Foreground = (isDeleteAction || isHideAction)
|
||||
? new SolidColorBrush(Color.Parse("#FFFF6B6B"))
|
||||
: Foreground,
|
||||
Tag = action.CommandKey
|
||||
@@ -602,7 +616,7 @@ public partial class MainWindow
|
||||
{
|
||||
Text = action.Title,
|
||||
FontSize = fontSize * 0.85,
|
||||
Foreground = isDeleteAction
|
||||
Foreground = (isDeleteAction || isHideAction)
|
||||
? new SolidColorBrush(Color.Parse("#FFFF6B6B"))
|
||||
: Foreground,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
@@ -646,6 +660,9 @@ public partial class MainWindow
|
||||
case "component.edit":
|
||||
OpenComponentSettings();
|
||||
break;
|
||||
case "launcher.hide":
|
||||
HideSelectedLauncherEntry();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,18 +736,58 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsWeatherComponentId(placement.ComponentId))
|
||||
{
|
||||
OpenWeatherComponentSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopDailyArtwork)
|
||||
{
|
||||
OpenDailyArtworkComponentSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopCnrDailyNews)
|
||||
{
|
||||
OpenCnrDailyNewsComponentSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopDailyWord)
|
||||
{
|
||||
OpenDailyWordComponentSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopBilibiliHotSearch)
|
||||
{
|
||||
OpenBilibiliHotSearchComponentSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopStcn24Forum)
|
||||
{
|
||||
OpenStcn24ForumComponentSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment)
|
||||
{
|
||||
OpenStudyEnvironmentComponentSettings();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWeatherComponentId(string componentId)
|
||||
{
|
||||
return string.Equals(componentId, BuiltInComponentIds.DesktopWeather, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(componentId, BuiltInComponentIds.DesktopWeatherClock, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(componentId, BuiltInComponentIds.DesktopHourlyWeather, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(componentId, BuiltInComponentIds.DesktopMultiDayWeather, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(componentId, BuiltInComponentIds.DesktopExtendedWeather, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void OpenDateComponentSettings()
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
@@ -795,6 +852,22 @@ public partial class MainWindow
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
}
|
||||
|
||||
private void OpenWeatherComponentSettings()
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new WeatherWidgetSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnWeatherSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
}
|
||||
|
||||
private void OpenStudyEnvironmentComponentSettings()
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
@@ -827,6 +900,70 @@ public partial class MainWindow
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
}
|
||||
|
||||
private void OpenCnrDailyNewsComponentSettings()
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new CnrDailyNewsSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnCnrDailyNewsSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
}
|
||||
|
||||
private void OpenDailyWordComponentSettings()
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new DailyWordSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnDailyWordSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
}
|
||||
|
||||
private void OpenBilibiliHotSearchComponentSettings()
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new BilibiliHotSearchSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnBilibiliHotSearchSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
}
|
||||
|
||||
private void OpenStcn24ForumComponentSettings()
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new Stcn24ForumSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnStcn24ForumSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
}
|
||||
|
||||
private void OnClassScheduleSettingsChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_selectedDesktopComponentHost is null)
|
||||
@@ -860,8 +997,6 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PersistSettings();
|
||||
}
|
||||
|
||||
private void OnStudyEnvironmentSettingsChanged(object? sender, EventArgs e)
|
||||
@@ -884,10 +1019,6 @@ public partial class MainWindow
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
_dailyArtworkMirrorSource = sender is DailyArtworkSettingsWindow settingsWindow
|
||||
? DailyArtworkMirrorSources.Normalize(settingsWindow.CurrentSource)
|
||||
: DailyArtworkMirrorSources.Normalize(_appSettingsService.Load().DailyArtworkMirrorSource);
|
||||
|
||||
foreach (var pageGrid in _desktopPageComponentGrids.Values)
|
||||
{
|
||||
foreach (var host in pageGrid.Children.OfType<Border>())
|
||||
@@ -903,8 +1034,6 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PersistSettings();
|
||||
}
|
||||
|
||||
private void OnWorldClockSettingsChanged(object? sender, EventArgs e)
|
||||
@@ -927,8 +1056,131 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PersistSettings();
|
||||
private void OnWeatherSettingsChanged(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;
|
||||
}
|
||||
|
||||
var child = TryGetContentHost(host)?.Child;
|
||||
switch (child)
|
||||
{
|
||||
case WeatherWidget weatherWidget:
|
||||
weatherWidget.RefreshFromSettings();
|
||||
break;
|
||||
case WeatherClockWidget weatherClockWidget:
|
||||
weatherClockWidget.RefreshFromSettings();
|
||||
break;
|
||||
case HourlyWeatherWidget hourlyWeatherWidget:
|
||||
hourlyWeatherWidget.RefreshFromSettings();
|
||||
break;
|
||||
case MultiDayWeatherWidget multiDayWeatherWidget:
|
||||
multiDayWeatherWidget.RefreshFromSettings();
|
||||
break;
|
||||
case ExtendedWeatherWidget extendedWeatherWidget:
|
||||
extendedWeatherWidget.RefreshFromSettings();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCnrDailyNewsSettingsChanged(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 CnrDailyNewsWidget widget)
|
||||
{
|
||||
widget.RefreshFromSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDailyWordSettingsChanged(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 DailyWordWidget widget)
|
||||
{
|
||||
widget.RefreshFromSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBilibiliHotSearchSettingsChanged(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 BilibiliHotSearchWidget widget)
|
||||
{
|
||||
widget.RefreshFromSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnStcn24ForumSettingsChanged(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 Stcn24ForumWidget widget)
|
||||
{
|
||||
widget.RefreshFromSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseComponentSettingsWindow()
|
||||
@@ -963,6 +1215,31 @@ public partial class MainWindow
|
||||
worldClockSettingsWindow.SettingsChanged -= OnWorldClockSettingsChanged;
|
||||
}
|
||||
|
||||
if (ComponentSettingsContentHost?.Content is WeatherWidgetSettingsWindow weatherSettingsWindow)
|
||||
{
|
||||
weatherSettingsWindow.SettingsChanged -= OnWeatherSettingsChanged;
|
||||
}
|
||||
|
||||
if (ComponentSettingsContentHost?.Content is CnrDailyNewsSettingsWindow cnrDailyNewsSettingsWindow)
|
||||
{
|
||||
cnrDailyNewsSettingsWindow.SettingsChanged -= OnCnrDailyNewsSettingsChanged;
|
||||
}
|
||||
|
||||
if (ComponentSettingsContentHost?.Content is DailyWordSettingsWindow dailyWordSettingsWindow)
|
||||
{
|
||||
dailyWordSettingsWindow.SettingsChanged -= OnDailyWordSettingsChanged;
|
||||
}
|
||||
|
||||
if (ComponentSettingsContentHost?.Content is BilibiliHotSearchSettingsWindow bilibiliHotSearchSettingsWindow)
|
||||
{
|
||||
bilibiliHotSearchSettingsWindow.SettingsChanged -= OnBilibiliHotSearchSettingsChanged;
|
||||
}
|
||||
|
||||
if (ComponentSettingsContentHost?.Content is Stcn24ForumSettingsWindow stcn24ForumSettingsWindow)
|
||||
{
|
||||
stcn24ForumSettingsWindow.SettingsChanged -= OnStcn24ForumSettingsChanged;
|
||||
}
|
||||
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
|
||||
DispatcherTimer.RunOnce(() =>
|
||||
@@ -1366,6 +1643,38 @@ public partial class MainWindow
|
||||
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
||||
}
|
||||
|
||||
if (string.Equals(componentId, BuiltInComponentIds.DesktopCnrDailyNews, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Keep CNR widget at a 2:1 ratio: 4x2, 6x3, 8x4...
|
||||
return SnapSpanToScaleRules(
|
||||
span,
|
||||
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
||||
}
|
||||
|
||||
if (string.Equals(componentId, BuiltInComponentIds.DesktopBilibiliHotSearch, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Keep Bilibili hot search widget at a 2:1 ratio: 4x2, 6x3, 8x4...
|
||||
return SnapSpanToScaleRules(
|
||||
span,
|
||||
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
||||
}
|
||||
|
||||
if (string.Equals(componentId, BuiltInComponentIds.DesktopStcn24Forum, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Keep STCN forum widget square with a minimum footprint of 4x4.
|
||||
return SnapSpanToScaleRules(
|
||||
span,
|
||||
new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 4));
|
||||
}
|
||||
|
||||
if (string.Equals(componentId, BuiltInComponentIds.DesktopExchangeRateCalculator, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Keep exchange rate converter square with minimum size 4x4.
|
||||
return SnapSpanToScaleRules(
|
||||
span,
|
||||
new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 4));
|
||||
}
|
||||
|
||||
if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyNoiseCurve, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Keep noise curve widget in a 2:1 ratio with minimum 4x2.
|
||||
@@ -1603,7 +1912,8 @@ public partial class MainWindow
|
||||
_currentDesktopCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService);
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService);
|
||||
component.Classes.Add(DesktopComponentClass);
|
||||
return component;
|
||||
}
|
||||
@@ -1621,6 +1931,7 @@ public partial class MainWindow
|
||||
CancelDesktopComponentDrag();
|
||||
CancelDesktopComponentResize(restoreOriginalSpan: true);
|
||||
ClearDesktopComponentSelection();
|
||||
ClearSelectedLauncherTile(refreshTaskbar: false);
|
||||
UpdateDesktopComponentHostEditState();
|
||||
ClearComponentLibraryPreviewControls();
|
||||
UpdateComponentLibraryLayout(_currentDesktopCellSize);
|
||||
@@ -1730,6 +2041,8 @@ public partial class MainWindow
|
||||
|
||||
private void SetSelectedDesktopComponent(Border? host)
|
||||
{
|
||||
ClearSelectedLauncherTile(refreshTaskbar: false);
|
||||
|
||||
// Clear previous selection
|
||||
if (_selectedDesktopComponentHost is not null && _selectedDesktopComponentHost != host)
|
||||
{
|
||||
@@ -2529,6 +2842,11 @@ public partial class MainWindow
|
||||
return Symbol.Apps;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Calculator;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Apps;
|
||||
@@ -2569,6 +2887,11 @@ public partial class MainWindow
|
||||
return L("component_category.info", "Info");
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return L("component_category.calculator", "Calculator");
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return L("component_category.study", "Study");
|
||||
@@ -2737,13 +3060,18 @@ public partial class MainWindow
|
||||
renderCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService);
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService);
|
||||
// Component library previews must stay non-interactive so drag gesture is reliable.
|
||||
previewControl.IsHitTestVisible = false;
|
||||
previewControl.Focusable = false;
|
||||
|
||||
var previewSurface = new Border
|
||||
{
|
||||
Width = previewSpan.WidthCells * renderCellSize,
|
||||
Height = previewSpan.HeightCells * renderCellSize,
|
||||
Background = Brushes.Transparent,
|
||||
IsHitTestVisible = false,
|
||||
Child = previewControl
|
||||
};
|
||||
|
||||
|
||||
@@ -22,9 +22,29 @@ public partial class MainWindow
|
||||
{
|
||||
private const int MinDesktopPageCount = 1;
|
||||
private const int MaxDesktopPageCount = 12;
|
||||
private enum LauncherEntryKind
|
||||
{
|
||||
Folder,
|
||||
Shortcut
|
||||
}
|
||||
|
||||
private sealed record LauncherHiddenItemToken(LauncherEntryKind Kind, string Key);
|
||||
|
||||
private sealed record LauncherHiddenItemView(
|
||||
LauncherEntryKind Kind,
|
||||
string Key,
|
||||
string DisplayName,
|
||||
string Monogram,
|
||||
Bitmap? IconBitmap);
|
||||
|
||||
private readonly WindowsStartMenuService _windowsStartMenuService = new();
|
||||
private readonly Dictionary<string, Bitmap> _launcherIconCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Stack<StartMenuFolderNode> _launcherFolderStack = [];
|
||||
private readonly HashSet<string> _hiddenLauncherFolderPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _hiddenLauncherAppPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private Button? _selectedLauncherTileButton;
|
||||
private LauncherEntryKind? _selectedLauncherEntryKind;
|
||||
private string? _selectedLauncherEntryKey;
|
||||
private StartMenuFolderNode _startMenuRoot = new("All Apps", string.Empty);
|
||||
private byte[]? _launcherFolderIconPngBytes;
|
||||
private Bitmap? _launcherFolderIconBitmap;
|
||||
@@ -52,6 +72,35 @@ public partial class MainWindow
|
||||
_currentDesktopSurfaceIndex = Math.Clamp(snapshot.CurrentDesktopSurfaceIndex, 0, LauncherSurfaceIndex);
|
||||
}
|
||||
|
||||
private void InitializeLauncherVisibilitySettings(AppSettingsSnapshot snapshot)
|
||||
{
|
||||
_hiddenLauncherFolderPaths.Clear();
|
||||
if (snapshot.HiddenLauncherFolderPaths is not null)
|
||||
{
|
||||
foreach (var folderPath in snapshot.HiddenLauncherFolderPaths)
|
||||
{
|
||||
var key = NormalizeLauncherHiddenKey(folderPath);
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
_hiddenLauncherFolderPaths.Add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_hiddenLauncherAppPaths.Clear();
|
||||
if (snapshot.HiddenLauncherAppPaths is not null)
|
||||
{
|
||||
foreach (var appPath in snapshot.HiddenLauncherAppPaths)
|
||||
{
|
||||
var key = NormalizeLauncherHiddenKey(appPath);
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
_hiddenLauncherAppPaths.Add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeDesktopSurfaceSwipeHandlers()
|
||||
{
|
||||
// Capture swipe intent before child controls consume pointer events.
|
||||
@@ -80,6 +129,7 @@ public partial class MainWindow
|
||||
_launcherFolderIconBitmap?.Dispose();
|
||||
_launcherFolderIconBitmap = null;
|
||||
RenderLauncherRootTiles();
|
||||
RenderLauncherHiddenItemsList();
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
catch
|
||||
@@ -89,6 +139,7 @@ public partial class MainWindow
|
||||
_launcherFolderIconBitmap?.Dispose();
|
||||
_launcherFolderIconBitmap = null;
|
||||
RenderLauncherRootTiles();
|
||||
RenderLauncherHiddenItemsList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +344,7 @@ public partial class MainWindow
|
||||
if (_currentDesktopSurfaceIndex != LauncherSurfaceIndex)
|
||||
{
|
||||
CloseLauncherFolderOverlay();
|
||||
ClearSelectedLauncherTile(refreshTaskbar: false);
|
||||
}
|
||||
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
@@ -338,12 +390,14 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果在组件编辑模式下点击空白区域,取消组件选中
|
||||
if (_isComponentLibraryOpen && _selectedDesktopComponentHost is not null)
|
||||
// 如果在组件编辑模式下点击空白区域,取消选中(组件或启动台图标)
|
||||
if (_isComponentLibraryOpen &&
|
||||
(_selectedDesktopComponentHost is not null || _selectedLauncherTileButton is not null))
|
||||
{
|
||||
if (!IsInteractivePointerSource(e.Source))
|
||||
{
|
||||
ClearDesktopComponentSelection();
|
||||
ClearSelectedLauncherTile(refreshTaskbar: false);
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
}
|
||||
}
|
||||
@@ -689,17 +743,28 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
ClearSelectedLauncherTile(refreshTaskbar: false);
|
||||
LauncherRootTilePanel.Children.Clear();
|
||||
var folders = _startMenuRoot.Folders;
|
||||
var apps = _startMenuRoot.Apps;
|
||||
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
if (!IsLauncherFolderVisible(folder))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LauncherRootTilePanel.Children.Add(CreateLauncherFolderTile(folder));
|
||||
}
|
||||
|
||||
foreach (var app in apps)
|
||||
{
|
||||
if (!IsLauncherAppVisible(app))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LauncherRootTilePanel.Children.Add(CreateLauncherAppTile(app));
|
||||
}
|
||||
|
||||
@@ -719,24 +784,30 @@ public partial class MainWindow
|
||||
var title = folder.Name;
|
||||
var subtitle = Lf("launcher.folder_items_format", "{0} apps", folder.TotalAppCount);
|
||||
var folderIconBitmap = GetLauncherFolderIconBitmap();
|
||||
var folderKey = NormalizeLauncherHiddenKey(folder.RelativePath);
|
||||
return CreateLauncherTileButton(
|
||||
title,
|
||||
subtitle,
|
||||
monogram: "DIR",
|
||||
iconBitmap: folderIconBitmap,
|
||||
() => OpenLauncherFolder(folder));
|
||||
() => OpenLauncherFolder(folder),
|
||||
LauncherEntryKind.Folder,
|
||||
folderKey);
|
||||
}
|
||||
|
||||
private Button CreateLauncherAppTile(StartMenuAppEntry app)
|
||||
{
|
||||
var iconBitmap = GetLauncherIconBitmap(app);
|
||||
var monogram = BuildMonogram(app.DisplayName);
|
||||
var appKey = NormalizeLauncherHiddenKey(app.RelativePath);
|
||||
return CreateLauncherTileButton(
|
||||
app.DisplayName,
|
||||
subtitle: string.Empty,
|
||||
monogram,
|
||||
iconBitmap,
|
||||
() => LaunchStartMenuEntry(app));
|
||||
() => LaunchStartMenuEntry(app),
|
||||
LauncherEntryKind.Shortcut,
|
||||
appKey);
|
||||
}
|
||||
|
||||
private Control CreateLauncherHintTile(string title, string subtitle)
|
||||
@@ -779,7 +850,9 @@ public partial class MainWindow
|
||||
string subtitle,
|
||||
string monogram,
|
||||
Bitmap? iconBitmap,
|
||||
Action clickAction)
|
||||
Action clickAction,
|
||||
LauncherEntryKind entryKind,
|
||||
string entryKey)
|
||||
{
|
||||
Control iconControl = iconBitmap is not null
|
||||
? new Image
|
||||
@@ -847,15 +920,380 @@ public partial class MainWindow
|
||||
Classes = { "glass-panel" },
|
||||
Margin = new Thickness(0, 0, 12, 12),
|
||||
BorderThickness = new Thickness(0),
|
||||
BorderBrush = Brushes.Transparent,
|
||||
CornerRadius = new CornerRadius(20),
|
||||
Padding = new Thickness(10),
|
||||
Content = content
|
||||
// 不设置固定 Width 和 Height,由 UpdateLauncherTileLayout 动态设置
|
||||
};
|
||||
button.Click += (_, _) => clickAction();
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
if (_isComponentLibraryOpen)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(entryKey))
|
||||
{
|
||||
SetSelectedLauncherTile(button, entryKind, entryKey);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
clickAction();
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
private static string NormalizeLauncherHiddenKey(string? key)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(key) ? string.Empty : key.Trim();
|
||||
}
|
||||
|
||||
private bool IsLauncherFolderVisible(StartMenuFolderNode folder)
|
||||
{
|
||||
var key = NormalizeLauncherHiddenKey(folder.RelativePath);
|
||||
return string.IsNullOrWhiteSpace(key) || !_hiddenLauncherFolderPaths.Contains(key);
|
||||
}
|
||||
|
||||
private bool IsLauncherAppVisible(StartMenuAppEntry app)
|
||||
{
|
||||
var key = NormalizeLauncherHiddenKey(app.RelativePath);
|
||||
return string.IsNullOrWhiteSpace(key) || !_hiddenLauncherAppPaths.Contains(key);
|
||||
}
|
||||
|
||||
private bool IsLauncherTileSelected()
|
||||
{
|
||||
return _selectedLauncherEntryKind.HasValue && !string.IsNullOrWhiteSpace(_selectedLauncherEntryKey);
|
||||
}
|
||||
|
||||
private void SetSelectedLauncherTile(Button button, LauncherEntryKind entryKind, string entryKey)
|
||||
{
|
||||
if (!_isComponentLibraryOpen || string.IsNullOrWhiteSpace(entryKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedKey = NormalizeLauncherHiddenKey(entryKey);
|
||||
if (string.IsNullOrWhiteSpace(normalizedKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedDesktopComponentHost is not null)
|
||||
{
|
||||
ClearDesktopComponentSelection();
|
||||
}
|
||||
|
||||
if (_selectedLauncherTileButton is not null && _selectedLauncherTileButton != button)
|
||||
{
|
||||
ApplyLauncherTileSelectionVisual(_selectedLauncherTileButton, isSelected: false);
|
||||
}
|
||||
|
||||
_selectedLauncherTileButton = button;
|
||||
_selectedLauncherEntryKind = entryKind;
|
||||
_selectedLauncherEntryKey = normalizedKey;
|
||||
ApplyLauncherTileSelectionVisual(button, isSelected: true);
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
}
|
||||
|
||||
private void ClearSelectedLauncherTile(bool refreshTaskbar)
|
||||
{
|
||||
if (_selectedLauncherTileButton is not null)
|
||||
{
|
||||
ApplyLauncherTileSelectionVisual(_selectedLauncherTileButton, isSelected: false);
|
||||
}
|
||||
|
||||
_selectedLauncherTileButton = null;
|
||||
_selectedLauncherEntryKind = null;
|
||||
_selectedLauncherEntryKey = null;
|
||||
|
||||
if (refreshTaskbar)
|
||||
{
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyLauncherTileSelectionVisual(Button button, bool isSelected)
|
||||
{
|
||||
var showSelection = isSelected && _isComponentLibraryOpen;
|
||||
button.BorderThickness = showSelection
|
||||
? new Thickness(Math.Clamp(_currentDesktopCellSize * 0.04, 1, 3))
|
||||
: new Thickness(0);
|
||||
button.BorderBrush = showSelection ? GetThemeBrush("AdaptiveAccentBrush") : Brushes.Transparent;
|
||||
}
|
||||
|
||||
private void HideSelectedLauncherEntry()
|
||||
{
|
||||
if (!_isComponentLibraryOpen ||
|
||||
_currentDesktopSurfaceIndex != LauncherSurfaceIndex ||
|
||||
_selectedLauncherEntryKind is null ||
|
||||
string.IsNullOrWhiteSpace(_selectedLauncherEntryKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var entryKind = _selectedLauncherEntryKind.Value;
|
||||
var entryKey = _selectedLauncherEntryKey!;
|
||||
ClearSelectedLauncherTile(refreshTaskbar: false);
|
||||
|
||||
var changed = entryKind switch
|
||||
{
|
||||
LauncherEntryKind.Folder => _hiddenLauncherFolderPaths.Add(entryKey),
|
||||
LauncherEntryKind.Shortcut => _hiddenLauncherAppPaths.Add(entryKey),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (changed)
|
||||
{
|
||||
ApplyLauncherVisibilitySettingsChange();
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
}
|
||||
|
||||
private void ApplyLauncherVisibilitySettingsChange()
|
||||
{
|
||||
ClearSelectedLauncherTile(refreshTaskbar: false);
|
||||
RenderLauncherRootTiles();
|
||||
if (_launcherFolderStack.Count > 0)
|
||||
{
|
||||
RenderLauncherFolderFromStack();
|
||||
}
|
||||
|
||||
RenderLauncherHiddenItemsList();
|
||||
PersistSettings();
|
||||
}
|
||||
|
||||
private void RenderLauncherHiddenItemsList()
|
||||
{
|
||||
if (LauncherHiddenItemsListPanel is null || LauncherHiddenItemsEmptyTextBlock is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LauncherHiddenItemsListPanel.Children.Clear();
|
||||
var hiddenItems = BuildLauncherHiddenItems();
|
||||
LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0;
|
||||
if (hiddenItems.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var hiddenItem in hiddenItems)
|
||||
{
|
||||
LauncherHiddenItemsListPanel.Children.Add(CreateLauncherHiddenItemRow(hiddenItem));
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<LauncherHiddenItemView> BuildLauncherHiddenItems()
|
||||
{
|
||||
var items = new List<LauncherHiddenItemView>();
|
||||
var seenFolders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var seenApps = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
CollectHiddenLauncherItems(_startMenuRoot, items, seenFolders, seenApps);
|
||||
|
||||
foreach (var key in _hiddenLauncherFolderPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!seenFolders.Contains(key))
|
||||
{
|
||||
items.Add(new LauncherHiddenItemView(
|
||||
LauncherEntryKind.Folder,
|
||||
key,
|
||||
BuildLauncherHiddenFallbackDisplayName(key),
|
||||
"DIR",
|
||||
GetLauncherFolderIconBitmap()));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var key in _hiddenLauncherAppPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!seenApps.Contains(key))
|
||||
{
|
||||
var fallbackName = BuildLauncherHiddenFallbackDisplayName(key);
|
||||
items.Add(new LauncherHiddenItemView(
|
||||
LauncherEntryKind.Shortcut,
|
||||
key,
|
||||
fallbackName,
|
||||
BuildMonogram(fallbackName),
|
||||
IconBitmap: null));
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
.OrderBy(item => item.DisplayName, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ThenBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void CollectHiddenLauncherItems(
|
||||
StartMenuFolderNode folder,
|
||||
List<LauncherHiddenItemView> items,
|
||||
HashSet<string> seenFolders,
|
||||
HashSet<string> seenApps)
|
||||
{
|
||||
foreach (var subFolder in folder.Folders)
|
||||
{
|
||||
var folderKey = NormalizeLauncherHiddenKey(subFolder.RelativePath);
|
||||
if (!string.IsNullOrWhiteSpace(folderKey) &&
|
||||
_hiddenLauncherFolderPaths.Contains(folderKey) &&
|
||||
seenFolders.Add(folderKey))
|
||||
{
|
||||
items.Add(new LauncherHiddenItemView(
|
||||
LauncherEntryKind.Folder,
|
||||
folderKey,
|
||||
subFolder.Name,
|
||||
"DIR",
|
||||
GetLauncherFolderIconBitmap()));
|
||||
}
|
||||
|
||||
CollectHiddenLauncherItems(subFolder, items, seenFolders, seenApps);
|
||||
}
|
||||
|
||||
foreach (var app in folder.Apps)
|
||||
{
|
||||
var appKey = NormalizeLauncherHiddenKey(app.RelativePath);
|
||||
if (string.IsNullOrWhiteSpace(appKey) ||
|
||||
!_hiddenLauncherAppPaths.Contains(appKey) ||
|
||||
!seenApps.Add(appKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
items.Add(new LauncherHiddenItemView(
|
||||
LauncherEntryKind.Shortcut,
|
||||
appKey,
|
||||
app.DisplayName,
|
||||
BuildMonogram(app.DisplayName),
|
||||
GetLauncherIconBitmap(app)));
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildLauncherHiddenFallbackDisplayName(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
var normalized = key.Replace('\\', '/');
|
||||
var fileName = Path.GetFileNameWithoutExtension(normalized);
|
||||
return string.IsNullOrWhiteSpace(fileName)
|
||||
? key
|
||||
: fileName;
|
||||
}
|
||||
|
||||
private Control CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem)
|
||||
{
|
||||
Control icon = hiddenItem.IconBitmap is not null
|
||||
? new Image
|
||||
{
|
||||
Source = hiddenItem.IconBitmap,
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
Stretch = Stretch.Uniform
|
||||
}
|
||||
: new Border
|
||||
{
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = hiddenItem.Monogram,
|
||||
FontSize = 10,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
|
||||
var typeText = hiddenItem.Kind == LauncherEntryKind.Folder
|
||||
? L("settings.launcher.hidden_type_folder", "Folder")
|
||||
: L("settings.launcher.hidden_type_shortcut", "Shortcut");
|
||||
|
||||
var infoPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 10,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
};
|
||||
infoPanel.Children.Add(icon);
|
||||
infoPanel.Children.Add(new StackPanel
|
||||
{
|
||||
Spacing = 2,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = hiddenItem.DisplayName,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 1
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = typeText,
|
||||
FontSize = 11,
|
||||
Opacity = 0.7
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var restoreButton = new Button
|
||||
{
|
||||
Content = L("settings.launcher.restore_button", "Show Again"),
|
||||
MinWidth = 110,
|
||||
Padding = new Thickness(12, 6),
|
||||
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
|
||||
};
|
||||
restoreButton.Click += OnRestoreLauncherHiddenItemClick;
|
||||
|
||||
var row = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
||||
ColumnSpacing = 10
|
||||
};
|
||||
row.Children.Add(infoPanel);
|
||||
Grid.SetColumn(infoPanel, 0);
|
||||
row.Children.Add(restoreButton);
|
||||
Grid.SetColumn(restoreButton, 1);
|
||||
|
||||
return new Border
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(14),
|
||||
Padding = new Thickness(10, 8),
|
||||
Child = row
|
||||
};
|
||||
}
|
||||
|
||||
private void OnRestoreLauncherHiddenItemClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button { Tag: LauncherHiddenItemToken token })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var removed = token.Kind switch
|
||||
{
|
||||
LauncherEntryKind.Folder => _hiddenLauncherFolderPaths.Remove(token.Key),
|
||||
LauncherEntryKind.Shortcut => _hiddenLauncherAppPaths.Remove(token.Key),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (!removed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyLauncherVisibilitySettingsChange();
|
||||
}
|
||||
|
||||
private Bitmap? GetLauncherIconBitmap(StartMenuAppEntry app)
|
||||
{
|
||||
if (app.IconPngBytes is null || app.IconPngBytes.Length == 0)
|
||||
@@ -914,6 +1352,7 @@ public partial class MainWindow
|
||||
|
||||
private void CloseLauncherFolderOverlay()
|
||||
{
|
||||
ClearSelectedLauncherTile(refreshTaskbar: false);
|
||||
_launcherFolderStack.Clear();
|
||||
if (LauncherFolderOverlay is not null)
|
||||
{
|
||||
@@ -936,6 +1375,7 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
ClearSelectedLauncherTile(refreshTaskbar: false);
|
||||
if (_launcherFolderStack.Count == 0)
|
||||
{
|
||||
CloseLauncherFolderOverlay();
|
||||
@@ -950,11 +1390,21 @@ public partial class MainWindow
|
||||
LauncherFolderTilePanel.Children.Clear();
|
||||
foreach (var subFolder in folder.Folders)
|
||||
{
|
||||
if (!IsLauncherFolderVisible(subFolder))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherFolderTile(subFolder));
|
||||
}
|
||||
|
||||
foreach (var app in folder.Apps)
|
||||
{
|
||||
if (!IsLauncherAppVisible(app))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherAppTile(app));
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ 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");
|
||||
SettingsNavLauncherTextBlock.Text = L("settings.nav.launcher", "App Launcher");
|
||||
|
||||
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
|
||||
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
|
||||
@@ -248,6 +250,18 @@ public partial class MainWindow
|
||||
"settings.region.timezone_desc",
|
||||
"Select a time zone. Clock and calendar widgets will follow this zone.");
|
||||
|
||||
ApplyUpdateLocalization();
|
||||
|
||||
LauncherSettingsPanelTitleTextBlock.Text = L("settings.launcher.title", "App Launcher");
|
||||
LauncherHiddenItemsSettingsExpander.Header = L("settings.launcher.hidden_header", "Hidden Items");
|
||||
LauncherHiddenItemsSettingsExpander.Description = L(
|
||||
"settings.launcher.hidden_desc",
|
||||
"Review hidden launcher entries and show them again.");
|
||||
LauncherHiddenItemsDescriptionTextBlock.Text = L(
|
||||
"settings.launcher.hidden_hint",
|
||||
"Right-click an icon in launcher to hide it. Hidden entries appear here.");
|
||||
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
|
||||
|
||||
SettingsNavAboutTextBlock.Text = L("settings.nav.about", "About");
|
||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||
VersionTextBlock.Text = Lf(
|
||||
@@ -266,9 +280,6 @@ public partial class MainWindow
|
||||
AboutStartupSettingsExpander.Description = L(
|
||||
"settings.about.startup_desc",
|
||||
"Launch the app automatically when signing in to Windows.");
|
||||
AutoStartWithWindowsToggleSwitch.Content = L(
|
||||
"settings.about.startup_toggle",
|
||||
"Launch at Windows sign-in");
|
||||
|
||||
if (WallpaperPlacementComboBox?.ItemCount >= 5)
|
||||
{
|
||||
@@ -290,6 +301,7 @@ public partial class MainWindow
|
||||
InitializeTimeZoneSettings();
|
||||
BuildComponentLibraryCategoryPages();
|
||||
RenderLauncherRootTiles();
|
||||
RenderLauncherHiddenItemsList();
|
||||
UpdateOpenSettingsActionVisualState();
|
||||
UpdateWallpaperDisplay();
|
||||
}
|
||||
|
||||
@@ -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,8 @@ public partial class MainWindow
|
||||
StatusBarSettingsPanel is null ||
|
||||
WeatherSettingsPanel is null ||
|
||||
RegionSettingsPanel is null ||
|
||||
UpdateSettingsPanel is null ||
|
||||
LauncherSettingsPanel is null ||
|
||||
AboutSettingsPanel is null)
|
||||
{
|
||||
return;
|
||||
@@ -76,7 +79,14 @@ 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;
|
||||
LauncherSettingsPanel.IsVisible = selectedIndex == 8;
|
||||
|
||||
if (selectedIndex == 8)
|
||||
{
|
||||
RenderLauncherHiddenItemsList();
|
||||
}
|
||||
|
||||
if (selectedIndex == 1)
|
||||
{
|
||||
@@ -547,10 +557,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 +572,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 +793,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 +801,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 +816,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 +828,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 +838,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();
|
||||
@@ -658,8 +884,10 @@ public partial class MainWindow
|
||||
WeatherExcludedAlerts = _weatherExcludedAlertsRaw,
|
||||
WeatherIconPackId = _weatherIconPackId,
|
||||
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,
|
||||
@@ -669,7 +897,9 @@ public partial class MainWindow
|
||||
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent,
|
||||
DesktopPageCount = _desktopPageCount,
|
||||
CurrentDesktopSurfaceIndex = _currentDesktopSurfaceIndex,
|
||||
DesktopComponentPlacements = _desktopComponentPlacements.ToList()
|
||||
DesktopComponentPlacements = _desktopComponentPlacements.ToList(),
|
||||
HiddenLauncherFolderPaths = _hiddenLauncherFolderPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
HiddenLauncherAppPaths = _hiddenLauncherAppPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList()
|
||||
};
|
||||
|
||||
_appSettingsService.Save(snapshot);
|
||||
@@ -2012,6 +2242,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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -447,12 +448,24 @@
|
||||
<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" />
|
||||
<TextBlock x:Name="SettingsNavAboutTextBlock" Text="关于" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</ListBoxItem>
|
||||
<ListBoxItem x:Name="SettingsNavLauncherItem" ToolTip.Tip="应用启动台">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<fi:FluentIcon x:Name="SettingsNavLauncherIcon" Icon="Apps" IconVariant="Regular" />
|
||||
<TextBlock x:Name="SettingsNavLauncherTextBlock" Text="应用启动台" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</ListBoxItem>
|
||||
</ListBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
@@ -1378,6 +1391,147 @@
|
||||
</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="LauncherSettingsPanel" IsVisible="False" Spacing="16">
|
||||
<TextBlock x:Name="LauncherSettingsPanelTitleTextBlock"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="App Launcher" />
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="LauncherHiddenItemsSettingsExpander"
|
||||
Header="Hidden Items"
|
||||
Description="Review hidden launcher entries and show them again."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock x:Name="LauncherHiddenItemsDescriptionTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="Right-click an icon in launcher to hide it. Hidden entries appear here." />
|
||||
<TextBlock x:Name="LauncherHiddenItemsEmptyTextBlock"
|
||||
IsVisible="False"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="No hidden items." />
|
||||
<ScrollViewer MaxHeight="420"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="LauncherHiddenItemsListPanel"
|
||||
Spacing="8" />
|
||||
</ScrollViewer>
|
||||
</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">
|
||||
@@ -1398,8 +1552,7 @@
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch x:Name="AutoStartWithWindowsToggleSwitch"
|
||||
Checked="OnAutoStartWithWindowsToggled"
|
||||
Unchecked="OnAutoStartWithWindowsToggled"
|
||||
Content="Launch at Windows sign-in" />
|
||||
Unchecked="OnAutoStartWithWindowsToggled" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
@@ -88,11 +88,14 @@ public partial class MainWindow : Window
|
||||
}
|
||||
private readonly MonetColorService _monetColorService = new();
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
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 ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||
private readonly ComponentRegistry _componentRegistry = ComponentRegistry
|
||||
.CreateDefault()
|
||||
.RegisterExtensions(
|
||||
@@ -126,6 +129,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>();
|
||||
@@ -152,7 +168,6 @@ public partial class MainWindow : Window
|
||||
private string _weatherExcludedAlertsRaw = string.Empty;
|
||||
private string _weatherIconPackId = "FluentRegular";
|
||||
private bool _weatherNoTlsRequests;
|
||||
private string _dailyArtworkMirrorSource = DailyArtworkMirrorSources.Overseas;
|
||||
private bool _autoStartWithWindows;
|
||||
private bool _suppressAutoStartToggleEvents;
|
||||
private string _weatherSearchKeyword = string.Empty;
|
||||
@@ -221,7 +236,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, 8);
|
||||
UpdateSettingsTabContent();
|
||||
|
||||
WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement);
|
||||
@@ -229,9 +244,11 @@ public partial class MainWindow : Window
|
||||
ApplyTaskbarSettings(snapshot);
|
||||
InitializeLocalization(snapshot.LanguageCode);
|
||||
InitializeWeatherSettings(snapshot);
|
||||
_dailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
||||
_ = _componentSettingsService.Load();
|
||||
InitializeAutoStartWithWindowsSetting(snapshot);
|
||||
InitializeUpdateSettings(snapshot);
|
||||
InitializeDesktopSurfaceState(snapshot);
|
||||
InitializeLauncherVisibilitySettings(snapshot);
|
||||
InitializeDesktopComponentPlacements(snapshot);
|
||||
InitializeSettingsIcons();
|
||||
|
||||
@@ -262,6 +279,8 @@ public partial class MainWindow : Window
|
||||
|
||||
_suppressSettingsPersistence = false;
|
||||
PersistSettings();
|
||||
|
||||
TriggerAutoUpdateCheckIfEnabled();
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
@@ -287,6 +306,7 @@ public partial class MainWindow : Window
|
||||
{
|
||||
recommendationServiceDisposable.Dispose();
|
||||
}
|
||||
_releaseUpdateService.Dispose();
|
||||
_wallpaperBitmap?.Dispose();
|
||||
_wallpaperBitmap = null;
|
||||
PropertyChanged -= OnWindowPropertyChanged;
|
||||
|
||||
Reference in New Issue
Block a user