Files
LanMountainDesktop/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs
lincube 915739ff7b 0.6.9
改变无声
2026-03-20 00:41:14 +08:00

496 lines
17 KiB
C#

using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Theme;
using Material.Icons;
namespace LanMountainDesktop.Views.Components;
public partial class StudySessionControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IDisposable
{
private static readonly Color[] PrimaryColorCandidates =
{
Color.Parse("#FFEAF5FF"),
Color.Parse("#FFDDEEFF"),
Color.Parse("#FFCEE3FA"),
Color.Parse("#FF1B2E45"),
Color.Parse("#FF233A54"),
Color.Parse("#FFFFFFFF"),
Color.Parse("#FF101C2A")
};
private static readonly Color[] SecondaryColorCandidates =
{
Color.Parse("#FFC7D9EC"),
Color.Parse("#FFBAD0E8"),
Color.Parse("#FFD9E8F6"),
Color.Parse("#FF2F4763"),
Color.Parse("#FF385673"),
Color.Parse("#FFEAF3FA"),
Color.Parse("#FF1A2C40")
};
private static readonly Color[] WarningColorCandidates =
{
Color.Parse("#FFFFD4D4"),
Color.Parse("#FFFEE2E2"),
Color.Parse("#FF7F1D1D"),
Color.Parse("#FF991B1B"),
Color.Parse("#FFFFFFFF"),
Color.Parse("#FF111827")
};
private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220");
private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
private readonly LocalizationService _localizationService = new();
private readonly DispatcherTimer _uiTimer = new()
{
Interval = TimeSpan.FromMilliseconds(250)
};
private double _currentCellSize = 48;
private string _languageCode = "zh-CN";
private bool _isAttached;
private bool _isOnActivePage = true;
private bool _isDisposed;
private bool _isCompactMode;
private bool _isUltraCompactMode;
private IDisposable? _monitoringLease;
private string? _transientMessage;
private DateTimeOffset _transientMessageExpireAt;
public StudySessionControlWidget()
{
InitializeComponent();
_uiTimer.Tick += OnUiTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ReloadLanguageCode();
ApplyCellSize(_currentCellSize);
RefreshVisual();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateAdaptiveLayout();
}
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
{
_ = isEditMode;
_isOnActivePage = isOnActivePage;
UpdateMonitoringLeaseState();
UpdateTimerState();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
ReloadLanguageCode();
UpdateMonitoringLeaseState();
UpdateTimerState();
RefreshVisual();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_monitoringLease?.Dispose();
_monitoringLease = null;
_uiTimer.Stop();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
UpdateAdaptiveLayout();
ApplyTypographyByBackground(ResolvePanelBackgroundColor());
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
RefreshVisual();
}
private void OnUiTimerTick(object? sender, EventArgs e)
{
RefreshVisual();
}
private void UpdateTimerState()
{
if (_isAttached && _isOnActivePage)
{
if (!_uiTimer.IsEnabled)
{
_uiTimer.Start();
}
return;
}
_uiTimer.Stop();
}
private void UpdateMonitoringLeaseState()
{
var shouldMonitor = _isAttached && _isOnActivePage;
if (shouldMonitor)
{
_monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease();
return;
}
_monitoringLease?.Dispose();
_monitoringLease = null;
}
private void OnActionButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var snapshot = _studyAnalyticsService.GetSnapshot();
var isReportViewing = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
if (isReportViewing)
{
_studyAnalyticsService.ClearLastSessionReport();
_transientMessage = null;
RefreshVisual();
return;
}
var isRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
var success = isRunning
? _studyAnalyticsService.StopStudySession()
: _studyAnalyticsService.StartStudySession();
if (!success)
{
_transientMessage = isRunning
? L("study.session_control.stop_failed", "Unable to stop session")
: L("study.session_control.start_failed", "Unable to start session");
_transientMessageExpireAt = DateTimeOffset.UtcNow.AddSeconds(2.2);
}
else
{
_transientMessage = null;
}
RefreshVisual();
}
private void RefreshVisual()
{
var snapshot = _studyAnalyticsService.GetSnapshot();
var now = DateTimeOffset.UtcNow;
var panelColor = ResolvePanelBackgroundColor();
ApplyTypographyByBackground(panelColor);
if (_transientMessage is not null && now > _transientMessageExpireAt)
{
_transientMessage = null;
}
var isReportViewing = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
if (isReportViewing)
{
PrimaryTextBlock.Text = L("study.session_control.report_preview", "Preview Report");
SecondaryTextBlock.Text = _transientMessage ?? L("study.session_control.report_confirm_hint", "Tap right button to confirm");
ActionIcon.Kind = MaterialIconKind.Check;
ApplyActionBadgeStyle(panelColor, Color.Parse("#FF34D399"));
ApplyTransientWarningTintIfNeeded(panelColor);
return;
}
var isRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
if (isRunning)
{
PrimaryTextBlock.Text = L("study.session_control.action.stop", "Stop Study Session");
SecondaryTextBlock.Text = _transientMessage ?? string.Format(
L("study.session_control.running_elapsed_format", "Elapsed {0}"),
FormatElapsed(snapshot.Session.Elapsed));
ActionIcon.Kind = MaterialIconKind.Stop;
ApplyActionBadgeStyle(panelColor, Color.Parse("#FFF97373"));
ApplyTransientWarningTintIfNeeded(panelColor);
return;
}
PrimaryTextBlock.Text = L("study.session_control.action.start", "Start Study Session");
SecondaryTextBlock.Text = _transientMessage ?? ResolveIdleHint(snapshot);
ActionIcon.Kind = MaterialIconKind.Play;
ApplyActionBadgeStyle(panelColor, Color.Parse("#FF60A5FA"));
ApplyTransientWarningTintIfNeeded(panelColor);
}
private string ResolveIdleHint(StudyAnalyticsSnapshot snapshot)
{
if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported)
{
return L("study.environment.status.unsupported", "Unsupported");
}
if (snapshot.State == StudyAnalyticsRuntimeState.Error || snapshot.StreamStatus == NoiseStreamStatus.Error)
{
return L("study.environment.status.error", "Error");
}
if (snapshot.Session.State == StudySessionRuntimeState.Completed && snapshot.LastSessionReport is not null)
{
return string.Format(
L("study.session_control.last_session_format", "Last {0}"),
FormatElapsed(snapshot.LastSessionReport.Duration));
}
return L("study.session_control.idle_hint", "Tap the right button to start");
}
private void UpdateAdaptiveLayout()
{
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.78, 2.4);
var widthScale = Bounds.Width > 1 ? Bounds.Width / 280d : cellScale;
var heightScale = Bounds.Height > 1 ? Bounds.Height / 140d : cellScale;
var boundsScale = Math.Clamp(Math.Min(widthScale, heightScale), 0.56, 2.2);
var scale = Math.Clamp(Math.Min(cellScale, boundsScale * 1.05), 0.56, 2.2);
_isCompactMode = scale < 0.92 || (Bounds.Width > 1 && Bounds.Width < 220) || (Bounds.Height > 1 && Bounds.Height < 92);
_isUltraCompactMode = scale < 0.74 || (Bounds.Width > 1 && Bounds.Width < 180) || (Bounds.Height > 1 && Bounds.Height < 76);
var compactMultiplier = _isUltraCompactMode ? 0.78 : _isCompactMode ? 0.90 : 1.0;
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(_currentCellSize * 0.34, 10, 28);
RootBorder.Padding = new Thickness(
Math.Clamp(14 * scale * compactMultiplier, 7, 22),
Math.Clamp(10 * scale * compactMultiplier, 5, 16));
LayoutGrid.ColumnSpacing = _isUltraCompactMode
? Math.Clamp(6 * scale, 3, 8)
: _isCompactMode
? Math.Clamp(8 * scale, 4, 10)
: Math.Clamp(10 * scale, 6, 14);
PrimaryTextBlock.FontSize = Math.Clamp(17 * scale, 10, 30);
SecondaryTextBlock.FontSize = Math.Clamp(11 * scale, 8, 18);
LeftTextStack.Spacing = _isUltraCompactMode ? 0 : Math.Clamp(2 * scale, 1, 4);
var buttonSize = Math.Clamp(48 * scale * compactMultiplier, 28, 72);
ActionButton.Width = buttonSize;
ActionButton.Height = buttonSize;
ActionIconBorder.Width = buttonSize;
ActionIconBorder.Height = buttonSize;
ActionIconBorder.CornerRadius = new CornerRadius(buttonSize / 2d);
ActionIcon.Width = Math.Clamp(buttonSize * 0.44, 14, 30);
ActionIcon.Height = Math.Clamp(buttonSize * 0.44, 14, 30);
SecondaryTextBlock.IsVisible = !_isUltraCompactMode;
}
private void ApplyTypographyByBackground(Color panelColor)
{
var samples = BuildPanelBackgroundSamples(panelColor);
var primary = CreateAdaptiveBrush(samples, PrimaryColorCandidates, minContrast: 4.5);
var secondary = CreateAdaptiveBrush(samples, SecondaryColorCandidates, minContrast: 4.5);
PrimaryTextBlock.Foreground = primary;
SecondaryTextBlock.Foreground = secondary;
}
private void ApplyTransientWarningTintIfNeeded(Color panelColor)
{
if (string.IsNullOrWhiteSpace(_transientMessage))
{
return;
}
var samples = BuildPanelBackgroundSamples(panelColor);
SecondaryTextBlock.Foreground = CreateAdaptiveBrush(samples, WarningColorCandidates, minContrast: 4.5);
}
private void ApplyActionBadgeStyle(Color panelColor, Color baseColor)
{
var panelLuminance = RelativeLuminance(ToOpaqueAgainst(panelColor, DarkSubstrate));
var badgeAlpha = panelLuminance > 0.58
? (byte)0xE2
: panelLuminance > 0.46
? (byte)0xD8
: (byte)0xC8;
var badgeColor = Color.FromArgb(badgeAlpha, baseColor.R, baseColor.G, baseColor.B);
var badgeComposite = ToOpaqueAgainst(badgeColor, ToOpaqueAgainst(panelColor, DarkSubstrate));
ActionIconBorder.Background = new SolidColorBrush(badgeColor);
ActionIconBorder.BorderBrush = new SolidColorBrush(Color.FromArgb(0x96, 0xFF, 0xFF, 0xFF));
ActionIcon.Foreground = CreateAdaptiveBrush(new[] { badgeComposite }, PrimaryColorCandidates, minContrast: 4.5);
}
private Color ResolvePanelBackgroundColor()
{
if (RootBorder.Background is ISolidColorBrush solidBackground)
{
return solidBackground.Color;
}
if (this.TryFindResource("AdaptiveGlassStrongBackgroundBrush", out var resource) &&
resource is ISolidColorBrush solidBrush)
{
return solidBrush.Color;
}
return Color.Parse("#FF1E293B");
}
private static IReadOnlyList<Color> BuildPanelBackgroundSamples(Color panelColor)
{
var opaqueOnDark = ToOpaqueAgainst(panelColor, DarkSubstrate);
var opaqueOnLight = ToOpaqueAgainst(panelColor, LightSubstrate);
return
[
opaqueOnDark,
opaqueOnLight,
ColorMath.Blend(opaqueOnDark, DarkSubstrate, 0.28),
ColorMath.Blend(opaqueOnDark, Color.Parse("#FFFFFFFF"), 0.16),
ColorMath.Blend(opaqueOnLight, Color.Parse("#FFFFFFFF"), 0.08),
ColorMath.Blend(opaqueOnLight, DarkSubstrate, 0.18)
];
}
private static SolidColorBrush CreateAdaptiveBrush(
IReadOnlyList<Color> backgroundSamples,
IReadOnlyList<Color> colorCandidates,
double minContrast)
{
if (colorCandidates.Count == 0)
{
return new SolidColorBrush(Color.Parse("#FFFFFFFF"));
}
for (var i = 0; i < colorCandidates.Count; i++)
{
var candidate = colorCandidates[i];
if (MinContrastRatio(candidate, backgroundSamples) >= minContrast)
{
return new SolidColorBrush(candidate);
}
}
var best = colorCandidates[0];
var bestContrast = MinContrastRatio(best, backgroundSamples);
for (var i = 1; i < colorCandidates.Count; i++)
{
var candidate = colorCandidates[i];
var contrast = MinContrastRatio(candidate, backgroundSamples);
if (contrast > bestContrast)
{
best = candidate;
bestContrast = contrast;
}
}
return new SolidColorBrush(best);
}
private static double MinContrastRatio(Color foreground, IReadOnlyList<Color> backgrounds)
{
if (backgrounds.Count == 0)
{
return 21;
}
var minimum = double.MaxValue;
for (var i = 0; i < backgrounds.Count; i++)
{
var background = backgrounds[i];
var visibleForeground = foreground.A >= 0xFF
? Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B)
: ToOpaqueAgainst(foreground, background);
var ratio = ColorMath.ContrastRatio(visibleForeground, background);
if (ratio < minimum)
{
minimum = ratio;
}
}
return minimum;
}
private static Color ToOpaqueAgainst(Color foreground, Color background)
{
if (foreground.A >= 0xFF)
{
return Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B);
}
var alpha = foreground.A / 255d;
var red = (byte)Math.Round((foreground.R * alpha) + (background.R * (1 - alpha)));
var green = (byte)Math.Round((foreground.G * alpha) + (background.G * (1 - alpha)));
var blue = (byte)Math.Round((foreground.B * alpha) + (background.B * (1 - alpha)));
return Color.FromArgb(0xFF, red, green, blue);
}
private static double RelativeLuminance(Color color)
{
static double ToLinear(byte channel)
{
var c = channel / 255d;
return c <= 0.03928
? c / 12.92
: Math.Pow((c + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R);
var g = ToLinear(color.G);
var b = ToLinear(color.B);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private static string FormatElapsed(TimeSpan elapsed)
{
if (elapsed.TotalHours >= 1)
{
return elapsed.ToString(@"hh\:mm\:ss");
}
return elapsed.ToString(@"mm\:ss");
}
private void ReloadLanguageCode()
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
_uiTimer.Stop();
_uiTimer.Tick -= OnUiTimerTick;
AttachedToVisualTree -= OnAttachedToVisualTree;
DetachedFromVisualTree -= OnDetachedFromVisualTree;
SizeChanged -= OnSizeChanged;
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
}
}