自习时段加入
This commit is contained in:
lincube
2026-03-04 20:58:17 +08:00
parent 40ddcd399d
commit 9ec879cc17
16 changed files with 945 additions and 20 deletions

View File

@@ -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>

View File

@@ -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";

View File

@@ -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",

View File

@@ -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" />

View File

@@ -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",

View File

@@ -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": "噪音等级分布",

View File

@@ -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",

View File

@@ -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)

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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");

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}