diff --git a/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs index a698902..e9794b2 100644 --- a/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -13,6 +13,8 @@ public static class BuiltInComponentIds public const string DesktopClassSchedule = "DesktopClassSchedule"; public const string DesktopMusicControl = "DesktopMusicControl"; public const string DesktopAudioRecorder = "DesktopAudioRecorder"; + public const string DesktopStudyEnvironment = "DesktopStudyEnvironment"; + public const string DesktopStudyNoiseCurve = "DesktopStudyNoiseCurve"; public const string Blank2x4 = "Blank2x4"; public const string Date = "Date"; public const string MonthCalendar = "MonthCalendar"; diff --git a/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs index ab2924e..88c3b37 100644 --- a/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs @@ -121,6 +121,24 @@ public sealed class ComponentRegistry MinHeightCells: 2, AllowStatusBarPlacement: false, AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopStudyEnvironment, + "Study Environment", + "MicOn", + "Study", + MinWidthCells: 2, + MinHeightCells: 1, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopStudyNoiseCurve, + "Noise Curve", + "DataLine", + "Study", + MinWidthCells: 4, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), new DesktopComponentDefinition( BuiltInComponentIds.DesktopDailyPoetry, "Daily Poetry", diff --git a/LanMontainDesktop/Localization/en-US.json b/LanMontainDesktop/Localization/en-US.json index 20ff2a0..688960c 100644 --- a/LanMontainDesktop/Localization/en-US.json +++ b/LanMontainDesktop/Localization/en-US.json @@ -213,6 +213,7 @@ "component_category.board": "Board", "component_category.media": "Media", "component_category.info": "Info", + "component_category.study": "Study", "component.date": "Calendar", "component.month_calendar": "Month Calendar", "component.lunar_calendar": "Lunar Calendar", @@ -232,6 +233,8 @@ "component.blackboard_landscape": "Blackboard (Landscape)", "component.browser": "Browser", "component.holiday_calendar": "Holiday Calendar", + "component.study_environment": "Environment", + "component.study_noise_curve": "Noise Curve", "poetry.widget.loading_content": "Loading poetry...", "poetry.widget.loading_author": "Loading...", "poetry.widget.fetch_failed": "Poetry fetch failed", @@ -267,6 +270,24 @@ "recording.widget.hint.saved_format": "Saved {0}", "recording.widget.save_picker_title": "Save recording file", "recording.widget.save_picker_type": "WAV audio", + "study.environment.status_label": "Environment", + "study.environment.status.initializing": "Initializing", + "study.environment.status.ready": "Ready", + "study.environment.status.quiet": "Quiet", + "study.environment.status.noisy": "Noisy", + "study.environment.status.paused": "Paused", + "study.environment.status.error": "Error", + "study.environment.status.unsupported": "Unsupported", + "study.environment.value.unavailable": "--", + "study.environment.value.display_format": "{0:F1} dB", + "study.environment.value.dbfs_format": "{0:F1} dBFS", + "study.environment.settings.title": "Environment Widget Settings", + "study.environment.settings.desc": "Configure real-time noise value display on the right side.", + "study.environment.settings.show_display_db": "Show display dB", + "study.environment.settings.show_dbfs": "Show dBFS", + "study.environment.settings.hint": "At least one display mode must stay enabled.", + "study.noise_curve.value_format": "{0:F1} dB", + "study.noise_curve.axis.now": "Now", "desktop.add_page": "Add page", "desktop.delete_page": "Delete page", "placement.fill": "Fill", diff --git a/LanMontainDesktop/Localization/zh-CN.json b/LanMontainDesktop/Localization/zh-CN.json index 40ee600..2ae6ac5 100644 --- a/LanMontainDesktop/Localization/zh-CN.json +++ b/LanMontainDesktop/Localization/zh-CN.json @@ -213,6 +213,7 @@ "component_category.board": "白板", "component_category.media": "媒体", "component_category.info": "信息推荐", + "component_category.study": "自习", "component.date": "日历", "component.month_calendar": "月历", "component.lunar_calendar": "农历", @@ -232,6 +233,8 @@ "component.blackboard_landscape": "横向小黑板", "component.browser": "浏览器", "component.holiday_calendar": "节假日日历", + "component.study_environment": "环境", + "component.study_noise_curve": "噪音曲线", "poetry.widget.loading_content": "正在加载诗词", "poetry.widget.loading_author": "加载中", "poetry.widget.fetch_failed": "诗词获取失败", @@ -267,6 +270,24 @@ "recording.widget.hint.saved_format": "已保存 {0}", "recording.widget.save_picker_title": "保存录音文件", "recording.widget.save_picker_type": "WAV 音频", + "study.environment.status_label": "环境状态", + "study.environment.status.initializing": "初始化中", + "study.environment.status.ready": "待机", + "study.environment.status.quiet": "安静", + "study.environment.status.noisy": "嘈杂", + "study.environment.status.paused": "已暂停", + "study.environment.status.error": "错误", + "study.environment.status.unsupported": "不支持", + "study.environment.value.unavailable": "--", + "study.environment.value.display_format": "{0:F1} dB", + "study.environment.value.dbfs_format": "{0:F1} dBFS", + "study.environment.settings.title": "环境组件设置", + "study.environment.settings.desc": "配置右侧实时噪音值显示内容。", + "study.environment.settings.show_display_db": "显示 display dB", + "study.environment.settings.show_dbfs": "显示 dBFS", + "study.environment.settings.hint": "至少启用一种显示方式。", + "study.noise_curve.value_format": "{0:F1} dB", + "study.noise_curve.axis.now": "现在", "desktop.add_page": "新增页面", "desktop.delete_page": "删除页面", "placement.fill": "填充", diff --git a/LanMontainDesktop/Models/AppSettingsSnapshot.cs b/LanMontainDesktop/Models/AppSettingsSnapshot.cs index c77ca0a..822270d 100644 --- a/LanMontainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMontainDesktop/Models/AppSettingsSnapshot.cs @@ -71,4 +71,8 @@ public sealed class AppSettingsSnapshot public List ImportedClassSchedules { get; set; } = []; public string ActiveImportedClassScheduleId { get; set; } = string.Empty; + + public bool StudyEnvironmentShowDisplayDb { get; set; } = true; + + public bool StudyEnvironmentShowDbfs { get; set; } } diff --git a/LanMontainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMontainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 04231cc..6e393d8 100644 --- a/LanMontainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMontainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -165,6 +165,16 @@ public sealed class DesktopComponentRuntimeRegistry "component.audio_recorder", () => new RecordingWidget(), cellSize => Math.Clamp(cellSize * 0.36, 16, 34)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopStudyEnvironment, + "component.study_environment", + () => new StudyEnvironmentWidget(), + cellSize => Math.Clamp(cellSize * 0.36, 12, 26)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopStudyNoiseCurve, + "component.study_noise_curve", + () => new StudyNoiseCurveWidget(), + cellSize => Math.Clamp(cellSize * 0.34, 12, 26)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopDailyPoetry, "component.daily_poetry", diff --git a/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml b/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml index a8f73c2..33745e1 100644 --- a/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml +++ b/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml @@ -94,57 +94,56 @@ - - - + RowSpacing="2"> + + + + + - - - - - + Padding="0" + Margin="0"> + + + + diff --git a/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs b/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs index c33efa7..991f681 100644 --- a/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs +++ b/LanMontainDesktop/Views/Components/ExtendedWeatherWidget.axaml.cs @@ -426,30 +426,34 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge var compactness = Math.Clamp((0.90 - scale) / 0.55, 0, 1); LayoutRoot.RowSpacing = Math.Clamp(height * 0.012, 5, 13); SummaryGrid.ColumnSpacing = Math.Clamp(width * 0.016, 8, 22); - SummaryInfoGrid.ColumnSpacing = Math.Clamp(width * 0.010, 6, 14); SummaryInfoGrid.RowSpacing = Math.Clamp(height * 0.003, 1, 4); + BottomInfoStack.Spacing = Math.Clamp(2.2 * scale, 1, 6); + ConditionRangeStack.Spacing = Math.Clamp(7 * scale, 4, 13); HourlyGrid.ColumnSpacing = Math.Clamp(width * 0.007, 3, 10); DailyGrid.RowSpacing = Math.Clamp(height * 0.009, 4, 10); TemperatureTextBlock.FontSize = Math.Clamp(height * 0.18, 52, 154); TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 370, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); - var cityFontSize = Math.Clamp(height * 0.040, 12, 30); - var topInfoFontSize = Math.Clamp(height * 0.044, 12, 32); + var topScaleH = Math.Clamp(height / 640d, 0.62, 2.0); + var topScaleW = Math.Clamp(width / 640d, 0.62, 2.0); + var topScale = Math.Clamp((topScaleH * 0.68) + (topScaleW * 0.32), 0.62, 2.0); + var cityFontSize = Math.Clamp(18 * topScale, 11, 26); + var conditionFontSize = Math.Clamp(19 * topScale, 12, 27); + var rangeFontSize = Math.Clamp(20 * topScale, 12, 30); CityTextBlock.FontSize = cityFontSize; - ConditionTextBlock.FontSize = topInfoFontSize; - RangeTextBlock.FontSize = topInfoFontSize; - CityTextBlock.FontWeight = ToVariableWeight(Lerp(520, 590, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); - var topInfoWeight = ToVariableWeight(Lerp(570, 630, Math.Clamp((scale - 0.50) / 1.2, 0, 1))); - ConditionTextBlock.FontWeight = topInfoWeight; - RangeTextBlock.FontWeight = topInfoWeight; - CityTextBlock.LineHeight = cityFontSize * 1.10; - ConditionTextBlock.LineHeight = topInfoFontSize * 1.08; - RangeTextBlock.LineHeight = topInfoFontSize * 1.08; + ConditionTextBlock.FontSize = conditionFontSize; + RangeTextBlock.FontSize = rangeFontSize; + CityTextBlock.FontWeight = ToVariableWeight(540); + ConditionTextBlock.FontWeight = ToVariableWeight(600); + RangeTextBlock.FontWeight = ToVariableWeight(620); + 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, 88, 280); - RangeTextBlock.MaxWidth = Math.Clamp(width * 0.17, 72, 210); - CityTextBlock.MaxWidth = Math.Clamp(width * 0.30, 96, 320); + 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); HourlyPanelBorder.Padding = new Thickness(0); HourlyPanelBorder.CornerRadius = new CornerRadius(0); diff --git a/LanMontainDesktop/Views/Components/StudyEnvironmentWidget.axaml b/LanMontainDesktop/Views/Components/StudyEnvironmentWidget.axaml new file mode 100644 index 0000000..0191ee3 --- /dev/null +++ b/LanMontainDesktop/Views/Components/StudyEnvironmentWidget.axaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs b/LanMontainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs new file mode 100644 index 0000000..e1a715e --- /dev/null +++ b/LanMontainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs @@ -0,0 +1,249 @@ +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using LanMontainDesktop.Models; +using LanMontainDesktop.Services; + +namespace LanMontainDesktop.Views.Components; + +public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget +{ + private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly DispatcherTimer _uiTimer = new() + { + Interval = TimeSpan.FromMilliseconds(250) + }; + + private double _currentCellSize = 48; + private bool _showDisplayDb = true; + private bool _showDbfs; + private string _languageCode = "zh-CN"; + private bool _isAttached; + private bool _isOnActivePage = true; + + public StudyEnvironmentWidget() + { + InitializeComponent(); + + _uiTimer.Tick += OnUiTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ReloadDisplaySettings(); + ApplyCellSize(_currentCellSize); + RefreshVisual(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + var scale = Math.Clamp(_currentCellSize / 48d, 0.82, 2.2); + + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 10, 28)); + RootBorder.Padding = new Thickness( + Math.Clamp(14 * scale, 8, 20), + Math.Clamp(10 * scale, 6, 16)); + + StatusTitleTextBlock.FontSize = Math.Clamp(11 * scale, 9, 18); + StatusValueTextBlock.FontSize = Math.Clamp(20 * scale, 12, 34); + NoiseValueTextBlock.FontSize = Math.Clamp(22 * scale, 12, 38); + NoiseSubValueTextBlock.FontSize = Math.Clamp(12 * scale, 9, 18); + } + + public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) + { + _ = isEditMode; + _isOnActivePage = isOnActivePage; + UpdateTimerState(); + } + + public void RefreshFromSettings() + { + ReloadDisplaySettings(); + RefreshVisual(); + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + ReloadDisplaySettings(); + _ = _studyAnalyticsService.StartOrResumeMonitoring(); + UpdateTimerState(); + RefreshVisual(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _uiTimer.Stop(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyCellSize(_currentCellSize); + } + + private void OnUiTimerTick(object? sender, EventArgs e) + { + RefreshVisual(); + } + + private void UpdateTimerState() + { + if (_isAttached && _isOnActivePage) + { + _uiTimer.Start(); + return; + } + + _uiTimer.Stop(); + } + + private void ReloadDisplaySettings() + { + var snapshot = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + _showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb; + _showDbfs = snapshot.StudyEnvironmentShowDbfs; + if (!_showDisplayDb && !_showDbfs) + { + _showDisplayDb = true; + } + } + + private void RefreshVisual() + { + var snapshot = _studyAnalyticsService.GetSnapshot(); + + StatusTitleTextBlock.Text = L("study.environment.status_label", "Environment"); + StatusValueTextBlock.Text = ResolveStatusText(snapshot); + StatusValueTextBlock.Foreground = ResolveStatusBrush(snapshot); + + if (snapshot.LatestRealtimePoint is not { } realtimePoint) + { + NoiseValueTextBlock.Text = L("study.environment.value.unavailable", "--"); + NoiseSubValueTextBlock.IsVisible = false; + return; + } + + var showDisplay = _showDisplayDb; + var showDbfs = _showDbfs; + if (!showDisplay && !showDbfs) + { + showDisplay = true; + } + + if (showDisplay && showDbfs) + { + NoiseValueTextBlock.Text = FormatDisplayDb(realtimePoint.DisplayDb); + NoiseSubValueTextBlock.Text = FormatDbfs(realtimePoint.Dbfs); + NoiseSubValueTextBlock.IsVisible = true; + return; + } + + NoiseValueTextBlock.Text = showDisplay + ? FormatDisplayDb(realtimePoint.DisplayDb) + : FormatDbfs(realtimePoint.Dbfs); + NoiseSubValueTextBlock.IsVisible = false; + } + + private string ResolveStatusText(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.State == StudyAnalyticsRuntimeState.Paused) + { + return L("study.environment.status.paused", "Paused"); + } + + if (snapshot.StreamStatus == NoiseStreamStatus.Noisy) + { + return L("study.environment.status.noisy", "Noisy"); + } + + if (snapshot.State == StudyAnalyticsRuntimeState.Running && snapshot.StreamStatus == NoiseStreamStatus.Quiet) + { + return L("study.environment.status.quiet", "Quiet"); + } + + if (snapshot.State == StudyAnalyticsRuntimeState.Ready) + { + return L("study.environment.status.ready", "Ready"); + } + + return L("study.environment.status.initializing", "Initializing"); + } + + private IBrush ResolveStatusBrush(StudyAnalyticsSnapshot snapshot) + { + if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported || + snapshot.State == StudyAnalyticsRuntimeState.Error || + snapshot.StreamStatus == NoiseStreamStatus.Error) + { + return CreateBrush("#FFFF7B7B"); + } + + if (snapshot.StreamStatus == NoiseStreamStatus.Noisy) + { + return CreateBrush("#FFFFB14A"); + } + + if (snapshot.State == StudyAnalyticsRuntimeState.Running && + snapshot.StreamStatus == NoiseStreamStatus.Quiet) + { + return CreateBrush("#FF6FD7A2"); + } + + return TryResolveThemeBrush("AdaptiveTextPrimaryBrush", "#FFEFF3FF"); + } + + private string FormatDisplayDb(double value) + { + return string.Format( + CultureInfo.InvariantCulture, + L("study.environment.value.display_format", "{0:F1} dB"), + value); + } + + private string FormatDbfs(double value) + { + return string.Format( + CultureInfo.InvariantCulture, + L("study.environment.value.dbfs_format", "{0:F1} dBFS"), + value); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } + + private static SolidColorBrush CreateBrush(string hexColor) + { + return new SolidColorBrush(Color.Parse(hexColor)); + } + + private IBrush TryResolveThemeBrush(string resourceKey, string fallbackHex) + { + if (TryGetResource(resourceKey, null, out var resource) && resource is IBrush brush) + { + return brush; + } + + return CreateBrush(fallbackHex); + } +} diff --git a/LanMontainDesktop/Views/Components/StudyEnvironmentWidgetSettingsWindow.axaml b/LanMontainDesktop/Views/Components/StudyEnvironmentWidgetSettingsWindow.axaml new file mode 100644 index 0000000..374a479 --- /dev/null +++ b/LanMontainDesktop/Views/Components/StudyEnvironmentWidgetSettingsWindow.axaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/StudyEnvironmentWidgetSettingsWindow.axaml.cs b/LanMontainDesktop/Views/Components/StudyEnvironmentWidgetSettingsWindow.axaml.cs new file mode 100644 index 0000000..2d288f0 --- /dev/null +++ b/LanMontainDesktop/Views/Components/StudyEnvironmentWidgetSettingsWindow.axaml.cs @@ -0,0 +1,90 @@ +using System; +using Avalonia.Controls; +using Avalonia.Interactivity; +using LanMontainDesktop.Services; + +namespace LanMontainDesktop.Views.Components; + +public partial class StudyEnvironmentWidgetSettingsWindow : UserControl +{ + private readonly AppSettingsService _appSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private string _languageCode = "zh-CN"; + private bool _suppressEvents; + + public event EventHandler? SettingsChanged; + + public StudyEnvironmentWidgetSettingsWindow() + { + InitializeComponent(); + LoadState(); + ApplyLocalization(); + } + + private void LoadState() + { + var snapshot = _appSettingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + + var showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb; + var showDbfs = snapshot.StudyEnvironmentShowDbfs; + if (!showDisplayDb && !showDbfs) + { + showDisplayDb = true; + } + + _suppressEvents = true; + ShowDisplayDbCheckBox.IsChecked = showDisplayDb; + ShowDbfsCheckBox.IsChecked = showDbfs; + _suppressEvents = false; + } + + private void ApplyLocalization() + { + TitleTextBlock.Text = L("study.environment.settings.title", "环境组件设置"); + DescriptionTextBlock.Text = L( + "study.environment.settings.desc", + "配置右侧实时噪音值显示内容。"); + ShowDisplayDbCheckBox.Content = L( + "study.environment.settings.show_display_db", + "显示 display dB"); + ShowDbfsCheckBox.Content = L( + "study.environment.settings.show_dbfs", + "显示 dBFS"); + HintTextBlock.Text = L( + "study.environment.settings.hint", + "至少启用一种显示方式。"); + } + + private void OnDisplayModeChanged(object? sender, RoutedEventArgs e) + { + _ = sender; + _ = e; + if (_suppressEvents) + { + return; + } + + var showDisplayDb = ShowDisplayDbCheckBox.IsChecked == true; + var showDbfs = ShowDbfsCheckBox.IsChecked == true; + if (!showDisplayDb && !showDbfs) + { + _suppressEvents = true; + ShowDisplayDbCheckBox.IsChecked = true; + _suppressEvents = false; + showDisplayDb = true; + } + + var snapshot = _appSettingsService.Load(); + snapshot.StudyEnvironmentShowDisplayDb = showDisplayDb; + snapshot.StudyEnvironmentShowDbfs = showDbfs; + _appSettingsService.Save(snapshot); + + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMontainDesktop/Views/Components/StudyNoiseCurveChartControl.cs b/LanMontainDesktop/Views/Components/StudyNoiseCurveChartControl.cs new file mode 100644 index 0000000..7c4f6e8 --- /dev/null +++ b/LanMontainDesktop/Views/Components/StudyNoiseCurveChartControl.cs @@ -0,0 +1,298 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using LanMontainDesktop.Models; + +namespace LanMontainDesktop.Views.Components; + +public sealed class StudyNoiseCurveChartControl : Control +{ + private static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#324E6780")); + private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1")); + private static readonly IBrush LineBrush = new SolidColorBrush(Color.Parse("#FF52AEEA")); + private static readonly IBrush FillBrush = new SolidColorBrush(Color.Parse("#3552AEEA")); + private static readonly Pen GridPen = new(GridBrush, 1); + private static readonly Pen AxisPen = new(AxisBrush, 1.1); + private static readonly Pen LinePen = new(LineBrush, 1.8); + + private IReadOnlyList _points = Array.Empty(); + private Point[]? _pointBuffer; + + public void UpdateSeries(IReadOnlyList? points) + { + _points = points ?? Array.Empty(); + InvalidateVisual(); + } + + public void CompactCaches() + { + if (_pointBuffer is not null && _pointBuffer.Length > 2048) + { + ArrayPool.Shared.Return(_pointBuffer, clearArray: false); + _pointBuffer = null; + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + ReleasePointBuffer(); + base.OnDetachedFromVisualTree(e); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + var bounds = Bounds; + if (bounds.Width <= 2 || bounds.Height <= 2) + { + return; + } + + var plot = new Rect( + x: 1, + y: 1, + width: Math.Max(1, bounds.Width - 2), + height: Math.Max(1, bounds.Height - 2)); + + DrawGrid(context, plot); + + if (_points.Count < 2) + { + return; + } + + var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 56, 360); + var pointCount = BuildPlotPoints(plot, maxSamples); + if (pointCount < 2 || _pointBuffer is null) + { + return; + } + + var span = _pointBuffer.AsSpan(0, pointCount); + DrawAreaFill(context, plot.Bottom, span); + DrawLine(context, span); + } + + private static void DrawGrid(DrawingContext context, Rect plot) + { + const int horizontalDivisions = 4; + const int verticalDivisions = 4; + + for (var i = 0; i <= horizontalDivisions; i++) + { + var y = plot.Top + plot.Height * (i / (double)horizontalDivisions); + context.DrawLine(GridPen, new Point(plot.Left, y), new Point(plot.Right, y)); + } + + for (var i = 0; i <= verticalDivisions; i++) + { + var x = plot.Left + plot.Width * (i / (double)verticalDivisions); + context.DrawLine(GridPen, new Point(x, plot.Top), new Point(x, plot.Bottom)); + } + + context.DrawLine(AxisPen, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom)); + context.DrawLine(AxisPen, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom)); + } + + private void DrawLine(DrawingContext context, ReadOnlySpan points) + { + var geometry = new StreamGeometry(); + using (var builder = geometry.Open()) + { + builder.BeginFigure(points[0], false); + for (var i = 1; i < points.Length; i++) + { + builder.LineTo(points[i]); + } + } + + context.DrawGeometry(brush: null, pen: LinePen, geometry); + } + + private void DrawAreaFill(DrawingContext context, double baselineY, ReadOnlySpan points) + { + var geometry = new StreamGeometry(); + using (var builder = geometry.Open()) + { + var first = points[0]; + builder.BeginFigure(new Point(first.X, baselineY), true); + builder.LineTo(first); + + for (var i = 1; i < points.Length; i++) + { + builder.LineTo(points[i]); + } + + var last = points[^1]; + builder.LineTo(new Point(last.X, baselineY)); + builder.LineTo(new Point(first.X, baselineY)); + builder.EndFigure(true); + } + + context.DrawGeometry(FillBrush, pen: null, geometry); + } + + private int BuildPlotPoints(Rect plot, int maxSamples) + { + var sourceCount = _points.Count; + if (sourceCount <= 1) + { + return 0; + } + + if (sourceCount <= maxSamples) + { + EnsurePointBufferCapacity(sourceCount); + if (_pointBuffer is null) + { + return 0; + } + + for (var i = 0; i < sourceCount; i++) + { + _pointBuffer[i] = MapToPlot(plot, _points[i], _points[0].Timestamp, _points[^1].Timestamp); + } + + return sourceCount; + } + + var bucketCount = Math.Max(1, (maxSamples - 2) / 2); + var targetCapacity = 2 + bucketCount * 2; + EnsurePointBufferCapacity(targetCapacity); + if (_pointBuffer is null) + { + return 0; + } + + var outputIndex = 0; + var startTimestamp = _points[0].Timestamp; + var endTimestamp = _points[^1].Timestamp; + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[0], startTimestamp, endTimestamp); + + var middleCount = sourceCount - 2; + var bucketWidth = middleCount / (double)bucketCount; + var lastSourceIndex = 0; + + for (var bucket = 0; bucket < bucketCount; bucket++) + { + var rangeStart = 1 + (int)Math.Floor(bucket * bucketWidth); + var rangeEnd = 1 + (int)Math.Floor((bucket + 1) * bucketWidth); + if (bucket == bucketCount - 1) + { + rangeEnd = sourceCount - 1; + } + + rangeStart = Math.Clamp(rangeStart, 1, sourceCount - 2); + rangeEnd = Math.Clamp(rangeEnd, rangeStart + 1, sourceCount - 1); + + var minIndex = rangeStart; + var maxIndex = rangeStart; + var minValue = _points[rangeStart].DisplayDb; + var maxValue = minValue; + + for (var i = rangeStart + 1; i < rangeEnd; i++) + { + var value = _points[i].DisplayDb; + if (value < minValue) + { + minValue = value; + minIndex = i; + } + + if (value > maxValue) + { + maxValue = value; + maxIndex = i; + } + } + + if (minIndex == maxIndex) + { + if (minIndex != lastSourceIndex) + { + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[minIndex], startTimestamp, endTimestamp); + lastSourceIndex = minIndex; + } + + continue; + } + + var first = minIndex < maxIndex ? minIndex : maxIndex; + var second = minIndex < maxIndex ? maxIndex : minIndex; + + if (first != lastSourceIndex) + { + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[first], startTimestamp, endTimestamp); + lastSourceIndex = first; + } + + if (second != lastSourceIndex) + { + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[second], startTimestamp, endTimestamp); + lastSourceIndex = second; + } + } + + var finalIndex = sourceCount - 1; + if (finalIndex != lastSourceIndex) + { + _pointBuffer[outputIndex++] = MapToPlot(plot, _points[finalIndex], startTimestamp, endTimestamp); + } + + return outputIndex; + } + + private static Point MapToPlot( + Rect plot, + NoiseRealtimePoint point, + DateTimeOffset start, + DateTimeOffset end) + { + const double minDisplayDb = 20; + const double maxDisplayDb = 100; + + var rangeTicks = Math.Max(1, (end - start).Ticks); + var offsetTicks = Math.Clamp((point.Timestamp - start).Ticks, 0, rangeTicks); + var x = plot.Left + plot.Width * (offsetTicks / (double)rangeTicks); + + var clampedDb = Math.Clamp(point.DisplayDb, minDisplayDb, maxDisplayDb); + var normalized = (clampedDb - minDisplayDb) / (maxDisplayDb - minDisplayDb); + var y = plot.Bottom - normalized * plot.Height; + return new Point(x, y); + } + + private void EnsurePointBufferCapacity(int required) + { + if (required <= 0) + { + return; + } + + if (_pointBuffer is not null && _pointBuffer.Length >= required) + { + return; + } + + var next = ArrayPool.Shared.Rent(required); + if (_pointBuffer is not null) + { + ArrayPool.Shared.Return(_pointBuffer, clearArray: false); + } + + _pointBuffer = next; + } + + private void ReleasePointBuffer() + { + if (_pointBuffer is null) + { + return; + } + + ArrayPool.Shared.Return(_pointBuffer, clearArray: false); + _pointBuffer = null; + } +} diff --git a/LanMontainDesktop/Views/Components/StudyNoiseCurveWidget.axaml b/LanMontainDesktop/Views/Components/StudyNoiseCurveWidget.axaml new file mode 100644 index 0000000..f434506 --- /dev/null +++ b/LanMontainDesktop/Views/Components/StudyNoiseCurveWidget.axaml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs b/LanMontainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs new file mode 100644 index 0000000..55e07af --- /dev/null +++ b/LanMontainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs @@ -0,0 +1,294 @@ +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using LanMontainDesktop.Models; +using LanMontainDesktop.Services; + +namespace LanMontainDesktop.Views.Components; + +public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget +{ + private readonly object _snapshotSync = new(); + private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly DispatcherTimer _renderTimer = new() + { + Interval = TimeSpan.FromMilliseconds(33) + }; + + private StudyAnalyticsSnapshot? _pendingSnapshot; + private bool _hasPendingSnapshot; + private double _currentCellSize = 48; + private string _languageCode = "zh-CN"; + private bool _isAttached; + private bool _isOnActivePage = true; + private bool _isSubscribed; + private int _framesSinceCompaction; + + public StudyNoiseCurveWidget() + { + InitializeComponent(); + + _renderTimer.Tick += OnRenderTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ReloadLanguageCode(); + ApplyCellSize(_currentCellSize); + ApplyDefaultXAxisLabels(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + var scale = Math.Clamp(_currentCellSize / 48d, 0.78, 2.4); + + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.44, 14, 42)); + RootBorder.Padding = new Thickness( + Math.Clamp(14 * scale, 8, 22), + Math.Clamp(10 * scale, 6, 16)); + + StatusTextBlock.FontSize = Math.Clamp(16 * scale, 11, 30); + RealtimeValueTextBlock.FontSize = Math.Clamp(18 * scale, 11, 34); + + var axisFontSize = Math.Clamp(10 * scale, 8.5, 18); + YTopTextBlock.FontSize = axisFontSize; + YUpperTextBlock.FontSize = axisFontSize; + YMiddleTextBlock.FontSize = axisFontSize; + YLowerTextBlock.FontSize = axisFontSize; + YBottomTextBlock.FontSize = axisFontSize; + XLeftTextBlock.FontSize = axisFontSize; + XCenterTextBlock.FontSize = axisFontSize; + XRightTextBlock.FontSize = axisFontSize; + } + + public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) + { + _ = isEditMode; + _isOnActivePage = isOnActivePage; + UpdateRenderLoopState(); + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + ReloadLanguageCode(); + + if (!_isSubscribed) + { + _studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated; + _isSubscribed = true; + } + + _ = _studyAnalyticsService.StartOrResumeMonitoring(); + + lock (_snapshotSync) + { + _pendingSnapshot = _studyAnalyticsService.GetSnapshot(); + _hasPendingSnapshot = true; + } + + UpdateRenderLoopState(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _renderTimer.Stop(); + + if (_isSubscribed) + { + _studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated; + _isSubscribed = false; + } + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyCellSize(_currentCellSize); + } + + private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e) + { + lock (_snapshotSync) + { + _pendingSnapshot = e.Snapshot; + _hasPendingSnapshot = true; + } + } + + private void OnRenderTimerTick(object? sender, EventArgs e) + { + StudyAnalyticsSnapshot? snapshot = null; + lock (_snapshotSync) + { + if (_hasPendingSnapshot) + { + snapshot = _pendingSnapshot; + _hasPendingSnapshot = false; + } + } + + if (snapshot is null) + { + return; + } + + ApplySnapshot(snapshot); + _framesSinceCompaction++; + if (_framesSinceCompaction >= 900) + { + ChartControl.CompactCaches(); + _framesSinceCompaction = 0; + } + } + + private void UpdateRenderLoopState() + { + if (_isAttached && _isOnActivePage) + { + if (!_renderTimer.IsEnabled) + { + _renderTimer.Start(); + } + + return; + } + + _renderTimer.Stop(); + } + + private void ApplySnapshot(StudyAnalyticsSnapshot snapshot) + { + StatusTextBlock.Text = ResolveStatusText(snapshot); + StatusTextBlock.Foreground = ResolveStatusBrush(snapshot); + + if (snapshot.LatestRealtimePoint is { } latestPoint) + { + RealtimeValueTextBlock.Text = string.Format( + CultureInfo.InvariantCulture, + L("study.noise_curve.value_format", "{0:F1} dB"), + latestPoint.DisplayDb); + } + else + { + RealtimeValueTextBlock.Text = L("study.environment.value.unavailable", "--"); + } + + ChartControl.UpdateSeries(snapshot.RealtimeBuffer); + UpdateXAxisLabels(snapshot); + } + + private void UpdateXAxisLabels(StudyAnalyticsSnapshot snapshot) + { + var buffer = snapshot.RealtimeBuffer; + if (buffer.Count < 2) + { + ApplyDefaultXAxisLabels(); + return; + } + + var duration = (buffer[^1].Timestamp - buffer[0].Timestamp).TotalSeconds; + if (double.IsNaN(duration) || double.IsInfinity(duration) || duration <= 1) + { + duration = 12; + } + + duration = Math.Clamp(duration, 4, 60); + var leftSeconds = Math.Round(duration, MidpointRounding.AwayFromZero); + var centerSeconds = Math.Round(duration / 2d, MidpointRounding.AwayFromZero); + XLeftTextBlock.Text = $"-{leftSeconds:0}s"; + XCenterTextBlock.Text = $"-{centerSeconds:0}s"; + XRightTextBlock.Text = L("study.noise_curve.axis.now", "现在"); + } + + private void ApplyDefaultXAxisLabels() + { + XLeftTextBlock.Text = "-12s"; + XCenterTextBlock.Text = "-6s"; + XRightTextBlock.Text = L("study.noise_curve.axis.now", "现在"); + } + + private string ResolveStatusText(StudyAnalyticsSnapshot snapshot) + { + if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported) + { + return L("study.environment.status.unsupported", "不支持"); + } + + if (snapshot.State == StudyAnalyticsRuntimeState.Error || snapshot.StreamStatus == NoiseStreamStatus.Error) + { + return L("study.environment.status.error", "错误"); + } + + if (snapshot.State == StudyAnalyticsRuntimeState.Paused) + { + return L("study.environment.status.paused", "已暂停"); + } + + if (snapshot.StreamStatus == NoiseStreamStatus.Noisy) + { + return L("study.environment.status.noisy", "嘈杂"); + } + + if (snapshot.State == StudyAnalyticsRuntimeState.Running && snapshot.StreamStatus == NoiseStreamStatus.Quiet) + { + return L("study.environment.status.quiet", "安静"); + } + + if (snapshot.State == StudyAnalyticsRuntimeState.Ready) + { + return L("study.environment.status.ready", "待机"); + } + + return L("study.environment.status.initializing", "初始化中"); + } + + private IBrush ResolveStatusBrush(StudyAnalyticsSnapshot snapshot) + { + if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported || + snapshot.State == StudyAnalyticsRuntimeState.Error || + snapshot.StreamStatus == NoiseStreamStatus.Error) + { + return new SolidColorBrush(Color.Parse("#FFFF9D9D")); + } + + if (snapshot.StreamStatus == NoiseStreamStatus.Noisy) + { + return new SolidColorBrush(Color.Parse("#FFFFD791")); + } + + if (snapshot.State == StudyAnalyticsRuntimeState.Running && snapshot.StreamStatus == NoiseStreamStatus.Quiet) + { + return new SolidColorBrush(Color.Parse("#FFCBFFE8")); + } + + return TryResolveThemeBrush("AdaptiveTextPrimaryBrush", "#FF1E293B"); + } + + 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); + } + + private IBrush TryResolveThemeBrush(string resourceKey, string fallbackHex) + { + if (TryGetResource(resourceKey, null, out var resource) && resource is IBrush brush) + { + return brush; + } + + return new SolidColorBrush(Color.Parse(fallbackHex)); + } +} diff --git a/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs index 4c07994..d013f6b 100644 --- a/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMontainDesktop/Views/MainWindow.ComponentSystem.cs @@ -703,6 +703,12 @@ public partial class MainWindow if (placement.ComponentId == BuiltInComponentIds.DesktopClassSchedule) { OpenClassScheduleComponentSettings(); + return; + } + + if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment) + { + OpenStudyEnvironmentComponentSettings(); } } @@ -738,6 +744,22 @@ public partial class MainWindow ComponentSettingsWindow.Opacity = 1; } + private void OpenStudyEnvironmentComponentSettings() + { + if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null) + { + return; + } + + var settingsContent = new StudyEnvironmentWidgetSettingsWindow(); + settingsContent.SettingsChanged += OnStudyEnvironmentSettingsChanged; + ComponentSettingsContentHost.Content = settingsContent; + + ComponentSettingsWindow.IsVisible = true; + ComponentSettingsWindow.Opacity = 0; + ComponentSettingsWindow.Opacity = 1; + } + private void OnClassScheduleSettingsChanged(object? sender, EventArgs e) { if (_selectedDesktopComponentHost is null) @@ -751,6 +773,21 @@ public partial class MainWindow } } + private void OnStudyEnvironmentSettingsChanged(object? sender, EventArgs e) + { + _ = sender; + _ = e; + if (_selectedDesktopComponentHost is null) + { + return; + } + + if (TryGetContentHost(_selectedDesktopComponentHost)?.Child is StudyEnvironmentWidget widget) + { + widget.RefreshFromSettings(); + } + } + private void CloseComponentSettingsWindow() { if (ComponentSettingsWindow is null) @@ -763,6 +800,11 @@ public partial class MainWindow classScheduleSettingsWindow.SettingsChanged -= OnClassScheduleSettingsChanged; } + if (ComponentSettingsContentHost?.Content is StudyEnvironmentWidgetSettingsWindow studyEnvironmentSettingsWindow) + { + studyEnvironmentSettingsWindow.SettingsChanged -= OnStudyEnvironmentSettingsChanged; + } + ComponentSettingsWindow.Opacity = 0; DispatcherTimer.RunOnce(() => @@ -1166,6 +1208,22 @@ public partial class MainWindow new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); } + if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyEnvironment, StringComparison.OrdinalIgnoreCase)) + { + // Keep study environment widget in a 2:1 ratio: 2x1, 4x2, 6x3... + return SnapSpanToScaleRules( + span, + new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 1)); + } + + if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyNoiseCurve, StringComparison.OrdinalIgnoreCase)) + { + // Keep noise curve widget in a 2:1 ratio with minimum 4x2. + return SnapSpanToScaleRules( + span, + new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); + } + return span; } @@ -2279,6 +2337,11 @@ public partial class MainWindow return Symbol.Apps; } + if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) + { + return Symbol.Apps; + } + return Symbol.Apps; } @@ -2314,6 +2377,11 @@ public partial class MainWindow return L("component_category.info", "Info"); } + if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) + { + return L("component_category.study", "Study"); + } + return categoryId; }