diff --git a/LanMountainDesktop/App.axaml b/LanMountainDesktop/App.axaml index b8408e9..c098505 100644 --- a/LanMountainDesktop/App.axaml +++ b/LanMountainDesktop/App.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:sty="using:FluentAvalonia.Styling" xmlns:fi="using:FluentIcons.Avalonia" + xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" x:Class="LanMountainDesktop.App" xmlns:local="using:LanMountainDesktop" RequestedThemeVariant="Default"> @@ -17,6 +18,7 @@ + @@ -58,5 +60,13 @@ + + diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs index 7c8ff75..a1fd5f2 100644 --- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -19,6 +19,7 @@ public static class BuiltInComponentIds public const string DesktopStudyScoreOverview = "DesktopStudyScoreOverview"; public const string DesktopStudyDeductionReasons = "DesktopStudyDeductionReasons"; public const string DesktopStudyInterruptDensity = "DesktopStudyInterruptDensity"; + public const string DesktopStudySessionControl = "DesktopStudySessionControl"; public const string Blank2x4 = "Blank2x4"; public const string Date = "Date"; public const string MonthCalendar = "MonthCalendar"; diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index 37bbbbf..cd26dc5 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -131,6 +131,15 @@ public sealed class ComponentRegistry AllowStatusBarPlacement: false, AllowDesktopPlacement: true, ResizeMode: DesktopComponentResizeMode.Free), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopStudySessionControl, + "Study Session", + "Play", + "Study", + MinWidthCells: 2, + MinHeightCells: 1, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), new DesktopComponentDefinition( BuiltInComponentIds.DesktopStudyNoiseCurve, "Noise Curve", diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index 95b8c86..0d2fa66 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -52,6 +52,7 @@ + diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index e074b71..348f1ae 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -234,6 +234,7 @@ "component.browser": "Browser", "component.holiday_calendar": "Holiday Calendar", "component.study_environment": "Environment", + "component.study_session_control": "Study Session Control", "component.study_noise_curve": "Noise Curve", "component.study_noise_distribution": "Noise Distribution", "component.study_score_overview": "Study Score Overview", @@ -290,6 +291,15 @@ "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.session_control.action.start": "Start Study Session", + "study.session_control.action.stop": "Stop Study Session", + "study.session_control.idle_hint": "Tap the right button to start", + "study.session_control.report_preview": "Preview Report", + "study.session_control.report_confirm_hint": "Tap right button to confirm", + "study.session_control.running_elapsed_format": "Elapsed {0}", + "study.session_control.last_session_format": "Last {0}", + "study.session_control.start_failed": "Unable to start session", + "study.session_control.stop_failed": "Unable to stop session", "study.noise_curve.value_format": "{0:F1} dB", "study.noise_curve.axis.now": "Now", "study.noise_distribution.title": "Noise Level Distribution", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 30eaa21..7613248 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -234,6 +234,7 @@ "component.browser": "浏览器", "component.holiday_calendar": "节假日日历", "component.study_environment": "环境", + "component.study_session_control": "自习时段控制", "component.study_noise_curve": "噪音曲线", "component.study_noise_distribution": "噪音等级分布", "component.study_score_overview": "自习评分总览", @@ -290,6 +291,15 @@ "study.environment.settings.show_display_db": "显示 display dB", "study.environment.settings.show_dbfs": "显示 dBFS", "study.environment.settings.hint": "至少启用一种显示方式。", + "study.session_control.action.start": "开始自习时段", + "study.session_control.action.stop": "结束自习时段", + "study.session_control.idle_hint": "点击右侧按钮开始", + "study.session_control.report_preview": "预览报告", + "study.session_control.report_confirm_hint": "点击右侧确定结束查看", + "study.session_control.running_elapsed_format": "已进行 {0}", + "study.session_control.last_session_format": "上次时段 {0}", + "study.session_control.start_failed": "启动失败", + "study.session_control.stop_failed": "结束失败", "study.noise_curve.value_format": "{0:F1} dB", "study.noise_curve.axis.now": "现在", "study.noise_distribution.title": "噪音等级分布", diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 2a50cb4..885e051 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -179,6 +179,11 @@ public sealed class DesktopComponentRuntimeRegistry "component.study_environment", () => new StudyEnvironmentWidget(), cellSize => Math.Clamp(cellSize * 0.36, 12, 26)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopStudySessionControl, + "component.study_session_control", + () => new StudySessionControlWidget(), + cellSize => Math.Clamp(cellSize * 0.36, 10, 24)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopStudyNoiseCurve, "component.study_noise_curve", diff --git a/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs index 084c644..815544c 100644 --- a/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs @@ -141,14 +141,18 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen ApplyTypographyByBackground(panelColor); var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running; - ModeTextBlock.Text = isSessionRunning + var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; + var isSessionView = isSessionRunning || isSessionReport; + ModeTextBlock.Text = isSessionView ? L("study.deduction.mode.session", "Session") : L("study.deduction.mode.realtime", "Realtime"); - ApplyModeBadgeColor(panelColor, isSessionRunning ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8")); + ApplyModeBadgeColor(panelColor, isSessionView ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8")); ApplyLocalizedLabels(); - var metrics = ComputeRealtimeDeduction(snapshot); + var metrics = isSessionReport && snapshot.LastSessionReport is not null + ? ComputeReportDeduction(snapshot.LastSessionReport, snapshot.Config) + : ComputeRealtimeDeduction(snapshot); if (metrics is null) { ApplyUnavailableMetrics(); @@ -407,6 +411,24 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen SegmentsPerMin: Math.Round(segmentsPerMin, 3)); } + private static DeductionMetrics? ComputeReportDeduction(StudySessionReport report, StudyAnalyticsConfig config) + { + if (!StudySessionReportProjection.TryAggregate(report, config, out var aggregate)) + { + return null; + } + + return new DeductionMetrics( + SustainedPenalty: aggregate.SustainedPenalty, + TimePenalty: aggregate.TimePenalty, + SegmentPenalty: aggregate.SegmentPenalty, + TotalPenalty: aggregate.TotalPenalty, + Score: aggregate.Score, + P50Dbfs: aggregate.P50Dbfs, + OverRatio: aggregate.OverRatio, + SegmentsPerMin: aggregate.SegmentsPerMin); + } + private static double Percentile(double[] sortedValues, double percentile) { if (sortedValues.Length == 0) diff --git a/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs index c0ef07a..24637c8 100644 --- a/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs @@ -140,8 +140,46 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg private void RefreshVisual() { var snapshot = _studyAnalyticsService.GetSnapshot(); + var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; StatusTitleTextBlock.Text = L("study.environment.status_label", "Environment"); + + if (isSessionReport && snapshot.LastSessionReport is not null) + { + StatusValueTextBlock.Text = L("study.score_overview.mode.session", "Session"); + StatusValueTextBlock.Foreground = TryResolveThemeBrush("AdaptiveTextPrimaryBrush", "#FFEFF3FF"); + + if (!StudySessionReportProjection.TryAggregate(snapshot.LastSessionReport, snapshot.Config, out var aggregate)) + { + NoiseValueTextBlock.Text = L("study.environment.value.unavailable", "--"); + NoiseSubValueTextBlock.IsVisible = false; + UpdateAdaptiveLayout(); + return; + } + + var reportShowDisplay = _showDisplayDb; + var reportShowDbfs = _showDbfs; + if (!reportShowDisplay && !reportShowDbfs) + { + reportShowDisplay = true; + } + + if (reportShowDisplay && reportShowDbfs) + { + NoiseValueTextBlock.Text = FormatDisplayDb(aggregate.AverageDisplayDb); + NoiseSubValueTextBlock.Text = FormatDbfs(aggregate.AverageDbfs); + NoiseSubValueTextBlock.IsVisible = true; + return; + } + + NoiseValueTextBlock.Text = reportShowDisplay + ? FormatDisplayDb(aggregate.AverageDisplayDb) + : FormatDbfs(aggregate.AverageDbfs); + NoiseSubValueTextBlock.IsVisible = false; + UpdateAdaptiveLayout(); + return; + } + StatusValueTextBlock.Text = ResolveStatusText(snapshot); StatusValueTextBlock.Foreground = ResolveStatusBrush(snapshot); diff --git a/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs index 7722aaa..3b5b8ea 100644 --- a/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs @@ -164,14 +164,24 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen ApplyLocalizedLabels(); var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running; - ModeTextBlock.Text = isSessionRunning + var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; + var isSessionView = isSessionRunning || isSessionReport; + ModeTextBlock.Text = isSessionView ? L("study.interrupt_density.mode.session", "Session") : L("study.interrupt_density.mode.realtime", "Realtime"); - ApplyModeBadgeColor(panelColor, isSessionRunning ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8")); + ApplyModeBadgeColor(panelColor, isSessionView ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8")); - var metrics = isSessionRunning - ? ComputeSessionDensity(snapshot) - : ComputeRealtimeDensity(snapshot); + InterruptDensityMetrics? metrics; + if (isSessionReport && snapshot.LastSessionReport is not null) + { + metrics = ComputeReportDensity(snapshot.LastSessionReport, snapshot.Config); + } + else + { + metrics = isSessionRunning + ? ComputeSessionDensity(snapshot) + : ComputeRealtimeDensity(snapshot); + } if (metrics is null) { @@ -429,6 +439,23 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen LevelKind: levelKind); } + private static InterruptDensityMetrics? ComputeReportDensity(StudySessionReport report, StudyAnalyticsConfig config) + { + if (!StudySessionReportProjection.TryAggregate(report, config, out var aggregate)) + { + return null; + } + + var threshold = Math.Max(1, config.MaxSegmentsPerMin); + var levelKind = ResolveLevelKind(aggregate.SegmentsPerMin, threshold); + return new InterruptDensityMetrics( + DensityPerMin: Math.Round(aggregate.SegmentsPerMin, 2), + SegmentCount: aggregate.SegmentCount, + Duration: aggregate.Duration, + ThresholdPerMin: threshold, + LevelKind: levelKind); + } + private static DensityLevelKind ResolveLevelKind(double densityPerMin, double thresholdPerMin) { var ratio = densityPerMin / Math.Max(1, thresholdPerMin); diff --git a/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs index 39c4f30..90d0140 100644 --- a/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs @@ -247,6 +247,31 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge var panelColor = ResolvePanelBackgroundColor(); ApplyTypographyByBackground(panelColor); + var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; + if (isSessionReport && snapshot.LastSessionReport is not null) + { + StatusTextBlock.Text = L("study.score_overview.mode.session", "Session"); + ApplyStatusBadgeStyle(StatusVisualKind.Quiet, panelColor); + + var reportPoints = StudySessionReportProjection.BuildSyntheticRealtimePoints(snapshot.LastSessionReport, snapshot.Config); + ChartControl.UpdateSeries(reportPoints); + UpdateXAxisLabels(reportPoints); + + if (StudySessionReportProjection.TryAggregate(snapshot.LastSessionReport, snapshot.Config, out var aggregate)) + { + RealtimeValueTextBlock.Text = string.Format( + CultureInfo.InvariantCulture, + L("study.noise_curve.value_format", "{0:F1} dB"), + aggregate.AverageDisplayDb); + } + else + { + RealtimeValueTextBlock.Text = L("study.environment.value.unavailable", "--"); + } + + return; + } + var statusKind = ResolveStatusVisualKind(snapshot); StatusTextBlock.Text = ResolveStatusText(snapshot); ApplyStatusBadgeStyle(statusKind, panelColor); @@ -264,7 +289,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge } ChartControl.UpdateSeries(snapshot.RealtimeBuffer); - UpdateXAxisLabels(snapshot); + UpdateXAxisLabels(snapshot.RealtimeBuffer); } private void ApplyTypographyByBackground(Color panelColor) @@ -454,9 +479,8 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge return 0.2126 * r + 0.7152 * g + 0.0722 * b; } - private void UpdateXAxisLabels(StudyAnalyticsSnapshot snapshot) + private void UpdateXAxisLabels(IReadOnlyList buffer) { - var buffer = snapshot.RealtimeBuffer; if (buffer.Count < 2) { ApplyDefaultXAxisLabels(); diff --git a/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs index 95a5480..a2bf6a8 100644 --- a/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -163,15 +163,21 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone ApplyLocalizedAxisLabels(); var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running; - ModeTextBlock.Text = isSessionRunning + var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; + var isSessionView = isSessionRunning || isSessionReport; + ModeTextBlock.Text = isSessionView ? L("study.noise_distribution.mode.session", "Session") : L("study.noise_distribution.mode.realtime", "Realtime"); - ApplyModeBadgeColor(panelColor, isSessionRunning ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8")); + ApplyModeBadgeColor(panelColor, isSessionView ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8")); - ChartControl.UpdateSeries(snapshot.RealtimeBuffer, snapshot.Config.BaselineDb); - UpdateXAxisLabels(snapshot); + var points = isSessionReport && snapshot.LastSessionReport is not null + ? StudySessionReportProjection.BuildSyntheticRealtimePoints(snapshot.LastSessionReport, snapshot.Config) + : snapshot.RealtimeBuffer; - var stats = ComputeDistributionStats(snapshot.RealtimeBuffer, snapshot.Config.BaselineDb); + ChartControl.UpdateSeries(points, snapshot.Config.BaselineDb); + UpdateXAxisLabels(points); + + var stats = ComputeDistributionStats(points, snapshot.Config.BaselineDb); if (stats is null) { SummaryTextBlock.Text = string.Format( @@ -497,9 +503,8 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone return 0.2126 * r + 0.7152 * g + 0.0722 * b; } - private void UpdateXAxisLabels(StudyAnalyticsSnapshot snapshot) + private void UpdateXAxisLabels(IReadOnlyList buffer) { - var buffer = snapshot.RealtimeBuffer; if (buffer.Count < 2) { ApplyDefaultXAxisLabels(); @@ -600,3 +605,5 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone return _localizationService.GetString(_languageCode, key, fallback); } } + + diff --git a/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs index efc3a0c..05b8ef9 100644 --- a/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs @@ -154,7 +154,7 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi ApplyTypographyByBackground(panelColor); var realtimeScore = ComputeRealtimeScore(snapshot); - if (realtimeScore is { } score) + if (snapshot.DataMode == StudyDataMode.Realtime && realtimeScore is { } score) { PushRealtimeScore(score, DateTimeOffset.UtcNow); } @@ -166,6 +166,12 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi return; } + if (snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null) + { + ApplySessionReportMode(snapshot, panelColor); + return; + } + ApplyRealtimeMode(snapshot, realtimeScore, panelColor); } @@ -199,6 +205,24 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi MaximumValueTextBlock.Text = FormatScoreOrUnavailable(historyStats.Maximum); } + private void ApplySessionReportMode(StudyAnalyticsSnapshot snapshot, Color panelColor) + { + var report = snapshot.LastSessionReport; + if (report is null) + { + ApplyRealtimeMode(snapshot, realtimeScore: null, panelColor); + return; + } + + ModeTextBlock.Text = L("study.score_overview.mode.session", "Session"); + ApplyModeBadgeColor(panelColor, Color.Parse("#FF0F6B49")); + + CurrentScoreTextBlock.Text = FormatScoreOrUnavailable(report.Metrics.CurrentScore); + AverageValueTextBlock.Text = FormatScoreOrUnavailable(report.Metrics.AvgScore); + MinimumValueTextBlock.Text = FormatScoreOrUnavailable(report.Metrics.MinScore); + MaximumValueTextBlock.Text = FormatScoreOrUnavailable(report.Metrics.MaxScore); + } + private void ApplyLocalizedLabels() { TitleTextBlock.Text = L("study.score_overview.title", "Study Score"); diff --git a/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml new file mode 100644 index 0000000..1333ef6 --- /dev/null +++ b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml @@ -0,0 +1,58 @@ + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs new file mode 100644 index 0000000..9a49223 --- /dev/null +++ b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs @@ -0,0 +1,471 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; +using LanMountainDesktop.Theme; +using Material.Icons; + +namespace LanMountainDesktop.Views.Components; + +public partial class StudySessionControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget +{ + private static readonly Color[] PrimaryColorCandidates = + { + Color.Parse("#FFEAF5FF"), + Color.Parse("#FFDDEEFF"), + Color.Parse("#FFCEE3FA"), + Color.Parse("#FF1B2E45"), + Color.Parse("#FF233A54"), + Color.Parse("#FFFFFFFF"), + Color.Parse("#FF101C2A") + }; + + private static readonly Color[] SecondaryColorCandidates = + { + Color.Parse("#FFC7D9EC"), + Color.Parse("#FFBAD0E8"), + Color.Parse("#FFD9E8F6"), + Color.Parse("#FF2F4763"), + Color.Parse("#FF385673"), + Color.Parse("#FFEAF3FA"), + Color.Parse("#FF1A2C40") + }; + + private static readonly Color[] WarningColorCandidates = + { + Color.Parse("#FFFFD4D4"), + Color.Parse("#FFFEE2E2"), + Color.Parse("#FF7F1D1D"), + Color.Parse("#FF991B1B"), + Color.Parse("#FFFFFFFF"), + Color.Parse("#FF111827") + }; + + private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220"); + private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA"); + + private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); + private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault(); + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly DispatcherTimer _uiTimer = new() + { + Interval = TimeSpan.FromMilliseconds(250) + }; + + private double _currentCellSize = 48; + private string _languageCode = "zh-CN"; + private bool _isAttached; + private bool _isOnActivePage = true; + private bool _isCompactMode; + private bool _isUltraCompactMode; + private IDisposable? _monitoringLease; + private string? _transientMessage; + private DateTimeOffset _transientMessageExpireAt; + + public StudySessionControlWidget() + { + InitializeComponent(); + + _uiTimer.Tick += OnUiTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ReloadLanguageCode(); + ApplyCellSize(_currentCellSize); + RefreshVisual(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + UpdateAdaptiveLayout(); + } + + public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) + { + _ = isEditMode; + _isOnActivePage = isOnActivePage; + UpdateMonitoringLeaseState(); + UpdateTimerState(); + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + ReloadLanguageCode(); + UpdateMonitoringLeaseState(); + UpdateTimerState(); + RefreshVisual(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _monitoringLease?.Dispose(); + _monitoringLease = null; + _uiTimer.Stop(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + UpdateAdaptiveLayout(); + ApplyTypographyByBackground(ResolvePanelBackgroundColor()); + } + + private void OnUiTimerTick(object? sender, EventArgs e) + { + RefreshVisual(); + } + + private void UpdateTimerState() + { + if (_isAttached && _isOnActivePage) + { + if (!_uiTimer.IsEnabled) + { + _uiTimer.Start(); + } + + return; + } + + _uiTimer.Stop(); + } + + private void UpdateMonitoringLeaseState() + { + var shouldMonitor = _isAttached && _isOnActivePage; + if (shouldMonitor) + { + _monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease(); + return; + } + + _monitoringLease?.Dispose(); + _monitoringLease = null; + } + + private void OnActionButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + var snapshot = _studyAnalyticsService.GetSnapshot(); + var isReportViewing = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; + if (isReportViewing) + { + _studyAnalyticsService.ClearLastSessionReport(); + _transientMessage = null; + RefreshVisual(); + return; + } + + var isRunning = snapshot.Session.State == StudySessionRuntimeState.Running; + + var success = isRunning + ? _studyAnalyticsService.StopStudySession() + : _studyAnalyticsService.StartStudySession(); + + if (!success) + { + _transientMessage = isRunning + ? L("study.session_control.stop_failed", "Unable to stop session") + : L("study.session_control.start_failed", "Unable to start session"); + _transientMessageExpireAt = DateTimeOffset.UtcNow.AddSeconds(2.2); + } + else + { + _transientMessage = null; + } + + RefreshVisual(); + } + + private void RefreshVisual() + { + var snapshot = _studyAnalyticsService.GetSnapshot(); + var now = DateTimeOffset.UtcNow; + var panelColor = ResolvePanelBackgroundColor(); + ApplyTypographyByBackground(panelColor); + + if (_transientMessage is not null && now > _transientMessageExpireAt) + { + _transientMessage = null; + } + + var isReportViewing = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; + if (isReportViewing) + { + PrimaryTextBlock.Text = L("study.session_control.report_preview", "Preview Report"); + SecondaryTextBlock.Text = _transientMessage ?? L("study.session_control.report_confirm_hint", "Tap right button to confirm"); + ActionIcon.Kind = MaterialIconKind.Check; + ApplyActionBadgeStyle(panelColor, Color.Parse("#FF34D399")); + ApplyTransientWarningTintIfNeeded(panelColor); + return; + } + + var isRunning = snapshot.Session.State == StudySessionRuntimeState.Running; + if (isRunning) + { + PrimaryTextBlock.Text = L("study.session_control.action.stop", "Stop Study Session"); + SecondaryTextBlock.Text = _transientMessage ?? string.Format( + L("study.session_control.running_elapsed_format", "Elapsed {0}"), + FormatElapsed(snapshot.Session.Elapsed)); + ActionIcon.Kind = MaterialIconKind.Stop; + ApplyActionBadgeStyle(panelColor, Color.Parse("#FFF97373")); + ApplyTransientWarningTintIfNeeded(panelColor); + return; + } + + PrimaryTextBlock.Text = L("study.session_control.action.start", "Start Study Session"); + SecondaryTextBlock.Text = _transientMessage ?? ResolveIdleHint(snapshot); + ActionIcon.Kind = MaterialIconKind.Play; + ApplyActionBadgeStyle(panelColor, Color.Parse("#FF60A5FA")); + ApplyTransientWarningTintIfNeeded(panelColor); + } + + private string ResolveIdleHint(StudyAnalyticsSnapshot snapshot) + { + if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported) + { + return L("study.environment.status.unsupported", "Unsupported"); + } + + if (snapshot.State == StudyAnalyticsRuntimeState.Error || snapshot.StreamStatus == NoiseStreamStatus.Error) + { + return L("study.environment.status.error", "Error"); + } + + if (snapshot.Session.State == StudySessionRuntimeState.Completed && snapshot.LastSessionReport is not null) + { + return string.Format( + L("study.session_control.last_session_format", "Last {0}"), + FormatElapsed(snapshot.LastSessionReport.Duration)); + } + + return L("study.session_control.idle_hint", "Tap the right button to start"); + } + + private void UpdateAdaptiveLayout() + { + var cellScale = Math.Clamp(_currentCellSize / 48d, 0.78, 2.4); + var widthScale = Bounds.Width > 1 ? Bounds.Width / 280d : cellScale; + var heightScale = Bounds.Height > 1 ? Bounds.Height / 140d : cellScale; + var boundsScale = Math.Clamp(Math.Min(widthScale, heightScale), 0.56, 2.2); + var scale = Math.Clamp(Math.Min(cellScale, boundsScale * 1.05), 0.56, 2.2); + + _isCompactMode = scale < 0.92 || (Bounds.Width > 1 && Bounds.Width < 220) || (Bounds.Height > 1 && Bounds.Height < 92); + _isUltraCompactMode = scale < 0.74 || (Bounds.Width > 1 && Bounds.Width < 180) || (Bounds.Height > 1 && Bounds.Height < 76); + + var compactMultiplier = _isUltraCompactMode ? 0.78 : _isCompactMode ? 0.90 : 1.0; + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 10, 28)); + RootBorder.Padding = new Thickness( + Math.Clamp(14 * scale * compactMultiplier, 7, 22), + Math.Clamp(10 * scale * compactMultiplier, 5, 16)); + + LayoutGrid.ColumnSpacing = _isUltraCompactMode + ? Math.Clamp(6 * scale, 3, 8) + : _isCompactMode + ? Math.Clamp(8 * scale, 4, 10) + : Math.Clamp(10 * scale, 6, 14); + + PrimaryTextBlock.FontSize = Math.Clamp(17 * scale, 10, 30); + SecondaryTextBlock.FontSize = Math.Clamp(11 * scale, 8, 18); + LeftTextStack.Spacing = _isUltraCompactMode ? 0 : Math.Clamp(2 * scale, 1, 4); + + var buttonSize = Math.Clamp(48 * scale * compactMultiplier, 28, 72); + ActionButton.Width = buttonSize; + ActionButton.Height = buttonSize; + ActionIconBorder.Width = buttonSize; + ActionIconBorder.Height = buttonSize; + ActionIconBorder.CornerRadius = new CornerRadius(buttonSize / 2d); + ActionIcon.Width = Math.Clamp(buttonSize * 0.44, 14, 30); + ActionIcon.Height = Math.Clamp(buttonSize * 0.44, 14, 30); + + SecondaryTextBlock.IsVisible = !_isUltraCompactMode; + } + + private void ApplyTypographyByBackground(Color panelColor) + { + var samples = BuildPanelBackgroundSamples(panelColor); + var primary = CreateAdaptiveBrush(samples, PrimaryColorCandidates, minContrast: 4.5); + var secondary = CreateAdaptiveBrush(samples, SecondaryColorCandidates, minContrast: 4.5); + + PrimaryTextBlock.Foreground = primary; + SecondaryTextBlock.Foreground = secondary; + } + + private void ApplyTransientWarningTintIfNeeded(Color panelColor) + { + if (string.IsNullOrWhiteSpace(_transientMessage)) + { + return; + } + + var samples = BuildPanelBackgroundSamples(panelColor); + SecondaryTextBlock.Foreground = CreateAdaptiveBrush(samples, WarningColorCandidates, minContrast: 4.5); + } + + private void ApplyActionBadgeStyle(Color panelColor, Color baseColor) + { + var panelLuminance = RelativeLuminance(ToOpaqueAgainst(panelColor, DarkSubstrate)); + var badgeAlpha = panelLuminance > 0.58 + ? (byte)0xE2 + : panelLuminance > 0.46 + ? (byte)0xD8 + : (byte)0xC8; + + var badgeColor = Color.FromArgb(badgeAlpha, baseColor.R, baseColor.G, baseColor.B); + var badgeComposite = ToOpaqueAgainst(badgeColor, ToOpaqueAgainst(panelColor, DarkSubstrate)); + + ActionIconBorder.Background = new SolidColorBrush(badgeColor); + ActionIconBorder.BorderBrush = new SolidColorBrush(Color.FromArgb(0x96, 0xFF, 0xFF, 0xFF)); + ActionIcon.Foreground = CreateAdaptiveBrush(new[] { badgeComposite }, PrimaryColorCandidates, minContrast: 4.5); + } + + private Color ResolvePanelBackgroundColor() + { + if (RootBorder.Background is ISolidColorBrush solidBackground) + { + return solidBackground.Color; + } + + if (Resources.TryGetResource("AdaptiveGlassStrongBackgroundBrush", ActualThemeVariant, out var resource) && + resource is ISolidColorBrush solidBrush) + { + return solidBrush.Color; + } + + return Color.Parse("#FF1E293B"); + } + + private static IReadOnlyList BuildPanelBackgroundSamples(Color panelColor) + { + var opaqueOnDark = ToOpaqueAgainst(panelColor, DarkSubstrate); + var opaqueOnLight = ToOpaqueAgainst(panelColor, LightSubstrate); + + return + [ + opaqueOnDark, + opaqueOnLight, + ColorMath.Blend(opaqueOnDark, DarkSubstrate, 0.28), + ColorMath.Blend(opaqueOnDark, Color.Parse("#FFFFFFFF"), 0.16), + ColorMath.Blend(opaqueOnLight, Color.Parse("#FFFFFFFF"), 0.08), + ColorMath.Blend(opaqueOnLight, DarkSubstrate, 0.18) + ]; + } + + private static SolidColorBrush CreateAdaptiveBrush( + IReadOnlyList backgroundSamples, + IReadOnlyList colorCandidates, + double minContrast) + { + if (colorCandidates.Count == 0) + { + return new SolidColorBrush(Color.Parse("#FFFFFFFF")); + } + + for (var i = 0; i < colorCandidates.Count; i++) + { + var candidate = colorCandidates[i]; + if (MinContrastRatio(candidate, backgroundSamples) >= minContrast) + { + return new SolidColorBrush(candidate); + } + } + + var best = colorCandidates[0]; + var bestContrast = MinContrastRatio(best, backgroundSamples); + for (var i = 1; i < colorCandidates.Count; i++) + { + var candidate = colorCandidates[i]; + var contrast = MinContrastRatio(candidate, backgroundSamples); + if (contrast > bestContrast) + { + best = candidate; + bestContrast = contrast; + } + } + + return new SolidColorBrush(best); + } + + private static double MinContrastRatio(Color foreground, IReadOnlyList backgrounds) + { + if (backgrounds.Count == 0) + { + return 21; + } + + var minimum = double.MaxValue; + for (var i = 0; i < backgrounds.Count; i++) + { + var background = backgrounds[i]; + var visibleForeground = foreground.A >= 0xFF + ? Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B) + : ToOpaqueAgainst(foreground, background); + + var ratio = ColorMath.ContrastRatio(visibleForeground, background); + if (ratio < minimum) + { + minimum = ratio; + } + } + + return minimum; + } + + private static Color ToOpaqueAgainst(Color foreground, Color background) + { + if (foreground.A >= 0xFF) + { + return Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B); + } + + var alpha = foreground.A / 255d; + var red = (byte)Math.Round((foreground.R * alpha) + (background.R * (1 - alpha))); + var green = (byte)Math.Round((foreground.G * alpha) + (background.G * (1 - alpha))); + var blue = (byte)Math.Round((foreground.B * alpha) + (background.B * (1 - alpha))); + return Color.FromArgb(0xFF, red, green, blue); + } + + private static double RelativeLuminance(Color color) + { + static double ToLinear(byte channel) + { + var c = channel / 255d; + return c <= 0.03928 + ? c / 12.92 + : Math.Pow((c + 0.055) / 1.055, 2.4); + } + + var r = ToLinear(color.R); + var g = ToLinear(color.G); + var b = ToLinear(color.B); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + private static string FormatElapsed(TimeSpan elapsed) + { + if (elapsed.TotalHours >= 1) + { + return elapsed.ToString(@"hh\:mm\:ss"); + } + + return elapsed.ToString(@"mm\:ss"); + } + + private void ReloadLanguageCode() + { + var snapshot = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/Views/Components/StudySessionReportProjection.cs b/LanMountainDesktop/Views/Components/StudySessionReportProjection.cs new file mode 100644 index 0000000..bf046cd --- /dev/null +++ b/LanMountainDesktop/Views/Components/StudySessionReportProjection.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Views.Components; + +internal readonly record struct StudySessionReportAggregate( + TimeSpan Duration, + double AverageDisplayDb, + double AverageDbfs, + double P95DisplayDb, + double P50Dbfs, + double OverRatio, + int SegmentCount, + double SegmentsPerMin, + double SustainedPenalty, + double TimePenalty, + double SegmentPenalty, + double TotalPenalty, + double Score); + +internal static class StudySessionReportProjection +{ + public static IReadOnlyList BuildSyntheticRealtimePoints( + StudySessionReport report, + StudyAnalyticsConfig config, + int maxPoints = 360) + { + if (report.Slices.Count == 0) + { + return Array.Empty(); + } + + var ordered = report.Slices + .OrderBy(slice => slice.StartAt) + .ToList(); + + var synthetic = new List(ordered.Count * 3); + var previousTimestamp = ordered[0].StartAt; + + for (var i = 0; i < ordered.Count; i++) + { + var slice = ordered[i]; + var start = slice.StartAt; + var end = slice.EndAt > start + ? slice.EndAt + : start.AddMilliseconds(Math.Max(1, ResolveSliceDurationMs(slice))); + + if (start <= previousTimestamp) + { + start = previousTimestamp.AddMilliseconds(1); + } + + if (end <= start) + { + end = start.AddMilliseconds(1); + } + + var middle = start + TimeSpan.FromTicks(Math.Max(1, (end - start).Ticks / 2)); + var avgDisplay = Math.Clamp(slice.Display.AvgDb, 20, 100); + var p95Display = Math.Clamp(slice.Display.P95Db, 20, 100); + var avgDbfs = Math.Clamp(slice.Raw.AvgDbfs, -100, 0); + var p95Dbfs = Math.Clamp(slice.Raw.P95Dbfs, -100, 0); + + synthetic.Add(CreatePoint(start, avgDisplay, avgDbfs, config.ScoreThresholdDbfs)); + synthetic.Add(CreatePoint(middle, p95Display, p95Dbfs, config.ScoreThresholdDbfs)); + synthetic.Add(CreatePoint(end, avgDisplay, avgDbfs, config.ScoreThresholdDbfs)); + + previousTimestamp = end; + } + + return DownsampleIfNeeded(synthetic, Math.Max(60, maxPoints)); + } + + public static bool TryAggregate( + StudySessionReport report, + StudyAnalyticsConfig config, + out StudySessionReportAggregate aggregate) + { + aggregate = default; + if (report.Slices.Count == 0) + { + return false; + } + + var totalDurationMs = 0d; + var weightedDisplay = 0d; + var weightedDisplayP95 = 0d; + var weightedDbfs = 0d; + var weightedP50Dbfs = 0d; + var weightedOverRatio = 0d; + var totalSegments = 0; + + for (var i = 0; i < report.Slices.Count; i++) + { + var slice = report.Slices[i]; + var durationMs = ResolveSliceDurationMs(slice); + if (durationMs <= 0) + { + continue; + } + + totalDurationMs += durationMs; + weightedDisplay += slice.Display.AvgDb * durationMs; + weightedDisplayP95 += slice.Display.P95Db * durationMs; + weightedDbfs += slice.Raw.AvgDbfs * durationMs; + weightedP50Dbfs += slice.Raw.P50Dbfs * durationMs; + weightedOverRatio += slice.Raw.OverRatioDbfs * durationMs; + totalSegments += Math.Max(0, slice.Raw.SegmentCount); + } + + if (totalDurationMs <= 0) + { + return false; + } + + var averageDisplay = weightedDisplay / totalDurationMs; + var p95Display = weightedDisplayP95 / totalDurationMs; + var averageDbfs = weightedDbfs / totalDurationMs; + var p50Dbfs = weightedP50Dbfs / totalDurationMs; + var overRatio = Math.Clamp(weightedOverRatio / totalDurationMs, 0, 1); + var minutes = Math.Max(1d / 60d, totalDurationMs / 60000d); + var segmentsPerMin = totalSegments / minutes; + + var sustainedPenalty = Clamp01((p50Dbfs - config.ScoreThresholdDbfs) / 6d); + var timePenalty = Clamp01(overRatio / 0.30d); + var segmentPenalty = Clamp01(segmentsPerMin / Math.Max(1, config.MaxSegmentsPerMin)); + var totalPenalty = (0.40d * sustainedPenalty) + (0.30d * timePenalty) + (0.30d * segmentPenalty); + var score = Math.Clamp(100d * (1d - totalPenalty), 0, 100); + + aggregate = new StudySessionReportAggregate( + Duration: TimeSpan.FromMilliseconds(totalDurationMs), + AverageDisplayDb: Math.Round(averageDisplay, 2), + AverageDbfs: Math.Round(averageDbfs, 2), + P95DisplayDb: Math.Round(p95Display, 2), + P50Dbfs: Math.Round(p50Dbfs, 2), + OverRatio: Math.Round(overRatio, 4), + SegmentCount: Math.Max(0, totalSegments), + SegmentsPerMin: Math.Round(segmentsPerMin, 3), + SustainedPenalty: Math.Round(sustainedPenalty, 4), + TimePenalty: Math.Round(timePenalty, 4), + SegmentPenalty: Math.Round(segmentPenalty, 4), + TotalPenalty: Math.Round(totalPenalty, 4), + Score: Math.Round(score, 1)); + return true; + } + + public static double ResolveSliceDurationMs(NoiseSliceSummary slice) + { + if (slice.ScoreDetail.DurationMs > 0) + { + return slice.ScoreDetail.DurationMs; + } + + if (slice.Raw.SampledDurationMs > 0) + { + return slice.Raw.SampledDurationMs; + } + + return Math.Max(1, (slice.EndAt - slice.StartAt).TotalMilliseconds); + } + + private static NoiseRealtimePoint CreatePoint( + DateTimeOffset timestamp, + double displayDb, + double dbfs, + double scoreThresholdDbfs) + { + var clampedDbfs = Math.Clamp(dbfs, -100, 0); + var rms = Math.Pow(10d, clampedDbfs / 20d); + return new NoiseRealtimePoint( + Timestamp: timestamp, + Rms: rms, + Dbfs: clampedDbfs, + DisplayDb: Math.Clamp(displayDb, 20, 100), + Peak: rms, + IsOverThreshold: clampedDbfs >= scoreThresholdDbfs); + } + + private static IReadOnlyList DownsampleIfNeeded(List points, int maxPoints) + { + if (points.Count <= maxPoints) + { + return points; + } + + var result = new List(maxPoints); + result.Add(points[0]); + + var middleCount = maxPoints - 2; + var sourceMiddleCount = points.Count - 2; + for (var i = 0; i < middleCount; i++) + { + var sourceIndex = 1 + (int)Math.Round(i * (sourceMiddleCount - 1) / (double)Math.Max(1, middleCount - 1)); + sourceIndex = Math.Clamp(sourceIndex, 1, points.Count - 2); + result.Add(points[sourceIndex]); + } + + result.Add(points[^1]); + return result; + } + + private static double Clamp01(double value) + { + return Math.Clamp(value, 0, 1); + } +}