diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6991b5a..3e6b45d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,13 +89,12 @@ jobs: -o ./publish/windows-${{ matrix.arch }} ` --self-contained ` -r win-${{ matrix.arch }} ` - -p:PublishSingleFile=true ` + -p:PublishSingleFile=false ` -p:SelfContained=true ` -p:DebugType=none ` -p:DebugSymbols=false ` - -p:PublishTrimmed=true ` - -p:TrimMode=partial ` - -p:PublishReadyToRun=true + -p:PublishTrimmed=false ` + -p:PublishReadyToRun=false shell: pwsh - name: Install Inno Setup @@ -221,13 +220,12 @@ jobs: -o ./publish/linux-x64 \ --self-contained \ -r linux-x64 \ - -p:PublishSingleFile=true \ + -p:PublishSingleFile=false \ -p:SelfContained=true \ -p:DebugType=none \ -p:DebugSymbols=false \ - -p:PublishTrimmed=true \ - -p:TrimMode=partial \ - -p:PublishReadyToRun=true + -p:PublishTrimmed=false \ + -p:PublishReadyToRun=false - name: Package as DEB run: | @@ -331,13 +329,12 @@ EOF -o ./publish/macos-${{ matrix.arch }} \ --self-contained \ -r osx-${{ matrix.arch }} \ - -p:PublishSingleFile=true \ + -p:PublishSingleFile=false \ -p:SelfContained=true \ -p:DebugType=none \ -p:DebugSymbols=false \ - -p:PublishTrimmed=true \ - -p:TrimMode=partial \ - -p:PublishReadyToRun=true + -p:PublishTrimmed=false \ + -p:PublishReadyToRun=false - name: Package as DMG run: | diff --git a/.gitignore b/.gitignore index d8a9d9a..7814091 100644 --- a/.gitignore +++ b/.gitignore @@ -481,3 +481,4 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp nul +/publish-test diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index b8a5e70..50ce6a6 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -2,8 +2,10 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Data.Core; using Avalonia.Data.Core.Plugins; +using System; using System.Linq; using Avalonia.Markup.Xaml; +using LanMountainDesktop.Services; using LanMountainDesktop.ViewModels; using LanMountainDesktop.Views; using AvaloniaWebView; @@ -14,6 +16,7 @@ public partial class App : Application { public override void Initialize() { + ConfigureWebViewUserDataFolder(); AvaloniaWebViewBuilder.Initialize(default); AvaloniaXamlLoader.Load(this); } @@ -46,4 +49,31 @@ public partial class App : Application BindingPlugins.DataValidators.Remove(plugin); } } + + private static void ConfigureWebViewUserDataFolder() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + const string userDataFolderEnvVar = "WEBVIEW2_USER_DATA_FOLDER"; + try + { + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(userDataFolderEnvVar))) + { + return; + } + + var userDataFolder = WebView2RuntimeProbe.ResolveUserDataFolder(); + Environment.SetEnvironmentVariable( + userDataFolderEnvVar, + userDataFolder, + EnvironmentVariableTarget.Process); + } + catch + { + // Keep startup resilient if user profile folders are unavailable. + } + } } diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md b/LanMountainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md index c3c7ace..001170d 100644 --- a/LanMountainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md +++ b/LanMountainDesktop/Assets/Weather/HyperOS3/ATTRIBUTION.md @@ -36,3 +36,20 @@ Extracted weather icon paths inside APK (`res/*.webp`): - `res/Mg.webp` -> `Icons/icon_windy.webp` Use only according to Xiaomi's applicable license and usage terms. + +## Soft Widget Icon Set (2026-03-05) + +To better match the Xiaomi weather time-card visual hierarchy, an additional local icon set was generated for this project: + +- `Icons/icon_hero_sun_soft.png` +- `Icons/icon_hero_moon_soft.png` +- `Icons/icon_mini_partly_cloudy_day_soft.png` +- `Icons/icon_mini_partly_cloudy_night_soft.png` +- `Icons/icon_mini_cloudy_soft.png` +- `Icons/icon_mini_rain_light_soft.png` +- `Icons/icon_mini_rain_heavy_soft.png` +- `Icons/icon_mini_storm_soft.png` +- `Icons/icon_mini_snow_soft.png` +- `Icons/icon_mini_fog_soft.png` + +These files are original derivative assets generated in-repo with local tooling, using the extracted Xiaomi package visual direction as reference (soft glow hero icon + lightweight forecast icons). diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_moon_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_moon_soft.png new file mode 100644 index 0000000..a17b056 Binary files /dev/null and b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_moon_soft.png differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_sun_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_sun_soft.png new file mode 100644 index 0000000..0b95e2d Binary files /dev/null and b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_sun_soft.png differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_cloudy_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_cloudy_soft.png new file mode 100644 index 0000000..9d752f9 Binary files /dev/null and b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_cloudy_soft.png differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_fog_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_fog_soft.png new file mode 100644 index 0000000..764884d Binary files /dev/null and b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_fog_soft.png differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_day_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_day_soft.png new file mode 100644 index 0000000..3ee7efe Binary files /dev/null and b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_day_soft.png differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_night_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_night_soft.png new file mode 100644 index 0000000..3ba9cbe Binary files /dev/null and b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_night_soft.png differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_heavy_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_heavy_soft.png new file mode 100644 index 0000000..2a9a4d0 Binary files /dev/null and b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_heavy_soft.png differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_light_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_light_soft.png new file mode 100644 index 0000000..08d5e68 Binary files /dev/null and b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_light_soft.png differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_snow_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_snow_soft.png new file mode 100644 index 0000000..e8ae667 Binary files /dev/null and b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_snow_soft.png differ diff --git a/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_storm_soft.png b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_storm_soft.png new file mode 100644 index 0000000..3711dc9 Binary files /dev/null and b/LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_storm_soft.png differ diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index 1d37bb0..15b7826 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -6,30 +6,17 @@ 1.0.0 app.manifest true - - - true - true - partial - true - false true - + - true - true - partial - true + false + false + false false none - - - - true - diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 66f7d19..075aec3 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -184,6 +184,9 @@ "settings.about.version_format": "Version: {0}", "settings.about.codename_format": "Code Name: {0}", "settings.about.font_format": "Font: {0}", + "settings.about.startup_header": "Windows Startup", + "settings.about.startup_desc": "Launch the app automatically when signing in to Windows.", + "settings.about.startup_toggle": "Launch at Windows sign-in", "settings.footer": "LanMountainDesktop Settings", "filepicker.title": "Select wallpaper", "filepicker.image_files": "Image files", @@ -255,6 +258,13 @@ "artwork.widget.fallback_artist": "Recommendation service unavailable", "artwork.widget.fallback_year": "Try again later", "artwork.widget.unknown_artist": "Unknown artist", + "artwork.settings.title": "Daily Artwork Settings", + "artwork.settings.desc": "Switch the data source used by Daily Artwork.", + "artwork.settings.source_label": "Mirror Source", + "artwork.settings.source_domestic": "Domestic Mirror", + "artwork.settings.source_overseas": "Overseas Mirror", + "artwork.settings.source_status_domestic": "Current source: Domestic mirror (optimized for China network)", + "artwork.settings.source_status_overseas": "Current source: Overseas mirror (art museum recommendations)", "music.widget.unsupported": "Music control is not supported on this platform", "music.widget.unsupported_hint": "This widget requires Windows SMTC", "music.widget.no_session": "No music source", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 75ad4a4..2ffcb4f 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -184,6 +184,9 @@ "settings.about.version_format": "版本号: {0}", "settings.about.codename_format": "版本代号: {0}", "settings.about.font_format": "字体: {0}", + "settings.about.startup_header": "Windows 自启动", + "settings.about.startup_desc": "在登录 Windows 时自动启动应用。", + "settings.about.startup_toggle": "登录 Windows 时启动", "settings.footer": "LanMountainDesktop 设置", "filepicker.title": "选择壁纸", "filepicker.image_files": "图片文件", @@ -255,6 +258,13 @@ "artwork.widget.fallback_artist": "推荐服务不可用", "artwork.widget.fallback_year": "稍后重试", "artwork.widget.unknown_artist": "未知作者", + "artwork.settings.title": "每日图片设置", + "artwork.settings.desc": "切换每日图片的数据源。", + "artwork.settings.source_label": "镜像源", + "artwork.settings.source_domestic": "国内镜像", + "artwork.settings.source_overseas": "国外镜像", + "artwork.settings.source_status_domestic": "当前源:国内镜像(优先中国网络)", + "artwork.settings.source_status_overseas": "当前源:国外镜像(艺术馆推荐)", "music.widget.unsupported": "当前平台不支持音乐控制", "music.widget.unsupported_hint": "该组件仅支持 Windows SMTC", "music.widget.no_session": "暂无音源", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index fd5a35c..d35b9b5 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -44,6 +44,10 @@ public sealed class AppSettingsSnapshot public bool WeatherNoTlsRequests { get; set; } + public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas; + + public bool AutoStartWithWindows { get; set; } + public List TopStatusComponentIds { get; set; } = []; public List PinnedTaskbarActions { get; set; } = diff --git a/LanMountainDesktop/Models/DailyArtworkMirrorSources.cs b/LanMountainDesktop/Models/DailyArtworkMirrorSources.cs new file mode 100644 index 0000000..102538f --- /dev/null +++ b/LanMountainDesktop/Models/DailyArtworkMirrorSources.cs @@ -0,0 +1,16 @@ +using System; + +namespace LanMountainDesktop.Models; + +public static class DailyArtworkMirrorSources +{ + public const string Domestic = "Domestic"; + public const string Overseas = "Overseas"; + + public static string Normalize(string? value) + { + return string.Equals(value, Domestic, StringComparison.OrdinalIgnoreCase) + ? Domestic + : Overseas; + } +} diff --git a/LanMountainDesktop/Models/RecommendationDataModels.cs b/LanMountainDesktop/Models/RecommendationDataModels.cs index 0dfd2ae..40b6a7c 100644 --- a/LanMountainDesktop/Models/RecommendationDataModels.cs +++ b/LanMountainDesktop/Models/RecommendationDataModels.cs @@ -10,6 +10,7 @@ public sealed record DailyArtworkSnapshot( string? Museum, string? ArtworkUrl, string? ImageUrl, + string? ThumbnailDataUrl, DateTimeOffset FetchedAt); public sealed record DailyPoetrySnapshot( diff --git a/LanMountainDesktop/Services/IRecommendationDataService.cs b/LanMountainDesktop/Services/IRecommendationDataService.cs index 148d2e9..04ad90f 100644 --- a/LanMountainDesktop/Services/IRecommendationDataService.cs +++ b/LanMountainDesktop/Services/IRecommendationDataService.cs @@ -7,6 +7,7 @@ namespace LanMountainDesktop.Services; public sealed record DailyArtworkQuery( string? Locale = null, + string? MirrorSource = null, bool ForceRefresh = false); public sealed record DailyPoetryQuery( @@ -35,11 +36,16 @@ public sealed record RecommendationApiOptions public string JinriShiciPoetryUrl { get; init; } = "https://v1.jinrishici.com/all.json"; public string ArtInstituteArtworkApiTemplate { get; init; } = - "https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link"; + "https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link,thumbnail"; public string ArtInstituteImageUrlTemplate { get; init; } = "https://www.artic.edu/iiif/2/{0}/full/843,/0/default.jpg"; + public string DomesticArtworkApiUrl { get; init; } = + "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=8&mkt=zh-CN"; + + public string DomesticArtworkHost { get; init; } = "https://cn.bing.com"; + public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(20); public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8); diff --git a/LanMountainDesktop/Services/RecommendationDataService.cs b/LanMountainDesktop/Services/RecommendationDataService.cs index 14022c6..dd21425 100644 --- a/LanMountainDesktop/Services/RecommendationDataService.cs +++ b/LanMountainDesktop/Services/RecommendationDataService.cs @@ -12,6 +12,8 @@ namespace LanMountainDesktop.Services; public sealed class RecommendationDataService : IRecommendationInfoService, IDisposable { + private const string UserAgent = "Mozilla/5.0"; + private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record ArtworkCandidate( @@ -19,13 +21,16 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis string? Artist, string? Year, string? ArtworkUrl, - string? ImageId); + string? ImageId, + string? ThumbnailDataUrl); private readonly RecommendationApiOptions _options; private readonly HttpClient _httpClient; private readonly bool _ownsHttpClient; + private readonly AppSettingsService _appSettingsService = new(); private readonly object _cacheGate = new(); - private DailyArtworkCacheEntry? _dailyArtworkCache; + private readonly Dictionary _dailyArtworkCacheBySource = + new(StringComparer.OrdinalIgnoreCase); private DailyPoetryCacheEntry? _dailyPoetryCache; public RecommendationDataService( @@ -60,7 +65,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis { lock (_cacheGate) { - _dailyArtworkCache = null; + _dailyArtworkCacheBySource.Clear(); _dailyPoetryCache = null; } } @@ -79,7 +84,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis try { using var request = new HttpRequestMessage(HttpMethod.Get, _options.JinriShiciPoetryUrl); - request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0"); + request.Headers.TryAddWithoutValidation("User-Agent", UserAgent); using var response = await _httpClient.SendAsync(request, cancellationToken); responseText = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) @@ -132,45 +137,25 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis CancellationToken cancellationToken = default) { var normalizedQuery = query ?? new DailyArtworkQuery(); - if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(out var cached)) + var mirrorSource = ResolveArtworkMirrorSource(normalizedQuery); + if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(mirrorSource, out var cached)) { return RecommendationQueryResult.Ok(cached); } - var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100); + return string.Equals(mirrorSource, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase) + ? await GetDailyArtworkFromDomesticSourceAsync(mirrorSource, cancellationToken) + : await GetDailyArtworkFromOverseasSourceAsync(mirrorSource, cancellationToken); + } + + private async Task> GetDailyArtworkFromOverseasSourceAsync( + string mirrorSource, + CancellationToken cancellationToken) + { var localDate = GetChinaLocalDate(); - var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100); - var requestUrl = string.Format( - CultureInfo.InvariantCulture, - _options.ArtInstituteArtworkApiTemplate, - page, - candidateCount); - - string responseText; - try - { - using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); - request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0"); - using var response = await _httpClient.SendAsync(request, cancellationToken); - responseText = await response.Content.ReadAsStringAsync(cancellationToken); - if (!response.IsSuccessStatusCode) - { - return RecommendationQueryResult.Fail( - "upstream_http_error", - $"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}"); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); - } - try { + var responseText = await FetchOverseasArtworkPayloadAsync(localDate, cancellationToken); using var document = JsonDocument.Parse(responseText); var root = document.RootElement; if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array) @@ -183,7 +168,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis { var title = ReadString(item, "title"); var imageId = ReadString(item, "image_id"); - if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(imageId)) + var thumbnailDataUrl = ReadString(item, "thumbnail", "lqip"); + if (string.IsNullOrWhiteSpace(title) || + (string.IsNullOrWhiteSpace(imageId) && string.IsNullOrWhiteSpace(thumbnailDataUrl))) { continue; } @@ -199,7 +186,8 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis artist, ReadString(item, "date_display"), ReadString(item, "api_link"), - imageId.Trim())); + string.IsNullOrWhiteSpace(imageId) ? null : imageId.Trim(), + string.IsNullOrWhiteSpace(thumbnailDataUrl) ? null : thumbnailDataUrl.Trim())); } if (candidates.Count == 0) @@ -217,24 +205,121 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis Museum: "The Art Institute of Chicago", ArtworkUrl: selected.ArtworkUrl, ImageUrl: BuildArtworkImageUrl(selected.ImageId), + ThumbnailDataUrl: selected.ThumbnailDataUrl, FetchedAt: DateTimeOffset.UtcNow); - SetDailyArtworkCache(snapshot); + SetDailyArtworkCache(mirrorSource, snapshot); return RecommendationQueryResult.Ok(snapshot); } + catch (OperationCanceledException) + { + throw; + } + catch (HttpRequestException ex) + { + return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); + } catch (Exception ex) { return RecommendationQueryResult.Fail("upstream_parse_error", ex.Message); } } - private bool TryGetDailyArtworkFromCache(out DailyArtworkSnapshot snapshot) + private async Task> GetDailyArtworkFromDomesticSourceAsync( + string mirrorSource, + CancellationToken cancellationToken) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, _options.DomesticArtworkApiUrl); + request.Headers.TryAddWithoutValidation("User-Agent", UserAgent); + using var response = await _httpClient.SendAsync(request, cancellationToken); + var responseText = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + return RecommendationQueryResult.Fail( + "upstream_http_error", + $"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}"); + } + + using var document = JsonDocument.Parse(responseText); + var root = document.RootElement; + if (!root.TryGetProperty("images", out var images) || images.ValueKind != JsonValueKind.Array) + { + return RecommendationQueryResult.Fail("upstream_parse_error", "Daily image list is missing."); + } + + var candidates = images.EnumerateArray().ToArray(); + if (candidates.Length == 0) + { + return RecommendationQueryResult.Fail("upstream_empty_result", "No daily image candidates were returned."); + } + + var localDate = GetChinaLocalDate(); + var indexSeed = localDate.Year * 1000 + localDate.DayOfYear; + var selected = candidates[Math.Abs(indexSeed) % candidates.Length]; + + var imageUrl = BuildDomesticImageUrl( + ReadString(selected, "url"), + _options.DomesticArtworkHost); + if (string.IsNullOrWhiteSpace(imageUrl)) + { + return RecommendationQueryResult.Fail("upstream_parse_error", "Daily image URL is missing."); + } + + var title = ReadString(selected, "title"); + if (string.IsNullOrWhiteSpace(title)) + { + title = ExtractDomesticTitle(ReadString(selected, "copyright")); + } + + if (string.IsNullOrWhiteSpace(title)) + { + title = "Bing Daily Image"; + } + + var dateText = ParseDomesticDateText(ReadString(selected, "startdate")); + var artworkUrl = BuildDomesticImageUrl( + ReadString(selected, "copyrightlink"), + _options.DomesticArtworkHost); + if (string.IsNullOrWhiteSpace(artworkUrl) || + artworkUrl.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase)) + { + artworkUrl = null; + } + + var snapshot = new DailyArtworkSnapshot( + Provider: "BingCN", + Title: title.Trim(), + Artist: "Bing China", + Year: dateText, + Museum: "Bing China", + ArtworkUrl: artworkUrl, + ImageUrl: imageUrl, + ThumbnailDataUrl: null, + FetchedAt: DateTimeOffset.UtcNow); + + SetDailyArtworkCache(mirrorSource, snapshot); + return RecommendationQueryResult.Ok(snapshot); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); + } + } + + private bool TryGetDailyArtworkFromCache(string mirrorSource, out DailyArtworkSnapshot snapshot) { lock (_cacheGate) { - if (_dailyArtworkCache is not null && _dailyArtworkCache.ExpireAt > DateTimeOffset.UtcNow) + if (_dailyArtworkCacheBySource.TryGetValue(mirrorSource, out var cacheEntry) && + cacheEntry.ExpireAt > DateTimeOffset.UtcNow) { - snapshot = _dailyArtworkCache.Snapshot; + snapshot = cacheEntry.Snapshot; return true; } } @@ -243,11 +328,11 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis return false; } - private void SetDailyArtworkCache(DailyArtworkSnapshot snapshot) + private void SetDailyArtworkCache(string mirrorSource, DailyArtworkSnapshot snapshot) { lock (_cacheGate) { - _dailyArtworkCache = new DailyArtworkCacheEntry( + _dailyArtworkCacheBySource[mirrorSource] = new DailyArtworkCacheEntry( snapshot, DateTimeOffset.UtcNow.Add(_options.CacheDuration)); } @@ -325,6 +410,105 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis imageId.Trim()); } + private string ResolveArtworkMirrorSource(DailyArtworkQuery query) + { + if (!string.IsNullOrWhiteSpace(query.MirrorSource)) + { + return DailyArtworkMirrorSources.Normalize(query.MirrorSource); + } + + try + { + var snapshot = _appSettingsService.Load(); + return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource); + } + catch + { + return DailyArtworkMirrorSources.Overseas; + } + } + + private async Task FetchOverseasArtworkPayloadAsync(DateOnly localDate, CancellationToken cancellationToken) + { + var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100); + var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100); + var requestUrl = string.Format( + CultureInfo.InvariantCulture, + _options.ArtInstituteArtworkApiTemplate, + page, + candidateCount); + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + request.Headers.TryAddWithoutValidation("User-Agent", UserAgent); + using var response = await _httpClient.SendAsync(request, cancellationToken); + var responseText = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}"); + } + + return responseText; + } + + private static string? BuildDomesticImageUrl(string? rawValue, string fallbackHost) + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + return null; + } + + var candidate = rawValue.Trim(); + if (Uri.TryCreate(candidate, UriKind.Absolute, out var absoluteUri)) + { + return absoluteUri.ToString(); + } + + if (!Uri.TryCreate(fallbackHost, UriKind.Absolute, out var hostUri)) + { + return null; + } + + var normalizedPath = candidate.StartsWith("/", StringComparison.Ordinal) ? candidate : $"/{candidate}"; + return new Uri(hostUri, normalizedPath).ToString(); + } + + private static string ExtractDomesticTitle(string? copyrightText) + { + if (string.IsNullOrWhiteSpace(copyrightText)) + { + return string.Empty; + } + + var compact = copyrightText.Trim(); + var bracketIndex = compact.IndexOf('('); + if (bracketIndex <= 0) + { + return compact; + } + + return compact[..bracketIndex].Trim(); + } + + private static string? ParseDomesticDateText(string? rawDate) + { + if (string.IsNullOrWhiteSpace(rawDate) || rawDate.Length < 8) + { + return null; + } + + if (DateTime.TryParseExact( + rawDate[..8], + "yyyyMMdd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var date)) + { + return date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + } + + return null; + } + private static string? ReadFirstNonEmptyLine(string? text) { if (string.IsNullOrWhiteSpace(text)) diff --git a/LanMountainDesktop/Services/WebView2RuntimeProbe.cs b/LanMountainDesktop/Services/WebView2RuntimeProbe.cs new file mode 100644 index 0000000..3abe548 --- /dev/null +++ b/LanMountainDesktop/Services/WebView2RuntimeProbe.cs @@ -0,0 +1,126 @@ +using System; +using System.IO; +using System.Reflection; +using System.Runtime.Versioning; +using Microsoft.Win32; + +namespace LanMountainDesktop.Services; + +public sealed record WebView2RuntimeAvailability( + bool IsAvailable, + string? Version, + string Message); + +public static class WebView2RuntimeProbe +{ + private const string WebView2RuntimeClientId = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; + private const string WebView2RuntimeKeyPath = @"SOFTWARE\Microsoft\EdgeUpdate\Clients\" + WebView2RuntimeClientId; + public const string RuntimeDownloadUrl = "https://go.microsoft.com/fwlink/p/?LinkId=2124703"; + + public static WebView2RuntimeAvailability GetAvailability() + { + if (!OperatingSystem.IsWindows()) + { + return new WebView2RuntimeAvailability( + IsAvailable: true, + Version: null, + Message: string.Empty); + } + + try + { + var version = TryGetVersionFromWebView2Api(); + if (string.IsNullOrWhiteSpace(version)) + { + version = TryGetVersionFromRegistry(); + } + + if (!string.IsNullOrWhiteSpace(version)) + { + return new WebView2RuntimeAvailability( + IsAvailable: true, + Version: version.Trim(), + Message: string.Empty); + } + + return new WebView2RuntimeAvailability( + IsAvailable: false, + Version: null, + Message: $"WebView2 Runtime is missing. Install it from {RuntimeDownloadUrl} and restart the app."); + } + catch (Exception ex) + { + return new WebView2RuntimeAvailability( + IsAvailable: false, + Version: null, + Message: $"WebView2 runtime check failed: {ex.Message}"); + } + } + + public static string ResolveUserDataFolder() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(localAppData)) + { + localAppData = AppContext.BaseDirectory; + } + + var userDataFolder = Path.Combine(localAppData, "LanMountainDesktop", "WebView2"); + Directory.CreateDirectory(userDataFolder); + return userDataFolder; + } + + private static string? TryGetVersionFromWebView2Api() + { + var type = Type.GetType( + "Microsoft.Web.WebView2.Core.CoreWebView2Environment, Microsoft.Web.WebView2.Core", + throwOnError: false); + if (type is null) + { + return null; + } + + var method = type.GetMethod( + "GetAvailableBrowserVersionString", + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: Type.EmptyTypes, + modifiers: null); + if (method is null) + { + return null; + } + + return method.Invoke(null, null) as string; + } + + [SupportedOSPlatform("windows")] + private static string? TryGetVersionFromRegistry() + { + return TryReadVersionFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry64) + ?? TryReadVersionFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry32) + ?? TryReadVersionFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry64) + ?? TryReadVersionFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry32); + } + + [SupportedOSPlatform("windows")] + private static string? TryReadVersionFromRegistry(RegistryHive hive, RegistryView view) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(hive, view); + using var runtimeKey = baseKey.OpenSubKey(WebView2RuntimeKeyPath, writable: false); + if (runtimeKey is null) + { + return null; + } + + var value = runtimeKey.GetValue("pv") as string; + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + catch + { + return null; + } + } +} diff --git a/LanMountainDesktop/Services/WindowsStartupService.cs b/LanMountainDesktop/Services/WindowsStartupService.cs new file mode 100644 index 0000000..58813c8 --- /dev/null +++ b/LanMountainDesktop/Services/WindowsStartupService.cs @@ -0,0 +1,75 @@ +using System; +using Microsoft.Win32; + +namespace LanMountainDesktop.Services; + +public sealed class WindowsStartupService +{ + private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string ValueName = "LanMountainDesktop"; + private readonly string _startupCommand; + + public WindowsStartupService() + { + var processPath = Environment.ProcessPath; + _startupCommand = string.IsNullOrWhiteSpace(processPath) + ? string.Empty + : $"\"{processPath}\""; + } + + public bool IsEnabled() + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + try + { + using var runKey = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: false); + return runKey?.GetValue(ValueName) is string value && + !string.IsNullOrWhiteSpace(value); + } + catch + { + return false; + } + } + + public bool SetEnabled(bool enabled) + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + if (enabled && string.IsNullOrWhiteSpace(_startupCommand)) + { + return false; + } + + try + { + using var runKey = Registry.CurrentUser.CreateSubKey(RunKeyPath); + if (runKey is null) + { + return false; + } + + if (enabled) + { + runKey.SetValue(ValueName, _startupCommand, RegistryValueKind.String); + } + else + { + runKey.DeleteValue(ValueName, throwOnMissingValue: false); + } + + return IsEnabled() == enabled; + } + catch + { + return false; + } + } +} diff --git a/LanMountainDesktop/Views/Components/BrowserWidget.axaml b/LanMountainDesktop/Views/Components/BrowserWidget.axaml index e27d47e..86e998d 100644 --- a/LanMountainDesktop/Views/Components/BrowserWidget.axaml +++ b/LanMountainDesktop/Views/Components/BrowserWidget.axaml @@ -23,7 +23,23 @@ Background="#FFFFFFFF" BorderBrush="#22000000" BorderThickness="1"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/DailyArtworkSettingsWindow.axaml.cs b/LanMountainDesktop/Views/Components/DailyArtworkSettingsWindow.axaml.cs new file mode 100644 index 0000000..695f5f8 --- /dev/null +++ b/LanMountainDesktop/Views/Components/DailyArtworkSettingsWindow.axaml.cs @@ -0,0 +1,97 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class DailyArtworkSettingsWindow : UserControl +{ + private readonly AppSettingsService _appSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private string _languageCode = "zh-CN"; + private bool _suppressEvents; + + public event EventHandler? SettingsChanged; + + public string CurrentSource => GetSelectedSource(); + + public DailyArtworkSettingsWindow() + { + InitializeComponent(); + LoadState(); + ApplyLocalization(); + } + + private void LoadState() + { + var snapshot = _appSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + + var source = DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource); + _suppressEvents = true; + MirrorSourceComboBox.SelectedIndex = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase) + ? 0 + : 1; + _suppressEvents = false; + UpdateSourceStatus(source); + } + + private void ApplyLocalization() + { + TitleTextBlock.Text = L("artwork.settings.title", "每日图片设置"); + DescriptionTextBlock.Text = L("artwork.settings.desc", "切换每日图片的数据源。"); + MirrorSourceLabelTextBlock.Text = L("artwork.settings.source_label", "镜像源"); + MirrorSourceDomesticItem.Content = L("artwork.settings.source_domestic", "国内镜像"); + MirrorSourceOverseasItem.Content = L("artwork.settings.source_overseas", "国外镜像"); + UpdateSourceStatus(GetSelectedSource()); + } + + private void OnMirrorSourceSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + var source = GetSelectedSource(); + var snapshot = _appSettingsService.Load(); + snapshot.DailyArtworkMirrorSource = source; + _appSettingsService.Save(snapshot); + + UpdateSourceStatus(source); + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + private string GetSelectedSource() + { + if (MirrorSourceComboBox.SelectedItem is ComboBoxItem comboBoxItem && + comboBoxItem.Tag is string tagValue) + { + return DailyArtworkMirrorSources.Normalize(tagValue); + } + + return DailyArtworkMirrorSources.Overseas; + } + + private void UpdateSourceStatus(string source) + { + if (StatusTextBlock is null) + { + return; + } + + StatusTextBlock.Text = string.Equals(source, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase) + ? L("artwork.settings.source_status_domestic", "当前源:国内镜像(优先中国网络)") + : L("artwork.settings.source_status_overseas", "当前源:国外镜像(艺术馆推荐)"); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml.cs b/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml.cs index b769003..3afddee 100644 --- a/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml.cs @@ -122,6 +122,15 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, } } + public void RefreshFromSettings() + { + _recommendationService.ClearCache(); + if (_isAttached) + { + _ = RefreshArtworkAsync(forceRefresh: true); + } + } + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = true; @@ -219,7 +228,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, UpdateAdaptiveLayout(); - var bitmap = await TryLoadArtworkBitmapAsync(snapshot.ImageUrl, cancellationToken); + var bitmap = await TryLoadArtworkBitmapAsync(snapshot.ImageUrl, snapshot.ThumbnailDataUrl, cancellationToken); if (cancellationToken.IsCancellationRequested || !_isAttached) { bitmap?.Dispose(); @@ -229,35 +238,118 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, SetArtworkBitmap(bitmap); } - private static async Task TryLoadArtworkBitmapAsync(string? imageUrl, CancellationToken cancellationToken) + private static async Task TryLoadArtworkBitmapAsync( + string? imageUrl, + string? thumbnailDataUrl, + CancellationToken cancellationToken) + { + foreach (var candidateUrl in BuildImageUrlCandidates(imageUrl)) + { + var remoteBitmap = await TryDownloadBitmapAsync(candidateUrl, cancellationToken); + if (remoteBitmap is not null) + { + return remoteBitmap; + } + } + + return TryDecodeBitmapFromDataUrl(thumbnailDataUrl); + } + + private static IEnumerable BuildImageUrlCandidates(string? imageUrl) { if (string.IsNullOrWhiteSpace(imageUrl)) + { + yield break; + } + + var normalizedUrl = imageUrl.Trim(); + yield return normalizedUrl; + + const string preferredSizeSegment = "/full/843,/0/default.jpg"; + if (normalizedUrl.Contains(preferredSizeSegment, StringComparison.OrdinalIgnoreCase)) + { + yield return normalizedUrl.Replace( + preferredSizeSegment, + "/full/1024,/0/default.jpg", + StringComparison.OrdinalIgnoreCase); + } + } + + private static async Task TryDownloadBitmapAsync(string imageUrl, CancellationToken cancellationToken) + { + var withReferrer = await SendImageRequestAsync(imageUrl, includeReferrer: true, cancellationToken); + if (withReferrer is not null) + { + return withReferrer; + } + + return await SendImageRequestAsync(imageUrl, includeReferrer: false, cancellationToken); + } + + private static async Task SendImageRequestAsync( + string imageUrl, + bool includeReferrer, + CancellationToken cancellationToken) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, imageUrl); + request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent); + request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"); + if (includeReferrer && Uri.TryCreate(imageUrl, UriKind.Absolute, out var imageUri)) + { + request.Headers.Referrer = new Uri($"{imageUri.Scheme}://{imageUri.Host}/", UriKind.Absolute); + } + + using var response = await ImageHttpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var memory = new MemoryStream(); + await stream.CopyToAsync(memory, cancellationToken); + memory.Position = 0; + return new Bitmap(memory); + } + catch (OperationCanceledException) + { + throw; + } + catch { return null; } + } - using var request = new HttpRequestMessage(HttpMethod.Get, imageUrl.Trim()); - request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent); - request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"); - if (Uri.TryCreate(imageUrl.Trim(), UriKind.Absolute, out var imageUri)) - { - request.Headers.Referrer = new Uri($"{imageUri.Scheme}://{imageUri.Host}/", UriKind.Absolute); - } - - using var response = await ImageHttpClient.SendAsync( - request, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken); - if (!response.IsSuccessStatusCode) + private static Bitmap? TryDecodeBitmapFromDataUrl(string? dataUrl) + { + if (string.IsNullOrWhiteSpace(dataUrl)) { return null; } - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - var memory = new MemoryStream(); - await stream.CopyToAsync(memory, cancellationToken); - memory.Position = 0; - return new Bitmap(memory); + var trimmed = dataUrl.Trim(); + var markerIndex = trimmed.IndexOf("base64,", StringComparison.OrdinalIgnoreCase); + if (markerIndex < 0 || markerIndex + 7 >= trimmed.Length) + { + return null; + } + + var base64Payload = trimmed[(markerIndex + 7)..]; + try + { + var bytes = Convert.FromBase64String(base64Payload); + return new Bitmap(new MemoryStream(bytes)); + } + catch + { + return null; + } } private void ApplyLoadingState() diff --git a/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml b/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml index fa96f7d..5d426c8 100644 --- a/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml +++ b/LanMountainDesktop/Views/Components/ExtendedWeatherWidget.axaml @@ -88,7 +88,7 @@ FontFeatures="tnum" VerticalAlignment="Center" Margin="0,-2,0,0" - TextTrimming="CharacterEllipsis" + TextTrimming="None" MaxLines="1" /> = 4) + { + LayoutRoot.RowDefinitions[0].Height = new GridLength(summaryHeight, GridUnitType.Pixel); + LayoutRoot.RowDefinitions[1].Height = new GridLength(hourlyHeight, GridUnitType.Pixel); + LayoutRoot.RowDefinitions[2].Height = new GridLength(separatorBandHeight, GridUnitType.Pixel); + LayoutRoot.RowDefinitions[3].Height = new GridLength(1, GridUnitType.Star); + } + + var topScale = Math.Clamp(((summaryHeight / 118d) * 0.44) + (visualScale * 0.84), 0.24, 4.00); + var iconGrowth = Math.Clamp((visualScale - 0.88) / 1.70, 0, 1); + var iconScaleBoost = ResolveHeroIconScaleBoost(_activeVisualKind); + var iconSize = Math.Clamp(Lerp(90, 122, iconGrowth) * topScale * iconScaleBoost, 14, 360); + iconSize = Math.Min(iconSize, Math.Max(14, innerWidth * Lerp(0.18, 0.26, iconGrowth))); + var temperatureSample = string.IsNullOrWhiteSpace(TemperatureTextBlock.Text) + ? "00°" + : TemperatureTextBlock.Text.Trim(); + var temperatureGlyphCount = Math.Clamp(temperatureSample.Length, 3, 6); + var temperatureMaxWidth = Math.Max(30, innerWidth - iconSize - SummaryGrid.ColumnSpacing - 6); + var rawTemperatureSize = Math.Clamp(Lerp(72, 102, iconGrowth) * topScale, 14, 340); + var fitTemperatureSize = temperatureMaxWidth / (temperatureGlyphCount * 0.62); + TemperatureTextBlock.FontSize = Math.Clamp(Math.Min(rawTemperatureSize, fitTemperatureSize), 10, 340); + TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 380, emphasis)); + TemperatureTextBlock.MaxWidth = Math.Clamp(temperatureMaxWidth, 30, Math.Max(300, innerWidth * 0.66)); + TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-2.2 * topScale, -12, 0), 0, 0); + + var cityFontSize = Math.Clamp(18.5 * topScale, 7, 86); + var conditionFontSize = Math.Clamp(20 * topScale, 7, 90); + var rangeFontSize = Math.Clamp(20 * topScale, 7, 90); CityTextBlock.FontSize = cityFontSize; ConditionTextBlock.FontSize = conditionFontSize; RangeTextBlock.FontSize = rangeFontSize; - CityTextBlock.FontWeight = ToVariableWeight(540); - ConditionTextBlock.FontWeight = ToVariableWeight(600); - RangeTextBlock.FontWeight = ToVariableWeight(620); + CityTextBlock.FontWeight = ToVariableWeight(Lerp(530, 620, emphasis)); + ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(580, 660, emphasis)); + RangeTextBlock.FontWeight = ToVariableWeight(Lerp(600, 680, emphasis)); CityTextBlock.LineHeight = cityFontSize * 1.08; ConditionTextBlock.LineHeight = conditionFontSize * 1.06; RangeTextBlock.LineHeight = rangeFontSize * 1.06; - var iconSize = Math.Clamp(height * 0.116, 36, 102); + WeatherIconImage.Width = iconSize; WeatherIconImage.Height = iconSize; - ConditionTextBlock.MaxWidth = Math.Clamp(width * 0.24, 58, 220); - RangeTextBlock.MaxWidth = Math.Clamp(width * 0.30, 88, 270); - CityTextBlock.MaxWidth = Math.Clamp(width * 0.36, 112, 300); + WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-2.4 * topScale, -12, 0), 0, 0); + ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.25, 28, 340); + RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.31, 34, 380); + CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.36, 34, 420); HourlyPanelBorder.Padding = new Thickness(0); HourlyPanelBorder.CornerRadius = new CornerRadius(0); + HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(4 * fitScale, 0, 18), 0, 0); - var hourlyBandHeight = Math.Clamp(height * 0.195, 74, 160); - var hourlyCellWidth = Math.Max(34, (width - HourlyPanelBorder.Padding.Left - HourlyPanelBorder.Padding.Right - (HourlyGrid.ColumnSpacing * 5)) / 6d); - var hourlyTempSize = Math.Clamp(hourlyBandHeight * 0.24, 10, 32); - var hourlyTimeSize = Math.Clamp(hourlyBandHeight * 0.18, 8, 22); - var hourlyIconSize = Math.Clamp(hourlyBandHeight * 0.20, 12, 30); - var hourlyStackSpacing = Math.Clamp(hourlyBandHeight * 0.03, 1, 4); + var hourlyCellWidth = Math.Max(12, (innerWidth - (HourlyGrid.ColumnSpacing * 5)) / 6d); + var hourlyCellScale = Math.Clamp( + Math.Min((visualScale * 0.44) + ((hourlyHeight / 120d) * 0.62), hourlyCellWidth / 76d), + 0.22, + 3.80); + var hourlyTempSize = Math.Clamp(19 * hourlyCellScale, 6, 72); + var hourlyTimeSize = Math.Clamp(14 * hourlyCellScale, 6, 52); + var hourlyIconSize = Math.Clamp(34 * hourlyCellScale, 8, 114); + var hourlyStackSpacing = Math.Clamp(2 * hourlyCellScale, 0.2, 10); for (var i = 0; i < _hourlyTempBlocks.Length; i++) { _hourlyTempBlocks[i].FontSize = hourlyTempSize; _hourlyTimeBlocks[i].FontSize = hourlyTimeSize; - _hourlyTempBlocks[i].FontWeight = ToVariableWeight(Lerp(540, 610, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); - _hourlyTimeBlocks[i].FontWeight = ToVariableWeight(Lerp(450, 530, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); - _hourlyTempBlocks[i].MaxWidth = hourlyCellWidth; - _hourlyTimeBlocks[i].MaxWidth = hourlyCellWidth; + _hourlyTempBlocks[i].FontWeight = ToVariableWeight(Lerp(540, 650, emphasis)); + _hourlyTimeBlocks[i].FontWeight = ToVariableWeight(Lerp(450, 560, emphasis)); + _hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 260); + _hourlyTimeBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 260); _hourlyIconBlocks[i].Width = hourlyIconSize; _hourlyIconBlocks[i].Height = hourlyIconSize; if (_hourlyTempBlocks[i].Parent is StackPanel stack) stack.Spacing = hourlyStackSpacing; } - var dailyLabelSize = Math.Clamp(height * 0.041, 10, 30); - var dailyTempSize = Math.Clamp(height * 0.043, 10, 33); - var dailyIconSize = Math.Clamp(height * 0.040, 12, 30); - var dailyLabelMaxWidth = Math.Clamp(width * (compactness > 0.3 ? 0.48 : 0.56), 120, 380); - var dailyHighWidth = Math.Clamp(width * 0.11, 34, 72); - var dailyLowWidth = Math.Clamp(width * 0.10, 30, 68); + SeparatorLine.Margin = new Thickness(0, Math.Clamp(separatorBandHeight * 0.45, 1, 16), 0, 0); + DailyGrid.Margin = new Thickness(0, Math.Clamp(6 * fitScale, 0.5, 24), 0, 0); + var dailyAreaHeight = Math.Max(50, innerHeight - summaryHeight - hourlyHeight - separatorBandHeight - (LayoutRoot.RowSpacing * 3) - DailyGrid.Margin.Top); + var dailyRowSpacing = Math.Clamp(dailyAreaHeight * 0.028, 1, 22); + DailyGrid.RowSpacing = dailyRowSpacing; + var dailyRowHeight = Math.Max(8, (dailyAreaHeight - (dailyRowSpacing * 4)) / 5d); + var dailyRowScale = Math.Clamp(((dailyRowHeight / 40d) * 0.62) + (visualScale * 0.44), 0.22, 3.80); + + var dailyLabelSize = Math.Clamp(18.5 * dailyRowScale, 6, 70); + var dailyTempSize = Math.Clamp(19 * dailyRowScale, 6, 72); + var dailyIconSize = Math.Clamp(30 * dailyRowScale, 8, 102); + var dailyLabelMaxWidth = Math.Clamp(innerWidth * 0.52, 28, 460); + var dailyHighWidth = Math.Clamp(innerWidth * 0.14, 14, 140); + var dailyLowWidth = Math.Clamp(innerWidth * 0.11, 12, 120); + var dailyHighRightGap = Math.Clamp(innerWidth * 0.018, 1, 28); for (var i = 0; i < _dailyLabelBlocks.Length; i++) { _dailyLabelBlocks[i].FontSize = dailyLabelSize; _dailyHighBlocks[i].FontSize = dailyTempSize; _dailyLowBlocks[i].FontSize = dailyTempSize; - _dailyLabelBlocks[i].FontWeight = ToVariableWeight(Lerp(520, 600, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); - _dailyHighBlocks[i].FontWeight = ToVariableWeight(Lerp(560, 640, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); - _dailyLowBlocks[i].FontWeight = ToVariableWeight(Lerp(470, 560, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); + _dailyLabelBlocks[i].FontWeight = ToVariableWeight(Lerp(520, 620, emphasis)); + _dailyHighBlocks[i].FontWeight = ToVariableWeight(Lerp(560, 680, emphasis)); + _dailyLowBlocks[i].FontWeight = ToVariableWeight(Lerp(470, 590, emphasis)); _dailyLabelBlocks[i].MaxWidth = dailyLabelMaxWidth; _dailyHighBlocks[i].Width = dailyHighWidth; _dailyLowBlocks[i].Width = dailyLowWidth; + _dailyHighBlocks[i].Margin = new Thickness(0, 0, dailyHighRightGap, 0); + _dailyLowBlocks[i].Margin = new Thickness(0); _dailyHighBlocks[i].HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right; _dailyLowBlocks[i].HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right; _dailyHighBlocks[i].TextAlignment = TextAlignment.Right; @@ -784,6 +830,13 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge private static double ResolveScale(double width, double height) => Math.Clamp(Math.Min(Math.Clamp(width / 620d, 0.42, 2.4), Math.Clamp(height / 620d, 0.42, 2.4)), 0.42, 2.4); private static double Lerp(double from, double to, double t) => from + ((to - from) * t); + private static double ResolveHeroIconScaleBoost(HyperOS3WeatherVisualKind kind) => + kind switch + { + HyperOS3WeatherVisualKind.RainLight or HyperOS3WeatherVisualKind.RainHeavy or HyperOS3WeatherVisualKind.Storm or HyperOS3WeatherVisualKind.Snow => 1.16, + HyperOS3WeatherVisualKind.ClearNight or HyperOS3WeatherVisualKind.CloudyNight => 1.08, + _ => 1.0 + }; private static FontWeight ToVariableWeight(double weight) => (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000); private static IBrush CreateSolidBrush(string colorHex) => new SolidColorBrush(Color.Parse(colorHex)); private static IBrush CreateSolidBrush(string colorHex, byte alpha) { var c = Color.Parse(colorHex); return new SolidColorBrush(Color.FromArgb(alpha, c.R, c.G, c.B)); } diff --git a/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml b/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml index fbf15f1..b434230 100644 --- a/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml +++ b/LanMountainDesktop/Views/Components/HourlyWeatherWidget.axaml @@ -63,7 +63,7 @@ FontFeatures="tnum" VerticalAlignment="Center" Margin="0,-2,0,0" - TextTrimming="CharacterEllipsis" + TextTrimming="None" MaxLines="1" /> items) { var fallbackIcon = HyperOS3WeatherAssetLoader.LoadImage( - HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(_activeVisualKind))); + HyperOS3WeatherTheme.ResolveMiniIconAsset(ToThemeKind(_activeVisualKind))); for (var i = 0; i < _hourlyTimeBlocks.Length; i++) { if (i >= items.Count) @@ -836,7 +836,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, var item = items[i]; _hourlyTimeBlocks[i].Text = item.TimeLabel; _hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage( - HyperOS3WeatherTheme.ResolveIconAsset(item.IconKind)); + HyperOS3WeatherTheme.ResolveMiniIconAsset(item.IconKind)); _hourlyTempBlocks[i].Text = item.TemperatureText; } } @@ -1168,68 +1168,84 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, private void ApplyAdaptiveTypography() { var (layoutWidth, layoutHeight) = ResolveLayoutViewport(); - var scaleX = Math.Clamp(layoutWidth / 608d, 0.58, 1.90); - var scaleY = Math.Clamp(layoutHeight / 288d, 0.58, 1.90); var innerWidth = Math.Max(120, layoutWidth); - var innerHeight = Math.Max(72, layoutHeight); - var compactness = Math.Clamp((1.0 - scaleY) / 0.55, 0, 1); + var innerHeight = Math.Max(56, layoutHeight); + var fitScale = Math.Clamp(Math.Min(innerWidth / 592d, innerHeight / 284d), 0.30, 3.20); + var cellScale = Math.Clamp(_currentCellSize / 44d, 0.34, 3.60); + var visualScale = Math.Clamp((fitScale * 0.72) + (cellScale * 0.28), 0.30, 3.60); + var emphasis = Math.Clamp((visualScale - 0.82) / 1.90, 0, 1); - ContentGrid.RowSpacing = Math.Clamp((4.2 - (compactness * 0.7)) * scaleY, 2, 8); - TopRowGrid.ColumnSpacing = Math.Clamp(8 * scaleX, 6, 13); - BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp((1.0 - (compactness * 0.4)) * scaleY, 0, 2)); + ContentGrid.RowSpacing = Math.Clamp(8 * fitScale, 1, 20); + TopRowGrid.ColumnSpacing = Math.Clamp(11 * fitScale, 3, 30); + BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(1.2 * fitScale, 0, 7)); - var contentHeight = Math.Max(60, innerHeight - ContentGrid.RowSpacing); - var topZoneRatio = Math.Clamp(0.38 + (compactness * 0.09), 0.36, 0.50); - var topZoneHeight = Math.Clamp(contentHeight * topZoneRatio, 60, 170); - var bottomZoneHeight = Math.Max(42, contentHeight - topZoneHeight); - var topScaleH = Math.Clamp(topZoneHeight / 102d, 0.62, 2.0); - var topScaleW = Math.Clamp(innerWidth / 620d, 0.62, 2.0); - var topScale = Math.Clamp((topScaleH * 0.68) + (topScaleW * 0.32), 0.62, 2.0); - var bottomScaleH = Math.Clamp(bottomZoneHeight / 122d, 0.56, 2.0); - var bottomScale = Math.Clamp((bottomScaleH * 0.74) + (scaleX * 0.26), 0.56, 1.95); - var bodyHeight = bottomZoneHeight; + var contentHeight = Math.Max(36, innerHeight - ContentGrid.RowSpacing); + var topZoneHeight = Math.Clamp(contentHeight * 0.47, 24, Math.Max(24, contentHeight - 12)); + var bottomZoneHeight = Math.Max(10, contentHeight - topZoneHeight); + if (ContentGrid.RowDefinitions.Count >= 2) + { + ContentGrid.RowDefinitions[0].Height = new GridLength(topZoneHeight, GridUnitType.Pixel); + ContentGrid.RowDefinitions[1].Height = new GridLength(1, GridUnitType.Star); + } - TemperatureTextBlock.FontSize = Math.Clamp(88 * topScale, 56, 132); - TemperatureTextBlock.FontWeight = ToVariableWeight(315); - TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-1.2 * topScale, -4, 0), 0, 0); - TemperatureTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 88, 196); + var topScale = Math.Clamp(((topZoneHeight / 116d) * 0.42) + (visualScale * 0.86), 0.24, 3.90); + var bottomScale = Math.Clamp(((bottomZoneHeight / 156d) * 0.44) + (visualScale * 0.72), 0.24, 3.80); + var iconGrowth = Math.Clamp((visualScale - 0.88) / 1.70, 0, 1); + var iconScaleBoost = ResolveHeroIconScaleBoost(_activeVisualKind); + var iconSize = Math.Clamp(Lerp(88, 116, iconGrowth) * topScale * iconScaleBoost, 14, 360); + iconSize = Math.Min(iconSize, Math.Max(14, innerWidth * Lerp(0.22, 0.32, iconGrowth))); + var temperatureSample = string.IsNullOrWhiteSpace(TemperatureTextBlock.Text) + ? "00°" + : TemperatureTextBlock.Text.Trim(); + var temperatureGlyphCount = Math.Clamp(temperatureSample.Length, 3, 6); + var temperatureMaxWidth = Math.Max(28, innerWidth - iconSize - TopRowGrid.ColumnSpacing - 4); + var rawTemperatureSize = Math.Clamp(Lerp(64, 92, iconGrowth) * topScale, 12, 320); + var fitTemperatureSize = temperatureMaxWidth / (temperatureGlyphCount * 0.62); + TemperatureTextBlock.FontSize = Math.Clamp(Math.Min(rawTemperatureSize, fitTemperatureSize), 9, 320); + TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 360, emphasis)); + TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-2.0 * topScale, -10, 0), 0, 0); + TemperatureTextBlock.MaxWidth = Math.Clamp(temperatureMaxWidth, 28, Math.Max(280, innerWidth * 0.68)); CityInfoBadge.Padding = new Thickness(0); CityInfoBadge.CornerRadius = new CornerRadius(0); - LocationIcon.FontSize = Math.Clamp(12 * topScale, 9, 17); - CityTextBlock.FontSize = Math.Clamp(18 * topScale, 11, 26); - CityTextBlock.FontWeight = ToVariableWeight(540); - CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.36, 112, 300); + LocationIcon.FontSize = Math.Clamp(13 * topScale, 6, 52); + CityTextBlock.FontSize = Math.Clamp(18.5 * topScale, 7, 88); + CityTextBlock.FontWeight = ToVariableWeight(Lerp(530, 620, emphasis)); + CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.37, 34, 460); ConditionInfoBadge.Padding = new Thickness(0); ConditionInfoBadge.CornerRadius = new CornerRadius(0); - ConditionRangeStack.Spacing = Math.Clamp(7 * topScale, 4, 13); - ConditionTextBlock.FontSize = Math.Clamp(19 * topScale, 12, 27); - RangeTextBlock.FontSize = Math.Clamp(20 * topScale, 12, 30); - ConditionTextBlock.FontWeight = ToVariableWeight(600); - RangeTextBlock.FontWeight = ToVariableWeight(620); - ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 58, 220); - RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.30, 88, 270); - BottomInfoStack.Spacing = Math.Clamp(2.2 * topScale, 1, 6); + ConditionRangeStack.Spacing = Math.Clamp(8.5 * topScale, 1, 24); + ConditionTextBlock.FontSize = Math.Clamp(19 * topScale, 7, 78); + RangeTextBlock.FontSize = Math.Clamp(21 * topScale, 7, 84); + ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(580, 660, emphasis)); + RangeTextBlock.FontWeight = ToVariableWeight(Lerp(600, 680, emphasis)); + ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 26, 320); + RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.31, 32, 360); + BottomInfoStack.Spacing = Math.Clamp(2.0 * topScale, 0.4, 14); - var iconSize = Math.Clamp(68 * topScale, 42, 98); WeatherIconImage.Width = iconSize; WeatherIconImage.Height = iconSize; + WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-2.2 * topScale, -10, 0), 0, 0); - HourlyPanelBorder.Padding = new Thickness(0, Math.Clamp(1 * scaleY, 0, 2), 0, 0); - HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(1.2 * scaleY, 0, 3), 0, 0); + HourlyPanelBorder.Padding = new Thickness(0); + HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(6 * fitScale, 1, 24), 0, 0); HourlyPanelBorder.CornerRadius = new CornerRadius(0); - HourlyGrid.ColumnSpacing = Math.Clamp(7 * scaleX, 4, 11); + HourlyGrid.ColumnSpacing = Math.Clamp(4 * fitScale, 0.5, 24); var hourlyColumnCount = Math.Max(1, _hourlyTimeBlocks.Length); var hourlyInnerWidth = Math.Max( - 96, - innerWidth - HourlyPanelBorder.Padding.Left - HourlyPanelBorder.Padding.Right - (HourlyGrid.ColumnSpacing * (hourlyColumnCount - 1))); - var hourlyCellWidth = Math.Max(34, hourlyInnerWidth / hourlyColumnCount); - var stackSpacing = Math.Clamp((1.6 + (bottomScale * 0.8)) * scaleY, 1, 4); - var hourlyTempSize = Math.Clamp(Math.Max(13, bodyHeight * 0.22) * (0.76 + (bottomScale * 0.24)), 13, 31); - var hourlyTimeSize = Math.Clamp(Math.Max(10, bodyHeight * 0.17) * (0.78 + (bottomScale * 0.22)), 10, 23); - var hourlyIconSize = Math.Clamp(Math.Max(14, bodyHeight * 0.25) * (0.78 + (bottomScale * 0.22)), 14, 35); + 32, + innerWidth - (HourlyGrid.ColumnSpacing * (hourlyColumnCount - 1))); + var hourlyCellWidth = Math.Max(12, hourlyInnerWidth / hourlyColumnCount); + var hourlyCellScale = Math.Clamp( + Math.Min((bottomScale * 0.66) + (visualScale * 0.44), hourlyCellWidth / 74d), + 0.22, + 3.60); + var stackSpacing = Math.Clamp(2 * hourlyCellScale, 0.2, 10); + var hourlyTempSize = Math.Clamp(19.5 * hourlyCellScale, 6, 72); + var hourlyTimeSize = Math.Clamp(14.5 * hourlyCellScale, 6, 50); + var hourlyIconSize = Math.Clamp(34 * hourlyCellScale, 8, 108); for (var i = 0; i < _hourlyTimeBlocks.Length; i++) { @@ -1237,10 +1253,10 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, _hourlyTimeBlocks[i].FontSize = hourlyTimeSize; _hourlyIconBlocks[i].Width = hourlyIconSize; _hourlyIconBlocks[i].Height = hourlyIconSize; - _hourlyTimeBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 34, 112); - _hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 34, 112); - _hourlyTimeBlocks[i].FontWeight = ToVariableWeight(500); - _hourlyTempBlocks[i].FontWeight = ToVariableWeight(590); + _hourlyTimeBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 240); + _hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 240); + _hourlyTimeBlocks[i].FontWeight = ToVariableWeight(Lerp(500, 600, emphasis)); + _hourlyTempBlocks[i].FontWeight = ToVariableWeight(Lerp(580, 690, emphasis)); if (_hourlyTimeBlocks[i].Parent is StackPanel hourlyStack) { hourlyStack.Spacing = stackSpacing; @@ -1253,10 +1269,20 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, return from + ((to - from) * t); } + private static double ResolveHeroIconScaleBoost(WeatherVisualKind kind) + { + return kind switch + { + WeatherVisualKind.RainLight or WeatherVisualKind.RainHeavy or WeatherVisualKind.Storm or WeatherVisualKind.Snow => 1.16, + WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight => 1.08, + _ => 1.0 + }; + } + private void SetMainWeatherIcon(WeatherVisualKind kind) { WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage( - HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(kind))); + HyperOS3WeatherTheme.ResolveHeroIconAsset(ToThemeKind(kind))); } private void SetLoadingSkeleton(bool isLoading) diff --git a/LanMountainDesktop/Views/Components/HyperOS3WeatherTheme.cs b/LanMountainDesktop/Views/Components/HyperOS3WeatherTheme.cs index f8f59ea..34f7ea3 100644 --- a/LanMountainDesktop/Views/Components/HyperOS3WeatherTheme.cs +++ b/LanMountainDesktop/Views/Components/HyperOS3WeatherTheme.cs @@ -102,18 +102,32 @@ public static class HyperOS3WeatherTheme [HyperOS3WeatherVisualKind.Fog] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/hyper_sky_back.png" }; - private static readonly IReadOnlyDictionary IconAssets = + private static readonly IReadOnlyDictionary HeroIconAssets = new Dictionary { - [HyperOS3WeatherVisualKind.ClearDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_sunny_day.webp", - [HyperOS3WeatherVisualKind.ClearNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_moon_clear.webp", - [HyperOS3WeatherVisualKind.CloudyDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_day.webp", - [HyperOS3WeatherVisualKind.CloudyNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_partly_cloudy_night.webp", - [HyperOS3WeatherVisualKind.RainLight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_light.webp", - [HyperOS3WeatherVisualKind.RainHeavy] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_rain_heavy.webp", - [HyperOS3WeatherVisualKind.Storm] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_thunder.webp", - [HyperOS3WeatherVisualKind.Snow] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_snow.webp", - [HyperOS3WeatherVisualKind.Fog] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_haze.webp" + [HyperOS3WeatherVisualKind.ClearDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_sun_soft.png", + [HyperOS3WeatherVisualKind.ClearNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_moon_soft.png", + [HyperOS3WeatherVisualKind.CloudyDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_sun_soft.png", + [HyperOS3WeatherVisualKind.CloudyNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_moon_soft.png", + [HyperOS3WeatherVisualKind.RainLight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_light_soft.png", + [HyperOS3WeatherVisualKind.RainHeavy] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_heavy_soft.png", + [HyperOS3WeatherVisualKind.Storm] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_storm_soft.png", + [HyperOS3WeatherVisualKind.Snow] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_snow_soft.png", + [HyperOS3WeatherVisualKind.Fog] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_hero_sun_soft.png" + }; + + private static readonly IReadOnlyDictionary MiniIconAssets = + new Dictionary + { + [HyperOS3WeatherVisualKind.ClearDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_day_soft.png", + [HyperOS3WeatherVisualKind.ClearNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_night_soft.png", + [HyperOS3WeatherVisualKind.CloudyDay] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_day_soft.png", + [HyperOS3WeatherVisualKind.CloudyNight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_partly_cloudy_night_soft.png", + [HyperOS3WeatherVisualKind.RainLight] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_light_soft.png", + [HyperOS3WeatherVisualKind.RainHeavy] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_rain_heavy_soft.png", + [HyperOS3WeatherVisualKind.Storm] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_storm_soft.png", + [HyperOS3WeatherVisualKind.Snow] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_snow_soft.png", + [HyperOS3WeatherVisualKind.Fog] = "avares://LanMountainDesktop/Assets/Weather/HyperOS3/Icons/icon_mini_fog_soft.png" }; private static readonly IReadOnlyDictionary Palettes = @@ -319,7 +333,17 @@ public static class HyperOS3WeatherTheme public static string? ResolveIconAsset(HyperOS3WeatherVisualKind kind) { - return IconAssets.TryGetValue(kind, out var asset) ? asset : null; + return ResolveMiniIconAsset(kind); + } + + public static string? ResolveHeroIconAsset(HyperOS3WeatherVisualKind kind) + { + return HeroIconAssets.TryGetValue(kind, out var asset) ? asset : null; + } + + public static string? ResolveMiniIconAsset(HyperOS3WeatherVisualKind kind) + { + return MiniIconAssets.TryGetValue(kind, out var asset) ? asset : null; } public static string ResolveSunCoreAsset() diff --git a/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml b/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml index 62a42a2..28d9490 100644 --- a/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml +++ b/LanMountainDesktop/Views/Components/MultiDayWeatherWidget.axaml @@ -63,7 +63,7 @@ FontFeatures="tnum" VerticalAlignment="Center" Margin="0,-2,0,0" - TextTrimming="CharacterEllipsis" + TextTrimming="None" MaxLines="1" /> = 3) + { + ContentGrid.RowDefinitions[0].Height = new GridLength(topZoneHeight, GridUnitType.Pixel); + ContentGrid.RowDefinitions[1].Height = new GridLength(separatorHeight, GridUnitType.Pixel); + ContentGrid.RowDefinitions[2].Height = new GridLength(1, GridUnitType.Star); + } - TemperatureTextBlock.FontSize = Math.Clamp(88 * topScale, 56, 132); - TemperatureTextBlock.FontWeight = ToVariableWeight(315); - TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-1.2 * topScale, -4, 0), 0, 0); - TemperatureTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 88, 196); + var topScale = Math.Clamp(((topZoneHeight / 116d) * 0.42) + (visualScale * 0.86), 0.24, 3.90); + var bottomScale = Math.Clamp(((bottomZoneHeight / 156d) * 0.44) + (visualScale * 0.72), 0.24, 3.80); + var iconGrowth = Math.Clamp((visualScale - 0.88) / 1.70, 0, 1); + var iconScaleBoost = ResolveHeroIconScaleBoost(_activeVisualKind); + var iconSize = Math.Clamp(Lerp(88, 116, iconGrowth) * topScale * iconScaleBoost, 14, 360); + iconSize = Math.Min(iconSize, Math.Max(14, innerWidth * Lerp(0.22, 0.32, iconGrowth))); + var temperatureSample = string.IsNullOrWhiteSpace(TemperatureTextBlock.Text) + ? "00°" + : TemperatureTextBlock.Text.Trim(); + var temperatureGlyphCount = Math.Clamp(temperatureSample.Length, 3, 6); + var temperatureMaxWidth = Math.Max(28, innerWidth - iconSize - TopRowGrid.ColumnSpacing - 4); + var rawTemperatureSize = Math.Clamp(Lerp(64, 92, iconGrowth) * topScale, 12, 320); + var fitTemperatureSize = temperatureMaxWidth / (temperatureGlyphCount * 0.62); + TemperatureTextBlock.FontSize = Math.Clamp(Math.Min(rawTemperatureSize, fitTemperatureSize), 9, 320); + TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 360, emphasis)); + TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-2.0 * topScale, -10, 0), 0, 0); + TemperatureTextBlock.MaxWidth = Math.Clamp(temperatureMaxWidth, 28, Math.Max(280, innerWidth * 0.68)); CityInfoBadge.Padding = new Thickness(0); CityInfoBadge.CornerRadius = new CornerRadius(0); - LocationIcon.FontSize = Math.Clamp(12 * topScale, 9, 17); - CityTextBlock.FontSize = Math.Clamp(18 * topScale, 11, 26); - CityTextBlock.FontWeight = ToVariableWeight(540); - CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.36, 112, 300); + LocationIcon.FontSize = Math.Clamp(13 * topScale, 6, 52); + CityTextBlock.FontSize = Math.Clamp(18.5 * topScale, 7, 88); + CityTextBlock.FontWeight = ToVariableWeight(Lerp(530, 620, emphasis)); + CityTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.37, 34, 460); ConditionInfoBadge.Padding = new Thickness(0); ConditionInfoBadge.CornerRadius = new CornerRadius(0); - ConditionIconStack.Spacing = Math.Clamp(7 * topScale, 4, 13); - ConditionTextBlock.FontSize = Math.Clamp(19 * topScale, 12, 27); - RangeTextBlock.FontSize = Math.Clamp(20 * topScale, 12, 30); - ConditionTextBlock.FontWeight = ToVariableWeight(600); - RangeTextBlock.FontWeight = ToVariableWeight(620); - ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 58, 220); - RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.30, 88, 270); - BottomInfoStack.Spacing = Math.Clamp(2.2 * topScale, 1, 6); + ConditionIconStack.Spacing = Math.Clamp(8.5 * topScale, 1, 24); + ConditionTextBlock.FontSize = Math.Clamp(19 * topScale, 7, 78); + RangeTextBlock.FontSize = Math.Clamp(21 * topScale, 7, 84); + ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(580, 660, emphasis)); + RangeTextBlock.FontWeight = ToVariableWeight(Lerp(600, 680, emphasis)); + ConditionTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.24, 26, 320); + RangeTextBlock.MaxWidth = Math.Clamp(innerWidth * 0.31, 32, 360); + BottomInfoStack.Spacing = Math.Clamp(2.0 * topScale, 0.4, 14); - var iconSize = Math.Clamp(68 * topScale, 42, 98); WeatherIconImage.Width = iconSize; WeatherIconImage.Height = iconSize; + WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-2.2 * topScale, -10, 0), 0, 0); - HourlyPanelBorder.Padding = new Thickness(0, Math.Clamp(1 * scaleY, 0, 2), 0, 0); - HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(1.2 * scaleY, 0, 3), 0, 0); + HourlyPanelBorder.Padding = new Thickness(0); + HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(6 * fitScale, 1, 24), 0, 0); HourlyPanelBorder.CornerRadius = new CornerRadius(0); - HourlyGrid.ColumnSpacing = Math.Clamp(7 * scaleX, 4, 11); + HourlyGrid.ColumnSpacing = Math.Clamp(5 * fitScale, 0.5, 28); var hourlyColumnCount = Math.Max(1, _hourlyTimeBlocks.Length); var hourlyInnerWidth = Math.Max( - 96, - innerWidth - HourlyPanelBorder.Padding.Left - HourlyPanelBorder.Padding.Right - (HourlyGrid.ColumnSpacing * (hourlyColumnCount - 1))); - var hourlyCellWidth = Math.Max(34, hourlyInnerWidth / hourlyColumnCount); - var stackSpacing = Math.Clamp((1.6 + (bottomScale * 0.8)) * scaleY, 1, 4); - var forecastRangeSize = Math.Clamp(Math.Max(13, bodyHeight * 0.22) * (0.76 + (bottomScale * 0.24)), 13, 31); - var forecastLabelSize = Math.Clamp(Math.Max(10, bodyHeight * 0.17) * (0.78 + (bottomScale * 0.22)), 10, 23); - var forecastIconSize = Math.Clamp(Math.Max(14, bodyHeight * 0.25) * (0.78 + (bottomScale * 0.22)), 14, 35); + 32, + innerWidth - (HourlyGrid.ColumnSpacing * (hourlyColumnCount - 1))); + var hourlyCellWidth = Math.Max(12, hourlyInnerWidth / hourlyColumnCount); + var hourlyCellScale = Math.Clamp( + Math.Min((bottomScale * 0.66) + (visualScale * 0.44), hourlyCellWidth / 78d), + 0.22, + 3.60); + var stackSpacing = Math.Clamp(2 * hourlyCellScale, 0.2, 10); + var forecastRangeSize = Math.Clamp(18.0 * hourlyCellScale, 6, 62); + var forecastLabelSize = Math.Clamp(13.8 * hourlyCellScale, 6, 48); + var forecastIconSize = Math.Clamp(32 * hourlyCellScale, 8, 100); for (var i = 0; i < _hourlyTimeBlocks.Length; i++) { @@ -1084,10 +1101,10 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge _hourlyTempBlocks[i].FontSize = forecastRangeSize; _hourlyIconBlocks[i].Width = forecastIconSize; _hourlyIconBlocks[i].Height = forecastIconSize; - _hourlyTimeBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 34, 112); - _hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 34, 112); - _hourlyTimeBlocks[i].FontWeight = ToVariableWeight(500); - _hourlyTempBlocks[i].FontWeight = ToVariableWeight(590); + _hourlyTimeBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 260); + _hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 260); + _hourlyTimeBlocks[i].FontWeight = ToVariableWeight(Lerp(500, 600, emphasis)); + _hourlyTempBlocks[i].FontWeight = ToVariableWeight(Lerp(580, 690, emphasis)); _hourlyTimeBlocks[i].TextAlignment = TextAlignment.Center; _hourlyTempBlocks[i].TextAlignment = TextAlignment.Center; if (_hourlyTimeBlocks[i].Parent is StackPanel hourlyStack) @@ -1102,10 +1119,20 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge return from + ((to - from) * t); } + private static double ResolveHeroIconScaleBoost(WeatherVisualKind kind) + { + return kind switch + { + WeatherVisualKind.RainLight or WeatherVisualKind.RainHeavy or WeatherVisualKind.Storm or WeatherVisualKind.Snow => 1.16, + WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight => 1.08, + _ => 1.0 + }; + } + private void SetMainWeatherIcon(WeatherVisualKind kind) { WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage( - HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(kind))); + HyperOS3WeatherTheme.ResolveHeroIconAsset(ToThemeKind(kind))); } private void SetLoadingSkeleton(bool isLoading) diff --git a/LanMountainDesktop/Views/Components/RecordingWidget.axaml.cs b/LanMountainDesktop/Views/Components/RecordingWidget.axaml.cs index 309cffe..9bd2081 100644 --- a/LanMountainDesktop/Views/Components/RecordingWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/RecordingWidget.axaml.cs @@ -9,6 +9,7 @@ using Avalonia.Input; using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.Threading; +using LanMountainDesktop.Models; using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.Components; @@ -23,6 +24,7 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget }; private readonly IAudioRecorderService _audioRecorderService = AudioRecorderServiceFactory.CreateRecorder(); + private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); private readonly AppSettingsService _settingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly List _waveBars = []; @@ -32,6 +34,7 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget private string _lastSavedFilePath = string.Empty; private double _currentCellSize = 48; private bool _isAttached; + private bool _pausedStudyMonitoringForRecording; public RecordingWidget() { @@ -115,6 +118,12 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget { _isAttached = false; _uiTimer.Stop(); + + var snapshot = _audioRecorderService.GetSnapshot(); + if (snapshot.State is not AudioRecorderRuntimeState.Recording and not AudioRecorderRuntimeState.Paused) + { + ResumeStudyMonitoringIfNeeded(); + } } private void OnSizeChanged(object? sender, SizeChangedEventArgs e) @@ -140,6 +149,7 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget } _audioRecorderService.Discard(); + ResumeStudyMonitoringIfNeeded(); RefreshVisual(); e.Handled = true; } @@ -165,7 +175,7 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget } else { - _audioRecorderService.StartOrResume(); + _ = TryStartRecordingWithMonitoringHandoff(); } RefreshVisual(); @@ -201,6 +211,7 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget } _ = _audioRecorderService.StopAndSave(outputPath); + ResumeStudyMonitoringIfNeeded(); RefreshVisual(); e.Handled = true; } @@ -208,6 +219,12 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget private void RefreshVisual() { var snapshot = _audioRecorderService.GetSnapshot(); + if (_pausedStudyMonitoringForRecording && + snapshot.State is AudioRecorderRuntimeState.Ready or AudioRecorderRuntimeState.Error or AudioRecorderRuntimeState.Unsupported) + { + ResumeStudyMonitoringIfNeeded(); + snapshot = _audioRecorderService.GetSnapshot(); + } TitleTextBlock.Text = L("recording.widget.title", "Recorder"); TimerTextBlock.Text = FormatDuration(snapshot.Duration); @@ -300,6 +317,60 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget HintTextBlock.Text = L("recording.widget.hint.ready", "Tap red button to record"); } + private bool TryStartRecordingWithMonitoringHandoff() + { + if (_audioRecorderService.StartOrResume()) + { + return true; + } + + if (!TryPauseStudyMonitoringForRecording()) + { + return false; + } + + if (_audioRecorderService.StartOrResume()) + { + return true; + } + + ResumeStudyMonitoringIfNeeded(); + return false; + } + + private bool TryPauseStudyMonitoringForRecording() + { + if (_pausedStudyMonitoringForRecording) + { + return true; + } + + var snapshot = _studyAnalyticsService.GetSnapshot(); + if (snapshot.State != StudyAnalyticsRuntimeState.Running) + { + return false; + } + + if (!_studyAnalyticsService.PauseMonitoring()) + { + return false; + } + + _pausedStudyMonitoringForRecording = true; + return true; + } + + private void ResumeStudyMonitoringIfNeeded() + { + if (!_pausedStudyMonitoringForRecording) + { + return; + } + + _pausedStudyMonitoringForRecording = false; + _ = _studyAnalyticsService.StartOrResumeMonitoring(); + } + private void InitializeWaveBars() { if (_waveBars.Count > 0) diff --git a/LanMountainDesktop/Views/Components/WeatherWidget.axaml b/LanMountainDesktop/Views/Components/WeatherWidget.axaml index 3eddda9..b150a8b 100644 --- a/LanMountainDesktop/Views/Components/WeatherWidget.axaml +++ b/LanMountainDesktop/Views/Components/WeatherWidget.axaml @@ -91,7 +91,7 @@ FontFeatures="tnum" VerticalAlignment="Top" Margin="-1,-7,0,0" - TextTrimming="CharacterEllipsis" + TextTrimming="None" MaxLines="1" /> 1 ? Bounds.Width : Math.Max(80, _currentCellSize * 2); + var hostHeight = Bounds.Height > 1 ? Bounds.Height : Math.Max(80, _currentCellSize * 2); var cornerRadius = Math.Clamp(_currentCellSize * metrics.CornerRadiusScale, 26, 46); var horizontalPadding = Math.Clamp(_currentCellSize * metrics.HorizontalPaddingScale, 10, 24); var verticalPadding = Math.Clamp(_currentCellSize * metrics.VerticalPaddingScale, 10, 24); @@ -165,8 +167,8 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime BackgroundLightLayer.CornerRadius = new CornerRadius(cornerRadius); BackgroundShadeLayer.CornerRadius = new CornerRadius(cornerRadius); ContentPaddingBorder.Padding = new Thickness( - Math.Clamp(horizontalPadding * scale, 10, 24), - Math.Clamp(verticalPadding * scale, 10, 24)); + Math.Clamp(Math.Min(horizontalPadding * scale, hostWidth * 0.12), 3, 24), + Math.Clamp(Math.Min(verticalPadding * scale, hostHeight * 0.12), 3, 24)); ApplyAdaptiveTypography(); ResetParticles(); } @@ -472,7 +474,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime palette.TertiaryText, backgroundSamples, WeatherTypographyAccessibility.WcagNormalTextContrast, - isNightVisual ? (byte)0xD6 : (byte)0xC2); + isNightVisual ? (byte)0xC4 : (byte)0xAE); var particleBrush = ResolveParticleBrush(ToThemeKind(kind), palette.ParticleColor); LocationIcon.Foreground = tertiary; CityTextBlock.Foreground = tertiary; @@ -815,25 +817,19 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime { var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 2; var height = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * 2; - var innerWidth = Math.Max(90, width - ContentPaddingBorder.Padding.Left - ContentPaddingBorder.Padding.Right); - var innerHeight = Math.Max(90, height - ContentPaddingBorder.Padding.Top - ContentPaddingBorder.Padding.Bottom); - var scaleX = Math.Clamp(innerWidth / 288d, 0.56, 2.2); - var scaleY = Math.Clamp(innerHeight / 288d, 0.56, 2.2); - var compactness = Math.Clamp((1.0 - scaleY) / 0.60, 0, 1); + var innerWidth = Math.Max(56, width - ContentPaddingBorder.Padding.Left - ContentPaddingBorder.Padding.Right); + var innerHeight = Math.Max(56, height - ContentPaddingBorder.Padding.Top - ContentPaddingBorder.Padding.Bottom); + var fitScale = Math.Clamp(Math.Min(innerWidth / 288d, innerHeight / 288d), 0.30, 3.20); + var cellScale = Math.Clamp(_currentCellSize / 44d, 0.34, 3.80); + var visualScale = Math.Clamp((fitScale * 0.72) + (cellScale * 0.28), 0.30, 3.80); + var emphasis = Math.Clamp((visualScale - 0.82) / 1.90, 0, 1); - ContentGrid.RowSpacing = Math.Clamp((2.8 - (compactness * 0.5)) * scaleY, 1, 6); - TopRowGrid.ColumnSpacing = Math.Clamp(7.5 * scaleX, 4, 13); + ContentGrid.RowSpacing = Math.Clamp(2.2 * fitScale, 0.5, 9); + TopRowGrid.ColumnSpacing = Math.Clamp(6.0 * fitScale, 2, 20); - var availableHeight = Math.Max(80, innerHeight - (ContentGrid.RowSpacing * 2)); - var topZoneRatio = Math.Clamp(0.52 + ((1 - compactness) * 0.03), 0.48, 0.56); - var bottomZoneRatio = Math.Clamp(0.36 - (compactness * 0.02), 0.32, 0.40); - var topZoneHeight = Math.Clamp(availableHeight * topZoneRatio, 44, availableHeight - 30); - var bottomZoneHeight = Math.Clamp(availableHeight * bottomZoneRatio, 34, availableHeight - topZoneHeight - 6); - if (topZoneHeight + bottomZoneHeight > availableHeight - 6) - { - bottomZoneHeight = Math.Max(24, availableHeight - topZoneHeight - 6); - topZoneHeight = Math.Max(42, availableHeight - bottomZoneHeight - 6); - } + var availableHeight = Math.Max(40, innerHeight - (ContentGrid.RowSpacing * 2)); + var topZoneHeight = Math.Clamp(availableHeight * 0.60, 22, Math.Max(22, availableHeight - 16)); + var bottomZoneHeight = Math.Max(12, availableHeight - topZoneHeight - 2); if (ContentGrid.RowDefinitions.Count >= 3) { @@ -842,46 +838,38 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime ContentGrid.RowDefinitions[2].Height = new GridLength(bottomZoneHeight, GridUnitType.Pixel); } - var topScaleH = Math.Clamp(topZoneHeight / 112d, 0.58, 2.2); - var topScaleW = Math.Clamp(innerWidth / 288d, 0.60, 2.2); - var topScale = Math.Clamp((topScaleH * 0.70) + (topScaleW * 0.30), 0.58, 2.2); - var bottomScaleH = Math.Clamp(bottomZoneHeight / 80d, 0.62, 2.2); - var bottomScale = Math.Clamp((bottomScaleH * 0.80) + (scaleX * 0.20), 0.62, 2.2); - - var iconSize = Math.Clamp( - Math.Max(52, topZoneHeight * 0.50) * (0.76 + (topScale * 0.24)), - 52, - 136); + var topScale = Math.Clamp(((topZoneHeight / 170d) * 0.42) + (visualScale * 0.84), 0.24, 4.00); + var bottomScale = Math.Clamp(((bottomZoneHeight / 84d) * 0.46) + (visualScale * 0.66), 0.24, 3.90); + var iconGrowth = Math.Clamp((visualScale - 0.88) / 1.70, 0, 1); + var iconScaleBoost = ResolveHeroIconScaleBoost(_activeVisualKind); + var iconSize = Math.Clamp(Lerp(96, 124, iconGrowth) * topScale * iconScaleBoost, 18, 360); + iconSize = Math.Min(iconSize, Math.Max(18, innerWidth * Lerp(0.34, 0.44, iconGrowth))); WeatherIconImage.Width = iconSize; WeatherIconImage.Height = iconSize; - WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-5 * topScale, -12, 0), 0, 0); + WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-4.2 * topScale, -14, 0), 0, 0); - TemperatureTextBlock.FontSize = Math.Clamp( - Math.Max(52, topZoneHeight * 0.69) * (0.74 + (topScale * 0.24)), - 50, - 146); - TemperatureTextBlock.FontWeight = ToVariableWeight(310); - TemperatureTextBlock.Margin = new Thickness(Math.Clamp(-2 * topScale, -5, 0), Math.Clamp(-8 * topScale, -14, -3), 0, 0); - var temperatureMaxWidthLimit = Math.Max(90, innerWidth * 0.70); - TemperatureTextBlock.MaxWidth = Math.Clamp( - innerWidth - iconSize - TopRowGrid.ColumnSpacing - 8, - 90, - temperatureMaxWidthLimit); + var temperatureSample = string.IsNullOrWhiteSpace(TemperatureTextBlock.Text) + ? "00°" + : TemperatureTextBlock.Text.Trim(); + var temperatureGlyphCount = Math.Clamp(temperatureSample.Length, 3, 6); + var temperatureMaxWidth = Math.Max(34, innerWidth - iconSize - TopRowGrid.ColumnSpacing - 2); + var rawTemperatureSize = Math.Clamp(Lerp(94, 118, iconGrowth) * topScale, 22, 340); + var fitTemperatureSize = temperatureMaxWidth / (temperatureGlyphCount * 0.62); + TemperatureTextBlock.FontSize = Math.Clamp(Math.Min(rawTemperatureSize, fitTemperatureSize), 10, 340); + TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 360, emphasis)); + TemperatureTextBlock.Margin = new Thickness(Math.Clamp(-1.4 * topScale, -6, 0), Math.Clamp(-7.6 * topScale, -16, -1), 0, 0); + TemperatureTextBlock.MaxWidth = Math.Clamp(temperatureMaxWidth, 34, Math.Max(34, innerWidth * 0.76)); - var bottomStackSpacing = Math.Clamp(1.2 * bottomScale, 1, 4); + var bottomStackSpacing = Math.Clamp(1.2 * bottomScale, 0.6, 8); BottomInfoStack.Spacing = bottomStackSpacing; - BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(1.8 * scaleY, 0, 4)); - BottomInfoStack.MaxHeight = Math.Max(32, bottomZoneHeight); + BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(1.4 * fitScale, 0, 6)); + BottomInfoStack.MaxHeight = Math.Max(10, bottomZoneHeight); - var bottomTextMaxWidth = Math.Min(innerWidth, Math.Max(56, innerWidth * 0.84)); - var conditionStackSpacing = Math.Clamp(1.4 + (2.1 * bottomScale), 1.2, 7); + var bottomTextMaxWidth = Math.Min(innerWidth, Math.Max(36, innerWidth * 0.86)); + var conditionStackSpacing = Math.Clamp(1.2 + (2.0 * bottomScale), 0.5, 12); ConditionStack.Spacing = conditionStackSpacing; ConditionStack.Margin = new Thickness(0); - var infoFontSizeRaw = Math.Clamp( - Math.Max(14, bottomZoneHeight * 0.38) * (0.82 + (bottomScale * 0.24)), - 15, - 42); - var infoFontSize = infoFontSizeRaw; + var infoFontSize = Math.Clamp(27 * bottomScale, 7, 86); const double infoLineHeightFactor = 1.10; var estimatedBottomUsedHeight = (infoFontSize * infoLineHeightFactor * 3) + @@ -890,35 +878,35 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime 2; if (estimatedBottomUsedHeight > bottomZoneHeight) { - var shrink = Math.Clamp(bottomZoneHeight / estimatedBottomUsedHeight, 0.58, 1.0); - infoFontSize = Math.Max(11, infoFontSize * shrink); - conditionStackSpacing = Math.Max(0.8, conditionStackSpacing * shrink); - bottomStackSpacing = Math.Max(0.8, bottomStackSpacing * shrink); + var shrink = Math.Clamp(bottomZoneHeight / estimatedBottomUsedHeight, 0.36, 1.0); + infoFontSize = Math.Max(6, infoFontSize * shrink); + conditionStackSpacing = Math.Max(0.3, conditionStackSpacing * shrink); + bottomStackSpacing = Math.Max(0.3, bottomStackSpacing * shrink); ConditionStack.Spacing = conditionStackSpacing; BottomInfoStack.Spacing = bottomStackSpacing; } - var infoFontWeight = ToVariableWeight(590); - ConditionTextBlock.FontSize = infoFontSize; + var infoFontWeight = ToVariableWeight(Lerp(580, 690, emphasis)); + ConditionTextBlock.FontSize = Math.Max(6, infoFontSize * 0.96); ConditionTextBlock.FontWeight = infoFontWeight; - ConditionTextBlock.LineHeight = infoFontSize * infoLineHeightFactor; + ConditionTextBlock.LineHeight = ConditionTextBlock.FontSize * infoLineHeightFactor; ConditionTextBlock.MaxWidth = bottomTextMaxWidth; - RangeTextBlock.FontSize = infoFontSize; + RangeTextBlock.FontSize = Math.Max(6, infoFontSize * 1.03); RangeTextBlock.FontWeight = infoFontWeight; - RangeTextBlock.LineHeight = infoFontSize * infoLineHeightFactor; + RangeTextBlock.LineHeight = RangeTextBlock.FontSize * infoLineHeightFactor; RangeTextBlock.MaxWidth = bottomTextMaxWidth; CityInfoBadge.Padding = new Thickness(0); CityInfoBadge.CornerRadius = new CornerRadius(0); CityInfoBadge.MaxWidth = bottomTextMaxWidth; LocationIcon.FontSize = Math.Clamp( - Math.Max(9, bottomZoneHeight * 0.16) * (0.76 + (bottomScale * 0.22)), - 9, - 18); + 12 * bottomScale, + 6, + 34); LocationIcon.FontSize = Math.Min(LocationIcon.FontSize, infoFontSize * 0.72); - CityTextBlock.FontSize = infoFontSize; - CityTextBlock.FontWeight = infoFontWeight; - CityTextBlock.LineHeight = infoFontSize * infoLineHeightFactor; + CityTextBlock.FontSize = Math.Max(6, infoFontSize * 0.84); + CityTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, emphasis)); + CityTextBlock.LineHeight = CityTextBlock.FontSize * infoLineHeightFactor; CityTextBlock.MaxWidth = bottomTextMaxWidth; } @@ -927,10 +915,20 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime return from + ((to - from) * t); } + private static double ResolveHeroIconScaleBoost(WeatherVisualKind kind) + { + return kind switch + { + WeatherVisualKind.RainLight or WeatherVisualKind.RainHeavy or WeatherVisualKind.Storm or WeatherVisualKind.Snow => 1.16, + WeatherVisualKind.ClearNight or WeatherVisualKind.CloudyNight => 1.08, + _ => 1.0 + }; + } + private void SetWeatherIcon(WeatherVisualKind kind) { WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage( - HyperOS3WeatherTheme.ResolveIconAsset(ToThemeKind(kind))); + HyperOS3WeatherTheme.ResolveHeroIconAsset(ToThemeKind(kind))); } private void SetLoadingSkeleton(bool isLoading) diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index 6a19049..93fc29d 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -706,6 +706,12 @@ public partial class MainWindow return; } + if (placement.ComponentId == BuiltInComponentIds.DesktopDailyArtwork) + { + OpenDailyArtworkComponentSettings(); + return; + } + if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment) { OpenStudyEnvironmentComponentSettings(); @@ -760,6 +766,22 @@ public partial class MainWindow ComponentSettingsWindow.Opacity = 1; } + private void OpenDailyArtworkComponentSettings() + { + if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) + { + return; + } + + var settingsContent = new DailyArtworkSettingsWindow(); + settingsContent.SettingsChanged += OnDailyArtworkSettingsChanged; + ComponentSettingsContentHost.Content = settingsContent; + + ComponentSettingsWindow.IsVisible = true; + ComponentSettingsWindow.Opacity = 0; + ComponentSettingsWindow.Opacity = 1; + } + private void OnClassScheduleSettingsChanged(object? sender, EventArgs e) { if (_selectedDesktopComponentHost is null) @@ -788,6 +810,34 @@ public partial class MainWindow } } + private void OnDailyArtworkSettingsChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + + _dailyArtworkMirrorSource = sender is DailyArtworkSettingsWindow settingsWindow + ? DailyArtworkMirrorSources.Normalize(settingsWindow.CurrentSource) + : DailyArtworkMirrorSources.Normalize(_appSettingsService.Load().DailyArtworkMirrorSource); + + foreach (var pageGrid in _desktopPageComponentGrids.Values) + { + foreach (var host in pageGrid.Children.OfType()) + { + if (!host.Classes.Contains(DesktopComponentHostClass)) + { + continue; + } + + if (TryGetContentHost(host)?.Child is DailyArtworkWidget widget) + { + widget.RefreshFromSettings(); + } + } + } + + PersistSettings(); + } + private void CloseComponentSettingsWindow() { if (ComponentSettingsWindow is null) @@ -805,6 +855,11 @@ public partial class MainWindow studyEnvironmentSettingsWindow.SettingsChanged -= OnStudyEnvironmentSettingsChanged; } + if (ComponentSettingsContentHost?.Content is DailyArtworkSettingsWindow dailyArtworkSettingsWindow) + { + dailyArtworkSettingsWindow.SettingsChanged -= OnDailyArtworkSettingsChanged; + } + ComponentSettingsWindow.Opacity = 0; DispatcherTimer.RunOnce(() => diff --git a/LanMountainDesktop/Views/MainWindow.Localization.cs b/LanMountainDesktop/Views/MainWindow.Localization.cs index e6100a8..88cedb6 100644 --- a/LanMountainDesktop/Views/MainWindow.Localization.cs +++ b/LanMountainDesktop/Views/MainWindow.Localization.cs @@ -262,6 +262,13 @@ public partial class MainWindow "settings.about.font_format", "Font: {0}", AppFontName); + AboutStartupSettingsExpander.Header = L("settings.about.startup_header", "Windows Startup"); + AboutStartupSettingsExpander.Description = L( + "settings.about.startup_desc", + "Launch the app automatically when signing in to Windows."); + AutoStartWithWindowsToggleSwitch.Content = L( + "settings.about.startup_toggle", + "Launch at Windows sign-in"); if (WallpaperPlacementComboBox?.ItemCount >= 5) { diff --git a/LanMountainDesktop/Views/MainWindow.Settings.cs b/LanMountainDesktop/Views/MainWindow.Settings.cs index 103cd96..81a1296 100644 --- a/LanMountainDesktop/Views/MainWindow.Settings.cs +++ b/LanMountainDesktop/Views/MainWindow.Settings.cs @@ -658,6 +658,8 @@ public partial class MainWindow WeatherExcludedAlerts = _weatherExcludedAlertsRaw, WeatherIconPackId = _weatherIconPackId, WeatherNoTlsRequests = _weatherNoTlsRequests, + DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(_dailyArtworkMirrorSource), + AutoStartWithWindows = _autoStartWithWindows, TopStatusComponentIds = _topStatusComponentIds.ToList(), PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(), EnableDynamicTaskbarActions = _enableDynamicTaskbarActions, @@ -790,14 +792,37 @@ public partial class MainWindow weatherCode: null, temperatureText: "--", updatedAt: null); - - UpdateWeatherLocationModePanels(); - UpdateWeatherLocationStatusText(); } finally { _suppressWeatherLocationEvents = false; } + + UpdateWeatherLocationModePanels(); + UpdateWeatherLocationStatusText(); + } + + private void InitializeAutoStartWithWindowsSetting(AppSettingsSnapshot snapshot) + { + _autoStartWithWindows = OperatingSystem.IsWindows() + ? _windowsStartupService.IsEnabled() + : snapshot.AutoStartWithWindows; + + if (AutoStartWithWindowsToggleSwitch is null) + { + return; + } + + _suppressAutoStartToggleEvents = true; + try + { + AutoStartWithWindowsToggleSwitch.IsEnabled = OperatingSystem.IsWindows(); + AutoStartWithWindowsToggleSwitch.IsChecked = _autoStartWithWindows; + } + finally + { + _suppressAutoStartToggleEvents = false; + } } private static WeatherLocationMode ParseWeatherLocationMode(string? value) @@ -1022,6 +1047,51 @@ public partial class MainWindow PersistSettings(); } + private void OnAutoStartWithWindowsToggled(object? sender, RoutedEventArgs e) + { + if (_suppressAutoStartToggleEvents || AutoStartWithWindowsToggleSwitch is null) + { + return; + } + + var requested = AutoStartWithWindowsToggleSwitch.IsChecked == true; + if (!OperatingSystem.IsWindows()) + { + _autoStartWithWindows = false; + _suppressAutoStartToggleEvents = true; + try + { + AutoStartWithWindowsToggleSwitch.IsEnabled = false; + AutoStartWithWindowsToggleSwitch.IsChecked = false; + } + finally + { + _suppressAutoStartToggleEvents = false; + } + + PersistSettings(); + return; + } + + var applied = _windowsStartupService.SetEnabled(requested); + _autoStartWithWindows = _windowsStartupService.IsEnabled(); + + if (!applied || _autoStartWithWindows != requested) + { + _suppressAutoStartToggleEvents = true; + try + { + AutoStartWithWindowsToggleSwitch.IsChecked = _autoStartWithWindows; + } + finally + { + _suppressAutoStartToggleEvents = false; + } + } + + PersistSettings(); + } + private async void OnSearchWeatherCityClick(object? sender, RoutedEventArgs e) { if (_isWeatherSearchInProgress || WeatherCitySearchTextBox is null || WeatherCityResultsComboBox is null) @@ -1942,6 +2012,15 @@ public partial class MainWindow }; } + if (AboutStartupSettingsExpander is not null) + { + AboutStartupSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource + { + Symbol = Symbol.Play, + IconVariant = variant + }; + } + UpdateThemeModeIcon(); } diff --git a/LanMountainDesktop/Views/MainWindow.axaml b/LanMountainDesktop/Views/MainWindow.axaml index 2f760f4..d37f7a3 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml +++ b/LanMountainDesktop/Views/MainWindow.axaml @@ -1390,6 +1390,19 @@ + + + + + + + diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs index 3ea0c65..8d1957e 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml.cs +++ b/LanMountainDesktop/Views/MainWindow.axaml.cs @@ -90,6 +90,7 @@ public partial class MainWindow : Window private readonly AppSettingsService _appSettingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly TimeZoneService _timeZoneService = new(); + private readonly WindowsStartupService _windowsStartupService = new(); private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService(); private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService(); private readonly ComponentRegistry _componentRegistry = ComponentRegistry @@ -151,6 +152,9 @@ public partial class MainWindow : Window private string _weatherExcludedAlertsRaw = string.Empty; private string _weatherIconPackId = "FluentRegular"; private bool _weatherNoTlsRequests; + private string _dailyArtworkMirrorSource = DailyArtworkMirrorSources.Overseas; + private bool _autoStartWithWindows; + private bool _suppressAutoStartToggleEvents; private string _weatherSearchKeyword = string.Empty; private bool _isWeatherSearchInProgress; private bool _isWeatherPreviewInProgress; @@ -225,6 +229,8 @@ public partial class MainWindow : Window ApplyTaskbarSettings(snapshot); InitializeLocalization(snapshot.LanguageCode); InitializeWeatherSettings(snapshot); + _dailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource); + InitializeAutoStartWithWindowsSetting(snapshot); InitializeDesktopSurfaceState(snapshot); InitializeDesktopComponentPlacements(snapshot); InitializeSettingsIcons(); diff --git a/LanMountainDesktop/installer/LanMountainDesktop.iss b/LanMountainDesktop/installer/LanMountainDesktop.iss index e1b1da2..8f8bc9b 100644 --- a/LanMountainDesktop/installer/LanMountainDesktop.iss +++ b/LanMountainDesktop/installer/LanMountainDesktop.iss @@ -48,6 +48,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" +Name: "startup"; Description: "Launch LanMountainDesktop when you sign in to Windows"; GroupDescription: "{cm:AdditionalTasks}"; Flags: unchecked [Files] Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs @@ -56,5 +57,53 @@ Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +[Registry] +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: startup; Flags: uninsdeletevalue + [Run] Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + +[Code] +const + WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'; + WebView2RuntimeDownloadUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703'; + +function IsWebView2RuntimeInstalled(): Boolean; +var + VersionValue: string; +begin + Result := + RegQueryStringValue(HKLM64, WebView2RuntimeKeyPath, 'pv', VersionValue) or + RegQueryStringValue(HKLM32, WebView2RuntimeKeyPath, 'pv', VersionValue) or + RegQueryStringValue(HKCU64, WebView2RuntimeKeyPath, 'pv', VersionValue) or + RegQueryStringValue(HKCU32, WebView2RuntimeKeyPath, 'pv', VersionValue); +end; + +function InitializeSetup(): Boolean; +var + ErrorCode: Integer; +begin + if IsWebView2RuntimeInstalled() then + begin + Result := True; + exit; + end; + + if MsgBox( + 'Microsoft Edge WebView2 Runtime is required for the browser component.'#13#10#13#10 + + 'Click "Yes" to open the official download page. Install it first, then run this installer again.', + mbConfirmation, + MB_YESNO) = IDYES then + begin + if not ShellExec('open', WebView2RuntimeDownloadUrl, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then + begin + MsgBox( + 'Unable to open the download page automatically.'#13#10 + + 'Please open this URL manually:'#13#10 + WebView2RuntimeDownloadUrl, + mbError, + MB_OK); + end; + end; + + Result := False; +end; diff --git a/README.md b/README.md index ff59934..9b2fad8 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ `LanMountainDesktop` 是一个基于 Avalonia 的桌面壳层项目,目标不是“做一个启动器”,而是把桌面变成可编排的信息与交互空间。 +> ⚠️ **注意**:该项目使用 Vibe Coding,介意勿用。 ## 项目定位 - 以网格化布局组织桌面组件,支持多页桌面与组件自由摆放。 - 提供顶部状态栏 + 底部任务栏的桌面框架,强调信息密度与可读性平衡。