Files
LanMountainDesktop/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs
2026-03-07 19:59:28 +08:00

879 lines
28 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
{
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
private static readonly HttpClient ImageHttpClient = new()
{
Timeout = TimeSpan.FromSeconds(8)
};
private const string BrowserUserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36";
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private static readonly IReadOnlyList<int> SupportedAutoRotateIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromMinutes(30)
};
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly Bitmap?[] _newsBitmaps = new Bitmap?[2];
private readonly List<string?> _newsUrls = [];
private readonly List<ExtraNewsRowVisual> _extraNewsRows = [];
private IReadOnlyList<DailyNewsItemSnapshot> _activeNewsItems = [];
private int _renderedNewsCount = 2;
private sealed class ExtraNewsRowVisual
{
public ExtraNewsRowVisual(
Grid rootGrid,
TextBlock titleTextBlock,
Border imageHost,
Image imageControl,
int newsIndex)
{
RootGrid = rootGrid;
TitleTextBlock = titleTextBlock;
ImageHost = imageHost;
ImageControl = imageControl;
NewsIndex = newsIndex;
}
public Grid RootGrid { get; }
public TextBlock TitleTextBlock { get; }
public Border ImageHost { get; }
public Image ImageControl { get; }
public int NewsIndex { get; }
public Bitmap? Bitmap { get; set; }
}
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
private CancellationTokenSource? _refreshCts;
private string _languageCode = "zh-CN";
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
private bool _autoRotateEnabled = true;
private bool _isNightVisual = true;
public CnrDailyNewsWidget()
{
InitializeComponent();
BrandPrimaryTextBlock.FontFamily = MiSansFontFamily;
BrandSecondaryTextBlock.FontFamily = MiSansFontFamily;
RefreshLabelTextBlock.FontFamily = MiSansFontFamily;
News1TitleTextBlock.FontFamily = MiSansFontFamily;
News2TitleTextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
_refreshTimer.Tick += OnRefreshTimerTick;
RefreshButton.Click += OnRefreshButtonClick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
ApplyAutoRotateSettings();
ApplyLoadingState();
UpdateRefreshButtonState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateAdaptiveLayout();
}
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
{
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
if (_isAttached)
{
_ = RefreshNewsAsync(forceRefresh: false);
}
}
public void RefreshFromSettings()
{
_recommendationService.ClearCache();
ApplyAutoRotateSettings();
if (_isAttached)
{
_ = RefreshNewsAsync(forceRefresh: true);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
ApplyAutoRotateSettings();
UpdateRefreshButtonState();
_ = RefreshNewsAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_refreshTimer.Stop();
CancelRefreshRequest();
DisposeNewsBitmaps();
ClearExtraNewsRows();
UpdateRefreshButtonState();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
UpdateAdaptiveLayout();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
BrandPrimaryTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
BrandSecondaryTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#6A6F77"));
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
RefreshLabelTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
News1TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
News2TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
}
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
{
if (_isRefreshing)
{
return;
}
await RefreshNewsAsync(forceRefresh: true);
e.Handled = true;
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshNewsAsync(forceRefresh: true);
}
private void OnNewsItem1PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
TryOpenNewsUrl(0);
e.Handled = true;
}
private void OnNewsItem2PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
TryOpenNewsUrl(1);
e.Handled = true;
}
private void OnExtraNewsItemPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
sender is not Control control ||
control.Tag is not int index)
{
return;
}
TryOpenNewsUrl(index);
e.Handled = true;
}
private async Task RefreshNewsAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateRefreshButtonState();
UpdateLanguageCode();
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new DailyNewsQuery(
Locale: _languageCode,
ItemCount: ResolveDesiredNewsItemCount(),
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetDailyNewsAsync(query, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
{
return;
}
if (!result.Success || result.Data is null)
{
ApplyFailedState();
return;
}
await ApplySnapshotAsync(result.Data, cts.Token);
}
catch (OperationCanceledException)
{
// Ignore canceled requests.
}
catch
{
if (_isAttached && !cts.IsCancellationRequested)
{
ApplyFailedState();
}
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
UpdateRefreshButtonState();
}
}
private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken)
{
var items = snapshot.Items is null
? []
: snapshot.Items.Take(2).ToArray();
_activeNewsItems = items;
var item1 = items.Length > 0 ? items[0] : null;
var item2 = items.Length > 1 ? items[1] : null;
UpdateHotHeadlineText(item1?.Title);
News2TitleTextBlock.Text = NormalizeCompactText(item2?.Title);
_newsUrls.Clear();
foreach (var item in items)
{
_newsUrls.Add(NormalizeHttpUrl(item.Url));
}
RenderExtraNewsRows([]);
UpdateNewsInteractionState();
StatusTextBlock.IsVisible = false;
UpdateAdaptiveLayout();
var loadTasks = new[]
{
TryDownloadBitmapAsync(item1?.ImageUrl, cancellationToken),
TryDownloadBitmapAsync(item2?.ImageUrl, cancellationToken)
};
var bitmaps = await Task.WhenAll(loadTasks);
if (cancellationToken.IsCancellationRequested || !_isAttached)
{
bitmaps[0]?.Dispose();
bitmaps[1]?.Dispose();
return;
}
SetNewsBitmap(0, bitmaps[0]);
SetNewsBitmap(1, bitmaps[1]);
}
private void ApplyLoadingState()
{
_activeNewsItems = [];
_newsUrls.Clear();
UpdateHotHeadlineText(L("cnrnews.widget.loading_title", "Loading headlines"));
News2TitleTextBlock.Text = L("cnrnews.widget.loading_subtitle", "Please wait");
StatusTextBlock.Text = L("cnrnews.widget.loading", "Loading...");
StatusTextBlock.IsVisible = true;
SetNewsBitmap(0, null);
SetNewsBitmap(1, null);
RenderExtraNewsRows([]);
UpdateNewsInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
_activeNewsItems = [];
_newsUrls.Clear();
News1TitleTextBlock.Inlines = null;
News1TitleTextBlock.Text = L("cnrnews.widget.fallback_title", "CNR news is temporarily unavailable");
News2TitleTextBlock.Text = L("cnrnews.widget.fallback_subtitle", "Tap refresh and try again");
StatusTextBlock.Text = L("cnrnews.widget.fetch_failed", "News fetch failed");
StatusTextBlock.IsVisible = true;
SetNewsBitmap(0, null);
SetNewsBitmap(1, null);
RenderExtraNewsRows([]);
UpdateNewsInteractionState();
UpdateAdaptiveLayout();
}
private int ResolveDesiredNewsItemCount()
{
return 2;
}
private void UpdateHotHeadlineText(string? title)
{
var normalizedTitle = NormalizeCompactText(title);
var hotLabel = L("cnrnews.widget.hot_label", "Hot");
var primaryForeground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
if (News1TitleTextBlock.Inlines is null)
{
News1TitleTextBlock.Text = $"{hotLabel} | {normalizedTitle}";
News1TitleTextBlock.Foreground = primaryForeground;
return;
}
News1TitleTextBlock.Inlines.Clear();
News1TitleTextBlock.Inlines.Add(new Run($"{hotLabel} | ")
{
Foreground = new SolidColorBrush(Color.Parse("#D6272E")),
FontWeight = FontWeight.SemiBold
});
News1TitleTextBlock.Inlines.Add(new Run(normalizedTitle)
{
Foreground = primaryForeground,
FontWeight = FontWeight.SemiBold
});
}
private void RenderExtraNewsRows(IReadOnlyList<DailyNewsItemSnapshot> extraItems)
{
ClearExtraNewsRows();
if (extraItems.Count == 0)
{
ExtraNewsItemsPanel.IsVisible = false;
_renderedNewsCount = 2;
return;
}
for (var i = 0; i < extraItems.Count; i++)
{
var item = extraItems[i];
var itemIndex = i + 2;
var rowGrid = new Grid
{
ColumnSpacing = 12,
Tag = itemIndex,
Cursor = new Cursor(StandardCursorType.Hand),
IsHitTestVisible = true
};
rowGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star)));
rowGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
rowGrid.PointerPressed += OnExtraNewsItemPointerPressed;
var textBlock = new TextBlock
{
Text = NormalizeCompactText(item.Title),
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327")),
FontFamily = MiSansFontFamily,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxLines = 2,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
IsHitTestVisible = false
};
var imageHost = new Border
{
Width = 160,
Height = 90,
CornerRadius = new CornerRadius(16),
ClipToBounds = true,
Background = new SolidColorBrush(Color.Parse("#E6E6E6")),
IsHitTestVisible = false
};
var image = new Image
{
Stretch = Stretch.UniformToFill,
IsHitTestVisible = false
};
imageHost.Child = image;
Grid.SetColumn(imageHost, 1);
rowGrid.Children.Add(textBlock);
rowGrid.Children.Add(imageHost);
ExtraNewsItemsPanel.Children.Add(rowGrid);
_extraNewsRows.Add(new ExtraNewsRowVisual(rowGrid, textBlock, imageHost, image, itemIndex));
}
ExtraNewsItemsPanel.IsVisible = true;
_renderedNewsCount = 2 + extraItems.Count;
}
private void ClearExtraNewsRows()
{
foreach (var row in _extraNewsRows)
{
row.RootGrid.PointerPressed -= OnExtraNewsItemPointerPressed;
if (ReferenceEquals(row.ImageControl.Source, row.Bitmap))
{
row.ImageControl.Source = null;
}
row.Bitmap?.Dispose();
row.Bitmap = null;
}
_extraNewsRows.Clear();
ExtraNewsItemsPanel.Children.Clear();
}
private void SetExtraNewsBitmap(int rowIndex, Bitmap? bitmap)
{
if (rowIndex < 0 || rowIndex >= _extraNewsRows.Count)
{
bitmap?.Dispose();
return;
}
var row = _extraNewsRows[rowIndex];
if (ReferenceEquals(row.ImageControl.Source, row.Bitmap))
{
row.ImageControl.Source = null;
}
row.Bitmap?.Dispose();
row.Bitmap = bitmap;
row.ImageControl.Source = bitmap;
}
private void UpdateAdaptiveLayout()
{
var scale = ResolveScale();
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
RootBorder.Padding = new Thickness(0);
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
CardBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 8, 24),
Math.Clamp(14 * scale, 7, 22),
Math.Clamp(16 * scale, 8, 24),
Math.Clamp(14 * scale, 7, 22));
var headlineFont = Math.Clamp(24 * scale, 12, 34);
BrandPrimaryTextBlock.FontSize = headlineFont;
BrandSecondaryTextBlock.FontSize = headlineFont;
var refreshHeight = Math.Clamp(42 * scale, 24, 52);
var refreshWidth = Math.Clamp(116 * scale, 76, 152);
RefreshButton.Height = refreshHeight;
RefreshButton.Width = refreshWidth;
RefreshButton.CornerRadius = new CornerRadius(refreshHeight / 2d);
RefreshGlyphIcon.FontSize = Math.Clamp(19 * scale, 11, 24);
RefreshLabelTextBlock.FontSize = Math.Clamp(22 * scale, 11, 29);
var imageWidth = Math.Clamp(totalWidth * 0.20, 60, 170);
var imageHeight = Math.Clamp(imageWidth * 0.56, 38, 94);
News1ImageHost.Width = imageWidth;
News1ImageHost.Height = imageHeight;
News2ImageHost.Width = imageWidth;
News2ImageHost.Height = imageHeight;
News1ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
News2ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
var columnGap = Math.Clamp(12 * scale, 6, 18);
NewsItem1Grid.ColumnSpacing = columnGap;
NewsItem2Grid.ColumnSpacing = columnGap;
NewsItem1Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
NewsItem2Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
var availableTextWidth = Math.Max(
84,
totalWidth - imageWidth - columnGap - Math.Clamp(20 * scale, 10, 32));
News1TitleTextBlock.MaxWidth = availableTextWidth;
News2TitleTextBlock.MaxWidth = availableTextWidth;
var newsFont = Math.Clamp(21 * scale, 10.5, 28);
News1TitleTextBlock.FontSize = newsFont;
News2TitleTextBlock.FontSize = newsFont;
var mainNewsLineHeight = newsFont * 1.14;
News1TitleTextBlock.LineHeight = mainNewsLineHeight;
News2TitleTextBlock.LineHeight = mainNewsLineHeight;
var mainNewsMinHeight = mainNewsLineHeight * 2;
News1TitleTextBlock.MinHeight = mainNewsMinHeight;
News2TitleTextBlock.MinHeight = mainNewsMinHeight;
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
News1TitleTextBlock.MaxLines = 2;
News2TitleTextBlock.MaxLines = 2;
foreach (var row in _extraNewsRows)
{
row.RootGrid.ColumnSpacing = columnGap;
if (row.RootGrid.ColumnDefinitions.Count > 1)
{
row.RootGrid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
}
row.ImageHost.Width = imageWidth;
row.ImageHost.Height = imageHeight;
row.ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
row.TitleTextBlock.MaxWidth = availableTextWidth;
row.TitleTextBlock.FontSize = Math.Clamp(19 * scale, 10, 25);
row.TitleTextBlock.LineHeight = row.TitleTextBlock.FontSize * 1.12;
row.TitleTextBlock.MinHeight = row.TitleTextBlock.LineHeight * 2;
row.TitleTextBlock.MaxLines = 2;
}
ExtraNewsItemsPanel.Spacing = Math.Clamp(6 * scale, 3, 10);
ApplyNightModeVisual();
}
private void UpdateRefreshButtonState()
{
RefreshButton.IsEnabled = !_isRefreshing;
RefreshButton.Opacity = _isAttached ? 1.0 : 0.85;
RefreshGlyphIcon.Opacity = _isRefreshing ? 0.56 : 1.0;
RefreshLabelTextBlock.Opacity = _isRefreshing ? 0.56 : 1.0;
}
private void UpdateNewsInteractionState()
{
var item1Enabled = _newsUrls.Count > 0 && !string.IsNullOrWhiteSpace(_newsUrls[0]);
var item2Enabled = _newsUrls.Count > 1 && !string.IsNullOrWhiteSpace(_newsUrls[1]);
NewsItem1Grid.IsHitTestVisible = item1Enabled;
NewsItem2Grid.IsHitTestVisible = item2Enabled;
NewsItem1Grid.Opacity = item1Enabled ? 1.0 : 0.72;
NewsItem2Grid.Opacity = item2Enabled ? 1.0 : 0.72;
foreach (var row in _extraNewsRows)
{
var index = row.NewsIndex;
var enabled = index >= 0 && index < _newsUrls.Count && !string.IsNullOrWhiteSpace(_newsUrls[index]);
row.RootGrid.IsHitTestVisible = enabled;
row.RootGrid.Opacity = enabled ? 1.0 : 0.72;
row.RootGrid.Cursor = enabled
? new Cursor(StandardCursorType.Hand)
: new Cursor(StandardCursorType.Arrow);
}
}
private static async Task<Bitmap?> TryDownloadBitmapAsync(string? imageUrl, CancellationToken cancellationToken)
{
var normalizedUrl = NormalizeHttpUrl(imageUrl);
if (string.IsNullOrWhiteSpace(normalizedUrl))
{
return null;
}
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, normalizedUrl);
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
using var response = await ImageHttpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var memory = new MemoryStream();
await stream.CopyToAsync(memory, cancellationToken);
memory.Position = 0;
return new Bitmap(memory);
}
catch (OperationCanceledException)
{
throw;
}
catch
{
return null;
}
}
private void TryOpenNewsUrl(int index)
{
if (index < 0 || index >= _newsUrls.Count)
{
return;
}
var normalizedUrl = NormalizeHttpUrl(_newsUrls[index]);
if (string.IsNullOrWhiteSpace(normalizedUrl))
{
return;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = normalizedUrl,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch
{
// Ignore malformed URLs or shell launch failures.
}
}
private static string? NormalizeHttpUrl(string? rawUrl)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return null;
}
var candidate = rawUrl.Trim();
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
return null;
}
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return uri.ToString();
}
private void SetNewsBitmap(int index, Bitmap? bitmap)
{
if (index < 0 || index >= _newsBitmaps.Length)
{
bitmap?.Dispose();
return;
}
var imageControl = index == 0 ? News1Image : News2Image;
var oldBitmap = _newsBitmaps[index];
if (ReferenceEquals(imageControl.Source, oldBitmap))
{
imageControl.Source = null;
}
oldBitmap?.Dispose();
_newsBitmaps[index] = bitmap;
imageControl.Source = bitmap;
}
private void DisposeNewsBitmaps()
{
SetNewsBitmap(0, null);
SetNewsBitmap(1, null);
}
private void UpdateLanguageCode()
{
try
{
var snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
private void ApplyAutoRotateSettings()
{
var enabled = true;
var intervalMinutes = 60;
try
{
var snapshot = _componentSettingsService.Load();
enabled = snapshot.CnrDailyNewsAutoRotateEnabled;
intervalMinutes = NormalizeAutoRotateIntervalMinutes(snapshot.CnrDailyNewsAutoRotateIntervalMinutes);
}
catch
{
// Keep fallback defaults.
}
_autoRotateEnabled = enabled;
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
if (!_isAttached)
{
return;
}
if (_autoRotateEnabled)
{
if (!_refreshTimer.IsEnabled)
{
_refreshTimer.Start();
}
}
else if (_refreshTimer.IsEnabled)
{
_refreshTimer.Stop();
}
}
private static int NormalizeAutoRotateIntervalMinutes(int minutes)
{
if (minutes <= 0)
{
return 60;
}
if (SupportedAutoRotateIntervalsMinutes.Contains(minutes))
{
return minutes;
}
return SupportedAutoRotateIntervalsMinutes
.OrderBy(value => Math.Abs(value - minutes))
.FirstOrDefault(60);
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
if (cts is null)
{
return;
}
cts.Cancel();
cts.Dispose();
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.0);
var widthScale = Bounds.Width > 1
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.56, 2.0)
: 1;
var heightScale = Bounds.Height > 1
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.56, 2.0)
: 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.56, 2.0);
}
private static string NormalizeCompactText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
}
}