From f83c6ede1dc58b282f3eddd0d1d85398ed8c26ea Mon Sep 17 00:00:00 2001 From: lincube Date: Sun, 15 Mar 2026 17:08:07 +0800 Subject: [PATCH] settings_re11 --- LanMountainDesktop/App.axaml.cs | 81 +- .../DesktopComponentRuntimeContext.cs | 1 + .../IComponentSettingsContextAware.cs | 1 + LanMountainDesktop/LanMountainDesktop.csproj | 1 + LanMountainDesktop/Localization/en-US.json | 37 +- LanMountainDesktop/Localization/zh-CN.json | 37 +- .../Models/AppSettingsSnapshot.cs | 10 + LanMountainDesktop/Models/MonetPalette.cs | 49 +- .../Services/AppearanceThemeService.cs | 1069 +++++++++++++++++ .../ComponentEditorMaterialThemeAdapter.cs | 28 +- .../Services/ComponentEditorWindowService.cs | 38 +- .../Services/GlassEffectService.cs | 116 +- .../Services/MonetColorService.cs | 274 ++--- .../Services/PendingRestartStateService.cs | 1 + .../Services/Settings/SettingsContracts.cs | 19 +- .../Settings/SettingsDomainServices.cs | 128 +- .../Services/Settings/SettingsPageRegistry.cs | 1 + .../Settings/SettingsWindowService.cs | 84 +- .../Services/ThemeAppearanceValues.cs | 87 ++ .../Services/ThemeColorSystemService.cs | 78 +- .../Services/XiaomiWeatherCodeMapper.cs | 101 ++ .../Services/XiaomiWeatherService.cs | 84 +- LanMountainDesktop/Styles/GlassModule.axaml | 4 +- LanMountainDesktop/Theme/ThemeColorContext.cs | 7 +- .../PrivacySettingsPageViewModel.cs | 91 ++ .../ViewModels/SettingsViewModels.cs | 540 ++++++--- .../WallpaperSettingsPageViewModel.cs | 5 +- .../WeatherSettingsPageViewModel.cs | 23 +- .../Views/ComponentEditorWindow.axaml | 2 +- .../DesktopComponentRuntimeRegistry.cs | 3 + .../Components/ExtendedWeatherWidget.axaml.cs | 68 +- .../Components/HourlyWeatherWidget.axaml.cs | 87 +- .../Views/Components/HyperOS3WeatherTheme.cs | 151 ++- .../Components/MultiDayWeatherWidget.axaml.cs | 87 +- .../Views/Components/WeatherWidget.axaml.cs | 85 +- .../Components/XiaomiWeatherVisualResolver.cs | 45 + .../Views/MainWindow.ComponentSystem.cs | 26 + .../Views/MainWindow.SettingsHardCut.Stubs.cs | 52 +- LanMountainDesktop/Views/MainWindow.axaml | 2 +- LanMountainDesktop/Views/MainWindow.axaml.cs | 20 + .../AppearanceSettingsPage.axaml | 219 +++- .../AppearanceSettingsPage.axaml.cs | 41 +- .../SettingsPages/PrivacySettingsPage.axaml | 36 + .../PrivacySettingsPage.axaml.cs | 30 + .../StatusBarSettingsPage.axaml.cs | 4 +- LanMountainDesktop/Views/SettingsWindow.axaml | 26 +- .../Views/SettingsWindow.axaml.cs | 63 +- LanMountainDesktop/plugins/PluginLoader.cs | 1 + .../plugins/PluginRuntimeService.cs | 15 +- 49 files changed, 3243 insertions(+), 815 deletions(-) create mode 100644 LanMountainDesktop/Services/AppearanceThemeService.cs create mode 100644 LanMountainDesktop/Services/ThemeAppearanceValues.cs create mode 100644 LanMountainDesktop/Services/XiaomiWeatherCodeMapper.cs create mode 100644 LanMountainDesktop/ViewModels/PrivacySettingsPageViewModel.cs create mode 100644 LanMountainDesktop/Views/Components/XiaomiWeatherVisualResolver.cs create mode 100644 LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml create mode 100644 LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml.cs diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index dfeb85c..0c20758 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -43,6 +43,7 @@ public partial class App : Application } private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); + private readonly IAppearanceThemeService _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate(); private readonly LocalizationService _localizationService = new(); private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService(); private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService(); @@ -84,6 +85,7 @@ public partial class App : Application public App() { _settingsFacade.Settings.Changed += OnSettingsChanged; + _appearanceThemeService.Changed += OnAppearanceThemeChanged; } public override void Initialize() @@ -104,7 +106,6 @@ public partial class App : Application RegisterUiUnhandledExceptionGuard(); LinuxDesktopEntryInstaller.EnsureInstalled(); InitializePluginRuntime(); - AppSettingsService.SettingsSaved += OnAppSettingsSaved; InitializeTrayIcon(); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) @@ -337,11 +338,11 @@ public partial class App : Application private void ApplyThemeFromSettings() { - var themeState = _settingsFacade.Theme.Get(); - RequestedThemeVariant = themeState.IsNightMode + var snapshot = _appearanceThemeService.GetCurrent(); + RequestedThemeVariant = snapshot.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light; - ApplyAdaptiveThemeResources(themeState); + ApplyAdaptiveThemeResources(); } private void ApplyCurrentCultureFromSettings() @@ -464,19 +465,6 @@ public partial class App : Application Dispatcher.UIThread.InvokeAsync(Reset, DispatcherPriority.Send).GetAwaiter().GetResult(); } - private void OnAppSettingsSaved(string _) - { - Dispatcher.UIThread.Post(() => - { - ApplyThemeFromSettings(); - ApplyCurrentCultureFromSettings(); - if (_trayIcons is not null) - { - InitializeTrayIcon(); - } - }, DispatcherPriority.Background); - } - private void OnSettingsChanged(object? sender, SettingsChangedEvent e) { _ = sender; @@ -490,13 +478,17 @@ public partial class App : Application { var changedKeys = e.ChangedKeys?.ToArray(); var refreshAll = changedKeys is null || changedKeys.Length == 0; + var liveAppearance = _appearanceThemeService.GetCurrent(); var themeChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) || - changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase) || - changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) || - changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) || - changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase); + changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) || + (string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) && + changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) || + (string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) && + (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))); var languageChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase); @@ -517,48 +509,17 @@ public partial class App : Application }, DispatcherPriority.Background); } - private void ApplyAdaptiveThemeResources(ThemeAppearanceSettingsState themeState) + private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e) { - var wallpaperState = _settingsFacade.Wallpaper.Get(); - var monetPalette = _settingsFacade.Theme.BuildPalette( - themeState.IsNightMode, - wallpaperState.WallpaperPath, - themeState.ThemeColor); - var accentColor = ResolveAccentColor(themeState.ThemeColor, monetPalette); - var context = new ThemeColorContext( - accentColor, - IsLightBackground: !themeState.IsNightMode, - IsLightNavBackground: !themeState.IsNightMode, - IsNightMode: themeState.IsNightMode, - MonetColors: monetPalette.MonetColors); - ThemeColorSystemService.ApplyThemeResources(Resources, context); - GlassEffectService.ApplyGlassResources(Resources, context); + _ = sender; + _ = e; + + Dispatcher.UIThread.Post(ApplyThemeFromSettings, DispatcherPriority.Background); } - private static Color ResolveAccentColor(string? colorText, MonetPalette monetPalette) + private void ApplyAdaptiveThemeResources() { - if (monetPalette.MonetColors is { Count: > 0 }) - { - return monetPalette.MonetColors[0]; - } - - return TryParseThemeColor(colorText); - } - - private static Color TryParseThemeColor(string? colorText) - { - if (!string.IsNullOrWhiteSpace(colorText)) - { - try - { - return Color.Parse(colorText); - } - catch - { - } - } - - return DefaultAccentColor; + _appearanceThemeService.ApplyThemeResources(Resources); } private void RegisterUiUnhandledExceptionGuard() @@ -606,8 +567,8 @@ public partial class App : Application } _exitCleanupCompleted = true; - AppSettingsService.SettingsSaved -= OnAppSettingsSaved; _settingsFacade.Settings.Changed -= OnSettingsChanged; + _appearanceThemeService.Changed -= OnAppearanceThemeChanged; try { HostUpdateWorkflowServiceProvider.GetOrCreate().TryApplyPendingUpdateOnExit(); diff --git a/LanMountainDesktop/ComponentSystem/DesktopComponentRuntimeContext.cs b/LanMountainDesktop/ComponentSystem/DesktopComponentRuntimeContext.cs index f9ee600..a05fabe 100644 --- a/LanMountainDesktop/ComponentSystem/DesktopComponentRuntimeContext.cs +++ b/LanMountainDesktop/ComponentSystem/DesktopComponentRuntimeContext.cs @@ -9,5 +9,6 @@ public sealed record DesktopComponentRuntimeContext( string? PlacementId, ISettingsFacadeService SettingsFacade, ISettingsService SettingsService, + IAppearanceThemeService AppearanceTheme, IComponentSettingsAccessor ComponentSettingsAccessor, IComponentInstanceSettingsStore ComponentSettingsStore); diff --git a/LanMountainDesktop/ComponentSystem/IComponentSettingsContextAware.cs b/LanMountainDesktop/ComponentSystem/IComponentSettingsContextAware.cs index c35ac20..6fc03a6 100644 --- a/LanMountainDesktop/ComponentSystem/IComponentSettingsContextAware.cs +++ b/LanMountainDesktop/ComponentSystem/IComponentSettingsContextAware.cs @@ -9,6 +9,7 @@ public sealed record DesktopComponentSettingsContext( string? PlacementId, ISettingsFacadeService SettingsFacade, ISettingsService SettingsService, + IAppearanceThemeService AppearanceTheme, IComponentSettingsAccessor ComponentSettingsAccessor, IComponentInstanceSettingsStore ComponentSettingsStore); diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index 21ffe83..522d396 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -54,6 +54,7 @@ + diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 3bd6c25..2e56b90 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -26,6 +26,7 @@ "settings.nav.weather": "Weather", "settings.nav.region": "Region", "settings.nav.update": "Update", + "settings.nav.privacy": "Privacy", "settings.nav.launcher": "App Launcher", "settings.nav.plugins": "Plugins", "settings.nav.about": "About", @@ -92,6 +93,12 @@ "settings.status_bar.spacing_mode_custom": "Custom", "settings.status_bar.spacing_custom_label": "Custom spacing (%)", "settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px", + "settings.privacy.title": "Privacy", + "settings.privacy.description": "Manage optional anonymous uploads that help improve the app over time.", + "settings.privacy.crash_upload_title": "Anonymous crash data uploads", + "settings.privacy.crash_upload_description": "Help us improve application stability.", + "settings.privacy.usage_upload_title": "Anonymous usage data uploads", + "settings.privacy.usage_upload_description": "Help us improve application features.", "settings.weather.title": "Weather", "settings.weather.description": "Configure weather location, Xiaomi weather preview, and startup positioning behavior.", "settings.weather.location_source_header": "Location Source", @@ -242,11 +249,38 @@ "settings.general.preview_date_label": "Date", "settings.general.render_mode_restart_message": "Rendering mode changes require restarting the app.", "settings.appearance.title": "Appearance", - "settings.appearance.description": "Adjust theme, wallpaper, and window chrome.", + "settings.appearance.description": "Adjust theme source, system material, and window chrome.", "settings.appearance.theme_header": "Theme", "settings.color.enable_night_mode_toggle": "Enable night mode", "settings.color.use_system_chrome_toggle": "Use system window chrome", "settings.color.theme_color_label": "Theme accent color", + "settings.appearance.theme_color_mode_label": "Theme color source", + "settings.appearance.system_material_label": "System material", + "settings.appearance.theme_color_mode.neutral": "Default neutral", + "settings.appearance.theme_color_mode.user": "User theme color Monet", + "settings.appearance.theme_color_mode.wallpaper": "Wallpaper Monet", + "settings.appearance.theme_color_mode_desc.neutral": "Use the default white and black neutral surfaces for light and dark mode.", + "settings.appearance.theme_color_mode_desc.user": "Use the selected theme color as the Monet seed for the whole shell.", + "settings.appearance.theme_color_mode_desc.wallpaper": "Use wallpaper colors. The app wallpaper is preferred, then the system wallpaper.", + "settings.appearance.theme_color_preview.app": "Currently previewing colors extracted from the app wallpaper.", + "settings.appearance.theme_color_preview.system": "Currently previewing colors extracted from the system wallpaper.", + "settings.appearance.theme_color_preview.fallback": "No usable wallpaper was found. The app is using a fallback accent.", + "settings.appearance.system_material.none": "None", + "settings.appearance.system_material.mica": "Mica", + "settings.appearance.system_material.acrylic": "Acrylic", + "settings.appearance.system_material_desc.switchable": "Apply the selected material to windows, Dock, status bar, and component hosts.", + "settings.appearance.system_material_desc.fixed": "Your current system only exposes the material modes listed here.", + "settings.appearance.restart_message": "Theme source and system material changes require restarting the app.", + "settings.appearance.preview.primary": "Primary", + "settings.appearance.preview.secondary": "Secondary", + "settings.appearance.preview.tertiary": "Tertiary", + "settings.appearance.preview.neutral": "Neutral", + "settings.appearance.preview.seed": "Seed", + "settings.appearance.preview.neutral_light": "White", + "settings.appearance.preview.neutral_dark": "Black", + "settings.appearance.preview.apply_seed": "Apply", + "settings.appearance.preview.wallpaper_candidates": "Wallpaper seed candidates", + "settings.appearance.preview.wallpaper_current": "Current", "settings.wallpaper.placement.fill": "Fill", "settings.wallpaper.placement.fit": "Fit", "settings.wallpaper.placement.stretch": "Stretch", @@ -345,6 +379,7 @@ "settings.restart_dialog.title": "Restart required", "settings.restart_dialog.render_mode_message": "Restart the app to switch the rendering mode from \"{0}\" to \"{1}\". Restart now?", "settings.restart_dialog.restart": "Restart now", + "settings.restart_dialog.later": "Later", "settings.restart_dialog.cancel": "Cancel", "settings.restart_dock.title": "Restart required", "settings.restart_dock.description": "Some changes will take effect after restarting the app.", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index e8158f7..eade1f7 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -26,6 +26,7 @@ "settings.nav.weather": "天气", "settings.nav.region": "地区", "settings.nav.update": "更新", + "settings.nav.privacy": "隐私", "settings.nav.launcher": "应用启动台", "settings.nav.plugins": "插件", "settings.nav.about": "关于", @@ -97,6 +98,12 @@ "settings.status_bar.spacing_mode_custom": "自定义", "settings.status_bar.spacing_custom_label": "自定义间距(%)", "settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px", + "settings.privacy.title": "隐私", + "settings.privacy.description": "管理可选的匿名上传设置,帮助我们逐步改进应用体验。", + "settings.privacy.crash_upload_title": "匿名上传崩溃数据", + "settings.privacy.crash_upload_description": "帮助我们提高应用稳定性。", + "settings.privacy.usage_upload_title": "匿名上传使用数据", + "settings.privacy.usage_upload_description": "帮助我们改善应用功能。", "settings.weather.title": "天气", "settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。", "settings.weather.location_source_header": "位置来源", @@ -247,11 +254,38 @@ "settings.general.preview_date_label": "日期", "settings.general.render_mode_restart_message": "渲染模式变更需要重启应用。", "settings.appearance.title": "外观", - "settings.appearance.description": "调整主题、壁纸与窗口外观。", + "settings.appearance.description": "调整主题来源、系统材质与窗口外观。", "settings.appearance.theme_header": "主题", "settings.color.enable_night_mode_toggle": "启用夜间模式", "settings.color.use_system_chrome_toggle": "使用系统窗口标题栏", "settings.color.theme_color_label": "主题强调色", + "settings.appearance.theme_color_mode_label": "主题色来源", + "settings.appearance.system_material_label": "系统材质", + "settings.appearance.theme_color_mode.neutral": "默认中性", + "settings.appearance.theme_color_mode.user": "用户主题色 Monet", + "settings.appearance.theme_color_mode.wallpaper": "壁纸 Monet 取色", + "settings.appearance.theme_color_mode_desc.neutral": "使用标准的日间白底黑字与夜间黑底白字中性色表面。", + "settings.appearance.theme_color_mode_desc.user": "使用用户选择的主题色作为整个桌面壳层的 Monet 种子色。", + "settings.appearance.theme_color_mode_desc.wallpaper": "使用壁纸颜色。优先取应用壁纸,失败后回退系统桌面壁纸。", + "settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。", + "settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。", + "settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。", + "settings.appearance.system_material.none": "无", + "settings.appearance.system_material.mica": "Mica", + "settings.appearance.system_material.acrylic": "Acrylic", + "settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。", + "settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。", + "settings.appearance.restart_message": "主题色来源和系统材质更改需要重启应用。", + "settings.appearance.preview.primary": "主色", + "settings.appearance.preview.secondary": "次色", + "settings.appearance.preview.tertiary": "三次色", + "settings.appearance.preview.neutral": "中性色", + "settings.appearance.preview.seed": "种子色", + "settings.appearance.preview.neutral_light": "白色", + "settings.appearance.preview.neutral_dark": "黑色", + "settings.appearance.preview.apply_seed": "应用", + "settings.appearance.preview.wallpaper_candidates": "壁纸候选主题色", + "settings.appearance.preview.wallpaper_current": "当前", "settings.wallpaper.placement.fill": "填充", "settings.wallpaper.placement.fit": "适应", "settings.wallpaper.placement.stretch": "拉伸", @@ -350,6 +384,7 @@ "settings.restart_dialog.title": "需要重启应用", "settings.restart_dialog.render_mode_message": "需要重启应用,才能将渲染模式从“{0}”切换到“{1}”。是否现在重启?", "settings.restart_dialog.restart": "立即重启", + "settings.restart_dialog.later": "稍后", "settings.restart_dialog.cancel": "取消", "settings.restart_dock.title": "需要重启应用", "settings.restart_dock.description": "部分更改需要在重启应用后才会生效。", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 3120c07..ad65e3e 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -16,6 +16,12 @@ public sealed class AppSettingsSnapshot public bool UseSystemChrome { get; set; } + public string ThemeColorMode { get; set; } = "default_neutral"; + + public string SystemMaterialMode { get; set; } = "none"; + + public string? SelectedWallpaperSeed { get; set; } + public string? WallpaperPath { get; set; } public string WallpaperType { get; set; } = "Image"; @@ -60,6 +66,10 @@ public sealed class AppSettingsSnapshot public bool IncludePrereleaseUpdates { get; set; } + public bool UploadAnonymousCrashData { get; set; } + + public bool UploadAnonymousUsageData { get; set; } + public string UpdateChannel { get; set; } = "stable"; public string UpdateMode { get; set; } = "download_then_confirm"; diff --git a/LanMountainDesktop/Models/MonetPalette.cs b/LanMountainDesktop/Models/MonetPalette.cs index 7b7aad1..e283a17 100644 --- a/LanMountainDesktop/Models/MonetPalette.cs +++ b/LanMountainDesktop/Models/MonetPalette.cs @@ -1,8 +1,49 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Avalonia.Media; namespace LanMountainDesktop.Models; -public sealed record MonetPalette( - IReadOnlyList RecommendedColors, - IReadOnlyList MonetColors); +public sealed record MonetPalette +{ + public MonetPalette( + IReadOnlyList recommendedColors, + Color seed, + Color primary, + Color secondary, + Color tertiary, + Color neutral, + Color neutralVariant) + { + RecommendedColors = recommendedColors; + Seed = seed; + Primary = primary; + Secondary = secondary; + Tertiary = tertiary; + Neutral = neutral; + NeutralVariant = neutralVariant; + MonetColors = + [ + primary, + secondary, + tertiary, + neutral, + neutralVariant + ]; + } + + public IReadOnlyList RecommendedColors { get; } + + public IReadOnlyList MonetColors { get; } + + public Color Seed { get; } + + public Color Primary { get; } + + public Color Secondary { get; } + + public Color Tertiary { get; } + + public Color Neutral { get; } + + public Color NeutralVariant { get; } +} diff --git a/LanMountainDesktop/Services/AppearanceThemeService.cs b/LanMountainDesktop/Services/AppearanceThemeService.cs new file mode 100644 index 0000000..d90a3a3 --- /dev/null +++ b/LanMountainDesktop/Services/AppearanceThemeService.cs @@ -0,0 +1,1069 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Threading; +using Avalonia.Media.Imaging; +using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.Theme; +using LibVLCSharp.Shared; +using Microsoft.Win32; + +namespace LanMountainDesktop.Services; + +public enum MaterialSurfaceRole +{ + WindowBackground = 0, + SettingsWindowBackground = 1, + DockBackground = 2, + StatusBarBackground = 3, + DesktopComponentHost = 4, + StatusBarComponentHost = 5, + OverlayPanel = 6 +} + +public sealed record AppearanceMaterialSurface( + Color BackgroundColor, + Color BorderColor, + double BlurRadius, + double Opacity); + +public sealed record AppearanceThemeSnapshot( + bool IsNightMode, + string ThemeColorMode, + string? UserThemeColor, + string? SelectedWallpaperSeed, + string ResolvedSeedSource, + MonetPalette MonetPalette, + Color AccentColor, + Color EffectiveSeedColor, + IReadOnlyList WallpaperSeedCandidates, + string SystemMaterialMode, + IReadOnlyList AvailableSystemMaterialModes, + bool CanChangeSystemMaterial, + bool UseSystemChrome, + string? ResolvedWallpaperPath); + +public interface IAppearanceThemeService +{ + AppearanceThemeSnapshot GetCurrent(); + + AppearanceThemeSnapshot BuildPreview(ThemeAppearanceSettingsState pendingState); + + event EventHandler? Changed; + + void ApplyThemeResources(IResourceDictionary resources); + + AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role); + + void ApplyWindowMaterial(Window window, MaterialSurfaceRole role); +} + +internal interface ISystemWallpaperService +{ + bool IsSupported { get; } + + string? GetWallpaperPath(); +} + +internal interface IWindowMaterialService +{ + IReadOnlyList GetAvailableModes(); + + bool CanChangeMode { get; } + + void Apply(Window window, string materialMode); +} + +internal interface IMaterialSurfaceService +{ + AppearanceMaterialSurface GetSurface(ThemeColorContext context, MaterialSurfaceRole role); +} + +internal interface IVideoWallpaperSeedExtractor +{ + IReadOnlyList ExtractSeedCandidates(string videoPath, MonetColorService monetColorService); +} + +internal readonly record struct WallpaperSeedSourceDescriptor( + string SourceKind, + string SourceKey, + string? ResolvedWallpaperPath, + string? FilePath, + Color? SolidColor); + +internal sealed record WallpaperSeedExtractionResult( + string SourceKind, + string SourceKey, + string? ResolvedWallpaperPath, + IReadOnlyList SeedCandidates); + +internal readonly record struct WallpaperPaletteResolution( + MonetPalette Palette, + IReadOnlyList SeedCandidates, + string ResolvedSeedSource, + Color EffectiveSeedColor, + string? ResolvedWallpaperPath); + +internal sealed class LibVlcVideoWallpaperSeedExtractor : IVideoWallpaperSeedExtractor +{ + public IReadOnlyList ExtractSeedCandidates(string videoPath, MonetColorService monetColorService) + { + if (string.IsNullOrWhiteSpace(videoPath) || !File.Exists(videoPath)) + { + return []; + } + + var snapshotPath = Path.Combine( + Path.GetTempPath(), + $"lanmountaindesktop-video-seed-{Guid.NewGuid():N}.png"); + + try + { + using var libVlc = new LibVLC("--no-audio", "--intf=dummy", "--no-video-title-show"); + using var media = new Media(libVlc, new Uri(videoPath)); + using var mediaPlayer = new MediaPlayer(libVlc) + { + Media = media + }; + + mediaPlayer.Play(); + + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < TimeSpan.FromSeconds(5)) + { + Thread.Sleep(180); + if (!mediaPlayer.TakeSnapshot(0, snapshotPath, 320, 180)) + { + continue; + } + + var fileInfo = new FileInfo(snapshotPath); + if (!fileInfo.Exists || fileInfo.Length <= 0) + { + continue; + } + + using var bitmap = new Bitmap(snapshotPath); + return monetColorService.ExtractSeedCandidates(bitmap); + } + } + catch (Exception ex) + { + AppLogger.Warn( + "Appearance.VideoWallpaperPalette", + $"Failed to extract wallpaper seed candidates from video '{videoPath}'.", + ex); + } + finally + { + try + { + if (File.Exists(snapshotPath)) + { + File.Delete(snapshotPath); + } + } + catch + { + // Best effort cleanup only. + } + } + + return []; + } +} + +internal sealed class SystemWallpaperService : ISystemWallpaperService +{ + public bool IsSupported => OperatingSystem.IsWindows(); + + public string? GetWallpaperPath() + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + try + { + using var key = Registry.CurrentUser.OpenSubKey(@"Control Panel\Desktop", writable: false); + var wallpaperPath = key?.GetValue("WallPaper") as string; + return string.IsNullOrWhiteSpace(wallpaperPath) || !File.Exists(wallpaperPath) + ? null + : wallpaperPath; + } + catch (Exception ex) + { + AppLogger.Warn("Appearance.SystemWallpaper", "Failed to resolve the current system wallpaper path.", ex); + return null; + } + } +} + +internal sealed class WindowMaterialService : IWindowMaterialService +{ + private const int Windows11Build = 22000; + private const int Windows11_24H2Build = 26100; + + public bool CanChangeMode => GetSupportProfile() == WindowMaterialSupportProfile.FullSwitching; + + public IReadOnlyList GetAvailableModes() + { + return GetSupportProfile() switch + { + WindowMaterialSupportProfile.FullSwitching => + [ + ThemeAppearanceValues.MaterialNone, + ThemeAppearanceValues.MaterialMica, + ThemeAppearanceValues.MaterialAcrylic + ], + WindowMaterialSupportProfile.FixedMica => + [ + ThemeAppearanceValues.MaterialNone, + ThemeAppearanceValues.MaterialMica + ], + WindowMaterialSupportProfile.FixedAcrylic => + [ + ThemeAppearanceValues.MaterialNone, + ThemeAppearanceValues.MaterialAcrylic + ], + _ => + [ + ThemeAppearanceValues.MaterialNone + ] + }; + } + + public void Apply(Window window, string materialMode) + { + ArgumentNullException.ThrowIfNull(window); + + window.Background = Brushes.Transparent; + + if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled()) + { + window.TransparencyLevelHint = + [ + WindowTransparencyLevel.None + ]; + return; + } + + var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode); + window.TransparencyLevelHint = normalizedMode switch + { + ThemeAppearanceValues.MaterialMica => + [ + WindowTransparencyLevel.Mica, + WindowTransparencyLevel.Blur, + WindowTransparencyLevel.None + ], + ThemeAppearanceValues.MaterialAcrylic => + [ + WindowTransparencyLevel.AcrylicBlur, + WindowTransparencyLevel.Blur, + WindowTransparencyLevel.None + ], + _ => + [ + WindowTransparencyLevel.None + ] + }; + } + + private static bool IsTransparencyEnabled() + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + try + { + using var key = Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", + writable: false); + var value = key?.GetValue("EnableTransparency"); + return value switch + { + int intValue => intValue != 0, + byte byteValue => byteValue != 0, + _ => true + }; + } + catch + { + return true; + } + } + + private static WindowMaterialSupportProfile GetSupportProfile() + { + if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled()) + { + return WindowMaterialSupportProfile.NoneOnly; + } + + if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, Windows11_24H2Build)) + { + return WindowMaterialSupportProfile.FullSwitching; + } + + if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, Windows11Build)) + { + return WindowMaterialSupportProfile.FixedMica; + } + + if (OperatingSystem.IsWindowsVersionAtLeast(10, 0)) + { + return WindowMaterialSupportProfile.FixedAcrylic; + } + + return WindowMaterialSupportProfile.NoneOnly; + } + + private enum WindowMaterialSupportProfile + { + NoneOnly = 0, + FixedMica = 1, + FixedAcrylic = 2, + FullSwitching = 3 + } +} + +internal sealed class MaterialSurfaceService : IMaterialSurfaceService +{ + public AppearanceMaterialSurface GetSurface(ThemeColorContext context, MaterialSurfaceRole role) + { + var monetPalette = context.MonetPalette; + var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? []; + var primary = context.UseNeutralSurfaces + ? context.AccentColor + : monetPalette?.Primary ?? (monetColors.Length > 0 ? monetColors[0] : context.AccentColor); + var secondary = monetPalette?.Secondary + ?? (monetColors.Length > 1 + ? monetColors[1] + : ColorMath.Blend(primary, Color.Parse("#FFFFFFFF"), 0.14)); + var neutralPrimary = monetPalette?.Neutral + ?? (monetColors.Length > 3 + ? monetColors[3] + : ResolveNeutralBase(context.IsNightMode, role)); + var neutralSecondary = monetPalette?.NeutralVariant + ?? (monetColors.Length > 4 + ? monetColors[4] + : ResolveLiftBase(context.IsNightMode, role)); + var materialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(context.SystemMaterialMode); + + var (tintStrength, liftStrength, alpha, blurRadius) = ResolveModeParameters(materialMode, role, context.IsNightMode); + var neutralBase = ResolveNeutralBase(context.IsNightMode, role); + var neutralLift = ResolveLiftBase(context.IsNightMode, role); + var isDockLike = role is MaterialSurfaceRole.DockBackground; + var isComponentLike = role is MaterialSurfaceRole.DesktopComponentHost or MaterialSurfaceRole.StatusBarComponentHost; + var baseMix = isDockLike ? 0.88 : isComponentLike ? 0.74 : 0.82; + var liftMix = isDockLike ? 0.58 : isComponentLike ? 0.34 : 0.46; + var neutralMix = isDockLike ? 0.22 : 0.16; + + var background = ColorMath.Blend(neutralBase, neutralPrimary, baseMix); + background = ColorMath.Blend(background, neutralLift, liftMix); + background = ColorMath.Blend(background, neutralSecondary, neutralMix); + if (!context.UseNeutralSurfaces) + { + background = ColorMath.Blend(background, primary, tintStrength); + background = ColorMath.Blend(background, secondary, liftStrength); + } + + if (isDockLike && !context.IsNightMode) + { + background = ColorMath.Blend(background, Color.Parse("#FFFFFFFF"), 0.12); + } + + background = Color.FromArgb(alpha, background.R, background.G, background.B); + + var borderSeed = context.IsNightMode + ? ColorMath.Blend(neutralSecondary, Color.Parse("#FFFFFFFF"), 0.16) + : ColorMath.Blend(neutralSecondary, Color.Parse("#FF334155"), 0.08); + if (!context.UseNeutralSurfaces && !isComponentLike) + { + borderSeed = ColorMath.Blend(borderSeed, primary, 0.08); + } + + var borderAlpha = role switch + { + MaterialSurfaceRole.DockBackground => context.IsNightMode ? (byte)0x34 : (byte)0x18, + MaterialSurfaceRole.DesktopComponentHost or MaterialSurfaceRole.StatusBarComponentHost => + context.IsNightMode ? (byte)0x18 : (byte)0x10, + MaterialSurfaceRole.StatusBarBackground => (byte)0x00, + _ => context.IsNightMode ? (byte)0x26 : (byte)0x16 + }; + var border = ColorMath.WithAlpha(borderSeed, borderAlpha); + + return new AppearanceMaterialSurface(background, border, blurRadius, 1.0); + } + + private static (double TintStrength, double LiftStrength, byte Alpha, double BlurRadius) ResolveModeParameters( + string materialMode, + MaterialSurfaceRole role, + bool isNightMode) + { + var isOverlay = role is MaterialSurfaceRole.DockBackground or MaterialSurfaceRole.StatusBarBackground or MaterialSurfaceRole.OverlayPanel; + return materialMode switch + { + ThemeAppearanceValues.MaterialAcrylic => ( + isOverlay ? 0.30 : 0.20, + isOverlay ? 0.22 : 0.14, + isNightMode ? (byte)0xD8 : (byte)0xE0, + isOverlay ? 36 : 28), + ThemeAppearanceValues.MaterialMica => ( + isOverlay ? 0.20 : 0.14, + isOverlay ? 0.12 : 0.08, + isNightMode ? (byte)0xEC : (byte)0xF2, + isOverlay ? 28 : 20), + _ => ( + isOverlay ? 0.12 : 0.08, + isOverlay ? 0.08 : 0.05, + (byte)0xFF, + 0) + }; + } + + private static Color ResolveNeutralBase(bool isNightMode, MaterialSurfaceRole role) + { + return role switch + { + MaterialSurfaceRole.WindowBackground => isNightMode ? Color.Parse("#FF0A0F16") : Color.Parse("#FFF7F8FA"), + MaterialSurfaceRole.SettingsWindowBackground => isNightMode ? Color.Parse("#FF0C121A") : Color.Parse("#FFF8FAFC"), + MaterialSurfaceRole.DockBackground => isNightMode ? Color.Parse("#FF111A24") : Color.Parse("#FFFAFBFD"), + MaterialSurfaceRole.StatusBarBackground => isNightMode ? Color.Parse("#FF101720") : Color.Parse("#FFF9FBFE"), + MaterialSurfaceRole.StatusBarComponentHost => isNightMode ? Color.Parse("#FF111A23") : Color.Parse("#FFFCFDFE"), + MaterialSurfaceRole.OverlayPanel => isNightMode ? Color.Parse("#FF131C27") : Color.Parse("#FFF4F7FB"), + _ => isNightMode ? Color.Parse("#FF121B26") : Color.Parse("#FFFDFEFF") + }; + } + + private static Color ResolveLiftBase(bool isNightMode, MaterialSurfaceRole role) + { + return role switch + { + MaterialSurfaceRole.DockBackground or MaterialSurfaceRole.StatusBarBackground or MaterialSurfaceRole.OverlayPanel => + isNightMode ? Color.Parse("#FF1B2633") : Color.Parse("#FFFFFFFF"), + _ => isNightMode ? Color.Parse("#FF17212D") : Color.Parse("#FFFFFFFF") + }; + } +} + +internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposable +{ + private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6"); + private readonly ISettingsFacadeService _settingsFacade; + private readonly ISystemWallpaperService _systemWallpaperService; + private readonly IWindowMaterialService _windowMaterialService; + private readonly IMaterialSurfaceService _materialSurfaceService; + private readonly IVideoWallpaperSeedExtractor _videoWallpaperSeedExtractor; + private readonly MonetColorService _monetColorService = new(); + private readonly string _liveThemeColorMode; + private readonly string _liveSystemMaterialMode; + private readonly string? _liveSelectedWallpaperSeed; + private readonly object _paletteGate = new(); + private readonly Dictionary _wallpaperSeedCache = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _pendingWallpaperSeedKeys = new(StringComparer.OrdinalIgnoreCase); + + public AppearanceThemeService( + ISettingsFacadeService settingsFacade, + ISystemWallpaperService systemWallpaperService, + IWindowMaterialService windowMaterialService, + IMaterialSurfaceService materialSurfaceService, + IVideoWallpaperSeedExtractor? videoWallpaperSeedExtractor = null) + { + _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); + _systemWallpaperService = systemWallpaperService ?? throw new ArgumentNullException(nameof(systemWallpaperService)); + _windowMaterialService = windowMaterialService ?? throw new ArgumentNullException(nameof(windowMaterialService)); + _materialSurfaceService = materialSurfaceService ?? throw new ArgumentNullException(nameof(materialSurfaceService)); + _videoWallpaperSeedExtractor = videoWallpaperSeedExtractor ?? new LibVlcVideoWallpaperSeedExtractor(); + var initialThemeState = _settingsFacade.Theme.Get(); + _liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode( + initialThemeState.ThemeColorMode, + initialThemeState.ThemeColor); + _liveSystemMaterialMode = ResolveSupportedMaterialMode(initialThemeState.SystemMaterialMode); + _liveSelectedWallpaperSeed = initialThemeState.SelectedWallpaperSeed; + _settingsFacade.Settings.Changed += OnSettingsChanged; + } + + public event EventHandler? Changed; + + public AppearanceThemeSnapshot GetCurrent() + { + return BuildCurrentSnapshot(queueWallpaperPaletteBuild: true); + } + + public AppearanceThemeSnapshot BuildPreview(ThemeAppearanceSettingsState pendingState) + { + ArgumentNullException.ThrowIfNull(pendingState); + + var normalizedThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode( + pendingState.ThemeColorMode, + pendingState.ThemeColor); + var normalizedSystemMaterialMode = ResolveSupportedMaterialMode(pendingState.SystemMaterialMode); + return BuildSnapshot( + pendingState with + { + ThemeColorMode = normalizedThemeColorMode, + SystemMaterialMode = normalizedSystemMaterialMode + }, + normalizedThemeColorMode, + normalizedSystemMaterialMode, + pendingState.SelectedWallpaperSeed, + queueWallpaperPaletteBuild: true); + } + + public void ApplyThemeResources(IResourceDictionary resources) + { + ArgumentNullException.ThrowIfNull(resources); + + var snapshot = GetCurrent(); + var context = CreateThemeContext(snapshot); + ThemeColorSystemService.ApplyThemeResources(resources, context); + GlassEffectService.ApplyGlassResources(resources, context); + } + + public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role) + { + var snapshot = GetCurrent(); + return _materialSurfaceService.GetSurface(CreateThemeContext(snapshot), role); + } + + public void ApplyWindowMaterial(Window window, MaterialSurfaceRole role) + { + ArgumentNullException.ThrowIfNull(window); + + // Avoid hot-switching real backdrops on already-visible windows. This has been + // a stability hotspot when users flip theme source/material at runtime. + if (window.IsVisible) + { + return; + } + + var snapshot = GetCurrent(); + + try + { + _windowMaterialService.Apply(window, snapshot.SystemMaterialMode); + } + catch (Exception ex) + { + AppLogger.Warn( + "Appearance.WindowMaterial", + $"Failed to apply window material '{snapshot.SystemMaterialMode}'. Falling back to none.", + ex); + _windowMaterialService.Apply(window, ThemeAppearanceValues.MaterialNone); + } + } + + public void Dispose() + { + _settingsFacade.Settings.Changed -= OnSettingsChanged; + } + + private AppearanceThemeSnapshot BuildCurrentSnapshot(bool queueWallpaperPaletteBuild) + { + var themeState = _settingsFacade.Theme.Get(); + return BuildSnapshot( + themeState, + _liveThemeColorMode, + _liveSystemMaterialMode, + _liveSelectedWallpaperSeed, + queueWallpaperPaletteBuild); + } + + private void OnSettingsChanged(object? sender, SettingsChangedEvent e) + { + _ = sender; + + if (e.Scope != SettingsScope.App) + { + return; + } + + var changedKeys = e.ChangedKeys?.ToArray(); + var refreshAll = changedKeys is null || changedKeys.Length == 0; + var respondsToThemeColor = string.Equals( + _liveThemeColorMode, + ThemeAppearanceValues.ColorModeSeedMonet, + StringComparison.OrdinalIgnoreCase); + var respondsToWallpaper = string.Equals( + _liveThemeColorMode, + ThemeAppearanceValues.ColorModeWallpaperMonet, + StringComparison.OrdinalIgnoreCase); + + if (!refreshAll && + !changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) && + !(respondsToThemeColor && + changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) && + !(respondsToWallpaper && + (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase)))) + { + return; + } + + RaiseChanged(queueWallpaperPaletteBuild: true); + } + + private AppearanceThemeSnapshot BuildSnapshot( + ThemeAppearanceSettingsState themeState, + string themeColorMode, + string systemMaterialMode, + string? selectedWallpaperSeed, + bool queueWallpaperPaletteBuild) + { + var availableModes = _windowMaterialService.GetAvailableModes(); + MonetPalette palette; + IReadOnlyList wallpaperSeedCandidates; + Color effectiveSeedColor; + string resolvedSeedSource; + string? resolvedWallpaperPath; + + if (string.Equals(themeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase)) + { + var wallpaperState = _settingsFacade.Wallpaper.Get(); + var wallpaperResolution = ResolveWallpaperPalette( + themeState.IsNightMode, + wallpaperState, + selectedWallpaperSeed, + queueWallpaperPaletteBuild); + palette = wallpaperResolution.Palette; + wallpaperSeedCandidates = wallpaperResolution.SeedCandidates; + effectiveSeedColor = wallpaperResolution.EffectiveSeedColor; + resolvedSeedSource = wallpaperResolution.ResolvedSeedSource; + resolvedWallpaperPath = wallpaperResolution.ResolvedWallpaperPath; + } + else + { + var preferredSeedColor = string.Equals(themeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) + ? themeState.ThemeColor + : null; + palette = _settingsFacade.Theme.BuildPalette(themeState.IsNightMode, null, preferredSeedColor); + wallpaperSeedCandidates = []; + effectiveSeedColor = ResolveEffectiveSeedColor(themeColorMode, themeState.ThemeColor, palette); + resolvedSeedSource = string.Equals(themeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase) + ? "neutral" + : "user_color"; + resolvedWallpaperPath = null; + } + + return new AppearanceThemeSnapshot( + themeState.IsNightMode, + themeColorMode, + themeState.ThemeColor, + selectedWallpaperSeed, + resolvedSeedSource, + palette, + ResolveAccentColor(themeColorMode, themeState.ThemeColor, palette), + effectiveSeedColor, + wallpaperSeedCandidates, + systemMaterialMode, + availableModes, + _windowMaterialService.CanChangeMode, + themeState.UseSystemChrome, + resolvedWallpaperPath); + } + + private ThemeColorContext CreateThemeContext(AppearanceThemeSnapshot snapshot) + { + return new ThemeColorContext( + snapshot.AccentColor, + IsLightBackground: !snapshot.IsNightMode, + IsLightNavBackground: !snapshot.IsNightMode, + IsNightMode: snapshot.IsNightMode, + MonetPalette: snapshot.MonetPalette, + MonetColors: snapshot.MonetPalette.MonetColors, + UseNeutralSurfaces: snapshot.ThemeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral, + SystemMaterialMode: snapshot.SystemMaterialMode); + } + + private string ResolveSupportedMaterialMode(string? requestedMode) + { + var normalized = ThemeAppearanceValues.NormalizeSystemMaterialMode(requestedMode); + var availableModes = _windowMaterialService.GetAvailableModes(); + return availableModes.Contains(normalized, StringComparer.OrdinalIgnoreCase) + ? normalized + : ThemeAppearanceValues.MaterialNone; + } + + private WallpaperPaletteResolution ResolveWallpaperPalette( + bool nightMode, + WallpaperSettingsState wallpaperState, + string? selectedWallpaperSeed, + bool queueWallpaperPaletteBuild) + { + var source = ResolveWallpaperSeedSource(wallpaperState); + if (string.Equals(source.SourceKind, "fallback", StringComparison.OrdinalIgnoreCase)) + { + return BuildFallbackWallpaperPaletteResolution(nightMode, source.ResolvedWallpaperPath); + } + + if (string.Equals(source.SourceKind, "app_solid", StringComparison.OrdinalIgnoreCase)) + { + var candidates = source.SolidColor is { } solidColor + ? new[] { solidColor } + : []; + return BuildWallpaperPaletteResolution(nightMode, source, candidates, selectedWallpaperSeed); + } + + lock (_paletteGate) + { + if (_wallpaperSeedCache.TryGetValue(source.SourceKey, out var cachedSeedResult)) + { + if (cachedSeedResult.SeedCandidates.Count > 0) + { + return BuildWallpaperPaletteResolution( + nightMode, + source with + { + SourceKind = cachedSeedResult.SourceKind, + ResolvedWallpaperPath = cachedSeedResult.ResolvedWallpaperPath + }, + cachedSeedResult.SeedCandidates, + selectedWallpaperSeed); + } + + return BuildFallbackWallpaperPaletteResolution(nightMode, cachedSeedResult.ResolvedWallpaperPath); + } + } + + if (queueWallpaperPaletteBuild) + { + QueueWallpaperSeedExtraction(source); + } + + return BuildFallbackWallpaperPaletteResolution(nightMode, source.ResolvedWallpaperPath); + } + + private static Color ResolveAccentColor( + string themeColorMode, + string? colorText, + MonetPalette monetPalette) + { + if (themeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral) + { + return DefaultAccentColor; + } + + if (monetPalette.Primary.A > 0) + { + return monetPalette.Primary; + } + + if (!string.IsNullOrWhiteSpace(colorText) && Color.TryParse(colorText, out var parsedColor)) + { + return parsedColor; + } + + return DefaultAccentColor; + } + + private static Color ResolveEffectiveSeedColor( + string themeColorMode, + string? userThemeColor, + MonetPalette monetPalette) + { + if (themeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral) + { + return DefaultAccentColor; + } + + if (themeColorMode == ThemeAppearanceValues.ColorModeSeedMonet && + !string.IsNullOrWhiteSpace(userThemeColor) && + Color.TryParse(userThemeColor, out var parsedColor)) + { + return parsedColor; + } + + return monetPalette.Seed; + } + + private WallpaperPaletteResolution BuildWallpaperPaletteResolution( + bool nightMode, + WallpaperSeedSourceDescriptor source, + IReadOnlyList seedCandidates, + string? selectedWallpaperSeed) + { + var validatedSeed = ResolveSelectedWallpaperSeed(seedCandidates, selectedWallpaperSeed); + var palette = _monetColorService.BuildPaletteFromSeedCandidates(seedCandidates, nightMode, validatedSeed); + return new WallpaperPaletteResolution( + palette, + seedCandidates, + source.SourceKind, + palette.Seed, + source.ResolvedWallpaperPath); + } + + private WallpaperPaletteResolution BuildFallbackWallpaperPaletteResolution(bool nightMode, string? resolvedWallpaperPath) + { + var palette = _settingsFacade.Theme.BuildPalette(nightMode, null, null); + return new WallpaperPaletteResolution( + palette, + [], + "fallback", + palette.Seed, + resolvedWallpaperPath); + } + + private void QueueWallpaperSeedExtraction(WallpaperSeedSourceDescriptor source) + { + if (string.Equals(source.SourceKind, "fallback", StringComparison.OrdinalIgnoreCase) || + string.Equals(source.SourceKind, "app_solid", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + lock (_paletteGate) + { + if (_pendingWallpaperSeedKeys.Contains(source.SourceKey)) + { + return; + } + + _pendingWallpaperSeedKeys.Add(source.SourceKey); + } + + _ = Task.Run(() => + { + WallpaperSeedExtractionResult? extractionResult = null; + + try + { + extractionResult = ExtractWallpaperSeedCandidates(source); + } + catch (Exception ex) + { + AppLogger.Warn( + "Appearance.WallpaperSeed", + $"Failed to build wallpaper seed candidates asynchronously. Source='{source.SourceKind}'; Path='{source.FilePath}'.", + ex); + } + finally + { + lock (_paletteGate) + { + _pendingWallpaperSeedKeys.Remove(source.SourceKey); + if (extractionResult is not null) + { + _wallpaperSeedCache[source.SourceKey] = extractionResult; + } + } + } + + if (extractionResult is not null) + { + RaiseChanged(queueWallpaperPaletteBuild: false); + } + }); + } + + private WallpaperSeedExtractionResult ExtractWallpaperSeedCandidates(WallpaperSeedSourceDescriptor source) + { + IReadOnlyList seedCandidates = source.SourceKind switch + { + "app_wallpaper" or "system_wallpaper" => ExtractImageSeedCandidates(source.FilePath), + "app_video" => ExtractVideoSeedCandidates(source.FilePath), + "app_solid" when source.SolidColor is { } solidColor => new[] { solidColor }, + _ => [] + }; + + return new WallpaperSeedExtractionResult( + source.SourceKind, + source.SourceKey, + source.ResolvedWallpaperPath, + seedCandidates); + } + + private IReadOnlyList ExtractImageSeedCandidates(string? wallpaperPath) + { + if (string.IsNullOrWhiteSpace(wallpaperPath) || !File.Exists(wallpaperPath)) + { + return []; + } + + try + { + using var bitmap = new Bitmap(wallpaperPath); + return _monetColorService.ExtractSeedCandidates(bitmap); + } + catch (Exception ex) + { + AppLogger.Warn( + "Appearance.WallpaperSeed", + $"Failed to extract wallpaper seed candidates from image '{wallpaperPath}'.", + ex); + return []; + } + } + + private IReadOnlyList ExtractVideoSeedCandidates(string? wallpaperPath) + { + if (string.IsNullOrWhiteSpace(wallpaperPath) || !File.Exists(wallpaperPath)) + { + return []; + } + + return _videoWallpaperSeedExtractor.ExtractSeedCandidates(wallpaperPath, _monetColorService); + } + + private WallpaperSeedSourceDescriptor ResolveWallpaperSeedSource(WallpaperSettingsState wallpaperState) + { + if (string.Equals(wallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(wallpaperState.Color) && + Color.TryParse(wallpaperState.Color, out var solidColor)) + { + var solidText = solidColor.ToString(); + return new WallpaperSeedSourceDescriptor( + "app_solid", + $"app_solid|{solidText}", + null, + null, + solidColor); + } + + var wallpaperPath = string.IsNullOrWhiteSpace(wallpaperState.WallpaperPath) + ? null + : wallpaperState.WallpaperPath.Trim(); + var appWallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(wallpaperPath); + if (!string.IsNullOrWhiteSpace(wallpaperPath) && File.Exists(wallpaperPath)) + { + if (appWallpaperMediaType == WallpaperMediaType.Image) + { + return new WallpaperSeedSourceDescriptor( + "app_wallpaper", + CreateWallpaperSourceKey("app_wallpaper", wallpaperPath), + wallpaperPath, + wallpaperPath, + null); + } + + if (appWallpaperMediaType == WallpaperMediaType.Video) + { + return new WallpaperSeedSourceDescriptor( + "app_video", + CreateWallpaperSourceKey("app_video", wallpaperPath), + wallpaperPath, + wallpaperPath, + null); + } + } + + var systemWallpaper = _systemWallpaperService.GetWallpaperPath(); + if (!string.IsNullOrWhiteSpace(systemWallpaper) && + File.Exists(systemWallpaper) && + _settingsFacade.WallpaperMedia.DetectMediaType(systemWallpaper) == WallpaperMediaType.Image) + { + return new WallpaperSeedSourceDescriptor( + "system_wallpaper", + CreateWallpaperSourceKey("system_wallpaper", systemWallpaper), + systemWallpaper, + systemWallpaper, + null); + } + + return new WallpaperSeedSourceDescriptor( + "fallback", + "fallback", + null, + null, + null); + } + + private void RaiseChanged(bool queueWallpaperPaletteBuild) + { + var snapshot = BuildCurrentSnapshot(queueWallpaperPaletteBuild); + if (Dispatcher.UIThread.CheckAccess()) + { + Changed?.Invoke(this, snapshot); + return; + } + + Dispatcher.UIThread.Post(() => Changed?.Invoke(this, snapshot), DispatcherPriority.Background); + } + + private static Color? ResolveSelectedWallpaperSeed( + IReadOnlyList seedCandidates, + string? selectedWallpaperSeed) + { + if (seedCandidates.Count == 0) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(selectedWallpaperSeed) && + Color.TryParse(selectedWallpaperSeed, out var parsedSeed)) + { + foreach (var candidate in seedCandidates) + { + if (candidate == parsedSeed) + { + return candidate; + } + } + } + + return seedCandidates[0]; + } + + private static string CreateWallpaperSourceKey(string sourceKind, string wallpaperPath) + { + long lastWriteTicks = 0; + long length = 0; + + try + { + var fileInfo = new FileInfo(wallpaperPath); + if (fileInfo.Exists) + { + lastWriteTicks = fileInfo.LastWriteTimeUtc.Ticks; + length = fileInfo.Length; + } + } + catch + { + // Keep the cache key resilient even if metadata lookup fails. + } + + return string.Concat( + sourceKind, + "|", + wallpaperPath, + "|", + lastWriteTicks.ToString(), + "|", + length.ToString()); + } +} + +internal static class HostAppearanceThemeProvider +{ + private static readonly object Gate = new(); + private static AppearanceThemeService? _instance; + + public static IAppearanceThemeService GetOrCreate() + { + lock (Gate) + { + return _instance ??= new AppearanceThemeService( + HostSettingsFacadeProvider.GetOrCreate(), + new SystemWallpaperService(), + new WindowMaterialService(), + new MaterialSurfaceService()); + } + } +} diff --git a/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs b/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs index a0a6848..47a9006 100644 --- a/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs +++ b/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs @@ -49,15 +49,19 @@ internal static class ComponentEditorMaterialThemeAdapter ArgumentNullException.ThrowIfNull(monetPalette); var isNightMode = themeState.IsNightMode; - var monetColors = monetPalette.MonetColors?.Where(color => color.A > 0).ToArray() ?? []; var fallbackThemeColor = TryParseColor(themeState.ThemeColor); - var useWallpaperPalette = wallpaperMediaType == WallpaperMediaType.Image && monetColors.Length > 0; + var useWallpaperPalette = wallpaperMediaType == WallpaperMediaType.Image && monetPalette.Primary.A > 0; var primary = useWallpaperPalette - ? monetColors[0] - : fallbackThemeColor ?? monetColors.FirstOrDefault(DefaultPrimary); - var secondary = ResolveSecondaryColor(primary, monetColors, isNightMode); - var tertiary = ResolveTertiaryColor(primary, secondary, monetColors, isNightMode); + ? monetPalette.Primary + : fallbackThemeColor ?? monetPalette.Primary; + if (primary == default) + { + primary = DefaultPrimary; + } + + var secondary = ResolveSecondaryColor(primary, monetPalette, isNightMode); + var tertiary = ResolveTertiaryColor(primary, secondary, monetPalette, isNightMode); var backgroundBase = isNightMode ? DarkBackgroundBase : LightBackgroundBase; var surfaceBase = isNightMode ? DarkSurfaceBase : LightSurfaceBase; @@ -120,11 +124,11 @@ internal static class ComponentEditorMaterialThemeAdapter onPrimary); } - private static Color ResolveSecondaryColor(Color primary, IReadOnlyList monetColors, bool isNightMode) + private static Color ResolveSecondaryColor(Color primary, MonetPalette monetPalette, bool isNightMode) { - if (monetColors.Count > 1) + if (monetPalette.Secondary != default) { - return monetColors[1]; + return monetPalette.Secondary; } return ColorMath.Blend( @@ -136,12 +140,12 @@ internal static class ComponentEditorMaterialThemeAdapter private static Color ResolveTertiaryColor( Color primary, Color secondary, - IReadOnlyList monetColors, + MonetPalette monetPalette, bool isNightMode) { - if (monetColors.Count > 2) + if (monetPalette.Tertiary != default) { - return monetColors[2]; + return monetPalette.Tertiary; } var blendTarget = isNightMode ? Color.Parse("#FFFFFFFF") : Color.Parse("#FF2A2230"); diff --git a/LanMountainDesktop/Services/ComponentEditorWindowService.cs b/LanMountainDesktop/Services/ComponentEditorWindowService.cs index 5749347..2b51d45 100644 --- a/LanMountainDesktop/Services/ComponentEditorWindowService.cs +++ b/LanMountainDesktop/Services/ComponentEditorWindowService.cs @@ -31,13 +31,16 @@ public interface IComponentEditorWindowService internal sealed class ComponentEditorWindowService : IComponentEditorWindowService { private readonly ISettingsFacadeService _settingsFacade; + private readonly IAppearanceThemeService _appearanceThemeService; private ComponentEditorWindow? _window; private string? _currentPlacementId; public ComponentEditorWindowService(ISettingsFacadeService settingsFacade) { _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); + _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate(); _settingsFacade.Settings.Changed += OnSettingsChanged; + _appearanceThemeService.Changed += OnAppearanceThemeChanged; } public bool IsOpen => _window is { IsVisible: true }; @@ -105,12 +108,15 @@ internal sealed class ComponentEditorWindowService : IComponentEditorWindowServi } var changedKeys = e.ChangedKeys?.ToArray() ?? []; + var liveAppearance = _appearanceThemeService.GetCurrent(); if (changedKeys.Length > 0 && !changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) && - !changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase) && - !changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) && - !changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) && - !changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase) && + !(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) && + changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) && + !(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) && + (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) && !changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase)) { return; @@ -121,21 +127,33 @@ internal sealed class ComponentEditorWindowService : IComponentEditorWindowServi private void ApplyTheme(ComponentEditorWindow window) { + var appearanceSnapshot = _appearanceThemeService.GetCurrent(); var themeState = _settingsFacade.Theme.Get(); var wallpaperState = _settingsFacade.Wallpaper.Get(); - var wallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(wallpaperState.WallpaperPath); - var monetPalette = _settingsFacade.Theme.BuildPalette( - themeState.IsNightMode, - wallpaperState.WallpaperPath, - themeState.ThemeColor); + var wallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType( + appearanceSnapshot.ResolvedWallpaperPath ?? wallpaperState.WallpaperPath); var palette = ComponentEditorMaterialThemeAdapter.Build( themeState, wallpaperState, - monetPalette, + appearanceSnapshot.MonetPalette, wallpaperMediaType); window.ApplyTheme(palette); window.ApplyChromeMode(themeState.UseSystemChrome); + _appearanceThemeService.ApplyWindowMaterial(window, MaterialSurfaceRole.WindowBackground); + } + + private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e) + { + _ = sender; + _ = e; + + if (_window is null) + { + return; + } + + ApplyTheme(_window); } private sealed class HostContext : IComponentEditorHostContext diff --git a/LanMountainDesktop/Services/GlassEffectService.cs b/LanMountainDesktop/Services/GlassEffectService.cs index 50bb293..2f071b7 100644 --- a/LanMountainDesktop/Services/GlassEffectService.cs +++ b/LanMountainDesktop/Services/GlassEffectService.cs @@ -9,57 +9,95 @@ public static class GlassEffectService { public static void ApplyGlassResources(IResourceDictionary resources, ThemeColorContext context) { + var materialSurfaceService = new MaterialSurfaceService(); + var monetPalette = context.MonetPalette; var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? []; - var primary = monetColors.Length > 0 ? monetColors[0] : context.AccentColor; - var secondary = monetColors.Length > 1 - ? monetColors[1] - : ColorMath.Blend(primary, Color.Parse("#FFFFFFFF"), 0.12); - - var panelBase = context.IsNightMode - ? ColorMath.Blend(Color.Parse("#FF101722"), primary, 0.26) - : ColorMath.Blend(Color.Parse("#FFF9FBFE"), primary, 0.14); - var panelRaised = context.IsNightMode - ? ColorMath.Blend(Color.Parse("#FF15202C"), secondary, 0.30) - : ColorMath.Blend(Color.Parse("#FFFFFFFF"), secondary, 0.18); - var overlayBase = context.IsNightMode - ? ColorMath.Blend(Color.Parse("#FF0E1622"), primary, 0.36) - : ColorMath.Blend(Color.Parse("#FFF3F7FD"), primary, 0.20); + var primary = context.UseNeutralSurfaces + ? context.AccentColor + : monetPalette?.Primary ?? (monetColors.Length > 0 ? monetColors[0] : context.AccentColor); + var neutralButtonBase = context.IsNightMode + ? Color.Parse("#FF171C24") + : Color.Parse("#FFFFFFFF"); + if (!context.UseNeutralSurfaces) + { + neutralButtonBase = ColorMath.Blend( + neutralButtonBase, + primary, + context.IsNightMode ? 0.08 : 0.04); + } var buttonBackground = Color.FromArgb( - context.IsNightMode ? (byte)0x4D : (byte)0x52, - panelRaised.R, - panelRaised.G, - panelRaised.B); - var buttonBorder = Color.FromArgb( - context.IsNightMode ? (byte)0x36 : (byte)0x26, - primary.R, - primary.G, - primary.B); + context.IsNightMode ? (byte)0xF0 : (byte)0xFF, + neutralButtonBase.R, + neutralButtonBase.G, + neutralButtonBase.B); + var buttonBorder = ColorMath.WithAlpha( + context.IsNightMode + ? ColorMath.Blend(neutralButtonBase, Color.Parse("#FFFFFFFF"), 0.14) + : ColorMath.Blend(neutralButtonBase, Color.Parse("#FF334155"), 0.10), + context.IsNightMode ? (byte)0x26 : (byte)0x14); resources["AdaptiveButtonBackgroundBrush"] = new SolidColorBrush(buttonBackground); resources["AdaptiveButtonBorderBrush"] = new SolidColorBrush(buttonBorder); resources["AdaptiveButtonHoverBackgroundBrush"] = new SolidColorBrush( - ColorMath.WithAlpha(ColorMath.Blend(buttonBackground, primary, 0.18), context.IsNightMode ? (byte)0x72 : (byte)0x7A)); + ColorMath.WithAlpha( + ColorMath.Blend(buttonBackground, primary, context.IsNightMode ? 0.14 : 0.08), + context.IsNightMode ? (byte)0xF4 : (byte)0xFF)); resources["AdaptiveButtonPressedBackgroundBrush"] = new SolidColorBrush( - ColorMath.WithAlpha(ColorMath.Blend(buttonBackground, primary, 0.30), context.IsNightMode ? (byte)0x8A : (byte)0x8C)); + ColorMath.WithAlpha( + ColorMath.Blend(buttonBackground, primary, context.IsNightMode ? 0.24 : 0.16), + context.IsNightMode ? (byte)0xF8 : (byte)0xFF)); - resources["AdaptiveGlassPanelBackgroundBrush"] = new SolidColorBrush( - Color.FromArgb(context.IsNightMode ? (byte)0xF2 : (byte)0xFA, panelBase.R, panelBase.G, panelBase.B)); - resources["AdaptiveGlassPanelBorderBrush"] = new SolidColorBrush( - Color.FromArgb(context.IsNightMode ? (byte)0x38 : (byte)0x24, primary.R, primary.G, primary.B)); - resources["AdaptiveGlassStrongBackgroundBrush"] = new SolidColorBrush( - Color.FromArgb(context.IsNightMode ? (byte)0xF6 : (byte)0xFC, panelRaised.R, panelRaised.G, panelRaised.B)); - resources["AdaptiveGlassStrongBorderBrush"] = new SolidColorBrush( - Color.FromArgb(context.IsNightMode ? (byte)0x4A : (byte)0x2C, secondary.R, secondary.G, secondary.B)); - resources["AdaptiveGlassOverlayBackgroundBrush"] = new SolidColorBrush( - Color.FromArgb(context.IsNightMode ? (byte)0xEA : (byte)0xF4, overlayBase.R, overlayBase.G, overlayBase.B)); + var windowSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.WindowBackground); + var settingsWindowSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.SettingsWindowBackground); + var dockSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.DockBackground); + var statusBarSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.StatusBarBackground); + var desktopComponentSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.DesktopComponentHost); + var statusBarComponentSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.StatusBarComponentHost); + var overlaySurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.OverlayPanel); + var strongSurfaceColor = ColorMath.Blend( + desktopComponentSurface.BackgroundColor, + overlaySurface.BackgroundColor, + context.IsNightMode ? 0.18 : 0.12); + var strongBorderColor = ColorMath.WithAlpha( + desktopComponentSurface.BorderColor, + context.IsNightMode ? (byte)0x20 : (byte)0x12); + var panelBorderColor = ColorMath.WithAlpha( + desktopComponentSurface.BorderColor, + context.IsNightMode ? (byte)0x18 : (byte)0x10); - resources["AdaptiveGlassPanelBlurRadius"] = context.IsNightMode ? 22.0 : 28.0; - resources["AdaptiveGlassStrongBlurRadius"] = context.IsNightMode ? 28.0 : 34.0; - resources["AdaptiveGlassOverlayBlurRadius"] = context.IsNightMode ? 34.0 : 40.0; + resources["AdaptiveWindowBackgroundBrush"] = new SolidColorBrush(windowSurface.BackgroundColor); + resources["AdaptiveWindowBorderBrush"] = new SolidColorBrush(windowSurface.BorderColor); + resources["AdaptiveSettingsWindowBackgroundBrush"] = new SolidColorBrush(settingsWindowSurface.BackgroundColor); + resources["AdaptiveSettingsWindowBorderBrush"] = new SolidColorBrush(settingsWindowSurface.BorderColor); + resources["AdaptiveDockBackgroundBrush"] = new SolidColorBrush(dockSurface.BackgroundColor); + resources["AdaptiveDockBorderBrush"] = new SolidColorBrush(dockSurface.BorderColor); + resources["AdaptiveStatusBarBackgroundBrush"] = new SolidColorBrush(statusBarSurface.BackgroundColor); + resources["AdaptiveStatusBarBorderBrush"] = new SolidColorBrush(statusBarSurface.BorderColor); + resources["AdaptiveDesktopComponentHostBackgroundBrush"] = new SolidColorBrush(desktopComponentSurface.BackgroundColor); + resources["AdaptiveDesktopComponentHostBorderBrush"] = new SolidColorBrush(desktopComponentSurface.BorderColor); + resources["AdaptiveStatusBarComponentHostBackgroundBrush"] = new SolidColorBrush(statusBarComponentSurface.BackgroundColor); + resources["AdaptiveStatusBarComponentHostBorderBrush"] = new SolidColorBrush(statusBarComponentSurface.BorderColor); + + resources["AdaptiveGlassPanelBackgroundBrush"] = new SolidColorBrush(desktopComponentSurface.BackgroundColor); + resources["AdaptiveGlassPanelBorderBrush"] = new SolidColorBrush(panelBorderColor); + resources["AdaptiveGlassStrongBackgroundBrush"] = new SolidColorBrush(strongSurfaceColor); + resources["AdaptiveGlassStrongBorderBrush"] = new SolidColorBrush(strongBorderColor); + resources["AdaptiveDockGlassBackgroundBrush"] = new SolidColorBrush(dockSurface.BackgroundColor); + resources["AdaptiveDockGlassBorderBrush"] = new SolidColorBrush(dockSurface.BorderColor); + resources["AdaptiveGlassOverlayBackgroundBrush"] = new SolidColorBrush(overlaySurface.BackgroundColor); + + resources["AdaptiveGlassPanelBlurRadius"] = desktopComponentSurface.BlurRadius; + resources["AdaptiveGlassStrongBlurRadius"] = dockSurface.BlurRadius; + resources["AdaptiveGlassOverlayBlurRadius"] = overlaySurface.BlurRadius; resources["AdaptiveGlassPanelOpacity"] = 1.0; resources["AdaptiveGlassStrongOpacity"] = 1.0; - resources["AdaptiveGlassOverlayOpacity"] = context.IsNightMode ? 0.95 : 0.98; + resources["AdaptiveGlassOverlayOpacity"] = overlaySurface.Opacity; resources["AdaptiveGlassNoiseOpacity"] = context.IsNightMode ? 0.012 : 0.008; + + resources["AdaptiveDockOpacity"] = dockSurface.Opacity; + resources["AdaptiveStatusBarOpacity"] = statusBarSurface.Opacity; + resources["AdaptiveDesktopComponentHostOpacity"] = desktopComponentSurface.Opacity; + resources["AdaptiveStatusBarComponentHostOpacity"] = statusBarComponentSurface.Opacity; } } diff --git a/LanMountainDesktop/Services/MonetColorService.cs b/LanMountainDesktop/Services/MonetColorService.cs index 33cc00f..28ed25f 100644 --- a/LanMountainDesktop/Services/MonetColorService.cs +++ b/LanMountainDesktop/Services/MonetColorService.cs @@ -1,84 +1,86 @@ -using System; +using System; using System.Collections.Generic; +using System.Linq; using System.Runtime.InteropServices; using Avalonia; using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Platform; using LanMountainDesktop.Models; +using MaterialColorUtilities.Palettes; +using MaterialColorUtilities.Utils; using Microsoft.Win32; namespace LanMountainDesktop.Services; public sealed class MonetColorService { + private static readonly Color DefaultSeedColor = Color.Parse("#FF3B82F6"); + public MonetPalette BuildPalette(Bitmap? wallpaper, bool nightMode, Color? preferredSeed = null) { - var recommended = BuildRecommendedPalette(nightMode); - var seed = preferredSeed ?? TryExtractSeedColor(wallpaper) ?? TryGetSystemMonetSeedColor() ?? Color.Parse("#FF3B82F6"); - var monet = BuildMonetPalette(seed, nightMode); - return new MonetPalette(recommended, monet); + var wallpaperCandidates = wallpaper is null + ? [] + : ExtractSeedCandidates(wallpaper); + return BuildPaletteCore(wallpaperCandidates, nightMode, preferredSeed); } - private static IReadOnlyList BuildRecommendedPalette(bool nightMode) + public MonetPalette BuildPaletteFromSeedCandidates( + IReadOnlyList? seedCandidates, + bool nightMode, + Color? preferredSeed = null) { - if (nightMode) - { - return - [ - Color.Parse("#FF3B82F6"), - Color.Parse("#FF22C55E"), - Color.Parse("#FFF59E0B"), - Color.Parse("#FFF97316"), - Color.Parse("#FFA855F7"), - Color.Parse("#FFEF4444") - ]; - } - - return - [ - Color.Parse("#FF1D4ED8"), - Color.Parse("#FF15803D"), - Color.Parse("#FFB45309"), - Color.Parse("#FFC2410C"), - Color.Parse("#FF7E22CE"), - Color.Parse("#FFB91C1C") - ]; + return BuildPaletteCore(seedCandidates ?? [], nightMode, preferredSeed); } - private static IReadOnlyList BuildMonetPalette(Color seed, bool nightMode) + public IReadOnlyList ExtractSeedCandidates(Bitmap wallpaper) { - var (hue, saturation, value) = ToHsv(seed); - var valueBase = nightMode ? Math.Max(0.70, value) : Math.Min(0.72, Math.Max(0.35, value)); - var saturationBase = Math.Clamp(saturation, 0.22, 0.74); - var offsets = new[] { 0d, 16d, -16d, 36d, -36d, 180d }; - var palette = new List(offsets.Length); - - for (var i = 0; i < offsets.Length; i++) - { - var hueShift = NormalizeHue(hue + offsets[i]); - var sat = Math.Clamp(saturationBase + ((i % 2 == 0) ? 0.05 : -0.05), 0.18, 0.86); - var val = Math.Clamp(valueBase + ((i < 3) ? 0.06 : -0.04), 0.32, 0.92); - palette.Add(FromHsv(hueShift, sat, val)); - } - - return palette; + ArgumentNullException.ThrowIfNull(wallpaper); + return ExtractWallpaperSeedCandidates(wallpaper); } - private static Color? TryExtractSeedColor(Bitmap? wallpaper) + private static Color? ResolveSeedColor( + IReadOnlyList wallpaperCandidates, + Color? preferredSeed) { - if (wallpaper is null) + if (wallpaperCandidates.Count == 0) { return null; } + if (preferredSeed is { } explicitSeed) + { + var exact = wallpaperCandidates.FirstOrDefault(candidate => candidate == explicitSeed); + if (exact != default) + { + return exact; + } + } + + return wallpaperCandidates[0]; + } + + private static IReadOnlyList BuildFallbackSeedCandidates() + { + return + [ + Color.Parse("#FF3B82F6"), + Color.Parse("#FF22C55E"), + Color.Parse("#FFF59E0B"), + Color.Parse("#FFF97316"), + Color.Parse("#FFA855F7") + ]; + } + + private static IReadOnlyList ExtractWallpaperSeedCandidates(Bitmap wallpaper) + { try { - var sampleWidth = Math.Clamp(wallpaper.PixelSize.Width, 1, 48); - var sampleHeight = Math.Clamp(wallpaper.PixelSize.Height, 1, 48); + var width = Math.Clamp(wallpaper.PixelSize.Width, 1, 96); + var height = Math.Clamp(wallpaper.PixelSize.Height, 1, 96); using var scaledBitmap = wallpaper.CreateScaledBitmap( - new PixelSize(sampleWidth, sampleHeight), + new PixelSize(width, height), BitmapInterpolationMode.MediumQuality); using var writeable = new WriteableBitmap( scaledBitmap.PixelSize, @@ -91,55 +93,52 @@ public sealed class MonetColorService var byteCount = framebuffer.RowBytes * framebuffer.Size.Height; if (byteCount <= 0 || framebuffer.Address == IntPtr.Zero) { - return null; + return []; } var pixelBuffer = new byte[byteCount]; Marshal.Copy(framebuffer.Address, pixelBuffer, 0, byteCount); - double bestScore = double.MinValue; - Color? bestColor = null; - + var argbPixels = new List(framebuffer.Size.Width * framebuffer.Size.Height); for (var y = 0; y < framebuffer.Size.Height; y++) { var rowOffset = y * framebuffer.RowBytes; for (var x = 0; x < framebuffer.Size.Width; x++) { var index = rowOffset + (x * 4); - var alpha = pixelBuffer[index + 3] / 255d; - if (alpha <= 0.15) + var alpha = pixelBuffer[index + 3]; + if (alpha <= 32) { continue; } - var blue = (pixelBuffer[index] / 255d) / alpha; - var green = (pixelBuffer[index + 1] / 255d) / alpha; - var red = (pixelBuffer[index + 2] / 255d) / alpha; - red = Math.Clamp(red, 0, 1); - green = Math.Clamp(green, 0, 1); - blue = Math.Clamp(blue, 0, 1); - - var color = Color.FromRgb( - (byte)Math.Round(red * 255), - (byte)Math.Round(green * 255), - (byte)Math.Round(blue * 255)); - var (_, saturation, value) = ToHsv(color); - var score = (saturation * 1.8) + (value * 0.6); - if (score <= bestScore) - { - continue; - } - - bestScore = score; - bestColor = color; + var blue = pixelBuffer[index]; + var green = pixelBuffer[index + 1]; + var red = pixelBuffer[index + 2]; + argbPixels.Add( + ((uint)alpha << 24) | + ((uint)red << 16) | + ((uint)green << 8) | + blue); } } - return bestColor; + if (argbPixels.Count == 0) + { + return []; + } + + var extracted = ImageUtils.ColorsFromImage(argbPixels.ToArray()); + return extracted + .Select(FromArgb) + .Distinct() + .Take(6) + .ToArray(); } - catch + catch (Exception ex) { - return null; + AppLogger.Warn("Appearance.WallpaperPalette", "Failed to extract wallpaper seed candidates.", ex); + return []; } } @@ -161,11 +160,17 @@ public sealed class MonetColorService return null; } - var bytes = BitConverter.GetBytes(accentDword); - var blue = bytes[0]; - var green = bytes[1]; - var red = bytes[2]; - return Color.FromRgb(red, green, blue); + var accentColor = unchecked((uint)accentDword); + var a = (byte)((accentColor >> 24) & 0xFF); + var b = (byte)((accentColor >> 16) & 0xFF); + var g = (byte)((accentColor >> 8) & 0xFF); + var r = (byte)(accentColor & 0xFF); + if (a == 0) + { + a = 0xFF; + } + + return Color.FromArgb(a, r, g, b); } catch { @@ -173,78 +178,51 @@ public sealed class MonetColorService } } - private static (double Hue, double Saturation, double Value) ToHsv(Color color) + private static uint ToArgb(Color color) { - var red = color.R / 255d; - var green = color.G / 255d; - var blue = color.B / 255d; - var max = Math.Max(red, Math.Max(green, blue)); - var min = Math.Min(red, Math.Min(green, blue)); - var delta = max - min; - - double hue; - if (delta < 0.0001) - { - hue = 0; - } - else if (Math.Abs(max - red) < 0.0001) - { - hue = 60 * (((green - blue) / delta) % 6); - } - else if (Math.Abs(max - green) < 0.0001) - { - hue = 60 * (((blue - red) / delta) + 2); - } - else - { - hue = 60 * (((red - green) / delta) + 4); - } - - hue = NormalizeHue(hue); - var saturation = max <= 0.0001 ? 0 : delta / max; - return (hue, saturation, max); + return + ((uint)color.A << 24) | + ((uint)color.R << 16) | + ((uint)color.G << 8) | + color.B; } - private static Color FromHsv(double hue, double saturation, double value) + private static Color FromArgb(uint argb) { - hue = NormalizeHue(hue); - saturation = Math.Clamp(saturation, 0, 1); - value = Math.Clamp(value, 0, 1); - - if (saturation <= 0.0001) - { - var gray = (byte)Math.Round(value * 255); - return Color.FromRgb(gray, gray, gray); - } - - var chroma = value * saturation; - var x = chroma * (1 - Math.Abs(((hue / 60d) % 2) - 1)); - var m = value - chroma; - - (double r, double g, double b) = hue switch - { - >= 0 and < 60 => (chroma, x, 0d), - >= 60 and < 120 => (x, chroma, 0d), - >= 120 and < 180 => (0d, chroma, x), - >= 180 and < 240 => (0d, x, chroma), - >= 240 and < 300 => (x, 0d, chroma), - _ => (chroma, 0d, x) - }; - - var red = (byte)Math.Round((r + m) * 255); - var green = (byte)Math.Round((g + m) * 255); - var blue = (byte)Math.Round((b + m) * 255); - return Color.FromRgb(red, green, blue); + var a = (byte)((argb >> 24) & 0xFF); + var r = (byte)((argb >> 16) & 0xFF); + var g = (byte)((argb >> 8) & 0xFF); + var b = (byte)(argb & 0xFF); + return Color.FromArgb(a, r, g, b); } - private static double NormalizeHue(double hue) + private static MonetPalette BuildPaletteCore( + IReadOnlyList wallpaperCandidates, + bool nightMode, + Color? preferredSeed) { - hue %= 360; - if (hue < 0) - { - hue += 360; - } + var recommendedColors = wallpaperCandidates.Count > 0 + ? wallpaperCandidates + : BuildFallbackSeedCandidates(); + var seed = ResolveSeedColor(wallpaperCandidates, preferredSeed) + ?? preferredSeed + ?? TryGetSystemMonetSeedColor() + ?? DefaultSeedColor; - return hue; + var corePalette = CorePalette.Of(ToArgb(seed), Style.TonalSpot); + var primary = FromArgb(corePalette.Primary.Tone(nightMode ? 80u : 40u)); + var secondary = FromArgb(corePalette.Secondary.Tone(nightMode ? 80u : 40u)); + var tertiary = FromArgb(corePalette.Tertiary.Tone(nightMode ? 80u : 40u)); + var neutral = FromArgb(corePalette.Neutral.Tone(nightMode ? 20u : 94u)); + var neutralVariant = FromArgb(corePalette.NeutralVariant.Tone(nightMode ? 30u : 90u)); + + return new MonetPalette( + recommendedColors, + seed, + primary, + secondary, + tertiary, + neutral, + neutralVariant); } } diff --git a/LanMountainDesktop/Services/PendingRestartStateService.cs b/LanMountainDesktop/Services/PendingRestartStateService.cs index 43e51fb..b392d60 100644 --- a/LanMountainDesktop/Services/PendingRestartStateService.cs +++ b/LanMountainDesktop/Services/PendingRestartStateService.cs @@ -7,6 +7,7 @@ public static class PendingRestartStateService { public const string RenderModeReason = "RenderMode"; public const string PluginCatalogReason = "PluginCatalog"; + public const string SettingsWindowReason = "SettingsWindow"; private static readonly object Gate = new(); private static readonly HashSet PendingReasons = new(StringComparer.OrdinalIgnoreCase); diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs index fced06e..3a33d36 100644 --- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs +++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Services; namespace LanMountainDesktop.Services.Settings; @@ -16,7 +17,13 @@ public enum WallpaperMediaType public sealed record GridSettingsState(int ShortSideCells, string SpacingPreset, int EdgeInsetPercent); public sealed record WallpaperSettingsState(string? WallpaperPath, string Type, string? Color, string Placement); -public sealed record ThemeAppearanceSettingsState(bool IsNightMode, string? ThemeColor, bool UseSystemChrome); +public sealed record ThemeAppearanceSettingsState( + bool IsNightMode, + string? ThemeColor, + bool UseSystemChrome, + string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral, + string SystemMaterialMode = ThemeAppearanceValues.MaterialNone, + string? SelectedWallpaperSeed = null); public sealed record StatusBarSettingsState( IReadOnlyList TopStatusComponentIds, IReadOnlyList PinnedTaskbarActions, @@ -37,6 +44,9 @@ public sealed record WeatherSettingsState( bool NoTlsRequests, string LocationQuery); public sealed record RegionSettingsState(string LanguageCode, string? TimeZoneId); +public sealed record PrivacySettingsState( + bool UploadAnonymousCrashData, + bool UploadAnonymousUsageData); public sealed record UpdateSettingsState( bool AutoCheckUpdates, bool IncludePrereleaseUpdates, @@ -166,6 +176,12 @@ public interface IRegionSettingsService TimeZoneService GetTimeZoneService(); } +public interface IPrivacySettingsService +{ + PrivacySettingsState Get(); + void Save(PrivacySettingsState state); +} + public interface IUpdateSettingsService { UpdateSettingsState Get(); @@ -224,6 +240,7 @@ public interface ISettingsFacadeService IStatusBarSettingsService StatusBar { get; } IWeatherSettingsService Weather { get; } IRegionSettingsService Region { get; } + IPrivacySettingsService Privacy { get; } IUpdateSettingsService Update { get; } ILauncherCatalogService LauncherCatalog { get; } ILauncherPolicyService LauncherPolicy { get; } diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 50e127d..11ff815 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -93,9 +93,12 @@ internal sealed class WallpaperSettingsService : IWallpaperSettingsService public WallpaperSettingsState Get() { var snapshot = _settingsService.Load(); + var normalizedType = snapshot.WallpaperType ?? "Image"; return new WallpaperSettingsState( - snapshot.WallpaperPath, - snapshot.WallpaperType ?? "Image", + string.Equals(normalizedType, "SolidColor", StringComparison.OrdinalIgnoreCase) + ? null + : snapshot.WallpaperPath, + normalizedType, snapshot.WallpaperColor, snapshot.WallpaperPlacement); } @@ -103,9 +106,24 @@ internal sealed class WallpaperSettingsService : IWallpaperSettingsService public void Save(WallpaperSettingsState state) { var snapshot = _settingsService.Load(); - snapshot.WallpaperPath = state.WallpaperPath; - snapshot.WallpaperType = state.Type; - snapshot.WallpaperColor = state.Color; + var normalizedType = string.IsNullOrWhiteSpace(state.Type) + ? "Image" + : state.Type.Trim(); + var normalizedPath = string.IsNullOrWhiteSpace(state.WallpaperPath) + ? null + : state.WallpaperPath.Trim(); + var normalizedColor = string.IsNullOrWhiteSpace(state.Color) + ? null + : state.Color.Trim(); + + if (string.Equals(normalizedType, "SolidColor", StringComparison.OrdinalIgnoreCase)) + { + normalizedPath = null; + } + + snapshot.WallpaperPath = normalizedPath; + snapshot.WallpaperType = normalizedType; + snapshot.WallpaperColor = normalizedColor; snapshot.WallpaperPlacement = string.IsNullOrWhiteSpace(state.Placement) ? "Fill" : state.Placement.Trim(); @@ -233,24 +251,68 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService return new ThemeAppearanceSettingsState( snapshot.IsNightMode ?? false, snapshot.ThemeColor, - snapshot.UseSystemChrome); + snapshot.UseSystemChrome, + ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor), + ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode), + snapshot.SelectedWallpaperSeed); } public void Save(ThemeAppearanceSettingsState state) { var snapshot = _settingsService.Load(); - snapshot.IsNightMode = state.IsNightMode; - snapshot.ThemeColor = state.ThemeColor; - snapshot.UseSystemChrome = state.UseSystemChrome; + var changedKeys = new List(); + var normalizedThemeColor = string.IsNullOrWhiteSpace(state.ThemeColor) ? null : state.ThemeColor; + var normalizedThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(state.ThemeColorMode, state.ThemeColor); + var normalizedSystemMaterialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(state.SystemMaterialMode); + var normalizedSelectedWallpaperSeed = string.IsNullOrWhiteSpace(state.SelectedWallpaperSeed) + ? null + : state.SelectedWallpaperSeed; + + if ((snapshot.IsNightMode ?? false) != state.IsNightMode) + { + snapshot.IsNightMode = state.IsNightMode; + changedKeys.Add(nameof(AppSettingsSnapshot.IsNightMode)); + } + + if (!string.Equals(snapshot.ThemeColor, normalizedThemeColor, StringComparison.OrdinalIgnoreCase)) + { + snapshot.ThemeColor = normalizedThemeColor; + changedKeys.Add(nameof(AppSettingsSnapshot.ThemeColor)); + } + + if (snapshot.UseSystemChrome != state.UseSystemChrome) + { + snapshot.UseSystemChrome = state.UseSystemChrome; + changedKeys.Add(nameof(AppSettingsSnapshot.UseSystemChrome)); + } + + if (!string.Equals(snapshot.ThemeColorMode, normalizedThemeColorMode, StringComparison.OrdinalIgnoreCase)) + { + snapshot.ThemeColorMode = normalizedThemeColorMode; + changedKeys.Add(nameof(AppSettingsSnapshot.ThemeColorMode)); + } + + if (!string.Equals(snapshot.SystemMaterialMode, normalizedSystemMaterialMode, StringComparison.OrdinalIgnoreCase)) + { + snapshot.SystemMaterialMode = normalizedSystemMaterialMode; + changedKeys.Add(nameof(AppSettingsSnapshot.SystemMaterialMode)); + } + + if (!string.Equals(snapshot.SelectedWallpaperSeed, normalizedSelectedWallpaperSeed, StringComparison.OrdinalIgnoreCase)) + { + snapshot.SelectedWallpaperSeed = normalizedSelectedWallpaperSeed; + changedKeys.Add(nameof(AppSettingsSnapshot.SelectedWallpaperSeed)); + } + + if (changedKeys.Count == 0) + { + return; + } + _settingsService.SaveSnapshot( SettingsScope.App, snapshot, - changedKeys: - [ - nameof(AppSettingsSnapshot.IsNightMode), - nameof(AppSettingsSnapshot.ThemeColor), - nameof(AppSettingsSnapshot.UseSystemChrome) - ]); + changedKeys: changedKeys); } public MonetPalette BuildPalette(bool nightMode, string? wallpaperPath, string? preferredSeedColor = null) @@ -525,6 +587,39 @@ internal sealed class RegionSettingsService : IRegionSettingsService } } +internal sealed class PrivacySettingsService : IPrivacySettingsService +{ + private readonly ISettingsService _settingsService; + + public PrivacySettingsService(ISettingsService settingsService) + { + _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + } + + public PrivacySettingsState Get() + { + var snapshot = _settingsService.Load(); + return new PrivacySettingsState( + snapshot.UploadAnonymousCrashData, + snapshot.UploadAnonymousUsageData); + } + + public void Save(PrivacySettingsState state) + { + var snapshot = _settingsService.Load(); + snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData; + snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData; + _settingsService.SaveSnapshot( + SettingsScope.App, + snapshot, + changedKeys: + [ + nameof(AppSettingsSnapshot.UploadAnonymousCrashData), + nameof(AppSettingsSnapshot.UploadAnonymousUsageData) + ]); + } +} + internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposable { private readonly ISettingsService _settingsService; @@ -920,6 +1015,7 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl _weatherSettingsService = new WeatherSettingsService(Settings); Weather = _weatherSettingsService; Region = new RegionSettingsService(Settings); + Privacy = new PrivacySettingsService(Settings); _updateSettingsService = new UpdateSettingsService(Settings); Update = _updateSettingsService; LauncherCatalog = new LauncherCatalogService(); @@ -949,6 +1045,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl public IRegionSettingsService Region { get; } + public IPrivacySettingsService Privacy { get; } + public IUpdateSettingsService Update { get; } public ILauncherCatalogService LauncherCatalog { get; } diff --git a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs index 59f4cd5..bc33847 100644 --- a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs +++ b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs @@ -177,6 +177,7 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable services.AddSingleton(_settingsFacade); services.AddSingleton(_settingsFacade.Settings); services.AddSingleton(_settingsFacade.Catalog); + services.AddSingleton(_ => HostAppearanceThemeProvider.GetOrCreate()); services.AddSingleton(_hostApplicationLifecycle); services.AddSingleton(_localizationService); services.AddSingleton(_ => HostLocationServiceProvider.GetOrCreate()); diff --git a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs index 30e3d52..f476e2b 100644 --- a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs +++ b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs @@ -52,10 +52,10 @@ public interface ISettingsWindowService internal sealed class SettingsWindowService : ISettingsWindowService { - private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6"); private readonly ISettingsPageRegistry _pageRegistry; private readonly IHostApplicationLifecycle _hostApplicationLifecycle; private readonly ISettingsFacadeService _settingsFacade; + private readonly IAppearanceThemeService _appearanceThemeService; private readonly LocalizationService _localizationService; private SettingsWindowViewModel _viewModel = null!; private SettingsWindow? _window; @@ -68,8 +68,10 @@ internal sealed class SettingsWindowService : ISettingsWindowService _pageRegistry = pageRegistry; _hostApplicationLifecycle = hostApplicationLifecycle; _settingsFacade = settingsFacade; + _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate(); _localizationService = new(); _settingsFacade.Settings.Changed += OnSettingsChanged; + _appearanceThemeService.Changed += OnAppearanceThemeChanged; } private string L(string key) @@ -86,9 +88,9 @@ internal sealed class SettingsWindowService : ISettingsWindowService { _pageRegistry.Rebuild(); _window ??= CreateWindow(); - var themeState = _settingsFacade.Theme.Get(); - _window.ApplyChromeMode(themeState.UseSystemChrome); - ApplyTheme(_window, themeState); + var appearanceSnapshot = _appearanceThemeService.GetCurrent(); + _window.ApplyChromeMode(appearanceSnapshot.UseSystemChrome); + ApplyTheme(_window); _window.ReloadPages(request.PageId); PositionWindow(_window, request); @@ -135,15 +137,15 @@ internal sealed class SettingsWindowService : ISettingsWindowService _viewModel = new SettingsWindowViewModel(_localizationService, languageCode).Initialize(); - var themeState = _settingsFacade.Theme.Get(); - var useSystemChrome = themeState.UseSystemChrome; + var appearanceSnapshot = _appearanceThemeService.GetCurrent(); + var useSystemChrome = appearanceSnapshot.UseSystemChrome; var window = new SettingsWindow( _viewModel, _pageRegistry, _hostApplicationLifecycle, useSystemChrome); - ApplyTheme(window, themeState); + ApplyTheme(window); window.ShowInTaskbar = false; window.Closed += (_, _) => { @@ -277,13 +279,16 @@ internal sealed class SettingsWindowService : ISettingsWindowService var changedKeys = e.ChangedKeys?.ToArray(); var refreshAll = changedKeys is null || changedKeys.Length == 0; var languageChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase); + var liveAppearance = _appearanceThemeService.GetCurrent(); var themeChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) || - changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase) || - changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) || - changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) || - changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase) || + (string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) && + changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) || + (string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) && + (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) || changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase); if (languageChanged) @@ -297,59 +302,36 @@ internal sealed class SettingsWindowService : ISettingsWindowService if (themeChanged) { - var themeState = _settingsFacade.Theme.Get(); - _window.ApplyChromeMode(themeState.UseSystemChrome); - ApplyTheme(_window, themeState); + var appearanceSnapshot = _appearanceThemeService.GetCurrent(); + _window.ApplyChromeMode(appearanceSnapshot.UseSystemChrome); + ApplyTheme(_window); } }, DispatcherPriority.Background); } - private static void ApplyTheme(SettingsWindow window, ThemeAppearanceSettingsState themeState) + private void ApplyTheme(SettingsWindow window) { - window.RequestedThemeVariant = themeState.IsNightMode + var appearanceSnapshot = _appearanceThemeService.GetCurrent(); + window.RequestedThemeVariant = appearanceSnapshot.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light; - - var settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); - var wallpaperState = settingsFacade.Wallpaper.Get(); - var monetPalette = settingsFacade.Theme.BuildPalette( - themeState.IsNightMode, - wallpaperState.WallpaperPath, - themeState.ThemeColor); - var accentColor = ResolveAccentColor(themeState.ThemeColor, monetPalette); - var context = new ThemeColorContext( - accentColor, - IsLightBackground: !themeState.IsNightMode, - IsLightNavBackground: !themeState.IsNightMode, - IsNightMode: themeState.IsNightMode, - MonetColors: monetPalette.MonetColors); - ThemeColorSystemService.ApplyThemeResources(window.Resources, context); - GlassEffectService.ApplyGlassResources(window.Resources, context); + _appearanceThemeService.ApplyThemeResources(window.Resources); + _appearanceThemeService.ApplyWindowMaterial(window, MaterialSurfaceRole.SettingsWindowBackground); } - private static Color ResolveAccentColor(string? colorText, MonetPalette monetPalette) + private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e) { - if (monetPalette.MonetColors is { Count: > 0 }) + _ = sender; + _ = e; + + Dispatcher.UIThread.Post(() => { - return monetPalette.MonetColors[0]; - } - - return TryParseThemeColor(colorText); - } - - private static Color TryParseThemeColor(string? colorText) - { - if (!string.IsNullOrWhiteSpace(colorText)) - { - try + if (_window is null || _viewModel is null) { - return Color.Parse(colorText); + return; } - catch - { - } - } - return DefaultAccentColor; + ApplyTheme(_window); + }, DispatcherPriority.Background); } } diff --git a/LanMountainDesktop/Services/ThemeAppearanceValues.cs b/LanMountainDesktop/Services/ThemeAppearanceValues.cs new file mode 100644 index 0000000..7f1093d --- /dev/null +++ b/LanMountainDesktop/Services/ThemeAppearanceValues.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LanMountainDesktop.Services; + +public static class ThemeAppearanceValues +{ + public const string ColorModeDefaultNeutral = "default_neutral"; + public const string ColorModeSeedMonet = "seed_monet"; + public const string ColorModeWallpaperMonet = "wallpaper_monet"; + + public const string MaterialNone = "none"; + public const string MaterialMica = "mica"; + public const string MaterialAcrylic = "acrylic"; + + public static readonly IReadOnlyList AllColorModes = + [ + ColorModeDefaultNeutral, + ColorModeSeedMonet, + ColorModeWallpaperMonet + ]; + + public static readonly IReadOnlyList AllMaterialModes = + [ + MaterialNone, + MaterialMica, + MaterialAcrylic + ]; + + public static string NormalizeThemeColorMode(string? value, string? themeColor = null) + { + if (string.Equals(value, ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase)) + { + return ColorModeDefaultNeutral; + } + + if (string.Equals(value, ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase)) + { + return ColorModeWallpaperMonet; + } + + if (string.Equals(value, ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase)) + { + return ColorModeSeedMonet; + } + + return string.IsNullOrWhiteSpace(themeColor) + ? ColorModeDefaultNeutral + : ColorModeSeedMonet; + } + + public static string NormalizeSystemMaterialMode(string? value) + { + if (string.Equals(value, MaterialMica, StringComparison.OrdinalIgnoreCase)) + { + return MaterialMica; + } + + if (string.Equals(value, MaterialAcrylic, StringComparison.OrdinalIgnoreCase)) + { + return MaterialAcrylic; + } + + return MaterialNone; + } + + public static IReadOnlyList NormalizeAvailableMaterialModes(IEnumerable? values) + { + if (values is null) + { + return [MaterialNone]; + } + + var normalized = values + .Select(NormalizeSystemMaterialMode) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (!normalized.Contains(MaterialNone, StringComparer.OrdinalIgnoreCase)) + { + normalized.Insert(0, MaterialNone); + } + + return normalized; + } +} diff --git a/LanMountainDesktop/Services/ThemeColorSystemService.cs b/LanMountainDesktop/Services/ThemeColorSystemService.cs index 9262ebf..eec7681 100644 --- a/LanMountainDesktop/Services/ThemeColorSystemService.cs +++ b/LanMountainDesktop/Services/ThemeColorSystemService.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using Avalonia.Controls; using Avalonia.Media; @@ -68,11 +69,19 @@ public static class ThemeColorSystemService private static AppThemePalette BuildPalette(ThemeColorContext context) { + var monetPalette = context.MonetPalette; var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? []; - var accent = monetColors.Length > 0 ? monetColors[0] : context.AccentColor; - var secondarySeed = monetColors.Length > 1 - ? monetColors[1] - : ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.14); + var accent = context.UseNeutralSurfaces + ? context.AccentColor + : monetPalette?.Primary ?? GetColorOrDefault(monetColors, 0, context.AccentColor); + var secondarySeed = monetPalette?.Secondary + ?? GetColorOrDefault(monetColors, 1, ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.14)); + var tertiarySeed = monetPalette?.Tertiary + ?? GetColorOrDefault(monetColors, 2, ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.22)); + var neutralSeed = monetPalette?.Neutral + ?? GetColorOrDefault(monetColors, 3, context.IsNightMode ? Color.Parse("#FF171C23") : Color.Parse("#FFF2F4F7")); + var neutralVariantSeed = monetPalette?.NeutralVariant + ?? GetColorOrDefault(monetColors, 4, context.IsNightMode ? Color.Parse("#FF20262E") : Color.Parse("#FFE8EDF2")); var accentLight1 = ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.22); var accentLight2 = ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.38); @@ -83,18 +92,23 @@ public static class ThemeColorSystemService var primary = context.IsNightMode ? accentLight1 : accentDark1; var secondary = context.IsNightMode - ? ColorMath.Blend(secondarySeed, Color.Parse("#FFFFFFFF"), 0.16) - : ColorMath.Blend(secondarySeed, Color.Parse("#FF111827"), 0.14); + ? ColorMath.Blend(secondarySeed, Color.Parse("#FFFFFFFF"), 0.12) + : ColorMath.Blend(secondarySeed, Color.Parse("#FF111827"), 0.10); - var surfaceBase = context.IsNightMode - ? ColorMath.Blend(Color.Parse("#FF0A1018"), accent, 0.18) - : ColorMath.Blend(Color.Parse("#FFF7F9FD"), accent, 0.09); - var surfaceRaised = context.IsNightMode - ? ColorMath.Blend(Color.Parse("#FF121A24"), secondarySeed, 0.24) - : ColorMath.Blend(Color.Parse("#FFFCFEFF"), secondarySeed, 0.12); - var surfaceOverlayBase = context.IsNightMode - ? ColorMath.Blend(Color.Parse("#FF18212D"), accent, 0.28) - : ColorMath.Blend(Color.Parse("#FFF1F5FB"), accent, 0.16); + var baseSurface = context.IsNightMode ? Color.Parse("#FF0B0F14") : Color.Parse("#FFF7F8FA"); + var raisedSurface = context.IsNightMode ? Color.Parse("#FF131922") : Color.Parse("#FFFFFFFF"); + var overlaySurface = context.IsNightMode ? Color.Parse("#FF171E28") : Color.Parse("#FFF1F4F8"); + var navSurfaceBase = context.IsLightNavBackground ? Color.Parse("#FFF8FAFC") : Color.Parse("#FF111827"); + + var surfaceBase = context.UseNeutralSurfaces + ? baseSurface + : ColorMath.Blend(baseSurface, neutralSeed, context.IsNightMode ? 0.84 : 0.78); + var surfaceRaised = context.UseNeutralSurfaces + ? raisedSurface + : ColorMath.Blend(raisedSurface, neutralVariantSeed, context.IsNightMode ? 0.72 : 0.60); + var surfaceOverlayBase = context.UseNeutralSurfaces + ? overlaySurface + : ColorMath.Blend(overlaySurface, neutralVariantSeed, context.IsNightMode ? 0.76 : 0.64); var surfaceOverlay = Color.FromArgb( context.IsNightMode ? (byte)0xE8 : (byte)0xF2, surfaceOverlayBase.R, @@ -115,9 +129,9 @@ public static class ThemeColorSystemService ? ColorMath.EnsureContrast(ColorMath.Blend(accent, Color.Parse("#FF0B1220"), 0.20), surfaceRaised, WcagNormalTextContrast) : ColorMath.EnsureContrast(ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.16), surfaceRaised, WcagNormalTextContrast); - var navSurface = context.IsLightNavBackground - ? ColorMath.Blend(surfaceRaised, accentLight2, 0.08) - : ColorMath.Blend(Color.Parse("#FF111827"), accentDark2, 0.24); + var navSurface = context.UseNeutralSurfaces + ? navSurfaceBase + : ColorMath.Blend(navSurfaceBase, neutralSeed, context.IsNightMode ? 0.66 : 0.70); var navText = ColorMath.EnsureContrast( context.IsLightNavBackground ? Color.Parse("#FF0B1220") : Color.Parse("#FFF8FAFC"), navSurface, @@ -129,18 +143,23 @@ public static class ThemeColorSystemService ? Color.FromArgb(0x33, surfaceRaised.R, surfaceRaised.G, surfaceRaised.B) : Color.FromArgb(0x38, navSurface.R, navSurface.G, navSurface.B); var navItemHoverBackground = context.IsLightNavBackground - ? ColorMath.WithAlpha(ColorMath.Blend(accentLight2, surfaceRaised, 0.30), 0x7A) - : ColorMath.WithAlpha(ColorMath.Blend(accentDark1, navSurface, 0.26), 0x88); - var navItemSelectedBackground = ColorMath.WithAlpha(accent, context.IsNightMode ? (byte)0xCE : (byte)0xD9); - var navSelectionIndicator = ColorMath.EnsureContrast(accentLight1, navSurface, WcagLargeTextContrast); + ? ColorMath.WithAlpha(ColorMath.Blend(accentLight2, navSurface, 0.12), 0x64) + : ColorMath.WithAlpha(ColorMath.Blend(accentDark1, navSurface, 0.18), 0x74); + var navItemSelectedBackground = context.UseNeutralSurfaces + ? ColorMath.WithAlpha(ColorMath.Blend(accent, navSurface, 0.24), context.IsNightMode ? (byte)0x8F : (byte)0x6A) + : ColorMath.WithAlpha(ColorMath.Blend(accent, navSurface, context.IsNightMode ? 0.28 : 0.22), context.IsNightMode ? (byte)0x88 : (byte)0x72); + var navSelectionIndicator = ColorMath.EnsureContrast( + context.UseNeutralSurfaces ? accent : accentLight1, + navSurface, + WcagLargeTextContrast); var toggleOn = context.IsNightMode ? accent : accentDark1; var toggleOff = context.IsNightMode - ? Color.FromArgb(0x88, accentDark2.R, accentDark2.G, accentDark2.B) - : Color.FromArgb(0x88, accentLight2.R, accentLight2.G, accentLight2.B); + ? Color.FromArgb(0x88, neutralVariantSeed.R, neutralVariantSeed.G, neutralVariantSeed.B) + : Color.FromArgb(0x88, neutralVariantSeed.R, neutralVariantSeed.G, neutralVariantSeed.B); var toggleBorder = context.IsNightMode - ? ColorMath.WithAlpha(ColorMath.Blend(accentLight2, Color.Parse("#FFF8FAFC"), 0.28), 0x8C) - : ColorMath.WithAlpha(ColorMath.Blend(accentDark2, Color.Parse("#FF334155"), 0.26), 0x78); + ? ColorMath.WithAlpha(ColorMath.Blend(neutralVariantSeed, Color.Parse("#FFF8FAFC"), 0.20), 0x8C) + : ColorMath.WithAlpha(ColorMath.Blend(tertiarySeed, Color.Parse("#FF334155"), 0.18), 0x78); var onAccent = ColorMath.EnsureContrast(Color.Parse("#FFFFFFFF"), accent, WcagNormalTextContrast); return new AppThemePalette( @@ -171,4 +190,11 @@ public static class ThemeColorSystemService toggleOff, toggleBorder); } + + private static Color GetColorOrDefault(IReadOnlyList colors, int index, Color fallback) + { + return index >= 0 && index < colors.Count + ? colors[index] + : fallback; + } } diff --git a/LanMountainDesktop/Services/XiaomiWeatherCodeMapper.cs b/LanMountainDesktop/Services/XiaomiWeatherCodeMapper.cs new file mode 100644 index 0000000..d199c49 --- /dev/null +++ b/LanMountainDesktop/Services/XiaomiWeatherCodeMapper.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; + +namespace LanMountainDesktop.Services; + +internal enum WeatherConditionBucket +{ + Unknown, + Clear, + PartlyCloudy, + Cloudy, + Haze, + Fog, + RainLight, + RainHeavy, + Storm, + Sleet, + Snow +} + +internal static class XiaomiWeatherCodeMapper +{ + private readonly record struct WeatherCodeEntry(string Zh, string En, WeatherConditionBucket Bucket); + + private static readonly IReadOnlyDictionary Entries = new Dictionary + { + [0] = new("\u6674", "Clear", WeatherConditionBucket.Clear), + [1] = new("\u591a\u4e91", "Partly Cloudy", WeatherConditionBucket.PartlyCloudy), + [2] = new("\u9634", "Cloudy", WeatherConditionBucket.Cloudy), + [3] = new("\u9635\u96e8", "Shower", WeatherConditionBucket.RainLight), + [4] = new("\u96f7\u9635\u96e8", "Thunder Shower", WeatherConditionBucket.Storm), + [5] = new("\u96f7\u9635\u96e8\u4f34\u6709\u51b0\u96f9", "Thunder Shower with Hail", WeatherConditionBucket.Storm), + [6] = new("\u96e8\u5939\u96ea", "Sleet", WeatherConditionBucket.Sleet), + [7] = new("\u5c0f\u96e8", "Light Rain", WeatherConditionBucket.RainLight), + [8] = new("\u4e2d\u96e8", "Moderate Rain", WeatherConditionBucket.RainHeavy), + [9] = new("\u5927\u96e8", "Heavy Rain", WeatherConditionBucket.RainHeavy), + [10] = new("\u66b4\u96e8", "Storm", WeatherConditionBucket.RainHeavy), + [11] = new("\u5927\u66b4\u96e8", "Heavy Storm", WeatherConditionBucket.RainHeavy), + [12] = new("\u7279\u5927\u66b4\u96e8", "Severe Storm", WeatherConditionBucket.RainHeavy), + [13] = new("\u9635\u96ea", "Snow Flurry", WeatherConditionBucket.Snow), + [14] = new("\u5c0f\u96ea", "Light Snow", WeatherConditionBucket.Snow), + [15] = new("\u4e2d\u96ea", "Moderate Snow", WeatherConditionBucket.Snow), + [16] = new("\u5927\u96ea", "Heavy Snow", WeatherConditionBucket.Snow), + [17] = new("\u66b4\u96ea", "Snowstorm", WeatherConditionBucket.Snow), + [18] = new("\u96fe", "Fog", WeatherConditionBucket.Fog), + [19] = new("\u51bb\u96e8", "Freezing Rain", WeatherConditionBucket.RainLight), + [20] = new("\u6c99\u5c18\u66b4", "Duststorm", WeatherConditionBucket.Haze), + [21] = new("\u5c0f\u5230\u4e2d\u96e8", "Light to Moderate Rain", WeatherConditionBucket.RainLight), + [22] = new("\u4e2d\u5230\u5927\u96e8", "Moderate to Heavy Rain", WeatherConditionBucket.RainHeavy), + [23] = new("\u5927\u5230\u66b4\u96e8", "Heavy Rain to Storm", WeatherConditionBucket.RainHeavy), + [24] = new("\u66b4\u96e8\u5230\u5927\u66b4\u96e8", "Storm to Heavy Storm", WeatherConditionBucket.RainHeavy), + [25] = new("\u5927\u66b4\u96e8\u5230\u7279\u5927\u66b4\u96e8", "Heavy to Severe Storm", WeatherConditionBucket.RainHeavy), + [26] = new("\u5c0f\u5230\u4e2d\u96ea", "Light to Moderate Snow", WeatherConditionBucket.Snow), + [27] = new("\u4e2d\u5230\u5927\u96ea", "Moderate to Heavy Snow", WeatherConditionBucket.Snow), + [28] = new("\u5927\u5230\u66b4\u96ea", "Heavy Snow to Snowstorm", WeatherConditionBucket.Snow), + [29] = new("\u6d6e\u5c18", "Dust", WeatherConditionBucket.Haze), + [30] = new("\u626c\u6c99", "Sand", WeatherConditionBucket.Haze), + [31] = new("\u5f3a\u6c99\u5c18\u66b4", "Sandstorm", WeatherConditionBucket.Haze), + [32] = new("\u6d53\u96fe", "Dense Fog", WeatherConditionBucket.Fog), + [49] = new("\u5f3a\u6d53\u96fe", "Strong Fog", WeatherConditionBucket.Fog), + [53] = new("\u973e", "Haze", WeatherConditionBucket.Haze), + [54] = new("\u4e2d\u5ea6\u973e", "Moderate Haze", WeatherConditionBucket.Haze), + [55] = new("\u91cd\u5ea6\u973e", "Heavy Haze", WeatherConditionBucket.Haze), + [56] = new("\u4e25\u91cd\u973e", "Severe Haze", WeatherConditionBucket.Haze), + [57] = new("\u5927\u96fe", "Heavy Fog", WeatherConditionBucket.Fog), + [58] = new("\u7279\u5f3a\u6d53\u96fe", "Extra Heavy Fog", WeatherConditionBucket.Fog), + [301] = new("\u96e8", "Rain", WeatherConditionBucket.RainLight), + [302] = new("\u96ea", "Snow", WeatherConditionBucket.Snow) + }; + + public static WeatherConditionBucket ResolveBucket(int? code) + { + if (!code.HasValue) + { + return WeatherConditionBucket.Unknown; + } + + return Entries.TryGetValue(code.Value, out var entry) + ? entry.Bucket + : WeatherConditionBucket.Unknown; + } + + public static string? ResolveDisplayText(int? code, string locale) + { + if (!code.HasValue) + { + return null; + } + + if (Entries.TryGetValue(code.Value, out var entry)) + { + return locale.StartsWith("zh", StringComparison.OrdinalIgnoreCase) + ? entry.Zh + : entry.En; + } + + return locale.StartsWith("zh", StringComparison.OrdinalIgnoreCase) + ? $"\u5929\u6c14\u7801 {code.Value}" + : $"Weather {code.Value}"; + } +} diff --git a/LanMountainDesktop/Services/XiaomiWeatherService.cs b/LanMountainDesktop/Services/XiaomiWeatherService.cs index 7985e75..0fc8df9 100644 --- a/LanMountainDesktop/Services/XiaomiWeatherService.cs +++ b/LanMountainDesktop/Services/XiaomiWeatherService.cs @@ -39,42 +39,6 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable { private sealed record CacheEntry(WeatherSnapshot Snapshot, DateTimeOffset ExpireAt); - private static readonly IReadOnlyDictionary ZhWeatherDescriptions = new Dictionary - { - [0] = "\u6674", - [1] = "\u591a\u4e91", - [2] = "\u9634", - [3] = "\u9635\u96e8", - [4] = "\u96f7\u9635\u96e8", - [7] = "\u5c0f\u96e8", - [8] = "\u4e2d\u96e8", - [9] = "\u5927\u96e8", - [13] = "\u9635\u96ea", - [14] = "\u5c0f\u96ea", - [15] = "\u4e2d\u96ea", - [16] = "\u5927\u96ea", - [18] = "\u96fe", - [32] = "\u973e" - }; - - private static readonly IReadOnlyDictionary EnWeatherDescriptions = new Dictionary - { - [0] = "Clear", - [1] = "Partly Cloudy", - [2] = "Cloudy", - [3] = "Shower", - [4] = "Thunder Shower", - [7] = "Light Rain", - [8] = "Moderate Rain", - [9] = "Heavy Rain", - [13] = "Snow Flurry", - [14] = "Light Snow", - [15] = "Moderate Snow", - [16] = "Heavy Snow", - [18] = "Fog", - [32] = "Haze" - }; - private readonly XiaomiWeatherApiOptions _options; private readonly HttpClient _httpClient; private readonly bool _ownsHttpClient; @@ -412,9 +376,7 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable TryGetNode(payload, "hourly") ?? TryGetNode(payload, "hourlyForecast"); - var weatherCode = ReadInt(currentNode, "weather", "value") ?? - ReadInt(currentNode, "weatherCode") ?? - ReadInt(currentNode, "code"); + var weatherCode = ReadWeatherCode(currentNode); var weatherText = ReadString(currentNode, "weather", "desc") ?? ReadString(currentNode, "weather", "text") ?? @@ -497,8 +459,12 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable var sunItem = GetArrayItem(sunArray, i); var precipitationItem = GetArrayItem(precipitationArray, i); - var dayCode = ReadInt(weatherItem, "from") ?? ReadInt(weatherItem, "day"); - var nightCode = ReadInt(weatherItem, "to") ?? ReadInt(weatherItem, "night"); + var dayCode = ReadInt(weatherItem, "from") ?? + ReadInt(weatherItem, "day") ?? + ReadWeatherCode(weatherItem); + var nightCode = ReadInt(weatherItem, "to") ?? + ReadInt(weatherItem, "night") ?? + ReadWeatherCode(weatherItem); var dayText = ResolveWeatherDescription(dayCode, locale); var nightText = ResolveWeatherDescription(nightCode, locale); @@ -591,11 +557,7 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable continue; } - var code = ReadInt(weatherItem, "value") ?? - ReadInt(weatherItem, "code") ?? - ReadInt(weatherItem, "weatherCode") ?? - ReadInt(weatherItem, "from") ?? - ReadInt(weatherItem); + var code = ReadInt(weatherItem, "from") ?? ReadWeatherCode(weatherItem); var weatherText = ReadString(weatherItem, "text") ?? ReadString(weatherItem, "desc") ?? ResolveWeatherDescription(code, locale); @@ -640,11 +602,7 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable continue; } - var code = ReadInt(item, "weatherCode") ?? - ReadInt(item, "code") ?? - ReadInt(item, "weather", "value") ?? - ReadInt(item, "weather") ?? - ReadInt(item, "from"); + var code = ReadInt(item, "from") ?? ReadWeatherCode(item); var weatherText = ReadString(item, "weatherText") ?? ReadString(item, "weather", "desc") ?? ReadString(item, "weather", "text") ?? @@ -903,6 +861,16 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable return null; } + private static int? ReadWeatherCode(JsonElement? node) + { + return ReadInt(node, "weather", "value") ?? + ReadInt(node, "weatherCode") ?? + ReadInt(node, "code") ?? + ReadInt(node, "weather") ?? + ReadInt(node, "value") ?? + ReadInt(node); + } + private static double? ReadDouble(JsonElement? node, params string[] path) { if (!node.HasValue) @@ -995,19 +963,7 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable private static string? ResolveWeatherDescription(int? code, string locale) { - if (!code.HasValue) - { - return null; - } - - var isZh = locale.StartsWith("zh", StringComparison.OrdinalIgnoreCase); - var source = isZh ? ZhWeatherDescriptions : EnWeatherDescriptions; - if (source.TryGetValue(code.Value, out var text)) - { - return text; - } - - return isZh ? $"\u5929\u6c14\u7801 {code.Value}" : $"Weather {code.Value}"; + return XiaomiWeatherCodeMapper.ResolveDisplayText(code, locale); } private static string Truncate(string? text, int maxLength) diff --git a/LanMountainDesktop/Styles/GlassModule.axaml b/LanMountainDesktop/Styles/GlassModule.axaml index 9fd498e..10b1142 100644 --- a/LanMountainDesktop/Styles/GlassModule.axaml +++ b/LanMountainDesktop/Styles/GlassModule.axaml @@ -184,8 +184,8 @@