mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.3.10
自习时段加入
This commit is contained in:
@@ -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 @@
|
||||
|
||||
<Application.Styles>
|
||||
<sty:FluentAvaloniaTheme />
|
||||
<mi:MaterialIconStyles />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
||||
|
||||
@@ -58,5 +60,13 @@
|
||||
<Style Selector="fi|SymbolIcon.icon-l, fi|FluentIcon.icon-l">
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="mi|MaterialIcon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Setter Property="Width" Value="20" />
|
||||
<Setter Property="Height" Value="20" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
||||
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
||||
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "噪音等级分布",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<NoiseRealtimePoint> buffer)
|
||||
{
|
||||
var buffer = snapshot.RealtimeBuffer;
|
||||
if (buffer.Count < 2)
|
||||
{
|
||||
ApplyDefaultXAxisLabels();
|
||||
|
||||
@@ -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<NoiseRealtimePoint> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<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:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="300"
|
||||
d:DesignHeight="150"
|
||||
x:Class="LanMountainDesktop.Views.Components.StudySessionControlWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="glass-strong"
|
||||
CornerRadius="18"
|
||||
Padding="14,10"
|
||||
ClipToBounds="True">
|
||||
<Grid x:Name="LayoutGrid"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<StackPanel x:Name="LeftTextStack"
|
||||
Spacing="2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="PrimaryTextBlock"
|
||||
Text="Start Study Session"
|
||||
FontSize="17"
|
||||
FontWeight="SemiBold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="SecondaryTextBlock"
|
||||
Text="Tap icon to start"
|
||||
FontSize="11"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<Button x:Name="ActionButton"
|
||||
Grid.Column="1"
|
||||
Width="48"
|
||||
Height="48"
|
||||
Padding="0"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Cursor="Hand"
|
||||
Click="OnActionButtonClick">
|
||||
<Border x:Name="ActionIconBorder"
|
||||
Width="48"
|
||||
Height="48"
|
||||
CornerRadius="24"
|
||||
Background="#2DFFFFFF"
|
||||
BorderBrush="#40FFFFFF"
|
||||
BorderThickness="1">
|
||||
<mi:MaterialIcon x:Name="ActionIcon"
|
||||
Kind="Play"
|
||||
Width="22"
|
||||
Height="22" />
|
||||
</Border>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -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<Color> BuildPanelBackgroundSamples(Color panelColor)
|
||||
{
|
||||
var opaqueOnDark = ToOpaqueAgainst(panelColor, DarkSubstrate);
|
||||
var opaqueOnLight = ToOpaqueAgainst(panelColor, LightSubstrate);
|
||||
|
||||
return
|
||||
[
|
||||
opaqueOnDark,
|
||||
opaqueOnLight,
|
||||
ColorMath.Blend(opaqueOnDark, DarkSubstrate, 0.28),
|
||||
ColorMath.Blend(opaqueOnDark, Color.Parse("#FFFFFFFF"), 0.16),
|
||||
ColorMath.Blend(opaqueOnLight, Color.Parse("#FFFFFFFF"), 0.08),
|
||||
ColorMath.Blend(opaqueOnLight, DarkSubstrate, 0.18)
|
||||
];
|
||||
}
|
||||
|
||||
private static SolidColorBrush CreateAdaptiveBrush(
|
||||
IReadOnlyList<Color> backgroundSamples,
|
||||
IReadOnlyList<Color> colorCandidates,
|
||||
double minContrast)
|
||||
{
|
||||
if (colorCandidates.Count == 0)
|
||||
{
|
||||
return new SolidColorBrush(Color.Parse("#FFFFFFFF"));
|
||||
}
|
||||
|
||||
for (var i = 0; i < colorCandidates.Count; i++)
|
||||
{
|
||||
var candidate = colorCandidates[i];
|
||||
if (MinContrastRatio(candidate, backgroundSamples) >= minContrast)
|
||||
{
|
||||
return new SolidColorBrush(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
var best = colorCandidates[0];
|
||||
var bestContrast = MinContrastRatio(best, backgroundSamples);
|
||||
for (var i = 1; i < colorCandidates.Count; i++)
|
||||
{
|
||||
var candidate = colorCandidates[i];
|
||||
var contrast = MinContrastRatio(candidate, backgroundSamples);
|
||||
if (contrast > bestContrast)
|
||||
{
|
||||
best = candidate;
|
||||
bestContrast = contrast;
|
||||
}
|
||||
}
|
||||
|
||||
return new SolidColorBrush(best);
|
||||
}
|
||||
|
||||
private static double MinContrastRatio(Color foreground, IReadOnlyList<Color> backgrounds)
|
||||
{
|
||||
if (backgrounds.Count == 0)
|
||||
{
|
||||
return 21;
|
||||
}
|
||||
|
||||
var minimum = double.MaxValue;
|
||||
for (var i = 0; i < backgrounds.Count; i++)
|
||||
{
|
||||
var background = backgrounds[i];
|
||||
var visibleForeground = foreground.A >= 0xFF
|
||||
? Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B)
|
||||
: ToOpaqueAgainst(foreground, background);
|
||||
|
||||
var ratio = ColorMath.ContrastRatio(visibleForeground, background);
|
||||
if (ratio < minimum)
|
||||
{
|
||||
minimum = ratio;
|
||||
}
|
||||
}
|
||||
|
||||
return minimum;
|
||||
}
|
||||
|
||||
private static Color ToOpaqueAgainst(Color foreground, Color background)
|
||||
{
|
||||
if (foreground.A >= 0xFF)
|
||||
{
|
||||
return Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B);
|
||||
}
|
||||
|
||||
var alpha = foreground.A / 255d;
|
||||
var red = (byte)Math.Round((foreground.R * alpha) + (background.R * (1 - alpha)));
|
||||
var green = (byte)Math.Round((foreground.G * alpha) + (background.G * (1 - alpha)));
|
||||
var blue = (byte)Math.Round((foreground.B * alpha) + (background.B * (1 - alpha)));
|
||||
return Color.FromArgb(0xFF, red, green, blue);
|
||||
}
|
||||
|
||||
private static double RelativeLuminance(Color color)
|
||||
{
|
||||
static double ToLinear(byte channel)
|
||||
{
|
||||
var c = channel / 255d;
|
||||
return c <= 0.03928
|
||||
? c / 12.92
|
||||
: Math.Pow((c + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
var r = ToLinear(color.R);
|
||||
var g = ToLinear(color.G);
|
||||
var b = ToLinear(color.B);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
private static string FormatElapsed(TimeSpan elapsed)
|
||||
{
|
||||
if (elapsed.TotalHours >= 1)
|
||||
{
|
||||
return elapsed.ToString(@"hh\:mm\:ss");
|
||||
}
|
||||
|
||||
return elapsed.ToString(@"mm\:ss");
|
||||
}
|
||||
|
||||
private void ReloadLanguageCode()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
@@ -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<NoiseRealtimePoint> BuildSyntheticRealtimePoints(
|
||||
StudySessionReport report,
|
||||
StudyAnalyticsConfig config,
|
||||
int maxPoints = 360)
|
||||
{
|
||||
if (report.Slices.Count == 0)
|
||||
{
|
||||
return Array.Empty<NoiseRealtimePoint>();
|
||||
}
|
||||
|
||||
var ordered = report.Slices
|
||||
.OrderBy(slice => slice.StartAt)
|
||||
.ToList();
|
||||
|
||||
var synthetic = new List<NoiseRealtimePoint>(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<NoiseRealtimePoint> DownsampleIfNeeded(List<NoiseRealtimePoint> points, int maxPoints)
|
||||
{
|
||||
if (points.Count <= maxPoints)
|
||||
{
|
||||
return points;
|
||||
}
|
||||
|
||||
var result = new List<NoiseRealtimePoint>(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user