mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 08:04:26 +08:00
0.3.0
自习组件加入
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "填充",
|
||||
|
||||
@@ -71,4 +71,8 @@ public sealed class AppSettingsSnapshot
|
||||
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
|
||||
|
||||
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
|
||||
|
||||
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
|
||||
|
||||
public bool StudyEnvironmentShowDbfs { get; set; }
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -94,57 +94,56 @@
|
||||
<Grid x:Name="SummaryInfoGrid"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,2,0,0"
|
||||
Margin="2,0,0,0"
|
||||
RowDefinitions="Auto,Auto"
|
||||
ColumnDefinitions="*,Auto"
|
||||
RowSpacing="2"
|
||||
ColumnSpacing="8">
|
||||
<Border x:Name="CityInfoBadge"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="2"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<TextBlock x:Name="CityTextBlock"
|
||||
Text="北京"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</Border>
|
||||
RowSpacing="2">
|
||||
<StackPanel x:Name="BottomInfoStack"
|
||||
Grid.Row="0"
|
||||
Orientation="Horizontal"
|
||||
Spacing="3"
|
||||
Margin="0,0,0,1"
|
||||
VerticalAlignment="Center">
|
||||
<Border x:Name="CityInfoBadge"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<TextBlock x:Name="CityTextBlock"
|
||||
Text="北京"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Border x:Name="ConditionInfoBadge"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="雾"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</Border>
|
||||
|
||||
<Border x:Name="RangeInfoBadge"
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="11°/4°"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
Opacity="0.92" />
|
||||
Padding="0"
|
||||
Margin="0">
|
||||
<StackPanel x:Name="ConditionRangeStack"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="9">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="雾"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="11°/4°"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
Opacity="0.92" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="300"
|
||||
d:DesignHeight="150"
|
||||
x:Class="LanMontainDesktop.Views.Components.StudyEnvironmentWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="18"
|
||||
Padding="14,10">
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<StackPanel Spacing="2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="StatusTitleTextBlock"
|
||||
Text="环境状态"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<TextBlock x:Name="StatusValueTextBlock"
|
||||
Text="安静"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="1"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="NoiseValueTextBlock"
|
||||
Text="--"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
TextAlignment="Right"
|
||||
HorizontalAlignment="Right"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<TextBlock x:Name="NoiseSubValueTextBlock"
|
||||
Text=""
|
||||
FontSize="12"
|
||||
TextAlignment="Right"
|
||||
HorizontalAlignment="Right"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
IsVisible="False" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="420"
|
||||
d:DesignHeight="280"
|
||||
x:Class="LanMontainDesktop.Views.Components.StudyEnvironmentWidgetSettingsWindow">
|
||||
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
|
||||
Padding="16">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="环境组件设置"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="DescriptionTextBlock"
|
||||
Text="配置右侧噪音值展示。"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<CheckBox x:Name="ShowDisplayDbCheckBox"
|
||||
IsChecked="True"
|
||||
Content="显示 display dB"
|
||||
Checked="OnDisplayModeChanged"
|
||||
Unchecked="OnDisplayModeChanged" />
|
||||
<CheckBox x:Name="ShowDbfsCheckBox"
|
||||
IsChecked="False"
|
||||
Content="显示 dBFS"
|
||||
Checked="OnDisplayModeChanged"
|
||||
Unchecked="OnDisplayModeChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="HintTextBlock"
|
||||
Text="至少启用一种显示方式。"
|
||||
FontSize="11"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
|
||||
private Point[]? _pointBuffer;
|
||||
|
||||
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points)
|
||||
{
|
||||
_points = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
public void CompactCaches()
|
||||
{
|
||||
if (_pointBuffer is not null && _pointBuffer.Length > 2048)
|
||||
{
|
||||
ArrayPool<Point>.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<Point> 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<Point> 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<Point>.Shared.Rent(required);
|
||||
if (_pointBuffer is not null)
|
||||
{
|
||||
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
|
||||
}
|
||||
|
||||
_pointBuffer = next;
|
||||
}
|
||||
|
||||
private void ReleasePointBuffer()
|
||||
{
|
||||
if (_pointBuffer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
|
||||
_pointBuffer = null;
|
||||
}
|
||||
}
|
||||
104
LanMontainDesktop/Views/Components/StudyNoiseCurveWidget.axaml
Normal file
104
LanMontainDesktop/Views/Components/StudyNoiseCurveWidget.axaml
Normal file
@@ -0,0 +1,104 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="using:LanMontainDesktop.Views.Components"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="640"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMontainDesktop.Views.Components.StudyNoiseCurveWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="glass-strong"
|
||||
CornerRadius="24"
|
||||
Padding="14,10"
|
||||
ClipToBounds="True">
|
||||
<Grid RowDefinitions="Auto,*"
|
||||
RowSpacing="8">
|
||||
<Grid Grid.Row="0"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
Text="安静"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<TextBlock x:Name="RealtimeValueTextBlock"
|
||||
Grid.Column="1"
|
||||
Text="-- dB"
|
||||
HorizontalAlignment="Right"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1"
|
||||
RowDefinitions="*,Auto"
|
||||
ColumnDefinitions="Auto,*"
|
||||
RowSpacing="4"
|
||||
ColumnSpacing="6">
|
||||
<Grid Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
RowDefinitions="Auto,*,*,*,Auto"
|
||||
VerticalAlignment="Stretch">
|
||||
<TextBlock x:Name="YTopTextBlock"
|
||||
Grid.Row="0"
|
||||
Text="100"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<TextBlock x:Name="YUpperTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="80"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<TextBlock x:Name="YMiddleTextBlock"
|
||||
Grid.Row="2"
|
||||
Text="60"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<TextBlock x:Name="YLowerTextBlock"
|
||||
Grid.Row="3"
|
||||
Text="40"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<TextBlock x:Name="YBottomTextBlock"
|
||||
Grid.Row="4"
|
||||
Text="20"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</Grid>
|
||||
|
||||
<local:StudyNoiseCurveChartControl x:Name="ChartControl"
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
MinHeight="64" />
|
||||
|
||||
<Grid Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock x:Name="XLeftTextBlock"
|
||||
Text="-12s"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<TextBlock x:Name="XCenterTextBlock"
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Center"
|
||||
Text="-6s"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<TextBlock x:Name="XRightTextBlock"
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Right"
|
||||
Text="现在"
|
||||
FontSize="10"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user