Compare commits

...

8 Commits

Author SHA1 Message Date
lincube
435b96c50c 0.4.9.3
内存泄露问题解决
2026-03-07 22:05:18 +08:00
lincube
49b18d6af1 0.4.9.2 2026-03-07 19:59:28 +08:00
lincube
d6ec159af4 0.4.9.1
小修复
2026-03-07 17:25:29 +08:00
lincube
0d14675cc0 0.4.9
Linux相关版本适配
2026-03-07 00:58:52 +08:00
lincube
1f509959a9 0.4.8
百度热搜组件、凤凰新闻组件。
2026-03-06 22:24:59 +08:00
lincube
382d1baaf1 0.4.7
2×2英语单词组件,修复了stcn组件
2026-03-06 18:38:20 +08:00
lincube
72a0be16b3 0.4.6
引入了托盘菜单,提供了应用启动台隐藏应用功能,优化了自动刷新功能,为STCN 24组件提供了更多信息选项。
2026-03-06 10:32:02 +08:00
lincube
de40471af6 0.4.5
新增STCN 24组件,优化应用启动台,允许用户隐藏应用启动台图标。优化组件拖动排放。
2026-03-06 08:53:45 +08:00
81 changed files with 10723 additions and 383 deletions

View File

@@ -275,6 +275,8 @@ jobs:
package_name="LanMountainDesktop"
package_version="${version}"
arch="amd64"
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
# Verify source directory exists
if [ ! -d "$source" ]; then
@@ -288,6 +290,7 @@ jobs:
mkdir -p "build-deb/usr/local/bin"
mkdir -p "build-deb/usr/share/applications"
mkdir -p "build-deb/usr/share/pixmaps"
mkdir -p "build-deb/usr/share/icons/hicolor/256x256/apps"
# Copy application files
cp -r "$source"/* "build-deb/usr/local/bin/"
@@ -300,6 +303,31 @@ jobs:
echo "Error: DEB package is empty after copy"
exit 1
fi
if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then
echo "Error: Linux desktop resources are missing"
ls -la "LanMountainDesktop/packaging/linux" || true
exit 1
fi
sed \
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop|g" \
-e "s|@@ICON@@|lanmountaindesktop|g" \
"$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop"
cp "$icon_source" "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
cp "$icon_source" "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
{
printf '%s\n' '#!/bin/sh'
printf '%s\n' 'set -e'
printf '%s\n' 'if command -v update-desktop-database >/dev/null 2>&1; then'
printf '%s\n' ' update-desktop-database /usr/share/applications >/dev/null 2>&1 || true'
printf '%s\n' 'fi'
printf '%s\n' 'if command -v gtk-update-icon-cache >/dev/null 2>&1; then'
printf '%s\n' ' gtk-update-icon-cache /usr/share/icons/hicolor >/dev/null 2>&1 || true'
printf '%s\n' 'fi'
} > "build-deb/DEBIAN/postinst"
# Create control file (NOTE: No leading spaces in control file)
{
@@ -313,6 +341,10 @@ jobs:
# Set proper permissions
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop" || chmod 755 "build-deb/usr/local/bin"/*
chmod 644 "build-deb/usr/share/applications/LanMountainDesktop.desktop"
chmod 644 "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
chmod 755 "build-deb/DEBIAN/postinst"
# Create DEB file
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then

View File

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

View File

@@ -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;
@@ -23,6 +24,8 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
LinuxDesktopEntryInstaller.EnsureInstalled();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
@@ -37,6 +40,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

View File

@@ -30,8 +30,12 @@ public static class BuiltInComponentIds
public const string DesktopDailyPoetry = "DesktopDailyPoetry";
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
public const string DesktopDailyWord = "DesktopDailyWord";
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
public const string DesktopIfengNews = "DesktopIfengNews";
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
public const string DesktopExchangeRateCalculator = "DesktopExchangeRateCalculator";
public const string DesktopWhiteboard = "DesktopWhiteboard";
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";

View File

@@ -234,6 +234,15 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopDailyWord2x2,
"Daily Word 2x2",
"Book",
"Info",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopCnrDailyNews,
"CNR Daily News",
@@ -243,6 +252,15 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopIfengNews,
"iFeng News",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBilibiliHotSearch,
"Bilibili Hot Search",
@@ -252,6 +270,24 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBaiduHotSearch,
"Baidu 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",

View File

@@ -13,6 +13,8 @@
"settings.nav.weather": "Weather",
"settings.nav.region": "Region",
"settings.nav.update": "Update",
"settings.nav.launcher": "App Launcher",
"settings.nav.plugins": "Plugins",
"settings.nav.about": "About",
"settings.wallpaper.title": "Wallpaper",
"settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.",
@@ -249,9 +251,26 @@
"desktop.page_index_format": "Desktop {0}",
"launcher.title": "App Launcher",
"launcher.subtitle": "Apps and folders from Windows Start Menu",
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
"launcher.empty": "No Start Menu entries found.",
"launcher.empty_linux": "No Linux desktop entries were 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",
"settings.plugins.title": "Plugins",
"settings.plugins.runtime_header": "Plugin Runtime",
"settings.plugins.runtime_desc": "Manage plugin loading and backend isolation.",
"settings.plugins.runtime_hint": "This page will host installed plugin management, permission review, and sandboxed backend runtime controls.",
"settings.plugins.runtime_status": "Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel.",
"button.component_library": "Edit Desktop",
"tooltip.component_library": "Edit Desktop",
"component_library.title": "Widgets",
@@ -284,8 +303,12 @@
"component.daily_poetry": "Daily Poetry",
"component.daily_artwork": "Daily Artwork",
"component.daily_word": "Daily Word",
"component.daily_word_2x2": "Daily Word 2x2",
"component.cnr_daily_news": "CNR Headlines",
"component.ifeng_news": "iFeng News",
"component.bilibili_hot_search": "Bilibili Hot Search",
"component.baidu_hot_search": "Baidu Hot Search",
"component.stcn24_forum": "STCN 24",
"component.exchange_rate_converter": "Exchange Rate Converter",
"component.whiteboard": "Blackboard (Portrait)",
"component.blackboard_landscape": "Blackboard (Landscape)",
@@ -331,6 +354,7 @@
"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.",
"dailyword2x2.widget.tap_to_show": "Tap to reveal meaning",
"cnrnews.widget.loading": "Loading...",
"cnrnews.widget.loading_title": "Fetching CNR headlines",
"cnrnews.widget.loading_subtitle": "Please wait",
@@ -347,6 +371,92 @@
"bilihot.widget.fetch_failed": "Hot search fetch failed",
"bilihot.widget.fallback_item": "No hot search data",
"bilihot.widget.more_hot": "More hot search",
"baiduhot.widget.brand": "Baidu Hot Search",
"baiduhot.widget.loading": "Loading...",
"baiduhot.widget.loading_item": "Loading...",
"baiduhot.widget.fetch_failed": "Hot search fetch failed",
"baiduhot.widget.fallback_item": "No hot search data",
"baiduhot.widget.refresh_tooltip": "Refresh",
"ifeng.widget.brand": "iFeng News",
"ifeng.widget.loading": "Loading...",
"ifeng.widget.loading_item": "Loading...",
"ifeng.widget.fetch_failed": "News fetch failed",
"ifeng.widget.fallback_item": "No news data",
"ifeng.widget.refresh_tooltip": "Refresh",
"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",
"baiduhot.settings.title": "Baidu hot search settings",
"baiduhot.settings.desc": "Configure source, auto refresh and refresh interval.",
"baiduhot.settings.source_label": "Data source",
"baiduhot.settings.source_official": "Official Source",
"baiduhot.settings.source_rss": "Third-party RSS",
"baiduhot.settings.auto_refresh_label": "Auto refresh",
"baiduhot.settings.auto_refresh_enabled": "Enable auto refresh",
"baiduhot.settings.frequency_label": "Refresh interval",
"ifeng.settings.title": "iFeng news settings",
"ifeng.settings.desc": "Configure channel, auto refresh and refresh interval.",
"ifeng.settings.channel_label": "News channel",
"ifeng.settings.channel_comprehensive": "Comprehensive",
"ifeng.settings.channel_mainland": "China Mainland",
"ifeng.settings.channel_taiwan": "Taiwan",
"ifeng.settings.auto_refresh_label": "Auto refresh",
"ifeng.settings.auto_refresh_enabled": "Enable auto refresh",
"ifeng.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",

View File

@@ -13,6 +13,8 @@
"settings.nav.weather": "天气",
"settings.nav.region": "地区",
"settings.nav.update": "更新",
"settings.nav.launcher": "应用启动台",
"settings.nav.plugins": "插件",
"settings.nav.about": "关于",
"settings.wallpaper.title": "壁纸",
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
@@ -249,9 +251,26 @@
"desktop.page_index_format": "桌面 {0}",
"launcher.title": "应用启动台",
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
"launcher.empty": "未找到开始菜单条目。",
"launcher.empty_linux": "未找到 Linux .desktop 应用条目。",
"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": "重新显示",
"settings.plugins.title": "插件",
"settings.plugins.runtime_header": "插件运行时",
"settings.plugins.runtime_desc": "管理插件加载与后端隔离运行。",
"settings.plugins.runtime_hint": "这里将承载已安装插件、权限审查和沙盒后端运行时控制。",
"settings.plugins.runtime_status": "插件管理界面尚未接入实际数据。下一步是把加载器、权限和 worker 隔离状态接到这里。",
"button.component_library": "桌面编辑",
"tooltip.component_library": "桌面编辑",
"component_library.title": "桌面编辑",
@@ -284,8 +303,12 @@
"component.daily_poetry": "每日诗词",
"component.daily_artwork": "每日名画",
"component.daily_word": "每日单词",
"component.daily_word_2x2": "每日单词 2x2",
"component.cnr_daily_news": "央广网头条",
"component.ifeng_news": "凤凰网新闻",
"component.bilibili_hot_search": "B站热搜",
"component.baidu_hot_search": "百度热搜",
"component.stcn24_forum": "STCN 24",
"component.exchange_rate_converter": "汇率换算",
"component.whiteboard": "竖向小黑板",
"component.blackboard_landscape": "横向小黑板",
@@ -331,6 +354,7 @@
"dailyword.widget.fallback_meaning": "有道词典暂不可用",
"dailyword.widget.fallback_example": "请点击右上角刷新重试",
"dailyword.widget.fallback_example_translation": "网络恢复后将自动更新",
"dailyword2x2.widget.tap_to_show": "点击查看释义",
"cnrnews.widget.loading": "加载中...",
"cnrnews.widget.loading_title": "正在获取新闻热点",
"cnrnews.widget.loading_subtitle": "请稍候",
@@ -347,6 +371,92 @@
"bilihot.widget.fetch_failed": "热搜获取失败",
"bilihot.widget.fallback_item": "暂无热搜",
"bilihot.widget.more_hot": "更多热搜",
"baiduhot.widget.brand": "百度热搜",
"baiduhot.widget.loading": "加载中...",
"baiduhot.widget.loading_item": "加载中...",
"baiduhot.widget.fetch_failed": "热搜获取失败",
"baiduhot.widget.fallback_item": "暂无热搜",
"baiduhot.widget.refresh_tooltip": "刷新",
"ifeng.widget.brand": "凤凰网新闻",
"ifeng.widget.loading": "加载中...",
"ifeng.widget.loading_item": "加载中...",
"ifeng.widget.fetch_failed": "新闻获取失败",
"ifeng.widget.fallback_item": "暂无新闻",
"ifeng.widget.refresh_tooltip": "刷新",
"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": "刷新频率",
"baiduhot.settings.title": "百度热搜设置",
"baiduhot.settings.desc": "配置数据源、自动刷新开关与刷新频率。",
"baiduhot.settings.source_label": "数据源",
"baiduhot.settings.source_official": "百度官方源",
"baiduhot.settings.source_rss": "第三方 RSS 源",
"baiduhot.settings.auto_refresh_label": "自动刷新",
"baiduhot.settings.auto_refresh_enabled": "启用自动刷新",
"baiduhot.settings.frequency_label": "刷新频率",
"ifeng.settings.title": "凤凰网新闻设置",
"ifeng.settings.desc": "配置频道、自动刷新开关与刷新频率。",
"ifeng.settings.channel_label": "新闻频道",
"ifeng.settings.channel_comprehensive": "综合",
"ifeng.settings.channel_mainland": "中国大陆",
"ifeng.settings.channel_taiwan": "台湾",
"ifeng.settings.auto_refresh_label": "自动刷新",
"ifeng.settings.auto_refresh_enabled": "启用自动刷新",
"ifeng.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": "央广网设置",

View File

@@ -44,8 +44,6 @@ 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;
@@ -78,29 +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 bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
public List<string> HiddenLauncherAppPaths { get; set; } = [];
public AppSettingsSnapshot Clone()
{
@@ -136,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;

View File

@@ -0,0 +1,19 @@
using System;
namespace LanMountainDesktop.Models;
public static class BaiduHotSearchSourceTypes
{
public const string Official = "Official";
public const string ThirdPartyRss = "ThirdPartyRss";
public static string Normalize(string? sourceType)
{
if (string.Equals(sourceType, ThirdPartyRss, StringComparison.OrdinalIgnoreCase))
{
return ThirdPartyRss;
}
return Official;
}
}

View File

@@ -0,0 +1,95 @@
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 IfengNewsAutoRefreshEnabled { get; set; } = true;
public int IfengNewsAutoRefreshIntervalMinutes { get; set; } = 20;
public string IfengNewsChannelType { get; set; } = IfengNewsChannelTypes.Comprehensive;
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 BaiduHotSearchAutoRefreshEnabled { get; set; } = true;
public int BaiduHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public string BaiduHotSearchSourceType { get; set; } = BaiduHotSearchSourceTypes.Official;
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;
}
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public static class IfengNewsChannelTypes
{
public const string Comprehensive = "Comprehensive";
public const string Mainland = "Mainland";
public const string Taiwan = "Taiwan";
public static IReadOnlyList<string> SupportedValues { get; } =
[
Comprehensive,
Mainland,
Taiwan
];
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 Comprehensive;
}
}

View File

@@ -52,6 +52,18 @@ public sealed record BilibiliHotSearchSnapshot(
IReadOnlyList<BilibiliHotSearchItemSnapshot> Items,
DateTimeOffset FetchedAt);
public sealed record BaiduHotSearchItemSnapshot(
string Title,
string Url,
long? HeatScore);
public sealed record BaiduHotSearchSnapshot(
string Provider,
string Source,
string BoardUrl,
IReadOnlyList<BaiduHotSearchItemSnapshot> Items,
DateTimeOffset FetchedAt);
public sealed record DailyWordSnapshot(
string Provider,
string Word,
@@ -70,3 +82,16 @@ public sealed record ExchangeRateSnapshot(
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);

View 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"
};
}
}

View File

@@ -1,4 +1,6 @@
namespace LanMountainDesktop.Models;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public sealed class StartMenuAppEntry
{
@@ -9,4 +11,10 @@ public sealed class StartMenuAppEntry
public required string RelativePath { get; init; }
public byte[]? IconPngBytes { get; init; }
public string? LaunchExecutable { get; init; }
public IReadOnlyList<string> LaunchArguments { get; init; } = [];
public string? WorkingDirectory { get; init; }
}

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

View File

@@ -7,5 +7,6 @@ public enum TaskbarActionId
AddDesktopPage,
DeleteDesktopPage,
DeleteComponent,
EditComponent
EditComponent,
HideLauncherEntry
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
@@ -39,7 +39,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
};
private static readonly IDeserializer CsesDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
@@ -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;

View File

@@ -0,0 +1,425 @@
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,
IfengNewsAutoRefreshEnabled = legacy.IfengNewsAutoRefreshEnabled,
IfengNewsAutoRefreshIntervalMinutes = legacy.IfengNewsAutoRefreshIntervalMinutes,
IfengNewsChannelType = legacy.IfengNewsChannelType,
DailyWordAutoRefreshEnabled = legacy.DailyWordAutoRefreshEnabled,
DailyWordAutoRefreshIntervalMinutes = legacy.DailyWordAutoRefreshIntervalMinutes,
BilibiliHotSearchAutoRefreshEnabled = legacy.BilibiliHotSearchAutoRefreshEnabled,
BilibiliHotSearchAutoRefreshIntervalMinutes = legacy.BilibiliHotSearchAutoRefreshIntervalMinutes,
BaiduHotSearchAutoRefreshEnabled = legacy.BaiduHotSearchAutoRefreshEnabled,
BaiduHotSearchAutoRefreshIntervalMinutes = legacy.BaiduHotSearchAutoRefreshIntervalMinutes,
BaiduHotSearchSourceType = legacy.BaiduHotSearchSourceType,
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.IfengNewsAutoRefreshIntervalMinutes = NormalizeIfengNewsInterval(normalized.IfengNewsAutoRefreshIntervalMinutes);
normalized.IfengNewsChannelType = IfengNewsChannelTypes.Normalize(normalized.IfengNewsChannelType);
normalized.DailyWordAutoRefreshIntervalMinutes = NormalizeDailyWordInterval(normalized.DailyWordAutoRefreshIntervalMinutes);
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes = NormalizeBilibiliHotSearchInterval(
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes);
normalized.BaiduHotSearchAutoRefreshIntervalMinutes = NormalizeBaiduHotSearchInterval(
normalized.BaiduHotSearchAutoRefreshIntervalMinutes);
normalized.BaiduHotSearchSourceType = BaiduHotSearchSourceTypes.Normalize(normalized.BaiduHotSearchSourceType);
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 NormalizeIfengNewsInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 20);
}
private static int NormalizeBilibiliHotSearchInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 15);
}
private static int NormalizeBaiduHotSearchInterval(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 IfengNewsAutoRefreshEnabled { get; set; } = true;
public int IfengNewsAutoRefreshIntervalMinutes { get; set; } = 20;
public string IfengNewsChannelType { get; set; } = IfengNewsChannelTypes.Comprehensive;
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 BaiduHotSearchAutoRefreshEnabled { get; set; } = true;
public int BaiduHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public string BaiduHotSearchSourceType { get; set; } = BaiduHotSearchSourceTypes.Official;
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;
}
}

View File

@@ -20,15 +20,33 @@ public sealed record DailyNewsQuery(
int? ItemCount = null,
bool ForceRefresh = false);
public sealed record IfengNewsQuery(
string? Locale = null,
int? ItemCount = null,
string? ChannelType = null,
bool ForceRefresh = false);
public sealed record BilibiliHotSearchQuery(
string? Locale = null,
int? ItemCount = null,
bool ForceRefresh = false);
public sealed record BaiduHotSearchQuery(
string? Locale = null,
int? ItemCount = null,
string? SourceType = 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,
@@ -76,6 +94,30 @@ public sealed record RecommendationApiOptions
"https://news.cnr.cn/native/gd/rss.xml"
];
public IReadOnlyList<string> IfengNewsComprehensiveRssFeedUrls { get; init; } =
[
"https://rss.injahow.cn/ifeng/news",
"https://rsshub.shuaizheng.org/ifeng/news"
];
public IReadOnlyList<string> IfengNewsMainlandRssFeedUrls { get; init; } =
[
"https://rss.injahow.cn/ifeng/news/shanklist/3-35197-/",
"https://rsshub.shuaizheng.org/ifeng/news/shanklist/3-35197-/"
];
public IReadOnlyList<string> IfengNewsTaiwanRssFeedUrls { get; init; } =
[
"https://rss.injahow.cn/ifeng/news/shanklist/3-35199-/",
"https://rsshub.shuaizheng.org/ifeng/news/shanklist/3-35199-/"
];
public string IfengNewsComprehensiveListPageUrl { get; init; } = "https://news.ifeng.com/";
public string IfengNewsMainlandListPageUrl { get; init; } = "https://news.ifeng.com/shanklist/3-35197-/";
public string IfengNewsTaiwanListPageUrl { get; init; } = "https://news.ifeng.com/shanklist/3-35199-/";
public string BilibiliHotSearchApiTemplate { get; init; } =
"https://api.bilibili.com/x/web-interface/search/square?limit={0}";
@@ -84,6 +126,17 @@ public sealed record RecommendationApiOptions
public string BilibiliSearchPageUrl { get; init; } = "https://search.bilibili.com/all";
public string BaiduHotSearchRssFeedUrl { get; init; } = "https://rss.aishort.top/?type=baidu";
public string BaiduHotSearchBoardUrl { get; init; } = "https://top.baidu.com/board?tab=realtime";
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}/";
@@ -225,7 +278,13 @@ public sealed record RecommendationApiOptions
public int DefaultDailyNewsCount { get; init; } = 2;
public int DefaultIfengNewsCount { get; init; } = 4;
public int DefaultBilibiliHotSearchCount { get; init; } = 5;
public int DefaultBaiduHotSearchCount { get; init; } = 4;
public int DefaultStcn24ForumPostCount { get; init; } = 4;
}
public interface IRecommendationInfoService
@@ -242,14 +301,26 @@ public interface IRecommendationInfoService
DailyNewsQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<DailyNewsSnapshot>> GetIfengNewsAsync(
IfengNewsQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<BilibiliHotSearchSnapshot>> GetBilibiliHotSearchAsync(
BilibiliHotSearchQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<BaiduHotSearchSnapshot>> GetBaiduHotSearchAsync(
BaiduHotSearchQuery 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);

View File

@@ -0,0 +1,192 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
namespace LanMountainDesktop.Services;
internal static class LinuxDesktopEntryInstaller
{
private const string DesktopFileName = "LanMountainDesktop.desktop";
private const string IconFileName = "lanmountaindesktop.png";
private const string IconName = "lanmountaindesktop";
public static void EnsureInstalled()
{
if (!OperatingSystem.IsLinux())
{
return;
}
try
{
var executablePath = ResolveExecutablePath();
if (string.IsNullOrWhiteSpace(executablePath))
{
return;
}
var dataHome = ResolveDataHome();
if (string.IsNullOrWhiteSpace(dataHome))
{
return;
}
var applicationsDir = Path.Combine(dataHome, "applications");
var iconDir = Path.Combine(dataHome, "icons", "hicolor", "256x256", "apps");
Directory.CreateDirectory(applicationsDir);
Directory.CreateDirectory(iconDir);
var desktopTargetPath = Path.Combine(applicationsDir, DesktopFileName);
var iconTargetPath = Path.Combine(iconDir, IconFileName);
TryCopyBundledIcon(iconTargetPath);
var desktopEntryContent = BuildDesktopEntryContent(executablePath);
WriteFileIfChanged(desktopTargetPath, desktopEntryContent);
TryRunCommand("chmod", "+x", executablePath);
TryRunCommand("chmod", "+x", desktopTargetPath);
TryRunCommand("update-desktop-database", applicationsDir);
TryRunCommand("gtk-update-icon-cache", Path.Combine(dataHome, "icons", "hicolor"));
}
catch
{
// Keep startup resilient if desktop integration fails.
}
}
private static string ResolveExecutablePath()
{
var processPath = Environment.ProcessPath;
if (!string.IsNullOrWhiteSpace(processPath))
{
return processPath;
}
var commandLineArgs = Environment.GetCommandLineArgs();
if (commandLineArgs.Length > 0 && !string.IsNullOrWhiteSpace(commandLineArgs[0]))
{
return commandLineArgs[0];
}
return string.Empty;
}
private static string ResolveDataHome()
{
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
if (!string.IsNullOrWhiteSpace(dataHome))
{
return dataHome.Trim();
}
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrWhiteSpace(homePath))
{
return string.Empty;
}
return Path.Combine(homePath, ".local", "share");
}
private static void TryCopyBundledIcon(string iconTargetPath)
{
foreach (var candidatePath in EnumerateIconSourceCandidates())
{
try
{
if (!File.Exists(candidatePath))
{
continue;
}
File.Copy(candidatePath, iconTargetPath, overwrite: true);
return;
}
catch
{
// Ignore failures and continue trying fallbacks.
}
}
}
private static string[] EnumerateIconSourceCandidates()
{
var baseDirectory = AppContext.BaseDirectory;
return
[
Path.Combine(baseDirectory, "share", "icons", "hicolor", "256x256", "apps", IconFileName),
Path.Combine(baseDirectory, IconFileName)
];
}
private static string BuildDesktopEntryContent(string executablePath)
{
var escapedExecutablePath = executablePath.Replace("\"", "\\\"", StringComparison.Ordinal);
return
"[Desktop Entry]\n" +
"Type=Application\n" +
"Version=1.0\n" +
"Name=LanMountainDesktop\n" +
"Comment=LanMountainDesktop desktop shell\n" +
$"Exec=\"{escapedExecutablePath}\" %U\n" +
$"Icon={IconName}\n" +
"Terminal=false\n" +
"Categories=Utility;Education;\n" +
"StartupWMClass=LanMountainDesktop\n";
}
private static void WriteFileIfChanged(string filePath, string content)
{
try
{
if (File.Exists(filePath))
{
var existing = File.ReadAllText(filePath);
if (string.Equals(existing, content, StringComparison.Ordinal))
{
return;
}
}
}
catch
{
// Fall through to attempt writing the content.
}
File.WriteAllText(filePath, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
private static void TryRunCommand(string fileName, params string[] arguments)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = fileName,
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true
};
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
using var process = Process.Start(startInfo);
if (process is null)
{
return;
}
_ = process.WaitForExit(2_500);
}
catch
{
// Ignore missing command or update failures.
}
}
}

View File

@@ -0,0 +1,371 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public sealed class LinuxDesktopEntryService
{
private static readonly Regex FieldCodeRegex =
new(@"%[fFuUdDnNickvm]", RegexOptions.Compiled);
public StartMenuFolderNode Load()
{
var root = new StartMenuFolderNode("All Apps", string.Empty);
if (!OperatingSystem.IsLinux())
{
return root;
}
var seenDesktopIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var applicationsRoot in EnumerateApplicationsRoots())
{
foreach (var desktopFilePath in EnumerateDesktopFilesSafe(applicationsRoot))
{
if (!TryParseDesktopEntry(desktopFilePath, applicationsRoot, out var appEntry))
{
continue;
}
if (seenDesktopIds.Add(appEntry.RelativePath))
{
root.Apps.Add(appEntry);
}
}
}
root.Apps.Sort((left, right) =>
string.Compare(left.DisplayName, right.DisplayName, CultureInfo.CurrentCulture, CompareOptions.IgnoreCase));
return root;
}
private static IEnumerable<string> EnumerateApplicationsRoots()
{
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
if (string.IsNullOrWhiteSpace(dataHome) && !string.IsNullOrWhiteSpace(homeDirectory))
{
dataHome = Path.Combine(homeDirectory, ".local", "share");
}
var dataDirs = (Environment.GetEnvironmentVariable("XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share")
.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var candidates = new List<string>();
if (!string.IsNullOrWhiteSpace(dataHome))
{
candidates.Add(Path.Combine(dataHome, "applications"));
}
foreach (var dataDir in dataDirs)
{
candidates.Add(Path.Combine(dataDir, "applications"));
}
if (!string.IsNullOrWhiteSpace(homeDirectory))
{
candidates.Add(Path.Combine(homeDirectory, ".local", "share", "flatpak", "exports", "share", "applications"));
}
candidates.Add("/var/lib/flatpak/exports/share/applications");
candidates.Add("/var/lib/snapd/desktop/applications");
return candidates
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<string> EnumerateDesktopFilesSafe(string applicationsRoot)
{
try
{
return Directory.EnumerateFiles(applicationsRoot, "*.desktop", SearchOption.AllDirectories);
}
catch
{
return Array.Empty<string>();
}
}
private static bool TryParseDesktopEntry(string desktopFilePath, string applicationsRoot, out StartMenuAppEntry appEntry)
{
appEntry = null!;
Dictionary<string, string> fields;
try
{
fields = ReadDesktopEntryFields(desktopFilePath);
}
catch
{
return false;
}
if (!fields.TryGetValue("Type", out var entryType) ||
!string.Equals(entryType, "Application", StringComparison.OrdinalIgnoreCase) ||
GetBooleanField(fields, "NoDisplay") ||
GetBooleanField(fields, "Hidden"))
{
return false;
}
var displayName = GetPreferredName(fields);
if (string.IsNullOrWhiteSpace(displayName))
{
return false;
}
if (!fields.TryGetValue("Exec", out var execValue) ||
!TryParseExec(execValue, out var launchExecutable, out var launchArguments))
{
return false;
}
if (fields.TryGetValue("TryExec", out var tryExecValue) &&
!string.IsNullOrWhiteSpace(tryExecValue) &&
!CommandExists(tryExecValue))
{
return false;
}
var desktopFileId = BuildDesktopFileId(desktopFilePath, applicationsRoot);
var iconValue = fields.TryGetValue("Icon", out var iconFieldValue)
? iconFieldValue
: string.Empty;
var workingDirectory = Path.IsPathRooted(launchExecutable)
? Path.GetDirectoryName(launchExecutable)
: null;
appEntry = new StartMenuAppEntry
{
DisplayName = displayName.Trim(),
FilePath = desktopFilePath,
RelativePath = desktopFileId,
IconPngBytes = LinuxIconService.TryGetIconPngBytes(iconValue, Path.GetDirectoryName(desktopFilePath)),
LaunchExecutable = launchExecutable,
LaunchArguments = launchArguments,
WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory
};
return true;
}
private static Dictionary<string, string> ReadDesktopEntryFields(string desktopFilePath)
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var inDesktopEntrySection = false;
foreach (var rawLine in File.ReadLines(desktopFilePath))
{
var line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
{
continue;
}
if (line.StartsWith('[') && line.EndsWith(']'))
{
inDesktopEntrySection = string.Equals(line, "[Desktop Entry]", StringComparison.OrdinalIgnoreCase);
continue;
}
if (!inDesktopEntrySection)
{
continue;
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0 || separatorIndex >= line.Length - 1)
{
continue;
}
var key = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].Trim();
fields[key] = value;
}
return fields;
}
private static bool GetBooleanField(IReadOnlyDictionary<string, string> fields, string key)
{
return fields.TryGetValue(key, out var value) &&
bool.TryParse(value, out var result) &&
result;
}
private static string GetPreferredName(IReadOnlyDictionary<string, string> fields)
{
if (TryGetLocalizedField(fields, "Name", out var localizedName))
{
return localizedName;
}
return fields.TryGetValue("Name", out var fallbackName)
? fallbackName
: string.Empty;
}
private static bool TryGetLocalizedField(IReadOnlyDictionary<string, string> fields, string baseKey, out string value)
{
value = string.Empty;
var uiCulture = CultureInfo.CurrentUICulture;
var candidates = new[]
{
$"{baseKey}[{uiCulture.Name}]",
$"{baseKey}[{uiCulture.TwoLetterISOLanguageName}]"
};
foreach (var key in candidates)
{
if (fields.TryGetValue(key, out var localizedValue) &&
!string.IsNullOrWhiteSpace(localizedValue))
{
value = localizedValue;
return true;
}
}
return false;
}
private static string BuildDesktopFileId(string desktopFilePath, string applicationsRoot)
{
var relativePath = Path.GetRelativePath(applicationsRoot, desktopFilePath)
.Replace(Path.DirectorySeparatorChar, '-')
.Replace(Path.AltDirectorySeparatorChar, '-');
return relativePath.Trim();
}
private static bool TryParseExec(string execValue, out string launchExecutable, out List<string> launchArguments)
{
launchExecutable = string.Empty;
launchArguments = [];
var tokens = TokenizeExec(execValue);
if (tokens.Count == 0)
{
return false;
}
var cleanedTokens = new List<string>(tokens.Count);
foreach (var token in tokens)
{
if (string.IsNullOrWhiteSpace(token))
{
continue;
}
var normalizedToken = token.Replace("%%", "%", StringComparison.Ordinal);
if (normalizedToken.Length == 2 && normalizedToken[0] == '%')
{
continue;
}
normalizedToken = FieldCodeRegex.Replace(normalizedToken, string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedToken))
{
continue;
}
cleanedTokens.Add(normalizedToken);
}
if (cleanedTokens.Count == 0)
{
return false;
}
launchExecutable = cleanedTokens[0];
launchArguments = cleanedTokens.Skip(1).ToList();
return true;
}
private static List<string> TokenizeExec(string execValue)
{
var tokens = new List<string>();
var current = new StringBuilder();
var inQuotes = false;
char quoteChar = '\0';
foreach (var c in execValue)
{
if ((c == '"' || c == '\'') &&
(!inQuotes || quoteChar == c))
{
if (inQuotes)
{
inQuotes = false;
quoteChar = '\0';
}
else
{
inQuotes = true;
quoteChar = c;
}
continue;
}
if (char.IsWhiteSpace(c) && !inQuotes)
{
if (current.Length > 0)
{
tokens.Add(current.ToString());
current.Clear();
}
continue;
}
current.Append(c);
}
if (current.Length > 0)
{
tokens.Add(current.ToString());
}
return tokens;
}
private static bool CommandExists(string command)
{
var trimmedCommand = command.Trim();
if (string.IsNullOrWhiteSpace(trimmedCommand))
{
return false;
}
if (Path.IsPathRooted(trimmedCommand))
{
return File.Exists(trimmedCommand);
}
var pathEntries = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var pathEntry in pathEntries)
{
try
{
var candidate = Path.Combine(pathEntry, trimmedCommand);
if (File.Exists(candidate))
{
return true;
}
}
catch
{
// Ignore malformed PATH entries.
}
}
return false;
}
}

View File

@@ -0,0 +1,214 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace LanMountainDesktop.Services;
internal static class LinuxIconService
{
private static readonly string[] SupportedRasterExtensions =
[
".png",
".ico"
];
private static readonly Regex SizeDirectoryRegex =
new(@"(?<size>\d{1,4})x\d{1,4}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly ConcurrentDictionary<string, string?> IconPathCache = new(StringComparer.OrdinalIgnoreCase);
public static byte[]? TryGetIconPngBytes(string? iconKey, string? desktopFileDirectory = null)
{
if (!OperatingSystem.IsLinux() || string.IsNullOrWhiteSpace(iconKey))
{
return null;
}
foreach (var candidatePath in ResolveIconCandidates(iconKey.Trim(), desktopFileDirectory))
{
if (TryReadIconBytes(candidatePath, out var bytes))
{
return bytes;
}
}
return null;
}
private static IEnumerable<string> ResolveIconCandidates(string iconKey, string? desktopFileDirectory)
{
if (Path.HasExtension(iconKey))
{
var directPath = ExpandHome(iconKey);
if (Path.IsPathRooted(directPath))
{
yield return directPath;
}
else if (!string.IsNullOrWhiteSpace(desktopFileDirectory))
{
yield return Path.GetFullPath(Path.Combine(desktopFileDirectory, directPath));
}
yield break;
}
var resolvedThemePath = ResolveThemedIconPath(iconKey);
if (!string.IsNullOrWhiteSpace(resolvedThemePath))
{
yield return resolvedThemePath;
}
}
private static string? ResolveThemedIconPath(string iconName)
{
return IconPathCache.GetOrAdd(iconName, static key => FindBestMatchingIconPath(key));
}
private static string? FindBestMatchingIconPath(string iconName)
{
var candidates = new List<(string Path, int Score)>();
foreach (var iconRoot in EnumerateIconRoots())
{
foreach (var extension in SupportedRasterExtensions)
{
foreach (var candidatePath in EnumerateFilesSafe(iconRoot, iconName + extension))
{
candidates.Add((candidatePath, ScoreIconPath(candidatePath)));
}
}
}
return candidates
.OrderByDescending(candidate => candidate.Score)
.ThenBy(candidate => candidate.Path.Length)
.Select(candidate => candidate.Path)
.FirstOrDefault();
}
private static IEnumerable<string> EnumerateIconRoots()
{
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
if (string.IsNullOrWhiteSpace(dataHome) && !string.IsNullOrWhiteSpace(homeDirectory))
{
dataHome = Path.Combine(homeDirectory, ".local", "share");
}
var dataDirs = (Environment.GetEnvironmentVariable("XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share")
.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var candidates = new List<string>();
if (!string.IsNullOrWhiteSpace(dataHome))
{
candidates.Add(Path.Combine(dataHome, "icons"));
candidates.Add(Path.Combine(dataHome, "pixmaps"));
}
foreach (var dataDir in dataDirs)
{
candidates.Add(Path.Combine(dataDir, "icons"));
candidates.Add(Path.Combine(dataDir, "pixmaps"));
}
if (!string.IsNullOrWhiteSpace(homeDirectory))
{
candidates.Add(Path.Combine(homeDirectory, ".icons"));
candidates.Add(Path.Combine(homeDirectory, ".local", "share", "flatpak", "exports", "share", "icons"));
}
candidates.Add("/var/lib/flatpak/exports/share/icons");
candidates.Add("/var/lib/snapd/desktop/icons");
return candidates
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<string> EnumerateFilesSafe(string rootPath, string fileName)
{
try
{
return Directory.EnumerateFiles(rootPath, fileName, SearchOption.AllDirectories);
}
catch
{
return Array.Empty<string>();
}
}
private static bool TryReadIconBytes(string filePath, out byte[] bytes)
{
bytes = [];
try
{
var extension = Path.GetExtension(filePath);
if (!SupportedRasterExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
!File.Exists(filePath))
{
return false;
}
bytes = File.ReadAllBytes(filePath);
return bytes.Length > 0;
}
catch
{
return false;
}
}
private static int ScoreIconPath(string filePath)
{
var score = 0;
var extension = Path.GetExtension(filePath);
if (extension.Equals(".png", StringComparison.OrdinalIgnoreCase))
{
score += 4_000;
}
else if (extension.Equals(".ico", StringComparison.OrdinalIgnoreCase))
{
score += 2_000;
}
if (filePath.Contains($"{Path.DirectorySeparatorChar}hicolor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
{
score += 8_000;
}
if (filePath.Contains($"{Path.DirectorySeparatorChar}apps{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
{
score += 1_000;
}
var match = SizeDirectoryRegex.Match(filePath);
if (match.Success &&
int.TryParse(match.Groups["size"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var size))
{
score += Math.Min(size, 512);
}
return score;
}
private static string ExpandHome(string path)
{
if (!path.StartsWith("~", StringComparison.Ordinal))
{
return path;
}
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrWhiteSpace(homeDirectory))
{
return path;
}
return path.Length == 1
? homeDirectory
: Path.Combine(homeDirectory, path[2..]);
}
}

View File

@@ -46,6 +46,8 @@ public sealed class LocalizationService
if (File.Exists(filePath))
{
var json = File.ReadAllText(filePath);
// Defensive: tolerate accidentally duplicated UTF-8 BOM characters at file start.
json = json.TrimStart('\uFEFF');
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
if (data is not null)
{
@@ -62,4 +64,3 @@ public sealed class LocalizationService
return result;
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,110 @@
<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.BaiduHotSearchSettingsWindow">
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
Padding="16">
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<TextBlock x:Name="TitleTextBlock"
Text="Baidu hot search settings"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="DescriptionTextBlock"
Grid.Row="1"
Text="Configure 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="Data source"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="SourceComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnSourceSelectionChanged">
<ComboBoxItem x:Name="SourceOfficialItem"
Tag="Official"
Content="Official Source" />
<ComboBoxItem x:Name="SourceThirdPartyRssItem"
Tag="ThirdPartyRss"
Content="Third-party RSS" />
</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="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>

View File

@@ -0,0 +1,193 @@
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 BaiduHotSearchSettingsWindow : 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 BaiduHotSearchSettingsWindow()
{
InitializeComponent();
InitializeFrequencyOptions();
LoadState();
ApplyLocalization();
}
private void LoadState()
{
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
var sourceType = BaiduHotSearchSourceTypes.Normalize(componentSnapshot.BaiduHotSearchSourceType);
var enabled = componentSnapshot.BaiduHotSearchAutoRefreshEnabled;
var interval = NormalizeInterval(componentSnapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
_suppressEvents = true;
SelectSourceType(sourceType);
AutoRefreshCheckBox.IsChecked = enabled;
SelectInterval(interval);
FrequencyCardBorder.IsVisible = enabled;
_suppressEvents = false;
}
private void ApplyLocalization()
{
TitleTextBlock.Text = L("baiduhot.settings.title", "Baidu hot search settings");
DescriptionTextBlock.Text = L("baiduhot.settings.desc", "Configure source, auto refresh and refresh interval.");
SourceLabelTextBlock.Text = L("baiduhot.settings.source_label", "Data source");
SourceOfficialItem.Content = L("baiduhot.settings.source_official", "Official Source");
SourceThirdPartyRssItem.Content = L("baiduhot.settings.source_rss", "Third-party RSS");
AutoRefreshLabelTextBlock.Text = L("baiduhot.settings.auto_refresh_label", "Auto refresh");
AutoRefreshCheckBox.Content = L("baiduhot.settings.auto_refresh_enabled", "Enable auto refresh");
FrequencyLabelTextBlock.Text = L("baiduhot.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.BaiduHotSearchSourceType = GetSelectedSourceType();
snapshot.BaiduHotSearchAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
snapshot.BaiduHotSearchAutoRefreshIntervalMinutes = GetSelectedInterval();
_componentSettingsService.Save(snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
private string GetSelectedSourceType()
{
if (SourceComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string sourceTag)
{
return BaiduHotSearchSourceTypes.Normalize(sourceTag);
}
return BaiduHotSearchSourceTypes.Official;
}
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 SelectSourceType(string sourceType)
{
var normalizedSourceType = BaiduHotSearchSourceTypes.Normalize(sourceType);
var selected = SourceComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
item.Tag is string sourceTag &&
string.Equals(BaiduHotSearchSourceTypes.Normalize(sourceTag), normalizedSourceType, StringComparison.OrdinalIgnoreCase));
SourceComboBox.SelectedItem = selected ?? SourceComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
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);
}
}

View File

@@ -0,0 +1,189 @@
<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.BaiduHotSearchWidget">
<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">
<TextBlock x:Name="BrandTextBlock"
Text="百度热搜"
Foreground="#2932E1"
FontSize="24"
FontWeight="Bold"
VerticalAlignment="Center"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
<Button x:Name="RefreshButton"
Grid.Column="1"
Width="34"
Height="34"
CornerRadius="17"
Background="#EFF1F5"
BorderBrush="Transparent"
BorderThickness="0"
Padding="0"
Focusable="False"
ToolTip.Tip="刷新"
Click="OnRefreshButtonClick">
<fi:SymbolIcon x:Name="RefreshGlyphIcon"
Symbol="ArrowClockwise"
IconVariant="Regular"
Foreground="#5E6671"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</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="#2932E1"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Right"
TextAlignment="Right" />
<TextBlock x:Name="HotItem1TextBlock"
Grid.Column="1"
Text="热搜内容"
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="#2932E1"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Right"
TextAlignment="Right" />
<TextBlock x:Name="HotItem2TextBlock"
Grid.Column="1"
Text="热搜内容"
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="#2932E1"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Right"
TextAlignment="Right" />
<TextBlock x:Name="HotItem3TextBlock"
Grid.Column="1"
Text="热搜内容"
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="#2932E1"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Right"
TextAlignment="Right" />
<TextBlock x:Name="HotItem4TextBlock"
Grid.Column="1"
Text="热搜内容"
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>

View File

@@ -0,0 +1,623 @@
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.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class BaiduHotSearchWidget : 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<BaiduHotSearchItemSnapshot> _activeItems = [];
private readonly List<HotItemVisual> _hotItemVisuals = [];
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;
private string _sourceType = BaiduHotSearchSourceTypes.Official;
private bool _isNightVisual = true;
private sealed record HotItemVisual(
Border Host,
Grid RowGrid,
TextBlock IndexTextBlock,
TextBlock TitleTextBlock);
public BaiduHotSearchWidget()
{
InitializeComponent();
BrandTextBlock.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;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
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)
{
_ = RefreshHotSearchAsync(forceRefresh: false);
}
}
public void RefreshFromSettings()
{
_recommendationService.ClearCache();
ApplyAutoRefreshSettings();
if (_isAttached)
{
_ = RefreshHotSearchAsync(forceRefresh: true);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
ApplyAutoRefreshSettings();
UpdateRefreshButtonState();
_ = RefreshHotSearchAsync(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 void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
UpdateAdaptiveLayout();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
foreach (var visual in _hotItemVisuals)
{
visual.IndexTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
}
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshHotSearchAsync(forceRefresh: true);
}
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
{
_ = sender;
await RefreshHotSearchAsync(forceRefresh: true);
e.Handled = true;
}
private async Task RefreshHotSearchAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateLanguageCode();
UpdateRefreshButtonState();
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new BaiduHotSearchQuery(
Locale: _languageCode,
ItemCount: MaxDisplayItemCount,
SourceType: _sourceType,
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetBaiduHotSearchAsync(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(BaiduHotSearchSnapshot snapshot)
{
BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜");
ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新"));
_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("baiduhot.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()
{
BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜");
ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新"));
_activeItems.Clear();
var loadingText = L("baiduhot.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("baiduhot.widget.loading", "加载中...");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜");
ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新"));
_activeItems.Clear();
var fallbackText = L("baiduhot.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("baiduhot.widget.fetch_failed", "热搜获取失败");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
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(22 * softScale, 18, 34);
var topRowHeight = Math.Clamp(availableRowsHeight * 0.30, minTopRowHeight, 54);
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);
}
}
BrandTextBlock.FontSize = Math.Clamp(topRowHeight * 0.48, 12, 24);
BrandTextBlock.MaxWidth = Math.Max(80, innerWidth - Math.Clamp(topRowHeight * 0.84, 20, 46));
var refreshButtonSize = Math.Clamp(topRowHeight * 0.84, 20, 46);
RefreshButton.Width = refreshButtonSize;
RefreshButton.Height = refreshButtonSize;
RefreshButton.CornerRadius = new CornerRadius(refreshButtonSize / 2d);
RefreshGlyphIcon.FontSize = Math.Clamp(refreshButtonSize * 0.46, 10, 20);
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.TitleTextBlock.FontSize = itemFont;
visual.TitleTextBlock.MaxWidth = itemTextWidth;
visual.TitleTextBlock.TextAlignment = TextAlignment.Left;
}
StatusTextBlock.FontSize = Math.Clamp(itemFont, 10, 20);
ApplyNightModeVisual();
}
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);
}
}
private void UpdateRefreshButtonState()
{
var enabled = _isAttached && !_isRefreshing;
RefreshButton.IsEnabled = enabled;
RefreshButton.Opacity = enabled ? 1.0 : 0.65;
}
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;
var sourceType = BaiduHotSearchSourceTypes.Official;
try
{
var snapshot = _componentSettingsService.Load();
enabled = snapshot.BaiduHotSearchAutoRefreshEnabled;
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
sourceType = BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType);
}
catch
{
// Keep fallback defaults.
}
_autoRefreshEnabled = enabled;
_sourceType = sourceType;
_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? 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();
}
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -8,6 +9,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
@@ -24,13 +26,15 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
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 _settingsService = new();
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 = [];
@@ -42,6 +46,8 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
private bool _autoRefreshEnabled = true;
private bool _isNightVisual = true;
private sealed record HotItemVisual(
Border Host,
@@ -74,9 +80,11 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
ApplyAutoRefreshSettings();
ApplyLoadingState();
}
@@ -98,6 +106,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
public void RefreshFromSettings()
{
_recommendationService.ClearCache();
ApplyAutoRefreshSettings();
if (_isAttached)
{
_ = RefreshHotSearchAsync(forceRefresh: true);
@@ -107,7 +116,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
_refreshTimer.Start();
ApplyAutoRefreshSettings();
_ = RefreshHotSearchAsync(forceRefresh: false);
}
@@ -123,6 +132,69 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
UpdateAdaptiveLayout();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
SearchBoxBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#ECF2FA"));
SearchBoxBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#3FFFFFFF") : Color.Parse("#22000000"));
SearchEntryTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
SearchGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
TopRightTitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#F472C4") : Color.Parse("#F44C9F"));
foreach (var visual in _hotItemVisuals)
{
visual.IndexTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#F472C4") : Color.Parse("#F44C9F"));
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
}
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshHotSearchAsync(forceRefresh: false);
@@ -390,6 +462,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
}
StatusTextBlock.FontSize = Math.Clamp(itemFont, 10, 20);
ApplyNightModeVisual();
}
private void UpdateInteractionState()
@@ -417,7 +490,7 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
{
try
{
var snapshot = _settingsService.Load();
var snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
@@ -426,6 +499,60 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
}
}
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))

View File

@@ -1,4 +1,4 @@
using System;
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
@@ -12,7 +12,7 @@ using WebViewCore.Events;
namespace LanMountainDesktop.Views.Components;
public partial class BrowserWidget : UserControl, IDesktopComponentWidget
, IDesktopPageVisibilityAwareComponentWidget
, IDesktopPageVisibilityAwareComponentWidget, IDisposable
{
private static readonly Uri DefaultHomeUri = new("https://www.bing.com");
private double _currentCellSize = 48;
@@ -22,6 +22,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
private bool _isEditMode;
private bool _isWebViewActive = true;
private readonly WebView2RuntimeAvailability _runtimeAvailability;
private bool _isDisposed;
public BrowserWidget()
{
@@ -48,6 +49,26 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
NavigateTo(DefaultHomeUri);
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
SizeChanged -= OnSizeChanged;
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
AttachedToVisualTree -= OnAttachedToVisualTree;
DetachedFromVisualTree -= OnDetachedFromVisualTree;
if (_runtimeAvailability.IsAvailable)
{
BrowserWebView.NavigationStarting -= OnBrowserWebViewNavigationStarting;
}
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);

View File

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

View File

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

View File

@@ -1,17 +1,20 @@
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 int[] SupportedIntervals = [5, 10, 40, 60, 720, 1440];
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";
@@ -21,17 +24,19 @@ public partial class CnrDailyNewsSettingsWindow : UserControl
public CnrDailyNewsSettingsWindow()
{
InitializeComponent();
InitializeFrequencyOptions();
LoadState();
ApplyLocalization();
}
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 enabled = snapshot.CnrDailyNewsAutoRotateEnabled;
var interval = NormalizeInterval(snapshot.CnrDailyNewsAutoRotateIntervalMinutes);
var enabled = componentSnapshot.CnrDailyNewsAutoRotateEnabled;
var interval = NormalizeInterval(componentSnapshot.CnrDailyNewsAutoRotateIntervalMinutes);
_suppressEvents = true;
AutoRotateCheckBox.IsChecked = enabled;
@@ -47,12 +52,7 @@ public partial class CnrDailyNewsSettingsWindow : UserControl
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");
Frequency5mItem.Content = L("cnrnews.settings.frequency_5m", "5 min");
Frequency10mItem.Content = L("cnrnews.settings.frequency_10m", "10 min");
Frequency40mItem.Content = L("cnrnews.settings.frequency_40m", "40 min");
Frequency1hItem.Content = L("cnrnews.settings.frequency_1h", "1 hour");
Frequency12hItem.Content = L("cnrnews.settings.frequency_12h", "12 hours");
Frequency24hItem.Content = L("cnrnews.settings.frequency_24h", "24 hours");
ApplyFrequencyLocalization();
}
private void OnAutoRotateChanged(object? sender, RoutedEventArgs e)
@@ -83,10 +83,10 @@ public partial class CnrDailyNewsSettingsWindow : UserControl
private void SaveState()
{
var snapshot = _appSettingsService.Load();
var snapshot = _componentSettingsService.Load();
snapshot.CnrDailyNewsAutoRotateEnabled = AutoRotateCheckBox.IsChecked == true;
snapshot.CnrDailyNewsAutoRotateIntervalMinutes = GetSelectedInterval();
_appSettingsService.Save(snapshot);
_componentSettingsService.Save(snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
@@ -115,19 +115,35 @@ public partial class CnrDailyNewsSettingsWindow : UserControl
private static int NormalizeInterval(int minutes)
{
if (minutes <= 0)
{
return 60;
}
return RefreshIntervalCatalog.Normalize(minutes, 60);
}
if (SupportedIntervals.Contains(minutes))
private void InitializeFrequencyOptions()
{
FrequencyComboBox.Items.Clear();
foreach (var minutes in SupportedIntervals)
{
return minutes;
FrequencyComboBox.Items.Add(new ComboBoxItem
{
Tag = minutes.ToString(),
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
});
}
}
return SupportedIntervals
.OrderBy(value => Math.Abs(value - minutes))
.FirstOrDefault(60);
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)

View File

@@ -14,6 +14,7 @@ using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
@@ -36,14 +37,15 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private static readonly int[] SupportedAutoRotateIntervalsMinutes = [5, 10, 40, 60, 720, 1440];
private static readonly IReadOnlyList<int> SupportedAutoRotateIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromMinutes(30)
};
private readonly AppSettingsService _settingsService = new();
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 = [];
@@ -87,6 +89,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
private bool _isAttached;
private bool _isRefreshing;
private bool _autoRotateEnabled = true;
private bool _isNightVisual = true;
public CnrDailyNewsWidget()
{
@@ -104,6 +107,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
@@ -160,6 +164,66 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
UpdateAdaptiveLayout();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
BrandPrimaryTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
BrandSecondaryTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#6A6F77"));
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
RefreshLabelTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
News1TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
News2TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
}
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
{
if (_isRefreshing)
@@ -353,9 +417,11 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
{
var normalizedTitle = NormalizeCompactText(title);
var hotLabel = L("cnrnews.widget.hot_label", "Hot");
var primaryForeground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
if (News1TitleTextBlock.Inlines is null)
{
News1TitleTextBlock.Text = $"{hotLabel} | {normalizedTitle}";
News1TitleTextBlock.Foreground = primaryForeground;
return;
}
@@ -367,7 +433,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
});
News1TitleTextBlock.Inlines.Add(new Run(normalizedTitle)
{
Foreground = new SolidColorBrush(Color.Parse("#202327")),
Foreground = primaryForeground,
FontWeight = FontWeight.SemiBold
});
}
@@ -400,7 +466,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
var textBlock = new TextBlock
{
Text = NormalizeCompactText(item.Title),
Foreground = new SolidColorBrush(Color.Parse("#202327")),
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327")),
FontFamily = MiSansFontFamily,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap,
@@ -555,6 +621,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
}
ExtraNewsItemsPanel.Spacing = Math.Clamp(6 * scale, 3, 10);
ApplyNightModeVisual();
}
private void UpdateRefreshButtonState()
@@ -705,7 +773,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
{
try
{
var snapshot = _settingsService.Load();
var snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
@@ -721,7 +789,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
try
{
var snapshot = _settingsService.Load();
var snapshot = _componentSettingsService.Load();
enabled = snapshot.CnrDailyNewsAutoRotateEnabled;
intervalMinutes = NormalizeAutoRotateIntervalMinutes(snapshot.CnrDailyNewsAutoRotateIntervalMinutes);
}

View File

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

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -14,9 +14,9 @@
BorderThickness="0"
Background="#C20A0A"
Padding="20,16,20,14">
<Grid RowDefinitions="Auto,*,Auto">
<Grid RowDefinitions="*,Auto">
<Canvas x:Name="DayDecorationCanvas"
Grid.RowSpan="3"
Grid.RowSpan="2"
IsVisible="False"
Width="212"
Height="148"
@@ -43,34 +43,32 @@
Data="M8,54 L38,24 L64,52 L8,54 Z" />
</Canvas>
<TextBlock x:Name="QuoteMarkTextBlock"
Text="&#8220;"
Foreground="#5CFAD0B7"
FontSize="96"
FontWeight="SemiBold"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Margin="1,0,0,0"
LineHeight="86" />
<Grid Grid.Row="0" RowDefinitions="Auto,*">
<TextBlock x:Name="QuoteMarkTextBlock"
Text="&#8220;"
Foreground="#5CFAD0B7"
FontSize="72"
FontWeight="SemiBold"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Margin="1,0,0,0"
LineHeight="65" />
<TextBlock x:Name="PoetryContentTextBlock"
Grid.Row="1"
Text="芳草年年惹恨幽。想前事悠悠。"
Foreground="#F8D8A8"
FontSize="54"
FontWeight="Medium"
LineHeight="60"
TextWrapping="Wrap"
VerticalAlignment="Top"
Margin="8,2,0,0" />
<TextBlock x:Name="PoetryContentTextBlock"
Grid.Row="1"
Text="芳草年年惹恨幽。想前事悠悠。"
Foreground="#F8D8A8"
FontSize="54"
FontWeight="Medium"
LineHeight="60"
TextWrapping="Wrap"
MaxLines="2"
VerticalAlignment="Top"
HorizontalAlignment="Left"
Margin="8,2,0,0" />
</Grid>
<Grid x:Name="AuthorPanel"
Grid.Row="2"
ColumnDefinitions="Auto,*"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="0,6,4,0"
IsHitTestVisible="False">
<Grid Grid.Row="1" ColumnDefinitions="Auto,*" VerticalAlignment="Bottom" Margin="0,6,0,0">
<Border x:Name="AuthorAccent"
Grid.Column="0"
Width="6"
@@ -92,6 +90,7 @@
</Grid>
<TextBlock x:Name="StatusTextBlock"
Grid.Row="0"
Text="Loading..."
IsVisible="False"
Foreground="#D9FFFFFF"
@@ -100,10 +99,10 @@
VerticalAlignment="Top" />
<Button x:Name="RefreshButton"
Grid.RowSpan="3"
Grid.Row="1"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,12,16,0"
VerticalAlignment="Bottom"
Margin="0,8,4,0"
Width="42"
Height="42"
CornerRadius="21"
@@ -113,13 +112,12 @@
Padding="0"
Focusable="False">
<TextBlock x:Name="RefreshGlyphTextBlock"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Text="&#8635;"
Text="&#xE72C;"
FontFamily="{StaticResource SymbolFontFamily}"
FontSize="22"
Foreground="#8C9097"
FontSize="26"
FontWeight="SemiLight"
LineHeight="26" />
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</Grid>
</Border>

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@@ -45,8 +45,8 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private const double MinPoetryFontSize = 12;
private const double MinAuthorFontSize = 10.5;
private const double MinPoetryFontSize = 8;
private const double MinAuthorFontSize = 7;
private readonly record struct TextFitResult(double FontSize, FontWeight FontWeight, double LineHeight);
@@ -109,7 +109,6 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
0,
0);
AuthorPanel.Margin = new Thickness(0, Math.Clamp(5 * scale, 2, 10), Math.Clamp(4 * scale, 2, 8), 0);
AuthorAccent.Width = Math.Clamp(6 * scale, 3.2, 9.5);
AuthorAccent.Height = Math.Clamp(24 * scale, 12, 34);
AuthorAccent.Margin = new Thickness(0, 0, Math.Clamp(8 * scale, 4, 13), 0);
@@ -351,11 +350,6 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
AuthorTextBlock.Foreground = CreateBrush("#F4D7A7");
AuthorAccent.Background = CreateBrush("#63F2AF90");
AuthorPanel.Margin = new Thickness(
0,
Math.Clamp(6 * scale, 2, 10),
Math.Clamp(6 * scale, 2, 10),
Math.Clamp(1 * scale, 0, 3));
DayDecorationCanvas.IsVisible = false;
RefreshButton.IsVisible = true;
@@ -380,11 +374,6 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
AuthorTextBlock.Foreground = CreateBrush("#272D38");
AuthorAccent.Background = CreateBrush("#C8090D");
AuthorPanel.Margin = new Thickness(
0,
Math.Clamp(6 * scale, 2, 10),
Math.Clamp(6 * scale, 2, 10),
Math.Clamp(2 * scale, 0, 4));
DayDecorationCanvas.IsVisible = true;
RefreshButton.IsVisible = true;
@@ -475,83 +464,19 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
DayDecorationCanvas.IsVisible = showDayDecorations;
RefreshButton.IsVisible = true;
var refreshReservedWidth = RefreshButton.Width + Math.Clamp(8 * scale, 5, 14);
var decorationReservedWidth = showDayDecorations
? Math.Clamp(innerWidth * 0.24, 34, 96)
: 0;
var quoteReservedWidth = QuoteMarkTextBlock.IsVisible
? Math.Clamp(10 * scale, 5, 16)
: 0;
var poemReservedRight = Math.Max(refreshReservedWidth, decorationReservedWidth);
var poemWidth = innerWidth - poemReservedRight - quoteReservedWidth;
var poemMinWidth = Math.Max(66, innerWidth * 0.56);
if (poemWidth < poemMinWidth)
{
poemWidth = poemMinWidth;
}
poemWidth = Math.Min(Math.Max(64, poemWidth), innerWidth);
var refreshButtonWidth = 42 + Math.Clamp(8 * scale, 5, 14);
var quoteMarkWidth = QuoteMarkTextBlock.IsVisible ? Math.Clamp(10 * scale, 5, 16) : 0;
var poemWidth = innerWidth - quoteMarkWidth - Math.Clamp(12 * scale, 6, 20);
poemWidth = Math.Min(Math.Max(64, poemWidth), innerWidth - Math.Clamp(16 * scale, 8, 24));
var authorMaxLines = innerWidth < Math.Max(_currentCellSize * 5.2, 252) ? 2 : 1;
var authorUnitsTarget = authorMaxLines == 1 ? 20 : 12;
var authorWidth = Math.Max(72, Math.Min(innerWidth * (isNightMode ? 0.5 : 0.56), innerWidth - 8));
var authorPrepared = PrepareAuthorText(_authorRawText, authorUnitsTarget, authorMaxLines);
var authorPreferredFontSize = Math.Clamp((isNightMode ? 25 : 23) * scale, 12, 34);
var authorMinFontSize = Math.Clamp(authorPreferredFontSize * 0.72, MinAuthorFontSize, authorPreferredFontSize);
var authorMinWeight = isNightMode ? 500 : 470;
var authorMaxWeight = isNightMode ? 650 : 600;
authorPrepared = EnsureTextFitsAtMinSize(
preparedText: authorPrepared,
sourceText: _authorRawText,
targetUnits: authorUnitsTarget,
maxLines: authorMaxLines,
maxWidth: authorWidth,
maxHeight: Math.Max(20, innerHeight * (authorMaxLines > 1 ? 0.38 : 0.28)),
minFontSize: authorMinFontSize,
minFontWeight: ToVariableWeight(authorMinWeight),
lineHeightFactor: 1.12);
var authorFit = FitTextStable(
authorPrepared,
authorWidth,
Math.Max(20, innerHeight * (authorMaxLines > 1 ? 0.38 : 0.28)),
minFontSize: authorMinFontSize,
maxFontSize: Math.Clamp(authorPreferredFontSize * 1.15, authorMinFontSize, 42),
maxLines: authorMaxLines,
lineHeightFactor: 1.12,
minWeight: authorMinWeight,
maxWeight: authorMaxWeight);
AuthorTextBlock.Text = authorPrepared;
AuthorTextBlock.TextWrapping = authorMaxLines > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap;
AuthorTextBlock.MaxLines = authorMaxLines;
AuthorTextBlock.MaxWidth = authorWidth;
AuthorTextBlock.FontSize = authorFit.FontSize;
AuthorTextBlock.LineHeight = authorFit.LineHeight;
AuthorTextBlock.FontWeight = authorFit.FontWeight;
AuthorPanel.MaxWidth = authorWidth + AuthorAccent.Width + AuthorAccent.Margin.Right + Math.Clamp(4 * scale, 2, 8);
var authorMeasured = MeasureTextSize(
authorPrepared,
authorFit.FontSize,
authorFit.FontWeight,
authorWidth,
authorFit.LineHeight);
var authorHeight = Math.Min(authorMeasured.Height, authorFit.LineHeight * authorMaxLines);
var authorBlockHeight = Math.Max(authorHeight, AuthorAccent.Height) +
AuthorPanel.Margin.Top +
AuthorPanel.Margin.Bottom +
Math.Clamp(4 * scale, 2, 8);
var poemMaxLines = innerHeight < _currentCellSize * 1.58
? 4
: innerHeight < _currentCellSize * 2.05
? 3
: 2;
var poemMaxLines = 2;
var poemUnitsTarget = EstimateTargetUnitsPerLine(poemWidth, scale, isNightMode);
var poemPrepared = PreparePoetryText(_poetryRawText, poemUnitsTarget, poemMaxLines);
var poemHeight = Math.Max(30, innerHeight - authorBlockHeight);
var poemPreferredFontSize = Math.Clamp((isNightMode ? 34 : 32) * scale, 16, 56);
var poemMinFontSize = Math.Clamp(poemPreferredFontSize * 0.72, MinPoetryFontSize, poemPreferredFontSize);
var availablePoemHeight = innerHeight * 0.72;
var poemPreferredFontSize = Math.Clamp((isNightMode ? 34 : 32) * scale, 14, 56);
var poemMinFontSize = Math.Clamp(poemPreferredFontSize * 0.65, MinPoetryFontSize, poemPreferredFontSize);
var poemMinWeight = isNightMode ? 540 : 500;
var poemMaxWeight = isNightMode ? 760 : 680;
poemPrepared = EnsureTextFitsAtMinSize(
@@ -560,19 +485,19 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
targetUnits: poemUnitsTarget,
maxLines: poemMaxLines,
maxWidth: poemWidth,
maxHeight: poemHeight,
maxHeight: availablePoemHeight,
minFontSize: poemMinFontSize,
minFontWeight: ToVariableWeight(poemMinWeight),
lineHeightFactor: 1.1);
lineHeightFactor: 1.12);
var poemFit = FitTextStable(
poemPrepared,
poemWidth,
poemHeight,
availablePoemHeight,
minFontSize: poemMinFontSize,
maxFontSize: Math.Clamp(poemPreferredFontSize * 1.20, poemMinFontSize, 62),
maxLines: poemMaxLines,
lineHeightFactor: 1.10,
lineHeightFactor: 1.12,
minWeight: poemMinWeight,
maxWeight: poemMaxWeight);
@@ -582,6 +507,43 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
PoetryContentTextBlock.FontSize = poemFit.FontSize;
PoetryContentTextBlock.LineHeight = poemFit.LineHeight;
PoetryContentTextBlock.FontWeight = poemFit.FontWeight;
var authorWidth = Math.Max(72, Math.Min(innerWidth * (isNightMode ? 0.5 : 0.56), innerWidth - 8));
var authorUnitsTarget = 20;
var authorPrepared = PrepareAuthorText(_authorRawText, authorUnitsTarget, 1);
var authorPreferredFontSize = Math.Clamp((isNightMode ? 25 : 23) * scale, 10, 34);
var authorMinFontSize = Math.Clamp(authorPreferredFontSize * 0.65, MinAuthorFontSize, authorPreferredFontSize);
var authorMinWeight = isNightMode ? 500 : 470;
var authorMaxWeight = isNightMode ? 650 : 600;
authorPrepared = EnsureTextFitsAtMinSize(
preparedText: authorPrepared,
sourceText: _authorRawText,
targetUnits: authorUnitsTarget,
maxLines: 1,
maxWidth: authorWidth,
maxHeight: AuthorAccent.Height,
minFontSize: authorMinFontSize,
minFontWeight: ToVariableWeight(authorMinWeight),
lineHeightFactor: 1.12);
var authorFit = FitTextStable(
authorPrepared,
authorWidth,
AuthorAccent.Height,
minFontSize: authorMinFontSize,
maxFontSize: Math.Clamp(authorPreferredFontSize * 1.15, authorMinFontSize, 42),
maxLines: 1,
lineHeightFactor: 1.12,
minWeight: authorMinWeight,
maxWeight: authorMaxWeight);
AuthorTextBlock.Text = authorPrepared;
AuthorTextBlock.TextWrapping = TextWrapping.NoWrap;
AuthorTextBlock.MaxLines = 1;
AuthorTextBlock.MaxWidth = authorWidth;
AuthorTextBlock.FontSize = authorFit.FontSize;
AuthorTextBlock.LineHeight = authorFit.LineHeight;
AuthorTextBlock.FontWeight = authorFit.FontWeight;
}
private void UpdateRefreshButtonState()

View File

@@ -0,0 +1,89 @@
<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="220"
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.DailyWord2x2Widget">
<Border x:Name="RootBorder"
CornerRadius="30"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#FCFBFA"
CornerRadius="30"
BorderBrush="Transparent"
BorderThickness="0"
Padding="12,11,12,11"
PointerPressed="OnCardPointerPressed">
<Grid RowDefinitions="Auto,*"
RowSpacing="8">
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="6">
<TextBlock x:Name="WordTextBlock"
Text="design"
Foreground="#2B2F35"
FontSize="38"
FontWeight="Bold"
MaxLines="1"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<Button x:Name="RefreshButton"
Grid.Column="1"
Width="30"
Height="30"
CornerRadius="15"
Background="#EEF1F4"
BorderBrush="Transparent"
BorderThickness="0"
Padding="0"
Focusable="False"
Click="OnRefreshButtonClick">
<fi:SymbolIcon x:Name="RefreshIcon"
Symbol="ArrowClockwise"
IconVariant="Regular"
FontSize="14"
Foreground="#5E6671" />
</Button>
</Grid>
<Grid Grid.Row="1">
<TextBlock x:Name="MeaningTextBlock"
Text="n. design; plan; layout"
Foreground="#5A6069"
FontSize="18"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="5"
IsVisible="False" />
<TextBlock x:Name="HiddenHintTextBlock"
Text="Tap to reveal meaning"
Foreground="#8A9099"
FontSize="18"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="4" />
</Grid>
</Grid>
</Border>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="Loading..."
Foreground="#6A6F77"
FontSize="14"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,566 @@
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.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.VisualTree;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class DailyWord2x2Widget : 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 = 2;
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 DailyWordSnapshot? _latestSnapshot;
private string _languageCode = "zh-CN";
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
private bool _autoRefreshEnabled = true;
private bool _isNightVisual = true;
private bool _isMeaningVisible;
public DailyWord2x2Widget()
{
InitializeComponent();
WordTextBlock.FontFamily = MiSansFontFamily;
MeaningTextBlock.FontFamily = MiSansFontFamily;
HiddenHintTextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
_refreshTimer.Tick += OnRefreshTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
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 void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
ApplyNightModeVisual();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFBFA"));
WordTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#2B2F35"));
MeaningTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5A6069"));
HiddenHintTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#8A9099"));
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EEF1F4"));
RefreshIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
}
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 void OnCardPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (_latestSnapshot is null || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
if (e.Source is Visual sourceVisual)
{
for (Visual? current = sourceVisual; current is not null; current = current.GetVisualParent())
{
if (ReferenceEquals(current, RefreshButton))
{
return;
}
}
}
_isMeaningVisible = !_isMeaningVisible;
UpdateRevealState();
UpdateAdaptiveLayout();
e.Handled = true;
}
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)
{
_latestSnapshot = snapshot;
WordTextBlock.Text = NormalizeCompactText(snapshot.Word);
MeaningTextBlock.Text = BuildMeaningPreview(snapshot.Meaning);
HiddenHintTextBlock.Text = L("dailyword2x2.widget.tap_to_show", "Tap to reveal meaning");
StatusTextBlock.IsVisible = false;
UpdateRevealState();
UpdateAdaptiveLayout();
}
private void ApplyLoadingState()
{
_latestSnapshot = null;
_isMeaningVisible = false;
WordTextBlock.Text = L("dailyword.widget.loading_word", "daily word");
MeaningTextBlock.Text = L("dailyword.widget.loading_meaning", "Fetching meaning...");
HiddenHintTextBlock.Text = L("dailyword.widget.loading", "Loading...");
StatusTextBlock.Text = L("dailyword.widget.loading", "Loading...");
StatusTextBlock.IsVisible = true;
UpdateRevealState();
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
_latestSnapshot = null;
_isMeaningVisible = false;
WordTextBlock.Text = L("dailyword.widget.fallback_word", "daily word");
MeaningTextBlock.Text = L("dailyword.widget.fallback_meaning", "Youdao dictionary is temporarily unavailable.");
HiddenHintTextBlock.Text = L("dailyword.widget.fetch_failed", "Daily word fetch failed");
StatusTextBlock.Text = L("dailyword.widget.fetch_failed", "Daily word fetch failed");
StatusTextBlock.IsVisible = true;
UpdateRevealState();
UpdateAdaptiveLayout();
}
private void UpdateRevealState()
{
var canShowMeaning = _latestSnapshot is not null && !string.IsNullOrWhiteSpace(MeaningTextBlock.Text);
var showMeaning = _isMeaningVisible && canShowMeaning;
MeaningTextBlock.IsVisible = showMeaning;
HiddenHintTextBlock.IsVisible = !showMeaning;
if (!showMeaning && _latestSnapshot is not null)
{
HiddenHintTextBlock.Text = L("dailyword2x2.widget.tap_to_show", "Tap to reveal meaning");
}
}
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(30 * scale, 14, 40));
CardBorder.CornerRadius = RootBorder.CornerRadius;
CardBorder.Padding = new Thickness(
Math.Clamp(12 * scale, 8, 18),
Math.Clamp(11 * scale, 7, 16),
Math.Clamp(12 * scale, 8, 18),
Math.Clamp(11 * scale, 7, 16));
var refreshSize = Math.Clamp(30 * scale, 20, 38);
RefreshButton.Width = refreshSize;
RefreshButton.Height = refreshSize;
RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d);
RefreshIcon.FontSize = Math.Clamp(14 * scale, 10, 20);
var contentWidth = Math.Max(80, totalWidth - CardBorder.Padding.Left - CardBorder.Padding.Right);
var wordWidth = Math.Max(48, contentWidth - refreshSize - Math.Clamp(6 * scale, 4, 10));
WordTextBlock.MaxWidth = wordWidth;
var contentHeight = Math.Max(52, totalHeight - CardBorder.Padding.Top - CardBorder.Padding.Bottom);
var wordHeightBudget = Math.Max(18, contentHeight * 0.34);
var detailHeightBudget = Math.Max(18, contentHeight - wordHeightBudget - Math.Clamp(8 * scale, 4, 14));
WordTextBlock.FontSize = FitFontSize(
WordTextBlock.Text,
wordWidth,
wordHeightBudget,
maxLines: 1,
minFontSize: Math.Clamp(18 * scale, 12, 22),
maxFontSize: Math.Clamp(38 * scale, 20, 50),
weight: FontWeight.Bold,
lineHeightFactor: 1.02);
WordTextBlock.LineHeight = WordTextBlock.FontSize * 1.02;
var detailFont = FitFontSize(
MeaningTextBlock.IsVisible ? MeaningTextBlock.Text : HiddenHintTextBlock.Text,
contentWidth,
detailHeightBudget,
maxLines: MeaningTextBlock.IsVisible ? 5 : 4,
minFontSize: Math.Clamp(12 * scale, 9, 14),
maxFontSize: Math.Clamp(18 * scale, 12, 22),
weight: FontWeight.SemiBold,
lineHeightFactor: 1.10);
MeaningTextBlock.MaxWidth = contentWidth;
MeaningTextBlock.FontSize = detailFont;
MeaningTextBlock.LineHeight = detailFont * 1.10;
MeaningTextBlock.MaxLines = totalHeight < _currentCellSize * 1.8 ? 4 : 5;
HiddenHintTextBlock.MaxWidth = contentWidth;
HiddenHintTextBlock.FontSize = detailFont;
HiddenHintTextBlock.LineHeight = detailFont * 1.10;
HiddenHintTextBlock.MaxLines = totalHeight < _currentCellSize * 1.8 ? 3 : 4;
StatusTextBlock.FontSize = Math.Clamp(14 * scale, 9, 18);
}
private void UpdateRefreshButtonState()
{
RefreshButton.IsEnabled = !_isRefreshing;
RefreshButton.Opacity = _isRefreshing ? 0.60 : 1.0;
RefreshIcon.Opacity = _isRefreshing ? 0.60 : 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 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 L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private static string BuildMeaningPreview(string? rawMeaning)
{
var normalized = NormalizeCompactText(rawMeaning);
if (string.IsNullOrWhiteSpace(normalized))
{
return "Meaning unavailable";
}
var compact = normalized.Replace("", "; ", StringComparison.Ordinal);
return compact.Length <= 160 ? compact : $"{compact[..160]}...";
}
private static string NormalizeCompactText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
}
private static double FitFontSize(
string? text,
double maxWidth,
double maxHeight,
int maxLines,
double minFontSize,
double maxFontSize,
FontWeight weight,
double lineHeightFactor)
{
var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
var min = Math.Max(6, minFontSize);
var max = Math.Max(min, maxFontSize);
var low = min;
var high = max;
var best = min;
for (var i = 0; i < 18; i++)
{
var candidate = (low + high) / 2d;
var lineHeight = candidate * lineHeightFactor;
var size = MeasureTextSize(content, candidate, weight, Math.Max(1, maxWidth), lineHeight);
var lineCount = Math.Max(1, (int)Math.Ceiling(size.Height / Math.Max(1, lineHeight)));
var fits = size.Height <= maxHeight + 0.6 && lineCount <= Math.Max(1, maxLines);
if (fits)
{
best = candidate;
low = candidate;
}
else
{
high = candidate;
}
}
return best;
}
private static Size MeasureTextSize(string text, double fontSize, FontWeight weight, double maxWidth, double lineHeight)
{
var probe = new TextBlock
{
Text = text,
FontFamily = MiSansFontFamily,
FontSize = fontSize,
FontWeight = weight,
TextWrapping = TextWrapping.Wrap,
LineHeight = lineHeight
};
probe.Measure(new Size(Math.Max(1, maxWidth), double.PositiveInfinity));
return probe.DesiredSize;
}
}

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -6,6 +8,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
@@ -21,13 +24,15 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
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 _settingsService = new();
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private readonly LocalizationService _localizationService = new();
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
@@ -36,6 +41,8 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
private bool _autoRefreshEnabled = true;
private bool _isNightVisual = true;
public DailyWordWidget()
{
@@ -53,9 +60,11 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
ApplyAutoRefreshSettings();
ApplyLoadingState();
UpdateRefreshButtonState();
}
@@ -78,6 +87,7 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
public void RefreshFromSettings()
{
_recommendationService.ClearCache();
ApplyAutoRefreshSettings();
if (_isAttached)
{
_ = RefreshWordAsync(forceRefresh: true);
@@ -87,8 +97,8 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
ApplyAutoRefreshSettings();
UpdateRefreshButtonState();
_refreshTimer.Start();
_ = RefreshWordAsync(forceRefresh: false);
}
@@ -105,6 +115,64 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
ApplyNightModeVisual();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFBFA"));
WordTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#FF9D6C") : Color.Parse("#F07541"));
PronunciationTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#6B7078"));
MeaningTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#2B2F35"));
ExampleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#2B2F35"));
ExampleTranslationTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#7A8088"));
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#14A0A6AF"));
RefreshIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#626870"));
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
}
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
{
if (_isRefreshing)
@@ -222,6 +290,14 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
var isFourByThree = false;
if (Bounds.Width > 1 && Bounds.Height > 1)
{
var widthRatio = Bounds.Width / (_currentCellSize * BaseWidthCells);
var heightRatio = Bounds.Height / (_currentCellSize * BaseHeightCells);
isFourByThree = widthRatio >= 0.9 && heightRatio >= 1.35;
}
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
RootBorder.Padding = new Thickness(0);
@@ -254,15 +330,15 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
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;
MeaningTextBlock.MaxLines = compactLayout ? 1 : (isFourByThree ? 3 : 2);
ExampleTextBlock.MaxLines = compactLayout ? 1 : (isFourByThree ? 4 : 2);
ExampleTranslationTextBlock.IsVisible = !compactLayout || isFourByThree;
ExampleTranslationTextBlock.MaxLines = isFourByThree ? 2 : 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 meaningHeightBudget = Math.Max(16, contentHeight * (compactLayout ? 0.26 : (isFourByThree ? 0.35 : 0.30)));
var exampleHeightBudget = Math.Max(16, contentHeight - wordHeightBudget - pronunciationHeightBudget - meaningHeightBudget - Math.Clamp(16 * scale, 8, 24));
if (!ExampleTranslationTextBlock.IsVisible)
{
@@ -343,7 +419,7 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
{
try
{
var snapshot = _settingsService.Load();
var snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
@@ -352,6 +428,60 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
}
}
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);
@@ -372,11 +502,26 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.0);
var widthCells = BaseWidthCells;
var heightCells = BaseHeightCells;
if (Bounds.Width > 1 && Bounds.Height > 1)
{
var widthRatio = Bounds.Width / (_currentCellSize * widthCells);
var heightRatio = Bounds.Height / (_currentCellSize * heightCells);
if (widthRatio >= 0.9 && heightRatio >= 1.35)
{
heightCells = 3;
}
}
var widthScale = Bounds.Width > 1
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.56, 2.0)
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * widthCells), 0.56, 2.0)
: 1;
var heightScale = Bounds.Height > 1
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.56, 2.0)
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * heightCells), 0.56, 2.0)
: 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.56, 2.0);
}

View File

@@ -240,16 +240,36 @@ public sealed class DesktopComponentRuntimeRegistry
"component.daily_word",
() => new DailyWordWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopDailyWord2x2,
"component.daily_word_2x2",
() => new DailyWord2x2Widget(),
cellSize => Math.Clamp(cellSize * 0.34, 12, 26)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopCnrDailyNews,
"component.cnr_daily_news",
() => new CnrDailyNewsWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopIfengNews,
"component.ifeng_news",
() => new IfengNewsWidget(),
cellSize => Math.Clamp(cellSize * 0.30, 12, 24)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopBilibiliHotSearch,
"component.bilibili_hot_search",
() => new BilibiliHotSearchWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopBaiduHotSearch,
"component.baidu_hot_search",
() => new BaiduHotSearchWidget(),
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",

View File

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

View File

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

View File

@@ -0,0 +1,113 @@
<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="320"
x:Class="LanMountainDesktop.Views.Components.IfengNewsSettingsWindow">
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
Padding="16">
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<TextBlock x:Name="TitleTextBlock"
Text="iFeng news settings"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="DescriptionTextBlock"
Grid.Row="1"
Text="Configure channel, 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="ChannelLabelTextBlock"
Text="News channel"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="ChannelComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnChannelSelectionChanged">
<ComboBoxItem x:Name="ChannelComprehensiveItem"
Tag="Comprehensive"
Content="Comprehensive" />
<ComboBoxItem x:Name="ChannelMainlandItem"
Tag="Mainland"
Content="China Mainland" />
<ComboBoxItem x:Name="ChannelTaiwanItem"
Tag="Taiwan"
Content="Taiwan" />
</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="Frequency15mItem"
Tag="15"
Content="15 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" />
</ComboBox>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,194 @@
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 IfengNewsSettingsWindow : 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 IfengNewsSettingsWindow()
{
InitializeComponent();
InitializeFrequencyOptions();
LoadState();
ApplyLocalization();
}
private void LoadState()
{
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
var channelType = IfengNewsChannelTypes.Normalize(componentSnapshot.IfengNewsChannelType);
var enabled = componentSnapshot.IfengNewsAutoRefreshEnabled;
var interval = NormalizeInterval(componentSnapshot.IfengNewsAutoRefreshIntervalMinutes);
_suppressEvents = true;
SelectChannelType(channelType);
AutoRefreshCheckBox.IsChecked = enabled;
SelectInterval(interval);
FrequencyCardBorder.IsVisible = enabled;
_suppressEvents = false;
}
private void ApplyLocalization()
{
TitleTextBlock.Text = L("ifeng.settings.title", "iFeng news settings");
DescriptionTextBlock.Text = L("ifeng.settings.desc", "Configure channel, auto refresh and refresh interval.");
ChannelLabelTextBlock.Text = L("ifeng.settings.channel_label", "News channel");
ChannelComprehensiveItem.Content = L("ifeng.settings.channel_comprehensive", "Comprehensive");
ChannelMainlandItem.Content = L("ifeng.settings.channel_mainland", "China Mainland");
ChannelTaiwanItem.Content = L("ifeng.settings.channel_taiwan", "Taiwan");
AutoRefreshLabelTextBlock.Text = L("ifeng.settings.auto_refresh_label", "Auto refresh");
AutoRefreshCheckBox.Content = L("ifeng.settings.auto_refresh_enabled", "Enable auto refresh");
FrequencyLabelTextBlock.Text = L("ifeng.settings.frequency_label", "Refresh interval");
ApplyFrequencyLocalization();
}
private void OnChannelSelectionChanged(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.IfengNewsChannelType = GetSelectedChannelType();
snapshot.IfengNewsAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
snapshot.IfengNewsAutoRefreshIntervalMinutes = GetSelectedInterval();
_componentSettingsService.Save(snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
private string GetSelectedChannelType()
{
if (ChannelComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string channelTag)
{
return IfengNewsChannelTypes.Normalize(channelTag);
}
return IfengNewsChannelTypes.Comprehensive;
}
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 SelectChannelType(string channelType)
{
var normalizedChannelType = IfengNewsChannelTypes.Normalize(channelType);
var selected = ChannelComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
item.Tag is string channelTag &&
string.Equals(IfengNewsChannelTypes.Normalize(channelTag), normalizedChannelType, StringComparison.OrdinalIgnoreCase));
ChannelComboBox.SelectedItem = selected ?? ChannelComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
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, 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);
}
}

View File

@@ -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="640"
x:Class="LanMountainDesktop.Views.Components.IfengNewsWidget">
<Border x:Name="RootBorder"
CornerRadius="32"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="32"
BorderBrush="Transparent"
BorderThickness="0"
Padding="14,14,14,14">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="8">
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBlock x:Name="BrandTextBlock"
Text="凤凰网新闻"
Foreground="#E24B2D"
FontSize="28"
FontWeight="Bold"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button x:Name="RefreshButton"
Grid.Column="1"
Width="36"
Height="36"
CornerRadius="18"
Background="#EFF1F5"
BorderBrush="Transparent"
BorderThickness="0"
Padding="0"
Focusable="False"
ToolTip.Tip="刷新"
Click="OnRefreshButtonClick">
<fi:SymbolIcon x:Name="RefreshGlyphIcon"
Symbol="ArrowClockwise"
IconVariant="Regular"
Foreground="#5E6671"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</Grid>
<Border x:Name="NewsItem1Host"
Grid.Row="1"
Tag="0"
Background="Transparent"
Padding="0,2"
PointerPressed="OnNewsItemPointerPressed">
<Grid x:Name="NewsItem1Grid"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBlock x:Name="NewsItem1TextBlock"
Text="新闻标题"
Foreground="#202327"
FontSize="22"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top" />
<Border x:Name="NewsItem1ImageHost"
Grid.Column="1"
Width="148"
Height="84"
CornerRadius="12"
ClipToBounds="True"
Background="#E6E8EC">
<Image x:Name="NewsItem1Image"
Stretch="UniformToFill" />
</Border>
</Grid>
</Border>
<Border x:Name="NewsItem2Host"
Grid.Row="2"
Tag="1"
Background="Transparent"
Padding="0,2"
PointerPressed="OnNewsItemPointerPressed">
<Grid x:Name="NewsItem2Grid"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBlock x:Name="NewsItem2TextBlock"
Text="新闻标题"
Foreground="#202327"
FontSize="22"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top" />
<Border x:Name="NewsItem2ImageHost"
Grid.Column="1"
Width="148"
Height="84"
CornerRadius="12"
ClipToBounds="True"
Background="#E6E8EC">
<Image x:Name="NewsItem2Image"
Stretch="UniformToFill" />
</Border>
</Grid>
</Border>
<Border x:Name="NewsItem3Host"
Grid.Row="3"
Tag="2"
Background="Transparent"
Padding="0,2"
PointerPressed="OnNewsItemPointerPressed">
<Grid x:Name="NewsItem3Grid"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBlock x:Name="NewsItem3TextBlock"
Text="新闻标题"
Foreground="#202327"
FontSize="22"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top" />
<Border x:Name="NewsItem3ImageHost"
Grid.Column="1"
Width="148"
Height="84"
CornerRadius="12"
ClipToBounds="True"
Background="#E6E8EC">
<Image x:Name="NewsItem3Image"
Stretch="UniformToFill" />
</Border>
</Grid>
</Border>
<Border x:Name="NewsItem4Host"
Grid.Row="4"
Tag="3"
Background="Transparent"
Padding="0,2"
PointerPressed="OnNewsItemPointerPressed">
<Grid x:Name="NewsItem4Grid"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBlock x:Name="NewsItem4TextBlock"
Text="新闻标题"
Foreground="#202327"
FontSize="22"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top" />
<Border x:Name="NewsItem4ImageHost"
Grid.Column="1"
Width="148"
Height="84"
CornerRadius="12"
ClipToBounds="True"
Background="#E6E8EC">
<Image x:Name="NewsItem4Image"
Stretch="UniformToFill" />
</Border>
</Grid>
</Border>
</Grid>
</Border>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="Loading"
Foreground="#6A6F77"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,712 @@
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.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class IfengNewsWidget : 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 = 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<DailyNewsItemSnapshot> _activeItems = [];
private readonly List<NewsItemVisual> _itemVisuals = [];
private readonly Bitmap?[] _newsBitmaps = new Bitmap?[MaxDisplayItemCount];
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
private CancellationTokenSource? _refreshCts;
private string _languageCode = "zh-CN";
private string _channelType = IfengNewsChannelTypes.Comprehensive;
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
private bool _autoRefreshEnabled = true;
private bool _isNightVisual = true;
private sealed record NewsItemVisual(
Border Host,
Grid RowGrid,
TextBlock TitleTextBlock,
Border ImageHost,
Image ImageControl);
public IfengNewsWidget()
{
InitializeComponent();
BrandTextBlock.FontFamily = MiSansFontFamily;
NewsItem1TextBlock.FontFamily = MiSansFontFamily;
NewsItem2TextBlock.FontFamily = MiSansFontFamily;
NewsItem3TextBlock.FontFamily = MiSansFontFamily;
NewsItem4TextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
_itemVisuals.Add(new NewsItemVisual(NewsItem1Host, NewsItem1Grid, NewsItem1TextBlock, NewsItem1ImageHost, NewsItem1Image));
_itemVisuals.Add(new NewsItemVisual(NewsItem2Host, NewsItem2Grid, NewsItem2TextBlock, NewsItem2ImageHost, NewsItem2Image));
_itemVisuals.Add(new NewsItemVisual(NewsItem3Host, NewsItem3Grid, NewsItem3TextBlock, NewsItem3ImageHost, NewsItem3Image));
_itemVisuals.Add(new NewsItemVisual(NewsItem4Host, NewsItem4Grid, NewsItem4TextBlock, NewsItem4ImageHost, NewsItem4Image));
_refreshTimer.Tick += OnRefreshTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
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)
{
_ = RefreshNewsAsync(forceRefresh: false);
}
}
public void RefreshFromSettings()
{
_recommendationService.ClearCache();
ApplyAutoRefreshSettings();
if (_isAttached)
{
_ = RefreshNewsAsync(forceRefresh: true);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
ApplyAutoRefreshSettings();
UpdateRefreshButtonState();
_ = RefreshNewsAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_refreshTimer.Stop();
CancelRefreshRequest();
DisposeNewsBitmaps();
UpdateRefreshButtonState();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
UpdateAdaptiveLayout();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
foreach (var visual in _itemVisuals)
{
visual.Host.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F7F8FA"));
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
}
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshNewsAsync(forceRefresh: true);
}
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
{
_ = sender;
await RefreshNewsAsync(forceRefresh: true);
e.Handled = true;
}
private void OnNewsItemPointerPressed(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 RefreshNewsAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateLanguageCode();
UpdateRefreshButtonState();
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new IfengNewsQuery(
Locale: _languageCode,
ItemCount: MaxDisplayItemCount,
ChannelType: _channelType,
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetIfengNewsAsync(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)
{
BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻");
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
_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("ifeng.widget.fallback_item", "暂无新闻");
for (var i = 0; i < _itemVisuals.Count; i++)
{
var visual = _itemVisuals[i];
visual.Host.IsVisible = true;
visual.TitleTextBlock.Text = i < _activeItems.Count
? NormalizeCompactText(_activeItems[i].Title)
: fallbackText;
SetNewsBitmap(i, null);
}
StatusTextBlock.IsVisible = false;
UpdateInteractionState();
UpdateAdaptiveLayout();
var tasks = Enumerable.Range(0, MaxDisplayItemCount)
.Select(index => TryDownloadBitmapAsync(
index < _activeItems.Count ? _activeItems[index].ImageUrl : null,
cancellationToken))
.ToArray();
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++)
{
SetNewsBitmap(i, bitmaps[i]);
}
}
private void ApplyLoadingState()
{
BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻");
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
_activeItems.Clear();
var loadingText = L("ifeng.widget.loading_item", "加载中...");
for (var i = 0; i < _itemVisuals.Count; i++)
{
var visual = _itemVisuals[i];
visual.Host.IsVisible = true;
visual.TitleTextBlock.Text = loadingText;
SetNewsBitmap(i, null);
}
StatusTextBlock.Text = L("ifeng.widget.loading", "加载中...");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻");
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
_activeItems.Clear();
var fallbackText = L("ifeng.widget.fallback_item", "暂无新闻");
for (var i = 0; i < _itemVisuals.Count; i++)
{
var visual = _itemVisuals[i];
visual.Host.IsVisible = true;
visual.TitleTextBlock.Text = fallbackText;
SetNewsBitmap(i, null);
}
StatusTextBlock.Text = L("ifeng.widget.fetch_failed", "新闻获取失败");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void UpdateAdaptiveLayout()
{
var scale = ResolveScale();
var softScale = Math.Clamp(scale, 0.80, 1.32);
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(32 * softScale, 16, 46));
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(32 * softScale, 16, 46));
var horizontalPadding = Math.Clamp(14 * softScale, 8, 20);
var verticalPadding = Math.Clamp(14 * softScale, 8, 20);
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
var rowSpacing = Math.Clamp(8 * softScale, 4, 12);
ContentGrid.RowSpacing = rowSpacing;
HeaderGrid.ColumnSpacing = Math.Clamp(10 * softScale, 6, 16);
var innerWidth = Math.Max(150, totalWidth - horizontalPadding * 2d);
var innerHeight = Math.Max(160, totalHeight - verticalPadding * 2d);
var availableRowsHeight = Math.Max(120, innerHeight - rowSpacing * 4d);
var headerHeight = Math.Clamp(availableRowsHeight * 0.16, 24, 54);
var itemHeight = Math.Max(32, (availableRowsHeight - headerHeight) / 4d);
if (ContentGrid.RowDefinitions.Count >= 5)
{
ContentGrid.RowDefinitions[0].Height = new GridLength(headerHeight);
for (var i = 1; i <= 4; i++)
{
ContentGrid.RowDefinitions[i].Height = new GridLength(itemHeight);
}
}
BrandTextBlock.FontSize = Math.Clamp(headerHeight * 0.62, 14, 30);
var refreshSize = Math.Clamp(headerHeight * 0.84, 22, 44);
RefreshButton.Width = refreshSize;
RefreshButton.Height = refreshSize;
RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d);
RefreshGlyphIcon.FontSize = Math.Clamp(refreshSize * 0.44, 10, 20);
var imageWidth = Math.Clamp(innerWidth * 0.27, 82, 176);
var imageHeight = Math.Clamp(imageWidth * 0.56, 46, 98);
var columnGap = Math.Clamp(itemHeight * 0.20, 6, 14);
var rowPadding = Math.Clamp(itemHeight * 0.08, 1, 5);
var textWidth = Math.Max(84, innerWidth - imageWidth - columnGap);
var titleFont = Math.Clamp(itemHeight * 0.32, 12, 24);
foreach (var visual in _itemVisuals)
{
visual.Host.Padding = new Thickness(0, rowPadding, 0, rowPadding);
visual.RowGrid.ColumnSpacing = columnGap;
if (visual.RowGrid.ColumnDefinitions.Count > 1)
{
visual.RowGrid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
}
visual.ImageHost.Width = imageWidth;
visual.ImageHost.Height = imageHeight;
visual.ImageHost.CornerRadius = new CornerRadius(Math.Clamp(imageHeight * 0.15, 8, 16));
visual.TitleTextBlock.MaxWidth = textWidth;
visual.TitleTextBlock.FontSize = titleFont;
visual.TitleTextBlock.LineHeight = titleFont * 1.12;
visual.TitleTextBlock.MinHeight = visual.TitleTextBlock.LineHeight * 2;
visual.TitleTextBlock.MaxLines = 2;
}
StatusTextBlock.FontSize = Math.Clamp(titleFont, 10, 20);
ApplyNightModeVisual();
}
private void UpdateInteractionState()
{
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.68;
visual.Host.Cursor = enabled
? new Cursor(StandardCursorType.Hand)
: new Cursor(StandardCursorType.Arrow);
}
}
private void UpdateRefreshButtonState()
{
var enabled = _isAttached && !_isRefreshing;
RefreshButton.IsEnabled = enabled;
RefreshButton.Opacity = enabled ? 1.0 : 0.65;
}
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;
var channelType = IfengNewsChannelTypes.Comprehensive;
try
{
var snapshot = _componentSettingsService.Load();
enabled = snapshot.IfengNewsAutoRefreshEnabled;
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.IfengNewsAutoRefreshIntervalMinutes);
channelType = IfengNewsChannelTypes.Normalize(snapshot.IfengNewsChannelType);
}
catch
{
// Keep fallback defaults.
}
_autoRefreshEnabled = enabled;
_channelType = channelType;
_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 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 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 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 visual = _itemVisuals[index];
var oldBitmap = _newsBitmaps[index];
if (ReferenceEquals(visual.ImageControl.Source, oldBitmap))
{
visual.ImageControl.Source = null;
}
oldBitmap?.Dispose();
_newsBitmaps[index] = bitmap;
visual.ImageControl.Source = bitmap;
}
private void DisposeNewsBitmaps()
{
for (var i = 0; i < _newsBitmaps.Length; i++)
{
SetNewsBitmap(i, null);
}
}
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.4);
}
private static string NormalizeCompactText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
}
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();
}
}

View File

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

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
@@ -8,13 +8,14 @@ using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IDisposable
{
private const int WaveBarCount = 22;
@@ -36,6 +37,8 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDe
private bool _isAttached;
private bool _isOnActivePage = true;
private bool _pausedStudyMonitoringForRecording;
private bool _isNightVisual = true;
private bool _isDisposed;
public RecordingWidget()
{
@@ -45,6 +48,7 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDe
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
InitializeWaveBars();
ReloadLanguageCode();
@@ -146,6 +150,68 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDe
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
ApplyNightModeVisual();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
RootBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#ECEFF3"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#D9DEE7"));
TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#11151D"));
TimerTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#A4A9B2"));
FutureLine.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#A3A8B3"));
DiscardButtonBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F8FAFD"));
DiscardButtonBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4451") : Color.Parse("#E0E5EC"));
DiscardIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#141922"));
SaveButtonBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F8FAFD"));
SaveButtonBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4451") : Color.Parse("#E0E5EC"));
SaveIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#141922"));
HintTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#7A818E"));
}
private void OnUiTick(object? sender, EventArgs e)
{
if (!_isAttached || !_isOnActivePage)
@@ -291,11 +357,18 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDe
SaveButtonBorder.Opacity = SaveButtonBorder.IsHitTestVisible ? 1 : 0.42;
RecordToggleButtonBorder.Opacity = RecordToggleButtonBorder.IsHitTestVisible ? 1 : 0.54;
TimerTextBlock.Foreground = CreateBrush(!isSupported
? "#B2B7C0"
: isReady
? "#A4A9B2"
: "#151922");
if (!isSupported)
{
TimerTextBlock.Foreground = CreateBrush(_isNightVisual ? "#A8B1C2" : "#B2B7C0");
}
else if (isReady)
{
TimerTextBlock.Foreground = CreateBrush(_isNightVisual ? "#A8B1C2" : "#A4A9B2");
}
else
{
TimerTextBlock.Foreground = CreateBrush(_isNightVisual ? "#E8EAED" : "#151922");
}
HintTextBlock.IsVisible = !isReady || !isSupported;
RecordDot.IsVisible = snapshot.State == AudioRecorderRuntimeState.Ready;
@@ -540,4 +613,23 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDe
return (false, path.LocalPath);
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
_uiTimer.Stop();
_uiTimer.Tick -= OnUiTick;
AttachedToVisualTree -= OnAttachedToVisualTree;
DetachedFromVisualTree -= OnDetachedFromVisualTree;
SizeChanged -= OnSizeChanged;
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
_audioRecorderService.Dispose();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,410 @@
<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,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>
<Border x:Name="PostItem5Host"
Grid.Row="5"
Tag="4"
Background="#F7F8FA"
CornerRadius="10"
Padding="8,6"
PointerPressed="OnPostItemPointerPressed">
<Grid x:Name="PostItem5Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<Border x:Name="PostItem5AvatarHost"
Width="30"
Height="30"
CornerRadius="15"
Background="#E7EBF4"
ClipToBounds="True">
<Grid>
<Image x:Name="PostItem5AvatarImage"
Stretch="UniformToFill" />
<TextBlock x:Name="PostItem5AvatarFallbackText"
Text="?"
Foreground="#4A5466"
FontSize="13"
FontWeight="SemiBold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
<TextBlock x:Name="PostItem5TitleTextBlock"
Grid.Column="1"
Text="Loading..."
Foreground="#202327"
FontSize="14"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="PostItem6Host"
Grid.Row="6"
Tag="5"
Background="#F7F8FA"
CornerRadius="10"
Padding="8,6"
PointerPressed="OnPostItemPointerPressed">
<Grid x:Name="PostItem6Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<Border x:Name="PostItem6AvatarHost"
Width="30"
Height="30"
CornerRadius="15"
Background="#E7EBF4"
ClipToBounds="True">
<Grid>
<Image x:Name="PostItem6AvatarImage"
Stretch="UniformToFill" />
<TextBlock x:Name="PostItem6AvatarFallbackText"
Text="?"
Foreground="#4A5466"
FontSize="13"
FontWeight="SemiBold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
<TextBlock x:Name="PostItem6TitleTextBlock"
Grid.Column="1"
Text="Loading..."
Foreground="#202327"
FontSize="14"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="PostItem7Host"
Grid.Row="7"
Tag="6"
Background="#F7F8FA"
CornerRadius="10"
Padding="8,6"
PointerPressed="OnPostItemPointerPressed">
<Grid x:Name="PostItem7Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<Border x:Name="PostItem7AvatarHost"
Width="30"
Height="30"
CornerRadius="15"
Background="#E7EBF4"
ClipToBounds="True">
<Grid>
<Image x:Name="PostItem7AvatarImage"
Stretch="UniformToFill" />
<TextBlock x:Name="PostItem7AvatarFallbackText"
Text="?"
Foreground="#4A5466"
FontSize="13"
FontWeight="SemiBold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
<TextBlock x:Name="PostItem7TitleTextBlock"
Grid.Column="1"
Text="Loading..."
Foreground="#202327"
FontSize="14"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="PostItem8Host"
Grid.Row="8"
Tag="7"
Background="#F7F8FA"
CornerRadius="10"
Padding="8,6"
PointerPressed="OnPostItemPointerPressed">
<Grid x:Name="PostItem8Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<Border x:Name="PostItem8AvatarHost"
Width="30"
Height="30"
CornerRadius="15"
Background="#E7EBF4"
ClipToBounds="True">
<Grid>
<Image x:Name="PostItem8AvatarImage"
Stretch="UniformToFill" />
<TextBlock x:Name="PostItem8AvatarFallbackText"
Text="?"
Foreground="#4A5466"
FontSize="13"
FontWeight="SemiBold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
<TextBlock x:Name="PostItem8TitleTextBlock"
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>

View File

@@ -0,0 +1,853 @@
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.Styling;
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 BaseDisplayItemCount = 4;
private const int MaxDisplayItemCount = 8;
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 int _visibleItemCount = BaseDisplayItemCount;
private bool _isAttached;
private bool _isRefreshing;
private bool _autoRefreshEnabled = true;
private bool _isNightVisual = 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;
PostItem5TitleTextBlock.FontFamily = MiSansFontFamily;
PostItem6TitleTextBlock.FontFamily = MiSansFontFamily;
PostItem7TitleTextBlock.FontFamily = MiSansFontFamily;
PostItem8TitleTextBlock.FontFamily = MiSansFontFamily;
PostItem1AvatarFallbackText.FontFamily = MiSansFontFamily;
PostItem2AvatarFallbackText.FontFamily = MiSansFontFamily;
PostItem3AvatarFallbackText.FontFamily = MiSansFontFamily;
PostItem4AvatarFallbackText.FontFamily = MiSansFontFamily;
PostItem5AvatarFallbackText.FontFamily = MiSansFontFamily;
PostItem6AvatarFallbackText.FontFamily = MiSansFontFamily;
PostItem7AvatarFallbackText.FontFamily = MiSansFontFamily;
PostItem8AvatarFallbackText.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));
_itemVisuals.Add(new ForumItemVisual(
PostItem5Host,
PostItem5Grid,
PostItem5AvatarHost,
PostItem5AvatarImage,
PostItem5AvatarFallbackText,
PostItem5TitleTextBlock));
_itemVisuals.Add(new ForumItemVisual(
PostItem6Host,
PostItem6Grid,
PostItem6AvatarHost,
PostItem6AvatarImage,
PostItem6AvatarFallbackText,
PostItem6TitleTextBlock));
_itemVisuals.Add(new ForumItemVisual(
PostItem7Host,
PostItem7Grid,
PostItem7AvatarHost,
PostItem7AvatarImage,
PostItem7AvatarFallbackText,
PostItem7TitleTextBlock));
_itemVisuals.Add(new ForumItemVisual(
PostItem8Host,
PostItem8Grid,
PostItem8AvatarHost,
PostItem8AvatarImage,
PostItem8AvatarFallbackText,
PostItem8TitleTextBlock));
_refreshTimer.Tick += OnRefreshTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
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 void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
UpdateAdaptiveLayout();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
HeaderTitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
HeaderDot.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#FF6B6B") : Color.Parse("#FF4D4F"));
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
foreach (var visual in _itemVisuals)
{
visual.Host.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F7F8FA"));
visual.AvatarHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4451") : Color.Parse("#E7EBF4"));
visual.AvatarFallbackText.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#4A5466"));
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
}
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
}
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: _visibleItemCount,
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 >= _visibleItemCount)
{
break;
}
}
var fallbackItemText = L("stcn24.widget.fallback_item", "暂无帖子");
for (var i = 0; i < _itemVisuals.Count; i++)
{
var visual = _itemVisuals[i];
var isRowVisible = i < _visibleItemCount;
visual.Host.IsVisible = isRowVisible;
if (!isRowVisible)
{
SetAvatarBitmap(i, null);
continue;
}
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
.Take(_visibleItemCount)
.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];
var isRowVisible = i < _visibleItemCount;
visual.Host.IsVisible = isRowVisible;
if (!isRowVisible)
{
SetAvatarBitmap(i, null);
continue;
}
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];
var isRowVisible = i < _visibleItemCount;
visual.Host.IsVisible = isRowVisible;
if (!isRowVisible)
{
SetAvatarBitmap(i, null);
continue;
}
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 inVisibleRange = i < _visibleItemCount;
visual.Host.IsVisible = inVisibleRange;
var enabled = inVisibleRange &&
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);
var estimatedHeaderHeight = Math.Max(
Math.Clamp(20 * softScale, 12, 28) + Math.Clamp(4 * softScale, 2, 8),
Math.Clamp(34 * softScale, 22, 42));
var estimatedRowHeight = avatarSize + (rowPaddingVertical * 2d);
var availablePostsHeight = Math.Max(
0d,
totalHeight -
CardBorder.Padding.Top -
CardBorder.Padding.Bottom -
estimatedHeaderHeight -
rowSpacing);
var rowFootprint = Math.Max(1d, estimatedRowHeight + rowSpacing);
var capacityByHeight = (int)Math.Floor((availablePostsHeight + rowSpacing) / rowFootprint);
var resolvedItemCount = Math.Clamp(capacityByHeight, BaseDisplayItemCount, MaxDisplayItemCount);
if (scale < 1.08d)
{
resolvedItemCount = Math.Min(resolvedItemCount, BaseDisplayItemCount);
}
var previousVisibleItemCount = _visibleItemCount;
_visibleItemCount = resolvedItemCount;
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);
ApplyNightModeVisual();
if (_visibleItemCount != previousVisibleItemCount &&
_isAttached &&
!_isRefreshing &&
_activeItems.Count < _visibleItemCount)
{
_ = RefreshPostsAsync(forceRefresh: false);
}
}
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();
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
@@ -9,11 +9,12 @@ using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IDisposable
{
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()
{
@@ -26,6 +27,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
private string _languageCode = "zh-CN";
private bool _isAttached;
private bool _isOnActivePage = true;
private bool _isDisposed;
private IDisposable? _monitoringLease;
public StudyEnvironmentWidget()
@@ -127,10 +129,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;
@@ -327,4 +330,23 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
return CreateBrush(fallbackHex);
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
_uiTimer.Stop();
_uiTimer.Tick -= OnUiTimerTick;
AttachedToVisualTree -= OnAttachedToVisualTree;
DetachedFromVisualTree -= OnDetachedFromVisualTree;
SizeChanged -= OnSizeChanged;
_monitoringLease?.Dispose();
_monitoringLease = null;
}
}

View File

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

View File

@@ -14,10 +14,10 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
private static readonly Pen GridPen = new(GridBrush, 1);
private static readonly Pen AxisPen = new(AxisBrush, 1.1);
private static readonly IBrush QuietPointBrush = new SolidColorBrush(Color.Parse("#FF34D399"));
private static readonly IBrush NormalPointBrush = new SolidColorBrush(Color.Parse("#FF60A5FA"));
private static readonly IBrush NoisyPointBrush = new SolidColorBrush(Color.Parse("#FFF59E0B"));
private static readonly IBrush ExtremePointBrush = new SolidColorBrush(Color.Parse("#FFEF4444"));
private static readonly IBrush QuietBrush = new SolidColorBrush(Color.Parse("#FF34D399"));
private static readonly IBrush NormalBrush = new SolidColorBrush(Color.Parse("#FF60A5FA"));
private static readonly IBrush NoisyBrush = new SolidColorBrush(Color.Parse("#FFF59E0B"));
private static readonly IBrush ExtremeBrush = new SolidColorBrush(Color.Parse("#FFEF4444"));
private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
private double _baselineDb = 45;
@@ -47,34 +47,102 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
DrawGrid(context, plot);
if (_points.Count == 0)
if (_points.Count < 2)
{
return;
}
DrawElectronCloud(context, plot);
}
private void DrawElectronCloud(DrawingContext context, Rect plot)
{
var start = _points[0].Timestamp;
var end = _points[^1].Timestamp;
var totalTicks = Math.Max(1, (end - start).Ticks);
var maxRenderPoints = Math.Clamp((int)Math.Floor(plot.Width * 1.5), 80, 520);
var step = Math.Max(1, _points.Count / Math.Max(1, maxRenderPoints));
var radius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 88d, 1.4, 3.8);
for (var i = 0; i < _points.Count; i += step)
var pointCount = _points.Count;
var cloudLayers = 8;
var baseRadius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 45d, 3, 12);
var sortedPoints = new List<(double X, double Y, NoiseDistributionLevel Level)>();
for (var i = 0; i < pointCount; i++)
{
var point = _points[i];
var level = ResolveLevel(point.DisplayDb, _baselineDb);
var x = MapX(plot, point.Timestamp, start, totalTicks);
var y = MapY(plot, level, point.Timestamp);
context.DrawEllipse(GetLevelBrush(level), pen: null, center: new Point(x, y), radiusX: radius, radiusY: radius);
var y = MapYContinuous(plot, point.DisplayDb);
var level = ResolveLevel(point.DisplayDb, _baselineDb);
sortedPoints.Add((x, y, level));
}
sortedPoints.Sort((a, b) => a.X.CompareTo(b.X));
for (var layer = cloudLayers - 1; layer >= 0; layer--)
{
var layerRatio = (double)layer / (cloudLayers - 1);
var layerRadius = baseRadius * (1.2 + layerRatio * 0.8);
var layerAlpha = (byte)(40 + layerRatio * 25);
foreach (var pt in sortedPoints)
{
var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha);
var jitterX = ComputeJitter(pt.X * 1000 + layer) * layerRadius * 0.3;
var jitterY = ComputeJitter(pt.Y * 1000 + layer) * layerRadius * 0.3;
context.DrawEllipse(
brush,
pen: null,
center: new Point(pt.X + jitterX, pt.Y + jitterY),
radiusX: layerRadius,
radiusY: layerRadius * 0.7);
}
}
var glowLayers = 5;
for (var layer = glowLayers - 1; layer >= 0; layer--)
{
var layerRatio = (double)layer / (glowLayers - 1);
var layerRadius = baseRadius * (0.8 + layerRatio * 0.6);
var layerAlpha = (byte)(20 + layerRatio * 15);
foreach (var pt in sortedPoints)
{
var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha);
context.DrawEllipse(
brush,
pen: null,
center: new Point(pt.X, pt.Y),
radiusX: layerRadius,
radiusY: layerRadius * 0.6);
}
}
// Ensure latest point is always visible.
var latest = _points[^1];
var latestLevel = ResolveLevel(latest.DisplayDb, _baselineDb);
var latestX = MapX(plot, latest.Timestamp, start, totalTicks);
var latestY = MapY(plot, latestLevel, latest.Timestamp);
context.DrawEllipse(GetLevelBrush(latestLevel), pen: new Pen(Brushes.White, 1), center: new Point(latestX, latestY), radiusX: radius + 0.8, radiusY: radius + 0.8);
var latestY = MapYContinuous(plot, latest.DisplayDb);
var latestLevel = ResolveLevel(latest.DisplayDb, _baselineDb);
for (var i = 3; i >= 0; i--)
{
var radius = baseRadius * (1.5 + i * 0.8);
var alpha = (byte)(30 - i * 6);
var glowBrush = GetLevelBrushWithAlpha(latestLevel, alpha);
context.DrawEllipse(glowBrush, null, new Point(latestX, latestY), radius, radius * 0.6);
}
context.DrawEllipse(
GetLevelBrush(latestLevel),
new Pen(Brushes.White, 1.5),
new Point(latestX, latestY),
baseRadius + 1,
baseRadius * 0.7 + 1);
context.DrawEllipse(
Brushes.White,
null,
new Point(latestX, latestY),
2,
2);
}
private static void DrawGrid(DrawingContext context, Rect plot)
@@ -103,34 +171,28 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
return plot.Left + plot.Width * (offsetTicks / (double)totalTicks);
}
private static double MapY(Rect plot, NoiseDistributionLevel level, DateTimeOffset timestamp)
private double MapYContinuous(Rect plot, double displayDb)
{
// 4 bands: quiet(bottom) -> extreme(top). Add deterministic jitter in each band.
var bandHeight = plot.Height / 4d;
var levelIndex = level switch
{
NoiseDistributionLevel.Quiet => 0,
NoiseDistributionLevel.Normal => 1,
NoiseDistributionLevel.Noisy => 2,
NoiseDistributionLevel.Extreme => 3,
_ => 1
};
var minDb = _baselineDb - 5;
var maxDb = _baselineDb + 25;
var dbRange = maxDb - minDb;
if (dbRange <= 0) dbRange = 30;
var centerY = plot.Bottom - ((levelIndex + 0.5) * bandHeight);
var jitter = ComputeJitter(timestamp.Ticks) * bandHeight * 0.26;
return Math.Clamp(centerY + jitter, plot.Top + 1.5, plot.Bottom - 1.5);
var normalizedDb = (displayDb - minDb) / dbRange;
normalizedDb = Math.Clamp(normalizedDb, 0, 1);
return plot.Bottom - (normalizedDb * plot.Height);
}
private static double ComputeJitter(long ticks)
private static double ComputeJitter(double value)
{
// Deterministic pseudo-random value in [-1, 1] to avoid overlap without animation noise.
var value = (ulong)ticks;
value ^= value >> 33;
value *= 0xff51afd7ed558ccdUL;
value ^= value >> 33;
value *= 0xc4ceb9fe1a85ec53UL;
value ^= value >> 33;
var normalized = (value & 0xFFFF) / 65535d;
var hash = (ulong)(value * 1000000);
hash ^= hash >> 33;
hash *= 0xff51afd7ed558ccdUL;
hash ^= hash >> 33;
hash *= 0xc4ceb9fe1a85ec53UL;
hash ^= hash >> 33;
var normalized = (hash & 0xFFFF) / 65535d;
return (normalized * 2d) - 1d;
}
@@ -162,11 +224,23 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
{
return level switch
{
NoiseDistributionLevel.Quiet => QuietPointBrush,
NoiseDistributionLevel.Normal => NormalPointBrush,
NoiseDistributionLevel.Noisy => NoisyPointBrush,
NoiseDistributionLevel.Extreme => ExtremePointBrush,
_ => NormalPointBrush
NoiseDistributionLevel.Quiet => QuietBrush,
NoiseDistributionLevel.Normal => NormalBrush,
NoiseDistributionLevel.Noisy => NoisyBrush,
NoiseDistributionLevel.Extreme => ExtremeBrush,
_ => NormalBrush
};
}
private static IBrush GetLevelBrushWithAlpha(NoiseDistributionLevel level, byte alpha)
{
return level switch
{
NoiseDistributionLevel.Quiet => new SolidColorBrush(Color.FromArgb(alpha, 0x34, 0xD3, 0x99)),
NoiseDistributionLevel.Normal => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA)),
NoiseDistributionLevel.Noisy => new SolidColorBrush(Color.FromArgb(alpha, 0xF5, 0x9E, 0x0B)),
NoiseDistributionLevel.Extreme => new SolidColorBrush(Color.FromArgb(alpha, 0xEF, 0x44, 0x44)),
_ => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA))
};
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -12,7 +12,7 @@ using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Views.Components;
public partial class StudyNoiseDistributionWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
public partial class StudyNoiseDistributionWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IDisposable
{
private static readonly Color[] ValueColorCandidates =
{
@@ -46,13 +46,14 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
private readonly LocalizationService _localizationService = new();
private readonly DispatcherTimer _uiTimer = new()
{
Interval = TimeSpan.FromMilliseconds(250)
Interval = TimeSpan.FromMilliseconds(100)
};
private double _currentCellSize = 48;
private string _languageCode = "zh-CN";
private bool _isAttached;
private bool _isOnActivePage = true;
private bool _isDisposed;
private bool _isCompactMode;
private bool _isUltraCompactMode;
private IDisposable? _monitoringLease;
@@ -604,6 +605,25 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
{
return _localizationService.GetString(_languageCode, key, fallback);
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
_uiTimer.Stop();
_uiTimer.Tick -= OnUiTimerTick;
AttachedToVisualTree -= OnAttachedToVisualTree;
DetachedFromVisualTree -= OnDetachedFromVisualTree;
SizeChanged -= OnSizeChanged;
_monitoringLease?.Dispose();
_monitoringLease = null;
}
}

View File

@@ -11,7 +11,7 @@ using Material.Icons;
namespace LanMountainDesktop.Views.Components;
public partial class StudySessionControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
public partial class StudySessionControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IDisposable
{
private static readonly Color[] PrimaryColorCandidates =
{
@@ -61,6 +61,7 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
private string _languageCode = "zh-CN";
private bool _isAttached;
private bool _isOnActivePage = true;
private bool _isDisposed;
private bool _isCompactMode;
private bool _isUltraCompactMode;
private IDisposable? _monitoringLease;
@@ -468,4 +469,20 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW
{
return _localizationService.GetString(_languageCode, key, fallback);
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
_uiTimer.Stop();
_uiTimer.Tick -= OnUiTimerTick;
AttachedToVisualTree -= OnAttachedToVisualTree;
DetachedFromVisualTree -= OnDetachedFromVisualTree;
SizeChanged -= OnSizeChanged;
}
}

View File

@@ -1,5 +1,7 @@
using System;
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;
@@ -64,12 +69,14 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
InitializeDialIfNeeded();
InitializeHandsIfNeeded();
ApplyCellSize(_currentCellSize);
ApplyDefaultWeatherIcon();
UpdateClockVisual();
ApplyAutoRefreshSettings();
}
public void SetTimeZoneService(TimeZoneService timeZoneService)
@@ -100,6 +107,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 +219,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);
}
@@ -222,6 +239,12 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightModeApplied = null;
ApplyModeVisualIfNeeded();
}
private void OnClockTimerTick(object? sender, EventArgs e)
{
UpdateClockVisual();
@@ -629,6 +652,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);

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Services;
@@ -81,6 +82,8 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
public required TextBlock OffsetTextBlock { get; init; }
public bool? IsNightApplied { get; set; }
public bool? IsSystemNightApplied { get; set; }
}
private readonly DispatcherTimer _clockTimer = new()
@@ -88,7 +91,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];
@@ -98,6 +102,7 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
private double _currentCellSize = BaseCellSize;
private DateTime _nextLanguageProbeUtc = DateTime.MinValue;
private string _secondHandMode = ClockSecondHandMode.Tick;
private bool _isNightVisual = true;
public WorldClockWidget()
{
@@ -113,6 +118,7 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
}
public void SetTimeZoneService(TimeZoneService timeZoneService)
@@ -210,6 +216,79 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_ = sender;
_ = e;
_isNightVisual = ResolveNightMode();
ApplyNightModeVisual();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
RootBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#F4F5F7"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#16000000"));
foreach (var entry in _entryVisuals)
{
ApplyTextThemeForSystemNight(entry, _isNightVisual);
}
}
private void ApplyTextThemeForSystemNight(ClockEntryVisual entry, bool isSystemNight)
{
if (entry.IsSystemNightApplied.HasValue && entry.IsSystemNightApplied.Value == isSystemNight)
{
return;
}
entry.IsSystemNightApplied = isSystemNight;
var cityForeground = isSystemNight ? "#E8EAED" : "#20232A";
var dayForeground = isSystemNight ? "#A8B1C2" : "#646C79";
var offsetForeground = isSystemNight ? "#A8B1C2" : "#7A7F89";
entry.CityTextBlock.Foreground = CreateBrush(cityForeground);
entry.DayTextBlock.Foreground = CreateBrush(dayForeground);
entry.OffsetTextBlock.Foreground = CreateBrush(offsetForeground);
}
private void OnTimeZoneChanged(object? sender, EventArgs e)
{
_ = sender;
@@ -445,17 +524,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 +613,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

View File

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

View File

@@ -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,6 +736,12 @@ public partial class MainWindow
return;
}
if (IsWeatherComponentId(placement.ComponentId))
{
OpenWeatherComponentSettings();
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopDailyArtwork)
{
OpenDailyArtworkComponentSettings();
@@ -731,6 +754,37 @@ public partial class MainWindow
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopIfengNews)
{
OpenIfengNewsComponentSettings();
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopDailyWord ||
placement.ComponentId == BuiltInComponentIds.DesktopDailyWord2x2)
{
OpenDailyWordComponentSettings();
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopBilibiliHotSearch)
{
OpenBilibiliHotSearchComponentSettings();
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopBaiduHotSearch)
{
OpenBaiduHotSearchComponentSettings();
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopStcn24Forum)
{
OpenStcn24ForumComponentSettings();
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment)
{
OpenStudyEnvironmentComponentSettings();
@@ -738,6 +792,15 @@ public partial class MainWindow
}
}
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)
@@ -802,6 +865,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)
@@ -850,6 +929,86 @@ public partial class MainWindow
ComponentSettingsWindow.Opacity = 1;
}
private void OpenIfengNewsComponentSettings()
{
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
{
return;
}
var settingsContent = new IfengNewsSettingsWindow();
settingsContent.SettingsChanged += OnIfengNewsSettingsChanged;
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 OpenBaiduHotSearchComponentSettings()
{
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
{
return;
}
var settingsContent = new BaiduHotSearchSettingsWindow();
settingsContent.SettingsChanged += OnBaiduHotSearchSettingsChanged;
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)
@@ -883,8 +1042,6 @@ public partial class MainWindow
}
}
}
PersistSettings();
}
private void OnStudyEnvironmentSettingsChanged(object? sender, EventArgs e)
@@ -907,10 +1064,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>())
@@ -926,8 +1079,6 @@ public partial class MainWindow
}
}
}
PersistSettings();
}
private void OnWorldClockSettingsChanged(object? sender, EventArgs e)
@@ -950,8 +1101,43 @@ 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)
@@ -974,8 +1160,121 @@ public partial class MainWindow
}
}
}
}
PersistSettings();
private void OnIfengNewsSettingsChanged(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 IfengNewsWidget 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;
}
var widget = TryGetContentHost(host)?.Child;
if (widget is DailyWordWidget dailyWordWidget)
{
dailyWordWidget.RefreshFromSettings();
}
else if (widget is DailyWord2x2Widget dailyWord2x2Widget)
{
dailyWord2x2Widget.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 OnBaiduHotSearchSettingsChanged(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 BaiduHotSearchWidget 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()
@@ -1010,11 +1309,41 @@ 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 IfengNewsSettingsWindow ifengNewsSettingsWindow)
{
ifengNewsSettingsWindow.SettingsChanged -= OnIfengNewsSettingsChanged;
}
if (ComponentSettingsContentHost?.Content is DailyWordSettingsWindow dailyWordSettingsWindow)
{
dailyWordSettingsWindow.SettingsChanged -= OnDailyWordSettingsChanged;
}
if (ComponentSettingsContentHost?.Content is BilibiliHotSearchSettingsWindow bilibiliHotSearchSettingsWindow)
{
bilibiliHotSearchSettingsWindow.SettingsChanged -= OnBilibiliHotSearchSettingsChanged;
}
if (ComponentSettingsContentHost?.Content is BaiduHotSearchSettingsWindow baiduHotSearchSettingsWindow)
{
baiduHotSearchSettingsWindow.SettingsChanged -= OnBaiduHotSearchSettingsChanged;
}
if (ComponentSettingsContentHost?.Content is Stcn24ForumSettingsWindow stcn24ForumSettingsWindow)
{
stcn24ForumSettingsWindow.SettingsChanged -= OnStcn24ForumSettingsChanged;
}
ComponentSettingsWindow.Opacity = 0;
DispatcherTimer.RunOnce(() =>
@@ -1426,6 +1755,14 @@ public partial class MainWindow
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopIfengNews, StringComparison.OrdinalIgnoreCase))
{
// Keep iFeng news widget square with a minimum footprint of 4x4.
return SnapSpanToScaleRules(
span,
new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 4));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopBilibiliHotSearch, StringComparison.OrdinalIgnoreCase))
{
// Keep Bilibili hot search widget at a 2:1 ratio: 4x2, 6x3, 8x4...
@@ -1434,6 +1771,22 @@ public partial class MainWindow
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopBaiduHotSearch, StringComparison.OrdinalIgnoreCase))
{
// Keep Baidu 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.
@@ -1698,6 +2051,7 @@ public partial class MainWindow
CancelDesktopComponentDrag();
CancelDesktopComponentResize(restoreOriginalSpan: true);
ClearDesktopComponentSelection();
ClearSelectedLauncherTile(refreshTaskbar: false);
UpdateDesktopComponentHostEditState();
ClearComponentLibraryPreviewControls();
UpdateComponentLibraryLayout(_currentDesktopCellSize);
@@ -1807,6 +2161,8 @@ public partial class MainWindow
private void SetSelectedDesktopComponent(Border? host)
{
ClearSelectedLauncherTile(refreshTaskbar: false);
// Clear previous selection
if (_selectedDesktopComponentHost is not null && _selectedDesktopComponentHost != host)
{
@@ -2826,12 +3182,16 @@ public partial class MainWindow
_weatherDataService,
_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
};

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
@@ -22,9 +22,30 @@ 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 LinuxDesktopEntryService _linuxDesktopEntryService = 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 +73,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.
@@ -67,7 +117,9 @@ public partial class MainWindow
{
var loadResult = await Task.Run(() =>
{
var loadedRoot = _windowsStartMenuService.Load();
var loadedRoot = OperatingSystem.IsLinux()
? _linuxDesktopEntryService.Load()
: _windowsStartMenuService.Load();
var folderIconBytes = OperatingSystem.IsWindows()
? WindowsIconService.TryGetSystemFolderIconPngBytes()
: null;
@@ -80,6 +132,7 @@ public partial class MainWindow
_launcherFolderIconBitmap?.Dispose();
_launcherFolderIconBitmap = null;
RenderLauncherRootTiles();
RenderLauncherHiddenItemsList();
}, DispatcherPriority.Background);
}
catch
@@ -89,6 +142,7 @@ public partial class MainWindow
_launcherFolderIconBitmap?.Dispose();
_launcherFolderIconBitmap = null;
RenderLauncherRootTiles();
RenderLauncherHiddenItemsList();
}
}
@@ -293,6 +347,7 @@ public partial class MainWindow
if (_currentDesktopSurfaceIndex != LauncherSurfaceIndex)
{
CloseLauncherFolderOverlay();
ClearSelectedLauncherTile(refreshTaskbar: false);
}
UpdateDesktopPageAwareComponentContext();
@@ -338,12 +393,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,24 +746,35 @@ 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));
}
if (LauncherRootTilePanel.Children.Count == 0)
{
LauncherRootTilePanel.Children.Add(CreateLauncherHintTile(
L("launcher.empty", "No Start Menu entries found."),
GetLauncherEmptyText(),
string.Empty));
}
@@ -719,24 +787,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 +853,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 +923,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 +1355,7 @@ public partial class MainWindow
private void CloseLauncherFolderOverlay()
{
ClearSelectedLauncherTile(refreshTaskbar: false);
_launcherFolderStack.Clear();
if (LauncherFolderOverlay is not null)
{
@@ -936,6 +1378,7 @@ public partial class MainWindow
return;
}
ClearSelectedLauncherTile(refreshTaskbar: false);
if (_launcherFolderStack.Count == 0)
{
CloseLauncherFolderOverlay();
@@ -950,11 +1393,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));
}
@@ -990,10 +1443,40 @@ public partial class MainWindow
return new string(letters).ToUpperInvariant();
}
private string GetLauncherEmptyText()
{
return OperatingSystem.IsLinux()
? L("launcher.empty_linux", "No Linux desktop entries were found.")
: L("launcher.empty", "No Start Menu entries found.");
}
private static void LaunchStartMenuEntry(StartMenuAppEntry app)
{
try
{
if (OperatingSystem.IsLinux() &&
!string.IsNullOrWhiteSpace(app.LaunchExecutable))
{
var linuxStartInfo = new ProcessStartInfo
{
FileName = app.LaunchExecutable,
UseShellExecute = false
};
if (!string.IsNullOrWhiteSpace(app.WorkingDirectory))
{
linuxStartInfo.WorkingDirectory = app.WorkingDirectory;
}
foreach (var argument in app.LaunchArguments)
{
linuxStartInfo.ArgumentList.Add(argument);
}
Process.Start(linuxStartInfo);
return;
}
var startInfo = new ProcessStartInfo
{
FileName = app.FilePath,

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Interactivity;
@@ -97,9 +97,13 @@ public partial class MainWindow
"Swipe to pick a category, tap to open, then drag a widget onto the desktop.");
LauncherTitleTextBlock.Text = L("launcher.title", "App Launcher");
LauncherSubtitleTextBlock.Text = L(
"launcher.subtitle",
"Displays all apps and folders based on the Windows Start menu structure.");
LauncherSubtitleTextBlock.Text = OperatingSystem.IsLinux()
? L(
"launcher.subtitle_linux",
"Displays installed apps discovered from Linux desktop entries.")
: L(
"launcher.subtitle",
"Displays all apps and folders based on the Windows Start menu structure.");
ToolTip.SetTip(LauncherFolderBackButton, L("common.back", "Back"));
ToolTip.SetTip(LauncherFolderCloseButton, L("common.close", "Close"));
@@ -111,6 +115,8 @@ public partial class MainWindow
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");
SettingsNavPluginsTextBlock.Text = L("settings.nav.plugins", "Plugins");
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
@@ -251,6 +257,28 @@ public partial class MainWindow
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.");
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
PluginSystemSettingsExpander.Description = L(
"settings.plugins.runtime_desc",
"Manage plugin loading and backend isolation.");
PluginSystemDescriptionTextBlock.Text = L(
"settings.plugins.runtime_hint",
"This page will host installed plugin management, permission review, and sandboxed backend runtime controls.");
PluginSystemStatusTextBlock.Text = L(
"settings.plugins.runtime_status",
"Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel.");
SettingsNavAboutTextBlock.Text = L("settings.nav.about", "About");
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
VersionTextBlock.Text = Lf(
@@ -269,9 +297,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)
{
@@ -293,6 +318,7 @@ public partial class MainWindow
InitializeTimeZoneSettings();
BuildComponentLibraryCategoryPages();
RenderLauncherRootTiles();
RenderLauncherHiddenItemsList();
UpdateOpenSettingsActionVisualState();
UpdateWallpaperDisplay();
}

View File

@@ -66,7 +66,9 @@ public partial class MainWindow
WeatherSettingsPanel is null ||
RegionSettingsPanel is null ||
UpdateSettingsPanel is null ||
AboutSettingsPanel is null)
LauncherSettingsPanel is null ||
AboutSettingsPanel is null ||
PluginSettingsPanel is null)
{
return;
}
@@ -80,6 +82,13 @@ public partial class MainWindow
RegionSettingsPanel.IsVisible = selectedIndex == 5;
UpdateSettingsPanel.IsVisible = selectedIndex == 6;
AboutSettingsPanel.IsVisible = selectedIndex == 7;
LauncherSettingsPanel.IsVisible = selectedIndex == 8;
PluginSettingsPanel.IsVisible = selectedIndex == 9;
if (selectedIndex == 8)
{
RenderLauncherHiddenItemsList();
}
if (selectedIndex == 1)
{
@@ -877,7 +886,6 @@ public partial class MainWindow
WeatherExcludedAlerts = _weatherExcludedAlertsRaw,
WeatherIconPackId = _weatherIconPackId,
WeatherNoTlsRequests = _weatherNoTlsRequests,
DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(_dailyArtworkMirrorSource),
AutoStartWithWindows = _autoStartWithWindows,
AutoCheckUpdates = _autoCheckUpdates,
IncludePrereleaseUpdates = IncludePrereleaseUpdates,
@@ -891,7 +899,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);

View File

@@ -460,6 +460,18 @@
<TextBlock x:Name="SettingsNavAboutTextBlock" Text="&#20851;&#20110;" VerticalAlignment="Center" />
</StackPanel>
</ListBoxItem>
<ListBoxItem x:Name="SettingsNavLauncherItem" ToolTip.Tip="&#24212;&#29992;&#21551;&#21160;&#21488;">
<StackPanel Orientation="Horizontal" Spacing="12">
<fi:FluentIcon x:Name="SettingsNavLauncherIcon" Icon="Apps" IconVariant="Regular" />
<TextBlock x:Name="SettingsNavLauncherTextBlock" Text="&#24212;&#29992;&#21551;&#21160;&#21488;" VerticalAlignment="Center" />
</StackPanel>
</ListBoxItem>
<ListBoxItem x:Name="SettingsNavPluginsItem" ToolTip.Tip="&#25554;&#20214;">
<StackPanel Orientation="Horizontal" Spacing="12">
<fi:SymbolIcon x:Name="SettingsNavPluginsIcon" Symbol="PuzzlePiece" IconVariant="Regular" />
<TextBlock x:Name="SettingsNavPluginsTextBlock" Text="&#25554;&#20214;" VerticalAlignment="Center" />
</StackPanel>
</ListBoxItem>
</ListBox>
</StackPanel>
</Border>
@@ -1493,6 +1505,39 @@
</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">
@@ -1513,8 +1558,38 @@
<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>
</StackPanel>
<StackPanel x:Name="PluginSettingsPanel" IsVisible="False" Spacing="16">
<TextBlock x:Name="PluginSettingsPanelTitleTextBlock"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="Plugins" />
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="PluginSystemSettingsExpander"
Header="Plugin Runtime"
Description="Manage plugin loading and backend isolation."
IsExpanded="True">
<ui:SettingsExpander.Footer>
<StackPanel Spacing="10">
<TextBlock x:Name="PluginSystemDescriptionTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="This page will host installed plugin management, permission review, and sandboxed backend runtime controls." />
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Padding="14">
<TextBlock x:Name="PluginSystemStatusTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
Text="Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel." />
</Border>
</StackPanel>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>

View File

@@ -88,6 +88,7 @@ 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();
@@ -167,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;
@@ -236,7 +236,7 @@ public partial class MainWindow : Window
GridSizeSlider.ValueChanged += OnGridSizeSliderChanged;
GridSizeNumberBox.ValueChanged += OnGridSizeNumberBoxChanged;
SettingsNavListBox.SelectedIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, 7);
SettingsNavListBox.SelectedIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, 9);
UpdateSettingsTabContent();
WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement);
@@ -244,10 +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();

View File

@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=LanMountainDesktop
Comment=LanMountainDesktop desktop shell
Exec=@@EXEC@@ %U
Icon=@@ICON@@
Terminal=false
Categories=Utility;Education;
StartupWMClass=LanMountainDesktop

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
APP_BIN="$SCRIPT_DIR/LanMountainDesktop"
DESKTOP_TEMPLATE="$SCRIPT_DIR/share/applications/LanMountainDesktop.desktop"
ICON_SOURCE="$SCRIPT_DIR/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
APPLICATIONS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICONS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps"
DESKTOP_TARGET="$APPLICATIONS_DIR/LanMountainDesktop.desktop"
ICON_TARGET="$ICONS_DIR/lanmountaindesktop.png"
check_audio_dependencies() {
MISSING_DEPS=""
if command -v dpkg >/dev/null 2>&1; then
if ! dpkg -s libportaudio2 >/dev/null 2>&1; then
MISSING_DEPS="$MISSING_DEPS libportaudio2"
fi
if ! dpkg -s libasound2 >/dev/null 2>&1; then
MISSING_DEPS="$MISSING_DEPS libasound2"
fi
elif command -v rpm >/dev/null 2>&1; then
if ! rpm -q portaudio-libs >/dev/null 2>&1; then
MISSING_DEPS="$MISSING_DEPS portaudio-libs"
fi
if ! rpm -q alsa-lib >/dev/null 2>&1; then
MISSING_DEPS="$MISSING_DEPS alsa-lib"
fi
elif command -v pacman >/dev/null 2>&1; then
if ! pacman -Q portaudio >/dev/null 2>&1; then
MISSING_DEPS="$MISSING_DEPS portaudio"
fi
if ! pacman -Q alsa-lib >/dev/null 2>&1; then
MISSING_DEPS="$MISSING_DEPS alsa-lib"
fi
elif command -v apk >/dev/null 2>&1; then
if ! apk -e info portaudio >/dev/null 2>&1; then
MISSING_DEPS="$MISSING_DEPS portaudio"
fi
if ! apk -e info alsa-lib >/dev/null 2>&1; then
MISSING_DEPS="$MISSING_DEPS alsa-lib"
fi
fi
if [ -n "$MISSING_DEPS" ]; then
return 1
fi
return 0
}
install_audio_dependencies() {
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y libportaudio2 libasound2
elif command -v dnf >/dev/null 2>&1; then
sudo dnf install -y portaudio-libs alsa-lib
elif command -v yum >/dev/null 2>&1; then
sudo yum install -y portaudio-libs alsa-lib
elif command -v pacman >/dev/null 2>&1; then
sudo pacman -S --noconfirm portaudio alsa-lib
elif command -v apk >/dev/null 2>&1; then
sudo apk add portaudio alsa-lib
else
printf '%s\n' "Warning: Could not detect package manager. Please install audio dependencies manually:"
printf '%s\n' " - libportaudio2 (or portaudio-libs/portaudio)"
printf '%s\n' " - libasound2 (or alsa-lib)"
fi
}
if ! check_audio_dependencies; then
printf '%s\n' "Installing audio dependencies for recording features..."
install_audio_dependencies
if ! check_audio_dependencies; then
printf '%s\n' "Warning: Audio dependencies may not be installed correctly."
printf '%s\n' "Recording and study monitoring features may not work properly."
fi
fi
mkdir -p "$APPLICATIONS_DIR" "$ICONS_DIR"
cp "$ICON_SOURCE" "$ICON_TARGET"
sed \
-e "s|@@EXEC@@|$APP_BIN|g" \
-e "s|@@ICON@@|lanmountaindesktop|g" \
"$DESKTOP_TEMPLATE" > "$DESKTOP_TARGET"
chmod +x "$APP_BIN" "$DESKTOP_TARGET"
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$APPLICATIONS_DIR" >/dev/null 2>&1 || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" >/dev/null 2>&1 || true
fi
printf '%s\n' "Installed desktop entry: $DESKTOP_TARGET"
printf '%s\n' "Installed icon: $ICON_TARGET"

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -141,6 +141,33 @@ function Create-PackageArchive {
return $archivePath
}
function Add-LinuxDesktopAssets {
param(
[Parameter(Mandatory = $true)][string]$PublishedDirectory,
[Parameter(Mandatory = $true)][string]$RepoRoot
)
$resourcesRoot = Join-Path $RepoRoot "packaging/linux"
$desktopTemplate = Join-Path $resourcesRoot "LanMountainDesktop.desktop"
$iconSource = Join-Path $resourcesRoot "lanmountaindesktop.png"
$installScriptSource = Join-Path $resourcesRoot "install.sh"
foreach ($requiredPath in @($desktopTemplate, $iconSource, $installScriptSource)) {
if (-not (Test-Path -LiteralPath $requiredPath)) {
throw "Linux packaging resource is missing: $requiredPath"
}
}
$applicationsDir = Join-Path $PublishedDirectory "share/applications"
$iconsDir = Join-Path $PublishedDirectory "share/icons/hicolor/256x256/apps"
[System.IO.Directory]::CreateDirectory($applicationsDir) | Out-Null
[System.IO.Directory]::CreateDirectory($iconsDir) | Out-Null
Copy-Item -LiteralPath $desktopTemplate -Destination (Join-Path $applicationsDir "LanMountainDesktop.desktop") -Force
Copy-Item -LiteralPath $iconSource -Destination (Join-Path $iconsDir "lanmountaindesktop.png") -Force
Copy-Item -LiteralPath $installScriptSource -Destination (Join-Path $PublishedDirectory "install.sh") -Force
}
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Resolve-ExistingPath -PathValue (Join-Path $scriptRoot "..")
@@ -184,6 +211,10 @@ if ($LASTEXITCODE -ne 0) {
Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
if ($RuntimeIdentifier -like "linux-*") {
Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot
}
if (-not $KeepSymbols) {
Get-ChildItem -Path $PublishDir -Recurse -File -Filter "*.pdb" | ForEach-Object {
[System.IO.File]::Delete($_.FullName)

26
run.md
View File

@@ -26,3 +26,29 @@ dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
- 启动失败提示 SDK 版本不匹配:确认 `dotnet --info` 中已安装 .NET 10 SDK。
- 桌面端视频相关能力异常:优先在 Windows 环境下验证。
- 配置重置:删除 `%LOCALAPPDATA%\LanMountainDesktop\settings.json` 后重启应用。
## 6. Linux 音频功能依赖
如果在 Linux 上使用录音机组件或自习监测组件,需要安装以下音频库:
### Debian/Ubuntu
```bash
sudo apt install libportaudio2 libasound2
```
### Fedora/RHEL
```bash
sudo dnf install portaudio-libs alsa-lib
```
### Arch Linux
```bash
sudo pacman -S portaudio alsa-lib
```
### Alpine Linux
```bash
sudo apk add portaudio alsa-lib
```
> 注:如果未安装这些依赖,录音和自习监测功能将不可用,但应用其他功能可以正常运行。