mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00: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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:sty="using:FluentAvalonia.Styling"
|
xmlns:sty="using:FluentAvalonia.Styling"
|
||||||
xmlns:fi="using:FluentIcons.Avalonia"
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
|
xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||||
x:Class="LanMountainDesktop.App"
|
x:Class="LanMountainDesktop.App"
|
||||||
xmlns:local="using:LanMountainDesktop"
|
xmlns:local="using:LanMountainDesktop"
|
||||||
RequestedThemeVariant="Default">
|
RequestedThemeVariant="Default">
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<sty:FluentAvaloniaTheme />
|
<sty:FluentAvaloniaTheme />
|
||||||
|
<mi:MaterialIconStyles />
|
||||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
||||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
||||||
|
|
||||||
@@ -58,5 +60,13 @@
|
|||||||
<Style Selector="fi|SymbolIcon.icon-l, fi|FluentIcon.icon-l">
|
<Style Selector="fi|SymbolIcon.icon-l, fi|FluentIcon.icon-l">
|
||||||
<Setter Property="FontSize" Value="20" />
|
<Setter Property="FontSize" Value="20" />
|
||||||
</Style>
|
</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.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopStudyScoreOverview = "DesktopStudyScoreOverview";
|
public const string DesktopStudyScoreOverview = "DesktopStudyScoreOverview";
|
||||||
public const string DesktopStudyDeductionReasons = "DesktopStudyDeductionReasons";
|
public const string DesktopStudyDeductionReasons = "DesktopStudyDeductionReasons";
|
||||||
public const string DesktopStudyInterruptDensity = "DesktopStudyInterruptDensity";
|
public const string DesktopStudyInterruptDensity = "DesktopStudyInterruptDensity";
|
||||||
|
public const string DesktopStudySessionControl = "DesktopStudySessionControl";
|
||||||
public const string Blank2x4 = "Blank2x4";
|
public const string Blank2x4 = "Blank2x4";
|
||||||
public const string Date = "Date";
|
public const string Date = "Date";
|
||||||
public const string MonthCalendar = "MonthCalendar";
|
public const string MonthCalendar = "MonthCalendar";
|
||||||
|
|||||||
@@ -131,6 +131,15 @@ public sealed class ComponentRegistry
|
|||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true,
|
AllowDesktopPlacement: true,
|
||||||
ResizeMode: DesktopComponentResizeMode.Free),
|
ResizeMode: DesktopComponentResizeMode.Free),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopStudySessionControl,
|
||||||
|
"Study Session",
|
||||||
|
"Play",
|
||||||
|
"Study",
|
||||||
|
MinWidthCells: 2,
|
||||||
|
MinHeightCells: 1,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.DesktopStudyNoiseCurve,
|
BuiltInComponentIds.DesktopStudyNoiseCurve,
|
||||||
"Noise Curve",
|
"Noise Curve",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
||||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" 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="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||||
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
||||||
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
|
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
|
||||||
|
|||||||
@@ -234,6 +234,7 @@
|
|||||||
"component.browser": "Browser",
|
"component.browser": "Browser",
|
||||||
"component.holiday_calendar": "Holiday Calendar",
|
"component.holiday_calendar": "Holiday Calendar",
|
||||||
"component.study_environment": "Environment",
|
"component.study_environment": "Environment",
|
||||||
|
"component.study_session_control": "Study Session Control",
|
||||||
"component.study_noise_curve": "Noise Curve",
|
"component.study_noise_curve": "Noise Curve",
|
||||||
"component.study_noise_distribution": "Noise Distribution",
|
"component.study_noise_distribution": "Noise Distribution",
|
||||||
"component.study_score_overview": "Study Score Overview",
|
"component.study_score_overview": "Study Score Overview",
|
||||||
@@ -290,6 +291,15 @@
|
|||||||
"study.environment.settings.show_display_db": "Show display dB",
|
"study.environment.settings.show_display_db": "Show display dB",
|
||||||
"study.environment.settings.show_dbfs": "Show dBFS",
|
"study.environment.settings.show_dbfs": "Show dBFS",
|
||||||
"study.environment.settings.hint": "At least one display mode must stay enabled.",
|
"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.value_format": "{0:F1} dB",
|
||||||
"study.noise_curve.axis.now": "Now",
|
"study.noise_curve.axis.now": "Now",
|
||||||
"study.noise_distribution.title": "Noise Level Distribution",
|
"study.noise_distribution.title": "Noise Level Distribution",
|
||||||
|
|||||||
@@ -234,6 +234,7 @@
|
|||||||
"component.browser": "浏览器",
|
"component.browser": "浏览器",
|
||||||
"component.holiday_calendar": "节假日日历",
|
"component.holiday_calendar": "节假日日历",
|
||||||
"component.study_environment": "环境",
|
"component.study_environment": "环境",
|
||||||
|
"component.study_session_control": "自习时段控制",
|
||||||
"component.study_noise_curve": "噪音曲线",
|
"component.study_noise_curve": "噪音曲线",
|
||||||
"component.study_noise_distribution": "噪音等级分布",
|
"component.study_noise_distribution": "噪音等级分布",
|
||||||
"component.study_score_overview": "自习评分总览",
|
"component.study_score_overview": "自习评分总览",
|
||||||
@@ -290,6 +291,15 @@
|
|||||||
"study.environment.settings.show_display_db": "显示 display dB",
|
"study.environment.settings.show_display_db": "显示 display dB",
|
||||||
"study.environment.settings.show_dbfs": "显示 dBFS",
|
"study.environment.settings.show_dbfs": "显示 dBFS",
|
||||||
"study.environment.settings.hint": "至少启用一种显示方式。",
|
"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.value_format": "{0:F1} dB",
|
||||||
"study.noise_curve.axis.now": "现在",
|
"study.noise_curve.axis.now": "现在",
|
||||||
"study.noise_distribution.title": "噪音等级分布",
|
"study.noise_distribution.title": "噪音等级分布",
|
||||||
|
|||||||
@@ -179,6 +179,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
"component.study_environment",
|
"component.study_environment",
|
||||||
() => new StudyEnvironmentWidget(),
|
() => new StudyEnvironmentWidget(),
|
||||||
cellSize => Math.Clamp(cellSize * 0.36, 12, 26)),
|
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(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.DesktopStudyNoiseCurve,
|
BuiltInComponentIds.DesktopStudyNoiseCurve,
|
||||||
"component.study_noise_curve",
|
"component.study_noise_curve",
|
||||||
|
|||||||
@@ -141,14 +141,18 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen
|
|||||||
ApplyTypographyByBackground(panelColor);
|
ApplyTypographyByBackground(panelColor);
|
||||||
|
|
||||||
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
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.session", "Session")
|
||||||
: L("study.deduction.mode.realtime", "Realtime");
|
: 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();
|
ApplyLocalizedLabels();
|
||||||
|
|
||||||
var metrics = ComputeRealtimeDeduction(snapshot);
|
var metrics = isSessionReport && snapshot.LastSessionReport is not null
|
||||||
|
? ComputeReportDeduction(snapshot.LastSessionReport, snapshot.Config)
|
||||||
|
: ComputeRealtimeDeduction(snapshot);
|
||||||
if (metrics is null)
|
if (metrics is null)
|
||||||
{
|
{
|
||||||
ApplyUnavailableMetrics();
|
ApplyUnavailableMetrics();
|
||||||
@@ -407,6 +411,24 @@ public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponen
|
|||||||
SegmentsPerMin: Math.Round(segmentsPerMin, 3));
|
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)
|
private static double Percentile(double[] sortedValues, double percentile)
|
||||||
{
|
{
|
||||||
if (sortedValues.Length == 0)
|
if (sortedValues.Length == 0)
|
||||||
|
|||||||
@@ -140,8 +140,46 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
|||||||
private void RefreshVisual()
|
private void RefreshVisual()
|
||||||
{
|
{
|
||||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||||
|
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||||
|
|
||||||
StatusTitleTextBlock.Text = L("study.environment.status_label", "Environment");
|
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.Text = ResolveStatusText(snapshot);
|
||||||
StatusValueTextBlock.Foreground = ResolveStatusBrush(snapshot);
|
StatusValueTextBlock.Foreground = ResolveStatusBrush(snapshot);
|
||||||
|
|
||||||
|
|||||||
@@ -164,14 +164,24 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
|
|||||||
ApplyLocalizedLabels();
|
ApplyLocalizedLabels();
|
||||||
|
|
||||||
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
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.session", "Session")
|
||||||
: L("study.interrupt_density.mode.realtime", "Realtime");
|
: 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
|
InterruptDensityMetrics? metrics;
|
||||||
? ComputeSessionDensity(snapshot)
|
if (isSessionReport && snapshot.LastSessionReport is not null)
|
||||||
: ComputeRealtimeDensity(snapshot);
|
{
|
||||||
|
metrics = ComputeReportDensity(snapshot.LastSessionReport, snapshot.Config);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
metrics = isSessionRunning
|
||||||
|
? ComputeSessionDensity(snapshot)
|
||||||
|
: ComputeRealtimeDensity(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
if (metrics is null)
|
if (metrics is null)
|
||||||
{
|
{
|
||||||
@@ -429,6 +439,23 @@ public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponen
|
|||||||
LevelKind: levelKind);
|
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)
|
private static DensityLevelKind ResolveLevelKind(double densityPerMin, double thresholdPerMin)
|
||||||
{
|
{
|
||||||
var ratio = densityPerMin / Math.Max(1, thresholdPerMin);
|
var ratio = densityPerMin / Math.Max(1, thresholdPerMin);
|
||||||
|
|||||||
@@ -247,6 +247,31 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
|||||||
var panelColor = ResolvePanelBackgroundColor();
|
var panelColor = ResolvePanelBackgroundColor();
|
||||||
ApplyTypographyByBackground(panelColor);
|
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);
|
var statusKind = ResolveStatusVisualKind(snapshot);
|
||||||
StatusTextBlock.Text = ResolveStatusText(snapshot);
|
StatusTextBlock.Text = ResolveStatusText(snapshot);
|
||||||
ApplyStatusBadgeStyle(statusKind, panelColor);
|
ApplyStatusBadgeStyle(statusKind, panelColor);
|
||||||
@@ -264,7 +289,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChartControl.UpdateSeries(snapshot.RealtimeBuffer);
|
ChartControl.UpdateSeries(snapshot.RealtimeBuffer);
|
||||||
UpdateXAxisLabels(snapshot);
|
UpdateXAxisLabels(snapshot.RealtimeBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyTypographyByBackground(Color panelColor)
|
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;
|
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)
|
if (buffer.Count < 2)
|
||||||
{
|
{
|
||||||
ApplyDefaultXAxisLabels();
|
ApplyDefaultXAxisLabels();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -163,15 +163,21 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
|||||||
ApplyLocalizedAxisLabels();
|
ApplyLocalizedAxisLabels();
|
||||||
|
|
||||||
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
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.session", "Session")
|
||||||
: L("study.noise_distribution.mode.realtime", "Realtime");
|
: 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);
|
var points = isSessionReport && snapshot.LastSessionReport is not null
|
||||||
UpdateXAxisLabels(snapshot);
|
? 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)
|
if (stats is null)
|
||||||
{
|
{
|
||||||
SummaryTextBlock.Text = string.Format(
|
SummaryTextBlock.Text = string.Format(
|
||||||
@@ -497,9 +503,8 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
|||||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
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)
|
if (buffer.Count < 2)
|
||||||
{
|
{
|
||||||
ApplyDefaultXAxisLabels();
|
ApplyDefaultXAxisLabels();
|
||||||
@@ -600,3 +605,5 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
|||||||
return _localizationService.GetString(_languageCode, key, fallback);
|
return _localizationService.GetString(_languageCode, key, fallback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
|||||||
ApplyTypographyByBackground(panelColor);
|
ApplyTypographyByBackground(panelColor);
|
||||||
|
|
||||||
var realtimeScore = ComputeRealtimeScore(snapshot);
|
var realtimeScore = ComputeRealtimeScore(snapshot);
|
||||||
if (realtimeScore is { } score)
|
if (snapshot.DataMode == StudyDataMode.Realtime && realtimeScore is { } score)
|
||||||
{
|
{
|
||||||
PushRealtimeScore(score, DateTimeOffset.UtcNow);
|
PushRealtimeScore(score, DateTimeOffset.UtcNow);
|
||||||
}
|
}
|
||||||
@@ -166,6 +166,12 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null)
|
||||||
|
{
|
||||||
|
ApplySessionReportMode(snapshot, panelColor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ApplyRealtimeMode(snapshot, realtimeScore, panelColor);
|
ApplyRealtimeMode(snapshot, realtimeScore, panelColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +205,24 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi
|
|||||||
MaximumValueTextBlock.Text = FormatScoreOrUnavailable(historyStats.Maximum);
|
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()
|
private void ApplyLocalizedLabels()
|
||||||
{
|
{
|
||||||
TitleTextBlock.Text = L("study.score_overview.title", "Study Score");
|
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