mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
0.3.8
噪音评分组件
This commit is contained in:
@@ -15,6 +15,7 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopAudioRecorder = "DesktopAudioRecorder";
|
||||
public const string DesktopStudyEnvironment = "DesktopStudyEnvironment";
|
||||
public const string DesktopStudyNoiseCurve = "DesktopStudyNoiseCurve";
|
||||
public const string DesktopStudyScoreOverview = "DesktopStudyScoreOverview";
|
||||
public const string Blank2x4 = "Blank2x4";
|
||||
public const string Date = "Date";
|
||||
public const string MonthCalendar = "MonthCalendar";
|
||||
|
||||
@@ -140,6 +140,15 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopStudyScoreOverview,
|
||||
"Study Score Overview",
|
||||
"DataLine",
|
||||
"Study",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopDailyPoetry,
|
||||
"Daily Poetry",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -6,8 +6,30 @@
|
||||
<Version>1.0.0</Version>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
|
||||
<!-- Release build optimizations -->
|
||||
<PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
|
||||
<PublishTrimmed Condition="'$(Configuration)' == 'Release'">true</PublishTrimmed>
|
||||
<TrimMode Condition="'$(Configuration)' == 'Release'">partial</TrimMode>
|
||||
<PublishReadyToRun Condition="'$(Configuration)' == 'Release'">true</PublishReadyToRun>
|
||||
<DebugSymbols Condition="'$(Configuration)' == 'Release'">false</DebugSymbols>
|
||||
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Release build optimizations for smaller, faster packages -->
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<TrimMode>partial</TrimMode>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
<DebugType>none</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Self-contained runtime settings -->
|
||||
<PropertyGroup Condition="'$(RuntimeIdentifier)' != ''">
|
||||
<SelfContained>true</SelfContained>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
"component.holiday_calendar": "Holiday Calendar",
|
||||
"component.study_environment": "Environment",
|
||||
"component.study_noise_curve": "Noise Curve",
|
||||
"component.study_score_overview": "Study Score Overview",
|
||||
"poetry.widget.loading_content": "Loading poetry...",
|
||||
"poetry.widget.loading_author": "Loading...",
|
||||
"poetry.widget.fetch_failed": "Poetry fetch failed",
|
||||
@@ -288,6 +289,17 @@
|
||||
"study.environment.settings.hint": "At least one display mode must stay enabled.",
|
||||
"study.noise_curve.value_format": "{0:F1} dB",
|
||||
"study.noise_curve.axis.now": "Now",
|
||||
"study.score_overview.title": "Study Score",
|
||||
"study.score_overview.mode.realtime": "Realtime",
|
||||
"study.score_overview.mode.session": "Session",
|
||||
"study.score_overview.current": "Current",
|
||||
"study.score_overview.average": "Average",
|
||||
"study.score_overview.minimum": "Minimum",
|
||||
"study.score_overview.maximum": "Maximum",
|
||||
"study.score_overview.average_short": "Avg",
|
||||
"study.score_overview.minimum_short": "Min",
|
||||
"study.score_overview.maximum_short": "Max",
|
||||
"study.score_overview.unavailable": "--",
|
||||
"desktop.add_page": "Add page",
|
||||
"desktop.delete_page": "Delete page",
|
||||
"placement.fill": "Fill",
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
"component.holiday_calendar": "节假日日历",
|
||||
"component.study_environment": "环境",
|
||||
"component.study_noise_curve": "噪音曲线",
|
||||
"component.study_score_overview": "自习评分总览",
|
||||
"poetry.widget.loading_content": "正在加载诗词",
|
||||
"poetry.widget.loading_author": "加载中",
|
||||
"poetry.widget.fetch_failed": "诗词获取失败",
|
||||
@@ -288,6 +289,17 @@
|
||||
"study.environment.settings.hint": "至少启用一种显示方式。",
|
||||
"study.noise_curve.value_format": "{0:F1} dB",
|
||||
"study.noise_curve.axis.now": "现在",
|
||||
"study.score_overview.title": "自习评分",
|
||||
"study.score_overview.mode.realtime": "实时",
|
||||
"study.score_overview.mode.session": "时段",
|
||||
"study.score_overview.current": "当前",
|
||||
"study.score_overview.average": "均分",
|
||||
"study.score_overview.minimum": "最低",
|
||||
"study.score_overview.maximum": "最高",
|
||||
"study.score_overview.average_short": "均",
|
||||
"study.score_overview.minimum_short": "低",
|
||||
"study.score_overview.maximum_short": "高",
|
||||
"study.score_overview.unavailable": "--",
|
||||
"desktop.add_page": "新增页面",
|
||||
"desktop.delete_page": "删除页面",
|
||||
"placement.fill": "填充",
|
||||
|
||||
35
LanMountainDesktop/TrimmerRoots.xml
Normal file
35
LanMountainDesktop/TrimmerRoots.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<linker>
|
||||
<!-- Avalonia and UI framework assemblies that should not be trimmed -->
|
||||
<assembly fullname="Avalonia" preserve="all" />
|
||||
<assembly fullname="Avalonia.Controls" preserve="all" />
|
||||
<assembly fullname="Avalonia.Core" preserve="all" />
|
||||
<assembly fullname="Avalonia.Dialogs" preserve="all" />
|
||||
<assembly fullname="Avalonia.Desktop" preserve="all" />
|
||||
<assembly fullname="Avalonia.Themes.Fluent" preserve="all" />
|
||||
<assembly fullname="Avalonia.Fonts.Inter" preserve="all" />
|
||||
|
||||
<!-- FluentUI packages -->
|
||||
<assembly fullname="FluentAvaloniaUI" preserve="all" />
|
||||
<assembly fullname="FluentIcons.Avalonia" preserve="all" />
|
||||
<assembly fullname="FluentIcons.Avalonia.Fluent" preserve="all" />
|
||||
|
||||
<!-- Media and rendering -->
|
||||
<assembly fullname="LibVLCSharp" preserve="all" />
|
||||
<assembly fullname="LibVLCSharp.Avalonia" preserve="all" />
|
||||
<assembly fullname="WebView.Avalonia" preserve="all" />
|
||||
<assembly fullname="WebView.Avalonia.Desktop" preserve="all" />
|
||||
|
||||
<!-- MVVM and utilities -->
|
||||
<assembly fullname="CommunityToolkit.Mvvm" preserve="all" />
|
||||
<assembly fullname="YamlDotNet" preserve="all" />
|
||||
<assembly fullname="DotNetCampus.AvaloniaInkCanvas" preserve="all" />
|
||||
<assembly fullname="PortAudioSharp2" preserve="all" />
|
||||
|
||||
<!-- System assemblies with reflection usage -->
|
||||
<assembly fullname="System.Drawing.Common" preserve="all" />
|
||||
<assembly fullname="System.Runtime.WindowsRuntime" preserve="all" />
|
||||
<assembly fullname="System.ComponentModel.TypeConverter" preserve="all" />
|
||||
<assembly fullname="System.Reflection" preserve="all" />
|
||||
<assembly fullname="System.Reflection.Emit" preserve="all" />
|
||||
<assembly fullname="System.Reflection.Emit.Lightweight" preserve="all" />
|
||||
</linker>
|
||||
@@ -184,6 +184,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
"component.study_noise_curve",
|
||||
() => new StudyNoiseCurveWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.34, 12, 26)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopStudyScoreOverview,
|
||||
"component.study_score_overview",
|
||||
() => new StudyScoreOverviewWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.34, 12, 28)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopDailyPoetry,
|
||||
"component.daily_poetry",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="360"
|
||||
d:DesignHeight="360"
|
||||
x:Class="LanMountainDesktop.Views.Components.StudyScoreOverviewWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="glass-strong"
|
||||
CornerRadius="24"
|
||||
Padding="16,14"
|
||||
ClipToBounds="True">
|
||||
<Grid x:Name="ContentRootGrid"
|
||||
RowDefinitions="Auto,Auto,*,Auto"
|
||||
RowSpacing="8">
|
||||
<Grid x:Name="TopRowGrid"
|
||||
Grid.Row="0"
|
||||
ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="8">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Text="Study Score"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Border x:Name="ModeBadgeBorder"
|
||||
Grid.Column="1"
|
||||
Padding="8,3"
|
||||
CornerRadius="8"
|
||||
BorderThickness="1"
|
||||
BorderBrush="#88FFFFFF"
|
||||
Background="#553B82F6"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="ModeTextBlock"
|
||||
Text="Realtime"
|
||||
FontSize="12"
|
||||
FontWeight="SemiBold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="#FFFFFFFF"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="CurrentLabelTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="Current"
|
||||
FontSize="12"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
|
||||
<TextBlock x:Name="CurrentScoreTextBlock"
|
||||
Grid.Row="2"
|
||||
Text="--"
|
||||
FontSize="76"
|
||||
FontWeight="SemiBold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
|
||||
<Grid Grid.Row="3"
|
||||
x:Name="SummaryGrid"
|
||||
ColumnDefinitions="*,*,*"
|
||||
ColumnSpacing="10">
|
||||
<StackPanel x:Name="AverageStack"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="AverageLabelTextBlock"
|
||||
Text="Average"
|
||||
FontSize="11"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<TextBlock x:Name="AverageValueTextBlock"
|
||||
Text="--"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel x:Name="MinimumStack"
|
||||
Grid.Column="1"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="MinimumLabelTextBlock"
|
||||
Text="Minimum"
|
||||
FontSize="11"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<TextBlock x:Name="MinimumValueTextBlock"
|
||||
Text="--"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel x:Name="MaximumStack"
|
||||
Grid.Column="2"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="MaximumLabelTextBlock"
|
||||
Text="Maximum"
|
||||
FontSize="11"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<TextBlock x:Name="MaximumValueTextBlock"
|
||||
Text="--"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold"
|
||||
MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,636 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
|
||||
{
|
||||
private static readonly Color[] ValueColorCandidates =
|
||||
{
|
||||
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 DarkSubstrate = Color.Parse("#FF0B1220");
|
||||
private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
|
||||
private static readonly FontFamily MiSansVariableFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
||||
|
||||
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly DispatcherTimer _uiTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(250)
|
||||
};
|
||||
|
||||
private readonly Queue<(DateTimeOffset Timestamp, double Score)> _realtimeHistory = new();
|
||||
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isCompactMode;
|
||||
private bool _isUltraCompactMode;
|
||||
private string _languageCode = "zh-CN";
|
||||
|
||||
public StudyScoreOverviewWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_uiTimer.Tick += OnUiTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
ApplyVariableFontFamily();
|
||||
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;
|
||||
UpdateTimerState();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ReloadLanguageCode();
|
||||
_ = _studyAnalyticsService.StartOrResumeMonitoring();
|
||||
UpdateTimerState();
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
_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 RefreshVisual()
|
||||
{
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
ApplyLocalizedLabels();
|
||||
|
||||
var panelColor = ResolvePanelBackgroundColor();
|
||||
ApplyTypographyByBackground(panelColor);
|
||||
|
||||
var realtimeScore = ComputeRealtimeScore(snapshot);
|
||||
if (realtimeScore is { } score)
|
||||
{
|
||||
PushRealtimeScore(score, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||
if (isSessionRunning)
|
||||
{
|
||||
ApplySessionMode(snapshot, realtimeScore, panelColor);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyRealtimeMode(snapshot, realtimeScore, panelColor);
|
||||
}
|
||||
|
||||
private void ApplySessionMode(StudyAnalyticsSnapshot snapshot, double? realtimeScore, Color panelColor)
|
||||
{
|
||||
var currentScore = realtimeScore ?? snapshot.Session.Metrics.CurrentScore;
|
||||
var avgScore = snapshot.Session.Metrics.AvgScore;
|
||||
var minScore = snapshot.Session.Metrics.MinScore;
|
||||
var maxScore = snapshot.Session.Metrics.MaxScore;
|
||||
|
||||
ModeTextBlock.Text = L("study.score_overview.mode.session", "Session");
|
||||
ApplyModeBadgeColor(panelColor, Color.Parse("#FF0F6B49"));
|
||||
|
||||
CurrentScoreTextBlock.Text = FormatScoreOrUnavailable(currentScore);
|
||||
AverageValueTextBlock.Text = FormatScoreOrUnavailable(avgScore);
|
||||
MinimumValueTextBlock.Text = FormatScoreOrUnavailable(minScore);
|
||||
MaximumValueTextBlock.Text = FormatScoreOrUnavailable(maxScore);
|
||||
}
|
||||
|
||||
private void ApplyRealtimeMode(StudyAnalyticsSnapshot snapshot, double? realtimeScore, Color panelColor)
|
||||
{
|
||||
ModeTextBlock.Text = L("study.score_overview.mode.realtime", "Realtime");
|
||||
ApplyModeBadgeColor(panelColor, Color.Parse("#FF2F5DA8"));
|
||||
|
||||
var currentScore = realtimeScore ?? snapshot.LatestSlice?.Score;
|
||||
var historyStats = GetHistoryStats();
|
||||
|
||||
CurrentScoreTextBlock.Text = FormatScoreOrUnavailable(currentScore);
|
||||
AverageValueTextBlock.Text = FormatScoreOrUnavailable(historyStats.Average);
|
||||
MinimumValueTextBlock.Text = FormatScoreOrUnavailable(historyStats.Minimum);
|
||||
MaximumValueTextBlock.Text = FormatScoreOrUnavailable(historyStats.Maximum);
|
||||
}
|
||||
|
||||
private void ApplyLocalizedLabels()
|
||||
{
|
||||
TitleTextBlock.Text = L("study.score_overview.title", "Study Score");
|
||||
CurrentLabelTextBlock.Text = L("study.score_overview.current", "Current");
|
||||
AverageLabelTextBlock.Text = _isCompactMode
|
||||
? L("study.score_overview.average_short", "Avg")
|
||||
: L("study.score_overview.average", "Average");
|
||||
MinimumLabelTextBlock.Text = _isCompactMode
|
||||
? L("study.score_overview.minimum_short", "Min")
|
||||
: L("study.score_overview.minimum", "Minimum");
|
||||
MaximumLabelTextBlock.Text = _isCompactMode
|
||||
? L("study.score_overview.maximum_short", "Max")
|
||||
: L("study.score_overview.maximum", "Maximum");
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.76, 2.4);
|
||||
var widthScale = Bounds.Width > 1 ? Bounds.Width / 360d : cellScale;
|
||||
var heightScale = Bounds.Height > 1 ? Bounds.Height / 360d : cellScale;
|
||||
var boundsScale = Math.Clamp(Math.Min(widthScale, heightScale), 0.52, 2.4);
|
||||
var scale = Math.Clamp(Math.Min(cellScale, boundsScale * 1.06), 0.52, 2.4);
|
||||
|
||||
_isCompactMode = scale < 0.92 || (Bounds.Width > 1 && Bounds.Width < 320) || (Bounds.Height > 1 && Bounds.Height < 300);
|
||||
_isUltraCompactMode = scale < 0.72 || (Bounds.Width > 1 && Bounds.Width < 270) || (Bounds.Height > 1 && Bounds.Height < 250);
|
||||
|
||||
var compactMultiplier = _isUltraCompactMode ? 0.76 : _isCompactMode ? 0.88 : 1.0;
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.50, 14, 42));
|
||||
RootBorder.Padding = new Thickness(
|
||||
Math.Clamp(16 * scale * compactMultiplier, 8, 24),
|
||||
Math.Clamp(14 * scale * compactMultiplier, 6, 20));
|
||||
|
||||
ContentRootGrid.RowSpacing = _isUltraCompactMode
|
||||
? Math.Clamp(4 * scale, 2, 5)
|
||||
: _isCompactMode
|
||||
? Math.Clamp(6 * scale, 3, 7)
|
||||
: Math.Clamp(8 * scale, 4, 10);
|
||||
TopRowGrid.ColumnSpacing = _isUltraCompactMode
|
||||
? Math.Clamp(6 * scale, 3, 8)
|
||||
: Math.Clamp(8 * scale, 4, 10);
|
||||
SummaryGrid.ColumnSpacing = _isUltraCompactMode
|
||||
? Math.Clamp(5 * scale, 3, 7)
|
||||
: _isCompactMode
|
||||
? Math.Clamp(7 * scale, 4, 9)
|
||||
: Math.Clamp(10 * scale, 6, 12);
|
||||
|
||||
var headlineFactor = _isUltraCompactMode ? 0.62 : _isCompactMode ? 0.80 : 1.0;
|
||||
var statFactor = _isUltraCompactMode ? 0.74 : _isCompactMode ? 0.90 : 1.0;
|
||||
var labelFactor = _isUltraCompactMode ? 0.84 : _isCompactMode ? 0.92 : 1.0;
|
||||
|
||||
TitleTextBlock.FontSize = Math.Clamp(14 * scale * labelFactor, 9, 24);
|
||||
ModeTextBlock.FontSize = Math.Clamp(12 * scale * labelFactor, 8, 18);
|
||||
CurrentLabelTextBlock.FontSize = Math.Clamp(12 * scale * labelFactor, 8, 18);
|
||||
CurrentScoreTextBlock.FontSize = Math.Clamp(76 * scale * headlineFactor, 22, 140);
|
||||
|
||||
AverageLabelTextBlock.FontSize = Math.Clamp(11 * scale * labelFactor, 8, 16);
|
||||
MinimumLabelTextBlock.FontSize = Math.Clamp(11 * scale * labelFactor, 8, 16);
|
||||
MaximumLabelTextBlock.FontSize = Math.Clamp(11 * scale * labelFactor, 8, 16);
|
||||
AverageValueTextBlock.FontSize = Math.Clamp(22 * scale * statFactor, 11, 38);
|
||||
MinimumValueTextBlock.FontSize = Math.Clamp(22 * scale * statFactor, 11, 38);
|
||||
MaximumValueTextBlock.FontSize = Math.Clamp(22 * scale * statFactor, 11, 38);
|
||||
|
||||
ModeBadgeBorder.Padding = new Thickness(
|
||||
Math.Clamp(8 * scale * compactMultiplier, 4, 12),
|
||||
Math.Clamp(3 * scale * compactMultiplier, 1.6, 6));
|
||||
ModeBadgeBorder.CornerRadius = new CornerRadius(Math.Clamp(8 * scale, 5, 14));
|
||||
|
||||
TitleTextBlock.IsVisible = !_isUltraCompactMode;
|
||||
CurrentLabelTextBlock.IsVisible = !_isUltraCompactMode;
|
||||
AverageLabelTextBlock.IsVisible = !_isUltraCompactMode;
|
||||
MinimumLabelTextBlock.IsVisible = !_isUltraCompactMode;
|
||||
MaximumLabelTextBlock.IsVisible = !_isUltraCompactMode;
|
||||
|
||||
AverageStack.Spacing = _isUltraCompactMode ? 0 : Math.Clamp(2 * scale, 1, 4);
|
||||
MinimumStack.Spacing = _isUltraCompactMode ? 0 : Math.Clamp(2 * scale, 1, 4);
|
||||
MaximumStack.Spacing = _isUltraCompactMode ? 0 : Math.Clamp(2 * scale, 1, 4);
|
||||
|
||||
ApplyVariableWeights(scale);
|
||||
ApplyLocalizedLabels();
|
||||
}
|
||||
|
||||
private void PushRealtimeScore(double score, DateTimeOffset now)
|
||||
{
|
||||
_realtimeHistory.Enqueue((now, score));
|
||||
|
||||
var cutoff = now - TimeSpan.FromMinutes(8);
|
||||
while (_realtimeHistory.Count > 0 && _realtimeHistory.Peek().Timestamp < cutoff)
|
||||
{
|
||||
_realtimeHistory.Dequeue();
|
||||
}
|
||||
|
||||
while (_realtimeHistory.Count > 960)
|
||||
{
|
||||
_realtimeHistory.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
private (double? Average, double? Minimum, double? Maximum) GetHistoryStats()
|
||||
{
|
||||
if (_realtimeHistory.Count == 0)
|
||||
{
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
var values = _realtimeHistory.Select(item => item.Score).ToArray();
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
return
|
||||
(
|
||||
Average: values.Average(),
|
||||
Minimum: values.Min(),
|
||||
Maximum: values.Max()
|
||||
);
|
||||
}
|
||||
|
||||
private static double? ComputeRealtimeScore(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
var points = snapshot.RealtimeBuffer;
|
||||
if (points.Count < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var start = points[0].Timestamp;
|
||||
var end = points[^1].Timestamp;
|
||||
var totalDurationMs = (end - start).TotalMilliseconds;
|
||||
if (totalDurationMs <= Math.Max(300, snapshot.Config.FrameMs * 3))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dbfsValues = points.Select(p => p.Dbfs).OrderBy(v => v).ToArray();
|
||||
var p50Dbfs = Percentile(dbfsValues, 0.50);
|
||||
|
||||
var overDurationMs = 0d;
|
||||
var weightedDurationMs = 0d;
|
||||
var segmentCount = 0;
|
||||
var segmentOpen = false;
|
||||
DateTimeOffset? lastOverThresholdAt = null;
|
||||
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var current = points[i];
|
||||
var next = points[i + 1];
|
||||
var dtMs = (next.Timestamp - current.Timestamp).TotalMilliseconds;
|
||||
if (dtMs <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
weightedDurationMs += dtMs;
|
||||
|
||||
if (current.IsOverThreshold)
|
||||
{
|
||||
overDurationMs += dtMs;
|
||||
if (segmentOpen)
|
||||
{
|
||||
lastOverThresholdAt = current.Timestamp;
|
||||
}
|
||||
else
|
||||
{
|
||||
var canMerge = lastOverThresholdAt.HasValue &&
|
||||
(current.Timestamp - lastOverThresholdAt.Value).TotalMilliseconds <= snapshot.Config.SegmentMergeGapMs;
|
||||
if (!canMerge)
|
||||
{
|
||||
segmentCount++;
|
||||
}
|
||||
|
||||
segmentOpen = true;
|
||||
lastOverThresholdAt = current.Timestamp;
|
||||
}
|
||||
}
|
||||
else if (segmentOpen && lastOverThresholdAt.HasValue)
|
||||
{
|
||||
var silentGapMs = (current.Timestamp - lastOverThresholdAt.Value).TotalMilliseconds;
|
||||
if (silentGapMs > snapshot.Config.SegmentMergeGapMs)
|
||||
{
|
||||
segmentOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (weightedDurationMs <= 0)
|
||||
{
|
||||
weightedDurationMs = points.Count * snapshot.Config.FrameMs;
|
||||
}
|
||||
|
||||
var overRatio = Math.Clamp(overDurationMs / Math.Max(1, weightedDurationMs), 0, 1);
|
||||
var minutes = Math.Max(1d / 60d, weightedDurationMs / 60000d);
|
||||
|
||||
var sustainedPenalty = Clamp01((p50Dbfs - snapshot.Config.ScoreThresholdDbfs) / 6d);
|
||||
var timePenalty = Clamp01(overRatio / 0.30d);
|
||||
var segmentsPerMin = segmentCount / minutes;
|
||||
var segmentPenalty = Clamp01(segmentsPerMin / Math.Max(1, snapshot.Config.MaxSegmentsPerMin));
|
||||
|
||||
var totalPenalty = (0.40d * sustainedPenalty) + (0.30d * timePenalty) + (0.30d * segmentPenalty);
|
||||
var score = Math.Clamp(100d * (1d - totalPenalty), 0, 100);
|
||||
return Math.Round(score, 1);
|
||||
}
|
||||
|
||||
private static double Percentile(double[] sortedValues, double percentile)
|
||||
{
|
||||
if (sortedValues.Length == 0)
|
||||
{
|
||||
return -100;
|
||||
}
|
||||
|
||||
if (sortedValues.Length == 1)
|
||||
{
|
||||
return sortedValues[0];
|
||||
}
|
||||
|
||||
var clamped = Math.Clamp(percentile, 0, 1);
|
||||
var position = (sortedValues.Length - 1) * clamped;
|
||||
var lower = (int)Math.Floor(position);
|
||||
var upper = (int)Math.Ceiling(position);
|
||||
if (lower == upper)
|
||||
{
|
||||
return sortedValues[lower];
|
||||
}
|
||||
|
||||
var factor = position - lower;
|
||||
return sortedValues[lower] + ((sortedValues[upper] - sortedValues[lower]) * factor);
|
||||
}
|
||||
|
||||
private static double Clamp01(double value)
|
||||
{
|
||||
return Math.Clamp(value, 0, 1);
|
||||
}
|
||||
|
||||
private string FormatScoreOrUnavailable(double? score)
|
||||
{
|
||||
if (!score.HasValue || double.IsNaN(score.Value) || double.IsInfinity(score.Value))
|
||||
{
|
||||
return L("study.score_overview.unavailable", "--");
|
||||
}
|
||||
|
||||
return score.Value.ToString("F1", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
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 void ApplyTypographyByBackground(Color panelColor)
|
||||
{
|
||||
var samples = BuildPanelBackgroundSamples(panelColor);
|
||||
var primary = CreateAdaptiveBrush(samples, ValueColorCandidates, minContrast: 4.5);
|
||||
var secondary = CreateAdaptiveBrush(samples, SecondaryColorCandidates, minContrast: 4.5);
|
||||
|
||||
TitleTextBlock.Foreground = secondary;
|
||||
CurrentLabelTextBlock.Foreground = secondary;
|
||||
AverageLabelTextBlock.Foreground = secondary;
|
||||
MinimumLabelTextBlock.Foreground = secondary;
|
||||
MaximumLabelTextBlock.Foreground = secondary;
|
||||
|
||||
CurrentScoreTextBlock.Foreground = primary;
|
||||
AverageValueTextBlock.Foreground = primary;
|
||||
MinimumValueTextBlock.Foreground = primary;
|
||||
MaximumValueTextBlock.Foreground = primary;
|
||||
}
|
||||
|
||||
private void ApplyModeBadgeColor(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));
|
||||
|
||||
ModeBadgeBorder.Background = new SolidColorBrush(badgeColor);
|
||||
ModeBadgeBorder.BorderBrush = new SolidColorBrush(Color.FromArgb(0x96, 0xFF, 0xFF, 0xFF));
|
||||
ModeTextBlock.Foreground = CreateAdaptiveBrush(new[] { badgeComposite }, ValueColorCandidates, minContrast: 4.5);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Color> BuildPanelBackgroundSamples(Color panelColor)
|
||||
{
|
||||
var opaqueOnDark = ToOpaqueAgainst(panelColor, DarkSubstrate);
|
||||
var opaqueOnLight = ToOpaqueAgainst(panelColor, LightSubstrate);
|
||||
|
||||
return new[]
|
||||
{
|
||||
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 void ReloadLanguageCode()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
}
|
||||
|
||||
private void ApplyVariableFontFamily()
|
||||
{
|
||||
TitleTextBlock.FontFamily = MiSansVariableFontFamily;
|
||||
ModeTextBlock.FontFamily = MiSansVariableFontFamily;
|
||||
CurrentLabelTextBlock.FontFamily = MiSansVariableFontFamily;
|
||||
CurrentScoreTextBlock.FontFamily = MiSansVariableFontFamily;
|
||||
AverageLabelTextBlock.FontFamily = MiSansVariableFontFamily;
|
||||
AverageValueTextBlock.FontFamily = MiSansVariableFontFamily;
|
||||
MinimumLabelTextBlock.FontFamily = MiSansVariableFontFamily;
|
||||
MinimumValueTextBlock.FontFamily = MiSansVariableFontFamily;
|
||||
MaximumLabelTextBlock.FontFamily = MiSansVariableFontFamily;
|
||||
MaximumValueTextBlock.FontFamily = MiSansVariableFontFamily;
|
||||
}
|
||||
|
||||
private void ApplyVariableWeights(double scale)
|
||||
{
|
||||
var weightProgress = Math.Clamp((scale - 0.52) / 1.6, 0, 1);
|
||||
var compactDelta = _isUltraCompactMode ? 40 : _isCompactMode ? 20 : 0;
|
||||
|
||||
TitleTextBlock.FontWeight = ToVariableWeight(Lerp(560, 680, weightProgress));
|
||||
ModeTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, weightProgress));
|
||||
CurrentLabelTextBlock.FontWeight = ToVariableWeight(Lerp(520, 640, weightProgress));
|
||||
CurrentScoreTextBlock.FontWeight = ToVariableWeight(Lerp(640 + compactDelta, 820, weightProgress));
|
||||
|
||||
AverageLabelTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress));
|
||||
MinimumLabelTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress));
|
||||
MaximumLabelTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress));
|
||||
AverageValueTextBlock.FontWeight = ToVariableWeight(Lerp(620 + compactDelta, 760, weightProgress));
|
||||
MinimumValueTextBlock.FontWeight = ToVariableWeight(Lerp(620 + compactDelta, 760, weightProgress));
|
||||
MaximumValueTextBlock.FontWeight = ToVariableWeight(Lerp(620 + compactDelta, 760, weightProgress));
|
||||
}
|
||||
|
||||
private static double Lerp(double from, double to, double ratio)
|
||||
{
|
||||
ratio = Math.Clamp(ratio, 0, 1);
|
||||
return from + ((to - from) * ratio);
|
||||
}
|
||||
|
||||
private static FontWeight ToVariableWeight(double weight)
|
||||
{
|
||||
return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
@@ -1216,6 +1216,14 @@ public partial class MainWindow
|
||||
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
||||
}
|
||||
|
||||
if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyScoreOverview, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Keep score overview widget square: 4x4, 5x5, 6x6...
|
||||
return SnapSpanToScaleRules(
|
||||
span,
|
||||
new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 4));
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user