mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-07-01 15:44:26 +08:00
0.2.8
天气组件、倒计时组件微调。引入浏览器组件。
This commit is contained in:
@@ -28,6 +28,11 @@
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||
</Style>
|
||||
|
||||
<!-- Mitigation for Avalonia ScrollContentPresenter offset recursion during gesture inertia -->
|
||||
<Style Selector="ScrollViewer">
|
||||
<Setter Property="ScrollViewer.IsScrollInertiaEnabled" Value="False" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="fi|SymbolIcon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Linq;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LanMontainDesktop.ViewModels;
|
||||
using LanMontainDesktop.Views;
|
||||
using AvaloniaWebView;
|
||||
|
||||
namespace LanMontainDesktop;
|
||||
|
||||
@@ -13,6 +14,7 @@ public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaWebViewBuilder.Initialize(default);
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
@@ -44,4 +46,4 @@ public partial class App : Application
|
||||
BindingPlugins.DataValidators.Remove(plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,5 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
|
||||
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||
public const string DesktopBrowser = "DesktopBrowser";
|
||||
}
|
||||
|
||||
@@ -159,6 +159,16 @@ public sealed class ComponentRegistry
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopBrowser,
|
||||
"Browser",
|
||||
"Globe",
|
||||
"Board",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.Date,
|
||||
"Calendar",
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('Windows')))
 or '$(RuntimeIdentifier)' == 'win-x64'
 or '$(RuntimeIdentifier)' == 'win-x86'" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('OSX')))
 or '$(RuntimeIdentifier)' == 'osx-x64'" />
|
||||
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -230,6 +230,7 @@
|
||||
"component.daily_artwork": "Daily Artwork",
|
||||
"component.whiteboard": "Blackboard (Portrait)",
|
||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||
"component.browser": "Browser",
|
||||
"component.holiday_calendar": "Holiday Calendar",
|
||||
"poetry.widget.loading_content": "Loading poetry...",
|
||||
"poetry.widget.loading_author": "Loading...",
|
||||
|
||||
@@ -230,6 +230,7 @@
|
||||
"component.daily_artwork": "每日名画",
|
||||
"component.whiteboard": "竖向小黑板",
|
||||
"component.blackboard_landscape": "横向小黑板",
|
||||
"component.browser": "浏览器",
|
||||
"component.holiday_calendar": "节假日日历",
|
||||
"poetry.widget.loading_content": "正在加载诗词",
|
||||
"poetry.widget.loading_author": "加载中",
|
||||
|
||||
138
LanMontainDesktop/Models/StudyAnalyticsModels.cs
Normal file
138
LanMontainDesktop/Models/StudyAnalyticsModels.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMontainDesktop.Models;
|
||||
|
||||
public enum StudyAnalyticsRuntimeState
|
||||
{
|
||||
Unsupported = 0,
|
||||
Ready = 1,
|
||||
Running = 2,
|
||||
Paused = 3,
|
||||
Error = 4
|
||||
}
|
||||
|
||||
public enum NoiseStreamStatus
|
||||
{
|
||||
Initializing = 0,
|
||||
Quiet = 1,
|
||||
Noisy = 2,
|
||||
Error = 3
|
||||
}
|
||||
|
||||
public enum StudySessionRuntimeState
|
||||
{
|
||||
Idle = 0,
|
||||
Running = 1,
|
||||
Completed = 2,
|
||||
Error = 3
|
||||
}
|
||||
|
||||
public enum StudyDataMode
|
||||
{
|
||||
Realtime = 0,
|
||||
SessionRunning = 1,
|
||||
SessionReport = 2
|
||||
}
|
||||
|
||||
public sealed record StudyAnalyticsConfig(
|
||||
int FrameMs = 50,
|
||||
int SliceSec = 30,
|
||||
double ScoreThresholdDbfs = -50,
|
||||
int SegmentMergeGapMs = 500,
|
||||
int MaxSegmentsPerMin = 6,
|
||||
double SilenceFloorDbfs = -90,
|
||||
double BaselineDb = 45,
|
||||
bool ShowRelativeDb = true,
|
||||
bool AlertSoundEnabled = false,
|
||||
int AvgWindowSec = 1,
|
||||
int RealtimeBufferCapacity = 240);
|
||||
|
||||
public sealed record NoiseRealtimePoint(
|
||||
DateTimeOffset Timestamp,
|
||||
double Rms,
|
||||
double Dbfs,
|
||||
double DisplayDb,
|
||||
double Peak,
|
||||
bool IsOverThreshold);
|
||||
|
||||
public sealed record NoiseSliceRawStats(
|
||||
double AvgDbfs,
|
||||
double MaxDbfs,
|
||||
double P50Dbfs,
|
||||
double P95Dbfs,
|
||||
double OverRatioDbfs,
|
||||
int SegmentCount,
|
||||
double SampledDurationMs,
|
||||
int GapCount,
|
||||
double MaxGapMs);
|
||||
|
||||
public sealed record NoiseSliceDisplayStats(
|
||||
double AvgDb,
|
||||
double P95Db);
|
||||
|
||||
public sealed record NoiseScoreBreakdown(
|
||||
double SustainedPenalty,
|
||||
double TimePenalty,
|
||||
double SegmentPenalty,
|
||||
double TotalPenalty,
|
||||
double Score,
|
||||
double SustainedLevelDbfs,
|
||||
double OverRatioDbfs,
|
||||
int SegmentCount,
|
||||
double Minutes,
|
||||
double DurationMs);
|
||||
|
||||
public sealed record NoiseSliceSummary(
|
||||
DateTimeOffset StartAt,
|
||||
DateTimeOffset EndAt,
|
||||
int FrameCount,
|
||||
NoiseSliceRawStats Raw,
|
||||
NoiseSliceDisplayStats Display,
|
||||
double Score,
|
||||
NoiseScoreBreakdown ScoreDetail);
|
||||
|
||||
public sealed record StudySessionOptions(
|
||||
string? Label = null,
|
||||
DateTimeOffset? PlannedEndAt = null);
|
||||
|
||||
public sealed record StudySessionMetrics(
|
||||
double CurrentScore,
|
||||
double AvgScore,
|
||||
double MinScore,
|
||||
double MaxScore,
|
||||
double WeightedOverRatioDbfs,
|
||||
int TotalSegmentCount,
|
||||
TimeSpan EffectiveDuration,
|
||||
int SliceCount);
|
||||
|
||||
public sealed record StudySessionSnapshot(
|
||||
StudySessionRuntimeState State,
|
||||
string? SessionId,
|
||||
string Label,
|
||||
DateTimeOffset? StartedAt,
|
||||
DateTimeOffset? EndedAt,
|
||||
TimeSpan Elapsed,
|
||||
StudySessionMetrics Metrics,
|
||||
string LastError);
|
||||
|
||||
public sealed record StudySessionReport(
|
||||
string SessionId,
|
||||
string Label,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset EndedAt,
|
||||
TimeSpan Duration,
|
||||
StudySessionMetrics Metrics,
|
||||
IReadOnlyList<NoiseSliceSummary> Slices);
|
||||
|
||||
public sealed record StudyAnalyticsSnapshot(
|
||||
StudyAnalyticsRuntimeState State,
|
||||
NoiseStreamStatus StreamStatus,
|
||||
StudyDataMode DataMode,
|
||||
StudyAnalyticsConfig Config,
|
||||
NoiseRealtimePoint? LatestRealtimePoint,
|
||||
NoiseSliceSummary? LatestSlice,
|
||||
IReadOnlyList<NoiseRealtimePoint> RealtimeBuffer,
|
||||
StudySessionSnapshot Session,
|
||||
StudySessionReport? LastSessionReport,
|
||||
string LastError);
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia;
|
||||
using Avalonia;
|
||||
using Avalonia.WebView.Desktop;
|
||||
using System;
|
||||
|
||||
namespace LanMontainDesktop;
|
||||
@@ -16,6 +17,7 @@ sealed class Program
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.UseDesktopWebView()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
|
||||
48
LanMontainDesktop/Services/IStudyAnalyticsService.cs
Normal file
48
LanMontainDesktop/Services/IStudyAnalyticsService.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using LanMontainDesktop.Models;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
public sealed class StudyAnalyticsSnapshotChangedEventArgs(StudyAnalyticsSnapshot snapshot) : EventArgs
|
||||
{
|
||||
public StudyAnalyticsSnapshot Snapshot { get; } = snapshot;
|
||||
}
|
||||
|
||||
public sealed class NoiseSliceClosedEventArgs(NoiseSliceSummary slice) : EventArgs
|
||||
{
|
||||
public NoiseSliceSummary Slice { get; } = slice;
|
||||
}
|
||||
|
||||
public sealed class StudySessionCompletedEventArgs(StudySessionReport report) : EventArgs
|
||||
{
|
||||
public StudySessionReport Report { get; } = report;
|
||||
}
|
||||
|
||||
public interface IStudyAnalyticsService : IDisposable
|
||||
{
|
||||
StudyAnalyticsSnapshot GetSnapshot();
|
||||
|
||||
StudyAnalyticsConfig GetConfig();
|
||||
|
||||
void UpdateConfig(StudyAnalyticsConfig config);
|
||||
|
||||
bool StartOrResumeMonitoring();
|
||||
|
||||
bool PauseMonitoring();
|
||||
|
||||
bool StopMonitoring();
|
||||
|
||||
bool StartStudySession(StudySessionOptions? options = null);
|
||||
|
||||
bool StopStudySession();
|
||||
|
||||
bool CancelStudySession();
|
||||
|
||||
void ClearLastSessionReport();
|
||||
|
||||
event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
|
||||
|
||||
event EventHandler<NoiseSliceClosedEventArgs>? SliceClosed;
|
||||
|
||||
event EventHandler<StudySessionCompletedEventArgs>? SessionCompleted;
|
||||
}
|
||||
492
LanMontainDesktop/Services/StudyAnalyticsInternals.cs
Normal file
492
LanMontainDesktop/Services/StudyAnalyticsInternals.cs
Normal file
@@ -0,0 +1,492 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using LanMontainDesktop.Models;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
internal readonly record struct NoisePipelineTickResult(
|
||||
NoiseRealtimePoint RealtimePoint,
|
||||
NoiseSliceSummary? ClosedSlice);
|
||||
|
||||
internal sealed class NoiseFramePipeline
|
||||
{
|
||||
private StudyAnalyticsConfig _config;
|
||||
private readonly Queue<NoiseRealtimePoint> _realtimeBuffer = new();
|
||||
private readonly List<NoiseRealtimePoint> _slicePoints = [];
|
||||
|
||||
private DateTimeOffset _sliceStartAt;
|
||||
private DateTimeOffset _lastFrameAt;
|
||||
private DateTimeOffset _lastOverThresholdAt;
|
||||
|
||||
private int _overThresholdFrameCount;
|
||||
private int _segmentCount;
|
||||
private bool _segmentOpen;
|
||||
private int _gapCount;
|
||||
private double _maxGapMs;
|
||||
|
||||
public NoiseFramePipeline(StudyAnalyticsConfig config)
|
||||
{
|
||||
_config = NormalizeConfig(config);
|
||||
}
|
||||
|
||||
public void UpdateConfig(StudyAnalyticsConfig config)
|
||||
{
|
||||
_config = NormalizeConfig(config);
|
||||
Reset();
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_realtimeBuffer.Clear();
|
||||
_slicePoints.Clear();
|
||||
_sliceStartAt = default;
|
||||
_lastFrameAt = default;
|
||||
_lastOverThresholdAt = default;
|
||||
_overThresholdFrameCount = 0;
|
||||
_segmentCount = 0;
|
||||
_segmentOpen = false;
|
||||
_gapCount = 0;
|
||||
_maxGapMs = 0;
|
||||
}
|
||||
|
||||
public IReadOnlyList<NoiseRealtimePoint> GetRealtimeBufferSnapshot()
|
||||
{
|
||||
return _realtimeBuffer.ToArray();
|
||||
}
|
||||
|
||||
public NoisePipelineTickResult AddFrame(DateTimeOffset timestamp, double rms, double dbfs, double displayDb, double peak)
|
||||
{
|
||||
if (_sliceStartAt == default)
|
||||
{
|
||||
_sliceStartAt = timestamp;
|
||||
}
|
||||
|
||||
if (_lastFrameAt != default)
|
||||
{
|
||||
var actualGapMs = (timestamp - _lastFrameAt).TotalMilliseconds;
|
||||
var expectedGapMs = _config.FrameMs;
|
||||
var jitterMs = Math.Max(0, actualGapMs - expectedGapMs);
|
||||
if (jitterMs > Math.Max(12, expectedGapMs * 0.8))
|
||||
{
|
||||
_gapCount++;
|
||||
_maxGapMs = Math.Max(_maxGapMs, jitterMs);
|
||||
}
|
||||
}
|
||||
|
||||
_lastFrameAt = timestamp;
|
||||
|
||||
var isOverThreshold = dbfs > _config.ScoreThresholdDbfs;
|
||||
if (isOverThreshold)
|
||||
{
|
||||
_overThresholdFrameCount++;
|
||||
if (_segmentOpen)
|
||||
{
|
||||
_lastOverThresholdAt = timestamp;
|
||||
}
|
||||
else
|
||||
{
|
||||
var canMergeToPrevious = _lastOverThresholdAt != default &&
|
||||
(timestamp - _lastOverThresholdAt).TotalMilliseconds <= _config.SegmentMergeGapMs;
|
||||
if (!canMergeToPrevious)
|
||||
{
|
||||
_segmentCount++;
|
||||
}
|
||||
|
||||
_segmentOpen = true;
|
||||
_lastOverThresholdAt = timestamp;
|
||||
}
|
||||
}
|
||||
else if (_segmentOpen && _lastOverThresholdAt != default)
|
||||
{
|
||||
var silentGapMs = (timestamp - _lastOverThresholdAt).TotalMilliseconds;
|
||||
if (silentGapMs > _config.SegmentMergeGapMs)
|
||||
{
|
||||
_segmentOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
var point = new NoiseRealtimePoint(
|
||||
timestamp,
|
||||
rms,
|
||||
dbfs,
|
||||
displayDb,
|
||||
peak,
|
||||
isOverThreshold);
|
||||
_slicePoints.Add(point);
|
||||
_realtimeBuffer.Enqueue(point);
|
||||
|
||||
while (_realtimeBuffer.Count > _config.RealtimeBufferCapacity)
|
||||
{
|
||||
_realtimeBuffer.Dequeue();
|
||||
}
|
||||
|
||||
var elapsedSeconds = (timestamp - _sliceStartAt).TotalSeconds;
|
||||
if (elapsedSeconds + 1e-6 < _config.SliceSec)
|
||||
{
|
||||
return new NoisePipelineTickResult(point, null);
|
||||
}
|
||||
|
||||
var slice = BuildClosedSlice(timestamp);
|
||||
ResetSliceState(timestamp);
|
||||
return new NoisePipelineTickResult(point, slice);
|
||||
}
|
||||
|
||||
private NoiseSliceSummary BuildClosedSlice(DateTimeOffset endAt)
|
||||
{
|
||||
var sampledDurationMs = _slicePoints.Count * _config.FrameMs;
|
||||
if (_slicePoints.Count == 0 || sampledDurationMs <= 0)
|
||||
{
|
||||
var emptyRaw = new NoiseSliceRawStats(
|
||||
AvgDbfs: _config.SilenceFloorDbfs,
|
||||
MaxDbfs: _config.SilenceFloorDbfs,
|
||||
P50Dbfs: _config.SilenceFloorDbfs,
|
||||
P95Dbfs: _config.SilenceFloorDbfs,
|
||||
OverRatioDbfs: 0,
|
||||
SegmentCount: 0,
|
||||
SampledDurationMs: 0,
|
||||
GapCount: _gapCount,
|
||||
MaxGapMs: _maxGapMs);
|
||||
var emptyDisplay = new NoiseSliceDisplayStats(_config.BaselineDb, _config.BaselineDb);
|
||||
var emptyScore = ScoreCalculator.Calculate(
|
||||
p50Dbfs: emptyRaw.P50Dbfs,
|
||||
overRatioDbfs: emptyRaw.OverRatioDbfs,
|
||||
segmentCount: emptyRaw.SegmentCount,
|
||||
sampledDurationMs: 0,
|
||||
scoreThresholdDbfs: _config.ScoreThresholdDbfs,
|
||||
maxSegmentsPerMin: _config.MaxSegmentsPerMin);
|
||||
return new NoiseSliceSummary(
|
||||
_sliceStartAt,
|
||||
endAt,
|
||||
0,
|
||||
emptyRaw,
|
||||
emptyDisplay,
|
||||
emptyScore.Score,
|
||||
emptyScore);
|
||||
}
|
||||
|
||||
var dbfsList = _slicePoints.Select(p => p.Dbfs).OrderBy(v => v).ToArray();
|
||||
var displayList = _slicePoints.Select(p => p.DisplayDb).OrderBy(v => v).ToArray();
|
||||
|
||||
var avgDbfs = ScoreCalculator.ComputeAverageDbfs(dbfsList);
|
||||
var maxDbfs = dbfsList[^1];
|
||||
var p50Dbfs = Percentile(sortedValues: dbfsList, percentile: 0.50);
|
||||
var p95Dbfs = Percentile(sortedValues: dbfsList, percentile: 0.95);
|
||||
var overRatio = _overThresholdFrameCount / (double)_slicePoints.Count;
|
||||
|
||||
var raw = new NoiseSliceRawStats(
|
||||
AvgDbfs: avgDbfs,
|
||||
MaxDbfs: maxDbfs,
|
||||
P50Dbfs: p50Dbfs,
|
||||
P95Dbfs: p95Dbfs,
|
||||
OverRatioDbfs: Math.Clamp(overRatio, 0, 1),
|
||||
SegmentCount: _segmentCount,
|
||||
SampledDurationMs: sampledDurationMs,
|
||||
GapCount: _gapCount,
|
||||
MaxGapMs: _maxGapMs);
|
||||
|
||||
var display = new NoiseSliceDisplayStats(
|
||||
AvgDb: Math.Round(displayList.Average(), 2),
|
||||
P95Db: Math.Round(Percentile(displayList, 0.95), 2));
|
||||
|
||||
var score = ScoreCalculator.Calculate(
|
||||
p50Dbfs: raw.P50Dbfs,
|
||||
overRatioDbfs: raw.OverRatioDbfs,
|
||||
segmentCount: raw.SegmentCount,
|
||||
sampledDurationMs: raw.SampledDurationMs,
|
||||
scoreThresholdDbfs: _config.ScoreThresholdDbfs,
|
||||
maxSegmentsPerMin: _config.MaxSegmentsPerMin);
|
||||
|
||||
return new NoiseSliceSummary(
|
||||
_sliceStartAt,
|
||||
endAt,
|
||||
_slicePoints.Count,
|
||||
raw,
|
||||
display,
|
||||
score.Score,
|
||||
score);
|
||||
}
|
||||
|
||||
private void ResetSliceState(DateTimeOffset nextSliceStartAt)
|
||||
{
|
||||
_slicePoints.Clear();
|
||||
_sliceStartAt = nextSliceStartAt;
|
||||
_lastOverThresholdAt = default;
|
||||
_overThresholdFrameCount = 0;
|
||||
_segmentCount = 0;
|
||||
_segmentOpen = false;
|
||||
_gapCount = 0;
|
||||
_maxGapMs = 0;
|
||||
}
|
||||
|
||||
private static double Percentile(double[] sortedValues, double percentile)
|
||||
{
|
||||
if (sortedValues.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
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 StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
|
||||
{
|
||||
var frameMs = Math.Clamp(config.FrameMs, 20, 250);
|
||||
var sliceSec = Math.Clamp(config.SliceSec, 5, 600);
|
||||
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
|
||||
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
|
||||
var maxSegments = Math.Clamp(config.MaxSegmentsPerMin, 1, 40);
|
||||
var silenceFloor = Math.Clamp(config.SilenceFloorDbfs, -100, -20);
|
||||
var baselineDb = Math.Clamp(config.BaselineDb, 20, 90);
|
||||
var avgWindowSec = Math.Clamp(config.AvgWindowSec, 1, 8);
|
||||
var ringCapacity = Math.Clamp(config.RealtimeBufferCapacity, 60, 1200);
|
||||
|
||||
return config with
|
||||
{
|
||||
FrameMs = frameMs,
|
||||
SliceSec = sliceSec,
|
||||
ScoreThresholdDbfs = threshold,
|
||||
SegmentMergeGapMs = mergeGapMs,
|
||||
MaxSegmentsPerMin = maxSegments,
|
||||
SilenceFloorDbfs = silenceFloor,
|
||||
BaselineDb = baselineDb,
|
||||
AvgWindowSec = avgWindowSec,
|
||||
RealtimeBufferCapacity = ringCapacity
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ScoreCalculator
|
||||
{
|
||||
public static NoiseScoreBreakdown Calculate(
|
||||
double p50Dbfs,
|
||||
double overRatioDbfs,
|
||||
int segmentCount,
|
||||
double sampledDurationMs,
|
||||
double scoreThresholdDbfs,
|
||||
int maxSegmentsPerMin)
|
||||
{
|
||||
var minutes = Math.Max(1d / 60d, sampledDurationMs / 60000d);
|
||||
var sustainedPenalty = Clamp01((p50Dbfs - scoreThresholdDbfs) / 6d);
|
||||
var timePenalty = Clamp01(overRatioDbfs / 0.30d);
|
||||
var segmentRatePerMin = segmentCount / minutes;
|
||||
var segmentPenalty = Clamp01(segmentRatePerMin / Math.Max(1, maxSegmentsPerMin));
|
||||
var totalPenalty = (0.40d * sustainedPenalty) + (0.30d * timePenalty) + (0.30d * segmentPenalty);
|
||||
var score = Math.Clamp(100d * (1d - totalPenalty), 0, 100);
|
||||
|
||||
return new NoiseScoreBreakdown(
|
||||
SustainedPenalty: Math.Round(sustainedPenalty, 4),
|
||||
TimePenalty: Math.Round(timePenalty, 4),
|
||||
SegmentPenalty: Math.Round(segmentPenalty, 4),
|
||||
TotalPenalty: Math.Round(totalPenalty, 4),
|
||||
Score: Math.Round(score, 2),
|
||||
SustainedLevelDbfs: Math.Round(p50Dbfs, 3),
|
||||
OverRatioDbfs: Math.Round(Math.Clamp(overRatioDbfs, 0, 1), 4),
|
||||
SegmentCount: Math.Max(0, segmentCount),
|
||||
Minutes: Math.Round(minutes, 4),
|
||||
DurationMs: Math.Max(0, sampledDurationMs));
|
||||
}
|
||||
|
||||
public static double ComputeAverageDbfs(double[] dbfsValues)
|
||||
{
|
||||
if (dbfsValues.Length == 0)
|
||||
{
|
||||
return -100;
|
||||
}
|
||||
|
||||
// Average in energy domain then convert back to dBFS.
|
||||
var avgPower = dbfsValues
|
||||
.Select(DbfsToPower)
|
||||
.Average();
|
||||
return Math.Round(PowerToDbfs(avgPower), 3);
|
||||
}
|
||||
|
||||
public static double DbfsToPower(double dbfs)
|
||||
{
|
||||
return Math.Pow(10d, dbfs / 10d);
|
||||
}
|
||||
|
||||
public static double PowerToDbfs(double power)
|
||||
{
|
||||
if (power <= 1e-12)
|
||||
{
|
||||
return -100;
|
||||
}
|
||||
|
||||
return Math.Clamp(10d * Math.Log10(power), -100, 0);
|
||||
}
|
||||
|
||||
private static double Clamp01(double value)
|
||||
{
|
||||
return Math.Clamp(value, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SessionAccumulator
|
||||
{
|
||||
private readonly List<NoiseSliceSummary> _slices = [];
|
||||
|
||||
private StudySessionRuntimeState _state = StudySessionRuntimeState.Idle;
|
||||
private string? _sessionId;
|
||||
private string _label = string.Empty;
|
||||
private DateTimeOffset? _startedAt;
|
||||
private DateTimeOffset? _endedAt;
|
||||
private string _lastError = string.Empty;
|
||||
|
||||
private double _sumEffectiveMs;
|
||||
private double _sumWeightedScore;
|
||||
private double _sumWeightedOverRatio;
|
||||
private int _totalSegments;
|
||||
private double _minScore = 100;
|
||||
private double _maxScore;
|
||||
private double _currentScore;
|
||||
|
||||
public bool IsRunning => _state == StudySessionRuntimeState.Running;
|
||||
|
||||
public bool Start(DateTimeOffset now, StudySessionOptions options)
|
||||
{
|
||||
if (IsRunning)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_state = StudySessionRuntimeState.Running;
|
||||
_sessionId = Guid.NewGuid().ToString("N");
|
||||
_label = string.IsNullOrWhiteSpace(options.Label) ? "Study Session" : options.Label.Trim();
|
||||
_startedAt = now;
|
||||
_endedAt = null;
|
||||
_lastError = string.Empty;
|
||||
_slices.Clear();
|
||||
_sumEffectiveMs = 0;
|
||||
_sumWeightedScore = 0;
|
||||
_sumWeightedOverRatio = 0;
|
||||
_totalSegments = 0;
|
||||
_minScore = 100;
|
||||
_maxScore = 0;
|
||||
_currentScore = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void AddSlice(NoiseSliceSummary slice)
|
||||
{
|
||||
if (!IsRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_slices.Add(slice);
|
||||
var effectiveMs = Math.Max(0, slice.Raw.SampledDurationMs);
|
||||
_sumEffectiveMs += effectiveMs;
|
||||
_sumWeightedScore += slice.Score * effectiveMs;
|
||||
_sumWeightedOverRatio += slice.Raw.OverRatioDbfs * effectiveMs;
|
||||
_totalSegments += Math.Max(0, slice.Raw.SegmentCount);
|
||||
_currentScore = slice.Score;
|
||||
_minScore = Math.Min(_minScore, slice.Score);
|
||||
_maxScore = Math.Max(_maxScore, slice.Score);
|
||||
}
|
||||
|
||||
public StudySessionReport? Stop(DateTimeOffset now)
|
||||
{
|
||||
if (!IsRunning || _startedAt is null || string.IsNullOrWhiteSpace(_sessionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_state = StudySessionRuntimeState.Completed;
|
||||
_endedAt = now;
|
||||
|
||||
var metrics = BuildMetrics();
|
||||
return new StudySessionReport(
|
||||
SessionId: _sessionId,
|
||||
Label: _label,
|
||||
StartedAt: _startedAt.Value,
|
||||
EndedAt: _endedAt.Value,
|
||||
Duration: _endedAt.Value - _startedAt.Value,
|
||||
Metrics: metrics,
|
||||
Slices: _slices.ToArray());
|
||||
}
|
||||
|
||||
public bool Cancel()
|
||||
{
|
||||
if (!IsRunning)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ResetToIdle();
|
||||
return true;
|
||||
}
|
||||
|
||||
public StudySessionSnapshot GetSnapshot(DateTimeOffset now)
|
||||
{
|
||||
var startedAt = _startedAt;
|
||||
var endedAt = _endedAt;
|
||||
var elapsed = startedAt is null
|
||||
? TimeSpan.Zero
|
||||
: (endedAt ?? now) - startedAt.Value;
|
||||
if (elapsed < TimeSpan.Zero)
|
||||
{
|
||||
elapsed = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
return new StudySessionSnapshot(
|
||||
State: _state,
|
||||
SessionId: _sessionId,
|
||||
Label: _label,
|
||||
StartedAt: startedAt,
|
||||
EndedAt: endedAt,
|
||||
Elapsed: elapsed,
|
||||
Metrics: BuildMetrics(),
|
||||
LastError: _lastError);
|
||||
}
|
||||
|
||||
public void ResetToIdle()
|
||||
{
|
||||
_state = StudySessionRuntimeState.Idle;
|
||||
_sessionId = null;
|
||||
_label = string.Empty;
|
||||
_startedAt = null;
|
||||
_endedAt = null;
|
||||
_lastError = string.Empty;
|
||||
_slices.Clear();
|
||||
_sumEffectiveMs = 0;
|
||||
_sumWeightedScore = 0;
|
||||
_sumWeightedOverRatio = 0;
|
||||
_totalSegments = 0;
|
||||
_minScore = 100;
|
||||
_maxScore = 0;
|
||||
_currentScore = 0;
|
||||
}
|
||||
|
||||
private StudySessionMetrics BuildMetrics()
|
||||
{
|
||||
var avgScore = _sumEffectiveMs <= 0 ? 0 : _sumWeightedScore / _sumEffectiveMs;
|
||||
var avgOverRatio = _sumEffectiveMs <= 0 ? 0 : _sumWeightedOverRatio / _sumEffectiveMs;
|
||||
var minScore = _slices.Count == 0 ? 0 : _minScore;
|
||||
var maxScore = _slices.Count == 0 ? 0 : _maxScore;
|
||||
return new StudySessionMetrics(
|
||||
CurrentScore: Math.Round(_currentScore, 2),
|
||||
AvgScore: Math.Round(avgScore, 2),
|
||||
MinScore: Math.Round(minScore, 2),
|
||||
MaxScore: Math.Round(maxScore, 2),
|
||||
WeightedOverRatioDbfs: Math.Round(avgOverRatio, 4),
|
||||
TotalSegmentCount: _totalSegments,
|
||||
EffectiveDuration: TimeSpan.FromMilliseconds(_sumEffectiveMs),
|
||||
SliceCount: _slices.Count);
|
||||
}
|
||||
}
|
||||
493
LanMontainDesktop/Services/StudyAnalyticsService.cs
Normal file
493
LanMontainDesktop/Services/StudyAnalyticsService.cs
Normal file
@@ -0,0 +1,493 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using LanMontainDesktop.Models;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
public static class StudyAnalyticsServiceFactory
|
||||
{
|
||||
private static readonly Lazy<IStudyAnalyticsService> SharedService = new(
|
||||
() => new StudyAnalyticsService(),
|
||||
isThreadSafe: true);
|
||||
|
||||
public static IStudyAnalyticsService CreateDefault()
|
||||
{
|
||||
return SharedService.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
{
|
||||
private readonly object _syncRoot = new();
|
||||
private readonly IAudioRecorderService _audioRecorderService;
|
||||
private readonly Timer _samplingTimer;
|
||||
private readonly NoiseFramePipeline _pipeline;
|
||||
private readonly SessionAccumulator _sessionAccumulator = new();
|
||||
|
||||
private StudyAnalyticsConfig _config = new();
|
||||
private StudyAnalyticsRuntimeState _state;
|
||||
private NoiseStreamStatus _streamStatus = NoiseStreamStatus.Initializing;
|
||||
private StudyDataMode _dataMode = StudyDataMode.Realtime;
|
||||
private NoiseRealtimePoint? _latestRealtime;
|
||||
private NoiseSliceSummary? _latestSlice;
|
||||
private StudySessionReport? _lastSessionReport;
|
||||
private string _lastError = string.Empty;
|
||||
private bool _disposed;
|
||||
|
||||
public StudyAnalyticsService(IAudioRecorderService? audioRecorderService = null)
|
||||
{
|
||||
_audioRecorderService = audioRecorderService ?? AudioRecorderServiceFactory.CreateDefault();
|
||||
_pipeline = new NoiseFramePipeline(_config);
|
||||
_samplingTimer = new Timer(OnSamplingTick, null, Timeout.Infinite, Timeout.Infinite);
|
||||
|
||||
var audioSnapshot = _audioRecorderService.GetSnapshot();
|
||||
if (audioSnapshot.IsSupported)
|
||||
{
|
||||
_state = StudyAnalyticsRuntimeState.Ready;
|
||||
_streamStatus = NoiseStreamStatus.Quiet;
|
||||
_lastError = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = StudyAnalyticsRuntimeState.Unsupported;
|
||||
_streamStatus = NoiseStreamStatus.Error;
|
||||
_lastError = audioSnapshot.LastError;
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler<StudyAnalyticsSnapshotChangedEventArgs>? SnapshotUpdated;
|
||||
|
||||
public event EventHandler<NoiseSliceClosedEventArgs>? SliceClosed;
|
||||
|
||||
public event EventHandler<StudySessionCompletedEventArgs>? SessionCompleted;
|
||||
|
||||
public StudyAnalyticsSnapshot GetSnapshot()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
public StudyAnalyticsConfig GetConfig()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _config;
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateConfig(StudyAnalyticsConfig config)
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
_config = NormalizeConfig(config);
|
||||
_pipeline.UpdateConfig(_config);
|
||||
if (_state == StudyAnalyticsRuntimeState.Running)
|
||||
{
|
||||
StartTimerLocked();
|
||||
}
|
||||
|
||||
_latestSlice = null;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
}
|
||||
|
||||
public bool StartOrResumeMonitoring()
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
bool started;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
started = TryStartMonitoringLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return started;
|
||||
}
|
||||
|
||||
public bool PauseMonitoring()
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
bool paused;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
if (_state != StudyAnalyticsRuntimeState.Running)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_audioRecorderService.Pause())
|
||||
{
|
||||
_state = StudyAnalyticsRuntimeState.Error;
|
||||
_streamStatus = NoiseStreamStatus.Error;
|
||||
_lastError = _audioRecorderService.GetSnapshot().LastError;
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
paused = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
StopTimerLocked();
|
||||
_state = StudyAnalyticsRuntimeState.Paused;
|
||||
_lastError = string.Empty;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
paused = true;
|
||||
}
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return paused;
|
||||
}
|
||||
|
||||
public bool StopMonitoring()
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
StudySessionReport? finishedReport = null;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
if (_state is StudyAnalyticsRuntimeState.Unsupported)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_audioRecorderService.Discard();
|
||||
StopTimerLocked();
|
||||
_pipeline.Reset();
|
||||
_latestRealtime = null;
|
||||
_latestSlice = null;
|
||||
_state = StudyAnalyticsRuntimeState.Ready;
|
||||
_streamStatus = NoiseStreamStatus.Quiet;
|
||||
_lastError = string.Empty;
|
||||
|
||||
if (_sessionAccumulator.IsRunning)
|
||||
{
|
||||
finishedReport = _sessionAccumulator.Stop(DateTimeOffset.UtcNow);
|
||||
_lastSessionReport = finishedReport;
|
||||
}
|
||||
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
if (finishedReport is not null)
|
||||
{
|
||||
SessionCompleted?.Invoke(this, new StudySessionCompletedEventArgs(finishedReport));
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool StartStudySession(StudySessionOptions? options = null)
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
bool started;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
if (_sessionAccumulator.IsRunning)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryStartMonitoringLocked())
|
||||
{
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
started = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
var normalizedOptions = options ?? new StudySessionOptions();
|
||||
if (!_sessionAccumulator.Start(DateTimeOffset.UtcNow, normalizedOptions))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_lastSessionReport = null;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
started = true;
|
||||
}
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return started;
|
||||
}
|
||||
|
||||
public bool StopStudySession()
|
||||
{
|
||||
StudySessionReport? report;
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
report = _sessionAccumulator.Stop(DateTimeOffset.UtcNow);
|
||||
if (report is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_lastSessionReport = report;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
SessionCompleted?.Invoke(this, new StudySessionCompletedEventArgs(report));
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool CancelStudySession()
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
if (!_sessionAccumulator.Cancel())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ClearLastSessionReport()
|
||||
{
|
||||
StudyAnalyticsSnapshot snapshot;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ThrowIfDisposedLocked();
|
||||
_lastSessionReport = null;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
StopTimerLocked();
|
||||
_samplingTimer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSamplingTick(object? state)
|
||||
{
|
||||
StudyAnalyticsSnapshot? snapshot = null;
|
||||
NoiseSliceSummary? closedSlice = null;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_disposed || _state != StudyAnalyticsRuntimeState.Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var audioSnapshot = _audioRecorderService.GetSnapshot();
|
||||
if (!audioSnapshot.IsSupported)
|
||||
{
|
||||
_state = StudyAnalyticsRuntimeState.Unsupported;
|
||||
_streamStatus = NoiseStreamStatus.Error;
|
||||
_lastError = audioSnapshot.LastError;
|
||||
StopTimerLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
else if (audioSnapshot.State == AudioRecorderRuntimeState.Error)
|
||||
{
|
||||
_state = StudyAnalyticsRuntimeState.Error;
|
||||
_streamStatus = NoiseStreamStatus.Error;
|
||||
_lastError = string.IsNullOrWhiteSpace(audioSnapshot.LastError)
|
||||
? "Audio recorder returned an error state."
|
||||
: audioSnapshot.LastError;
|
||||
StopTimerLocked();
|
||||
snapshot = BuildSnapshotLocked(DateTimeOffset.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var rms = Math.Clamp(audioSnapshot.InputLevel, 0, 1);
|
||||
var dbfs = ConvertInputLevelToDbfs(rms, _config.SilenceFloorDbfs);
|
||||
var displayDb = ComputeDisplayDb(dbfs, _config);
|
||||
var tickResult = _pipeline.AddFrame(
|
||||
now,
|
||||
rms,
|
||||
dbfs,
|
||||
displayDb,
|
||||
peak: rms);
|
||||
|
||||
_latestRealtime = tickResult.RealtimePoint;
|
||||
_streamStatus = tickResult.RealtimePoint.IsOverThreshold
|
||||
? NoiseStreamStatus.Noisy
|
||||
: NoiseStreamStatus.Quiet;
|
||||
|
||||
if (tickResult.ClosedSlice is not null)
|
||||
{
|
||||
closedSlice = tickResult.ClosedSlice;
|
||||
_latestSlice = closedSlice;
|
||||
if (_sessionAccumulator.IsRunning)
|
||||
{
|
||||
_sessionAccumulator.AddSlice(closedSlice);
|
||||
}
|
||||
}
|
||||
|
||||
_lastError = string.Empty;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(now);
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshot is not null)
|
||||
{
|
||||
SnapshotUpdated?.Invoke(this, new StudyAnalyticsSnapshotChangedEventArgs(snapshot));
|
||||
}
|
||||
|
||||
if (closedSlice is not null)
|
||||
{
|
||||
SliceClosed?.Invoke(this, new NoiseSliceClosedEventArgs(closedSlice));
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryStartMonitoringLocked()
|
||||
{
|
||||
if (_state == StudyAnalyticsRuntimeState.Unsupported)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_state == StudyAnalyticsRuntimeState.Running)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_audioRecorderService.StartOrResume())
|
||||
{
|
||||
_state = StudyAnalyticsRuntimeState.Error;
|
||||
_streamStatus = NoiseStreamStatus.Error;
|
||||
_lastError = _audioRecorderService.GetSnapshot().LastError;
|
||||
return false;
|
||||
}
|
||||
|
||||
_state = StudyAnalyticsRuntimeState.Running;
|
||||
_streamStatus = NoiseStreamStatus.Quiet;
|
||||
_lastError = string.Empty;
|
||||
StartTimerLocked();
|
||||
UpdateDataModeLocked();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void StartTimerLocked()
|
||||
{
|
||||
_samplingTimer.Change(
|
||||
dueTime: TimeSpan.Zero,
|
||||
period: TimeSpan.FromMilliseconds(_config.FrameMs));
|
||||
}
|
||||
|
||||
private void StopTimerLocked()
|
||||
{
|
||||
_samplingTimer.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
}
|
||||
|
||||
private void UpdateDataModeLocked()
|
||||
{
|
||||
if (_sessionAccumulator.IsRunning)
|
||||
{
|
||||
_dataMode = StudyDataMode.SessionRunning;
|
||||
return;
|
||||
}
|
||||
|
||||
_dataMode = _lastSessionReport is null
|
||||
? StudyDataMode.Realtime
|
||||
: StudyDataMode.SessionReport;
|
||||
}
|
||||
|
||||
private StudyAnalyticsSnapshot BuildSnapshotLocked(DateTimeOffset now)
|
||||
{
|
||||
return new StudyAnalyticsSnapshot(
|
||||
State: _state,
|
||||
StreamStatus: _streamStatus,
|
||||
DataMode: _dataMode,
|
||||
Config: _config,
|
||||
LatestRealtimePoint: _latestRealtime,
|
||||
LatestSlice: _latestSlice,
|
||||
RealtimeBuffer: _pipeline.GetRealtimeBufferSnapshot(),
|
||||
Session: _sessionAccumulator.GetSnapshot(now),
|
||||
LastSessionReport: _lastSessionReport,
|
||||
LastError: _lastError);
|
||||
}
|
||||
|
||||
private static double ConvertInputLevelToDbfs(double level, double silenceFloorDbfs)
|
||||
{
|
||||
var clampedLevel = Math.Clamp(level, 0, 1);
|
||||
if (clampedLevel <= 1e-5)
|
||||
{
|
||||
return silenceFloorDbfs;
|
||||
}
|
||||
|
||||
var dbfs = 20d * Math.Log10(clampedLevel);
|
||||
return Math.Clamp(dbfs, silenceFloorDbfs, 0);
|
||||
}
|
||||
|
||||
private static double ComputeDisplayDb(double dbfs, StudyAnalyticsConfig config)
|
||||
{
|
||||
// Keep score and calibration decoupled: scoring uses dBFS, display maps it to user-facing dB.
|
||||
var referenceDelta = dbfs - config.ScoreThresholdDbfs;
|
||||
return Math.Round(config.BaselineDb + referenceDelta, 2);
|
||||
}
|
||||
|
||||
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
|
||||
{
|
||||
var frameMs = Math.Clamp(config.FrameMs, 20, 250);
|
||||
var sliceSec = Math.Clamp(config.SliceSec, 5, 600);
|
||||
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
|
||||
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
|
||||
var maxSegments = Math.Clamp(config.MaxSegmentsPerMin, 1, 40);
|
||||
var silenceFloor = Math.Clamp(config.SilenceFloorDbfs, -100, -20);
|
||||
var baselineDb = Math.Clamp(config.BaselineDb, 20, 90);
|
||||
var avgWindowSec = Math.Clamp(config.AvgWindowSec, 1, 8);
|
||||
var ringCapacity = Math.Clamp(config.RealtimeBufferCapacity, 60, 1200);
|
||||
|
||||
return config with
|
||||
{
|
||||
FrameMs = frameMs,
|
||||
SliceSec = sliceSec,
|
||||
ScoreThresholdDbfs = threshold,
|
||||
SegmentMergeGapMs = mergeGapMs,
|
||||
MaxSegmentsPerMin = maxSegments,
|
||||
SilenceFloorDbfs = silenceFloor,
|
||||
BaselineDb = baselineDb,
|
||||
AvgWindowSec = avgWindowSec,
|
||||
RealtimeBufferCapacity = ringCapacity
|
||||
};
|
||||
}
|
||||
|
||||
private void ThrowIfDisposedLocked()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(StudyAnalyticsService));
|
||||
}
|
||||
}
|
||||
}
|
||||
73
LanMontainDesktop/Views/Components/BrowserWidget.axaml
Normal file
73
LanMontainDesktop/Views/Components/BrowserWidget.axaml
Normal file
@@ -0,0 +1,73 @@
|
||||
<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:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:webview="clr-namespace:AvaloniaWebView;assembly=Avalonia.WebView"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="480"
|
||||
d:DesignHeight="480"
|
||||
x:Class="LanMontainDesktop.Views.Components.BrowserWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Background="#F4F7FC"
|
||||
CornerRadius="24"
|
||||
ClipToBounds="True"
|
||||
Padding="10">
|
||||
<Grid RowDefinitions="*,Auto"
|
||||
RowSpacing="8">
|
||||
<Border x:Name="WebViewHostBorder"
|
||||
Grid.Row="0"
|
||||
CornerRadius="16"
|
||||
ClipToBounds="True"
|
||||
Background="#FFFFFFFF"
|
||||
BorderBrush="#22000000"
|
||||
BorderThickness="1">
|
||||
<webview:WebView x:Name="BrowserWebView" />
|
||||
</Border>
|
||||
|
||||
<Border x:Name="AddressBarBorder"
|
||||
Grid.Row="1"
|
||||
CornerRadius="14"
|
||||
Background="#ECF2FA"
|
||||
BorderBrush="#22000000"
|
||||
BorderThickness="1"
|
||||
Padding="8,6">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="8">
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="0"
|
||||
Width="34"
|
||||
Height="34"
|
||||
Padding="0"
|
||||
CornerRadius="17"
|
||||
ToolTip.Tip="Refresh"
|
||||
Click="OnRefreshButtonClick">
|
||||
<fi:SymbolIcon Symbol="ArrowClockwise"
|
||||
FontSize="15" />
|
||||
</Button>
|
||||
|
||||
<TextBox x:Name="AddressTextBox"
|
||||
Grid.Column="1"
|
||||
VerticalContentAlignment="Center"
|
||||
HorizontalContentAlignment="Left"
|
||||
Watermark="https://example.com"
|
||||
Text="https://www.bing.com"
|
||||
KeyDown="OnAddressTextBoxKeyDown" />
|
||||
|
||||
<Button x:Name="GoButton"
|
||||
Grid.Column="2"
|
||||
Width="34"
|
||||
Height="34"
|
||||
Padding="0"
|
||||
CornerRadius="17"
|
||||
ToolTip.Tip="Go"
|
||||
Click="OnGoButtonClick">
|
||||
<fi:SymbolIcon Symbol="ArrowRight"
|
||||
FontSize="15" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
291
LanMontainDesktop/Views/Components/BrowserWidget.axaml.cs
Normal file
291
LanMontainDesktop/Views/Components/BrowserWidget.axaml.cs
Normal file
@@ -0,0 +1,291 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using AvaloniaWebView;
|
||||
using WebViewCore.Events;
|
||||
|
||||
namespace LanMontainDesktop.Views.Components;
|
||||
|
||||
public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
, IDesktopPageVisibilityAwareComponentWidget
|
||||
{
|
||||
private static readonly Uri DefaultHomeUri = new("https://www.bing.com");
|
||||
private double _currentCellSize = 48;
|
||||
private bool? _isNightModeApplied;
|
||||
private Uri _lastKnownUri = DefaultHomeUri;
|
||||
private bool _isOnActiveDesktopPage;
|
||||
private bool _isEditMode;
|
||||
private bool _isWebViewActive = true;
|
||||
|
||||
public BrowserWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyTheme(force: true);
|
||||
BrowserWebView.NavigationStarting += OnBrowserWebViewNavigationStarting;
|
||||
UpdateWebViewActiveState();
|
||||
NavigateTo(DefaultHomeUri);
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 12, 28));
|
||||
RootBorder.Padding = new Thickness(
|
||||
Math.Clamp(_currentCellSize * 0.20, 8, 18));
|
||||
|
||||
WebViewHostBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.24, 10, 22));
|
||||
AddressBarBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.22, 10, 20));
|
||||
AddressBarBorder.Padding = new Thickness(8, 6);
|
||||
|
||||
var rowSpacing = 8d;
|
||||
if (RootBorder.Child is Grid rootGrid)
|
||||
{
|
||||
rootGrid.RowSpacing = rowSpacing;
|
||||
}
|
||||
|
||||
var buttonSize = Math.Clamp(_currentCellSize * 0.72, 30, 36);
|
||||
var buttonCorner = buttonSize * 0.5;
|
||||
var iconSize = Math.Clamp(buttonSize * 0.44, 14, 16);
|
||||
foreach (var button in new[] { RefreshButton, GoButton })
|
||||
{
|
||||
button.Width = buttonSize;
|
||||
button.Height = buttonSize;
|
||||
button.CornerRadius = new CornerRadius(buttonCorner);
|
||||
}
|
||||
|
||||
if (RefreshButton.Content is FluentIcons.Avalonia.SymbolIcon refreshIcon)
|
||||
{
|
||||
refreshIcon.FontSize = iconSize;
|
||||
}
|
||||
|
||||
if (GoButton.Content is FluentIcons.Avalonia.SymbolIcon goIcon)
|
||||
{
|
||||
goIcon.FontSize = iconSize;
|
||||
}
|
||||
|
||||
AddressTextBox.FontSize = Math.Clamp(_currentCellSize * 0.30, 12, 15);
|
||||
AddressTextBox.Height = buttonSize;
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
ApplyTheme(force: true);
|
||||
UpdateWebViewActiveState();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isOnActiveDesktopPage = false;
|
||||
UpdateWebViewActiveState();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
ApplyTheme(force: false);
|
||||
}
|
||||
|
||||
private void ApplyTheme(bool force)
|
||||
{
|
||||
var isNightMode = ResolveIsNightMode();
|
||||
if (!force && _isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isNightModeApplied = isNightMode;
|
||||
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF141A24") : Color.Parse("#FFF4F7FC"));
|
||||
WebViewHostBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF0A0E15") : Color.Parse("#FFFFFFFF"));
|
||||
WebViewHostBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#33FFFFFF") : Color.Parse("#22000000"));
|
||||
AddressBarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1BFFFFFF") : Color.Parse("#ECF2FA"));
|
||||
AddressBarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#22000000"));
|
||||
|
||||
var idleBackground = new SolidColorBrush(isNightMode ? Color.Parse("#24FFFFFF") : Color.Parse("#DCE6F5"));
|
||||
var idleForeground = new SolidColorBrush(isNightMode ? Color.Parse("#FFE5E7EB") : Color.Parse("#FF1E293B"));
|
||||
|
||||
foreach (var button in new[] { RefreshButton, GoButton })
|
||||
{
|
||||
button.Background = idleBackground;
|
||||
button.Foreground = idleForeground;
|
||||
button.BorderThickness = new Thickness(0);
|
||||
}
|
||||
|
||||
AddressTextBox.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1F000000") : Color.Parse("#FFFFFFFF"));
|
||||
AddressTextBox.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#2FFFFFFF") : Color.Parse("#22000000"));
|
||||
AddressTextBox.Foreground = idleForeground;
|
||||
AddressTextBox.CaretBrush = idleForeground;
|
||||
}
|
||||
|
||||
private bool ResolveIsNightMode()
|
||||
{
|
||||
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ActualThemeVariant == ThemeVariant.Light)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||
value is ISolidColorBrush brush)
|
||||
{
|
||||
return CalculateRelativeLuminance(brush.Color) < 0.45;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double CalculateRelativeLuminance(Color color)
|
||||
{
|
||||
static double ToLinear(double channel)
|
||||
{
|
||||
return channel <= 0.03928
|
||||
? channel / 12.92
|
||||
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
var red = ToLinear(color.R / 255d);
|
||||
var green = ToLinear(color.G / 255d);
|
||||
var blue = ToLinear(color.B / 255d);
|
||||
return (0.2126 * red) + (0.7152 * green) + (0.0722 * blue);
|
||||
}
|
||||
|
||||
private void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_isWebViewActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (BrowserWebView.Url is not null)
|
||||
{
|
||||
BrowserWebView.Reload();
|
||||
return;
|
||||
}
|
||||
|
||||
NavigateTo(DefaultHomeUri);
|
||||
}
|
||||
|
||||
private void OnGoButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
NavigateFromAddressBar();
|
||||
}
|
||||
|
||||
private void OnAddressTextBoxKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Enter)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NavigateFromAddressBar();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void NavigateFromAddressBar()
|
||||
{
|
||||
var target = TryNormalizeUri(AddressTextBox.Text);
|
||||
if (target is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NavigateTo(target);
|
||||
}
|
||||
|
||||
private void NavigateTo(Uri uri)
|
||||
{
|
||||
_lastKnownUri = uri;
|
||||
AddressTextBox.Text = uri.ToString();
|
||||
if (_isWebViewActive)
|
||||
{
|
||||
BrowserWebView.Url = uri;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBrowserWebViewNavigationStarting(object? sender, WebViewUrlLoadingEventArg e)
|
||||
{
|
||||
if (e.Url is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastKnownUri = e.Url;
|
||||
AddressTextBox.Text = e.Url.ToString();
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_isOnActiveDesktopPage = isOnActivePage;
|
||||
_isEditMode = isEditMode;
|
||||
UpdateWebViewActiveState();
|
||||
}
|
||||
|
||||
private void UpdateWebViewActiveState()
|
||||
{
|
||||
var shouldBeActive = _isOnActiveDesktopPage && !_isEditMode && IsVisible;
|
||||
if (_isWebViewActive == shouldBeActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isWebViewActive = shouldBeActive;
|
||||
if (!_isWebViewActive)
|
||||
{
|
||||
if (BrowserWebView.Url is Uri currentUri)
|
||||
{
|
||||
_lastKnownUri = currentUri;
|
||||
}
|
||||
|
||||
BrowserWebView.IsHitTestVisible = false;
|
||||
BrowserWebView.IsVisible = false;
|
||||
BrowserWebView.Url = null;
|
||||
return;
|
||||
}
|
||||
|
||||
BrowserWebView.IsVisible = true;
|
||||
BrowserWebView.IsHitTestVisible = true;
|
||||
BrowserWebView.Url = _lastKnownUri;
|
||||
}
|
||||
|
||||
private static Uri? TryNormalizeUri(string? rawText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidate = rawText.Trim();
|
||||
if (!candidate.Contains("://", StringComparison.Ordinal))
|
||||
{
|
||||
candidate = $"https://{candidate}";
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) ||
|
||||
uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
|
||||
? uri
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
"component.blackboard_landscape",
|
||||
() => new WhiteboardWidget(baseWidthCells: 4),
|
||||
cellSize => Math.Clamp(cellSize * 0.24, 10, 24)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopBrowser,
|
||||
"component.browser",
|
||||
() => new BrowserWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.24, 10, 24)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.HolidayCalendar,
|
||||
"component.holiday_calendar",
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,2,0,0"
|
||||
RowDefinitions="Auto,Auto"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnDefinitions="*,Auto"
|
||||
RowSpacing="2"
|
||||
ColumnSpacing="8">
|
||||
<Border x:Name="CityInfoBadge"
|
||||
@@ -115,12 +115,27 @@
|
||||
MaxLines="1" />
|
||||
</Border>
|
||||
|
||||
<Border x:Name="RangeInfoBadge"
|
||||
<Border x:Name="ConditionInfoBadge"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="雾"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</Border>
|
||||
|
||||
<Border x:Name="RangeInfoBadge"
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<TextBlock x:Name="RangeTextBlock"
|
||||
Text="11°/4°"
|
||||
FontSize="20"
|
||||
@@ -131,21 +146,6 @@
|
||||
MaxLines="1"
|
||||
Opacity="0.92" />
|
||||
</Border>
|
||||
|
||||
<Border x:Name="ConditionInfoBadge"
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Background="Transparent"
|
||||
CornerRadius="0"
|
||||
Padding="0">
|
||||
<TextBlock x:Name="ConditionTextBlock"
|
||||
Text="雾"
|
||||
FontSize="20"
|
||||
FontWeight="SemiBold"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Image x:Name="WeatherIconImage"
|
||||
|
||||
@@ -426,22 +426,30 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
var compactness = Math.Clamp((0.90 - scale) / 0.55, 0, 1);
|
||||
LayoutRoot.RowSpacing = Math.Clamp(height * 0.012, 5, 13);
|
||||
SummaryGrid.ColumnSpacing = Math.Clamp(width * 0.016, 8, 22);
|
||||
SummaryInfoGrid.ColumnSpacing = Math.Clamp(width * 0.010, 6, 14);
|
||||
SummaryInfoGrid.RowSpacing = Math.Clamp(height * 0.003, 1, 4);
|
||||
HourlyGrid.ColumnSpacing = Math.Clamp(width * 0.007, 3, 10);
|
||||
DailyGrid.RowSpacing = Math.Clamp(height * 0.009, 4, 10);
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(height * 0.18, 52, 154);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(Lerp(300, 370, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
CityTextBlock.FontSize = Math.Clamp(height * 0.040, 12, 30);
|
||||
ConditionTextBlock.FontSize = Math.Clamp(height * 0.046, 13, 34);
|
||||
RangeTextBlock.FontSize = Math.Clamp(height * 0.043, 12, 32);
|
||||
var cityFontSize = Math.Clamp(height * 0.040, 12, 30);
|
||||
var topInfoFontSize = Math.Clamp(height * 0.044, 12, 32);
|
||||
CityTextBlock.FontSize = cityFontSize;
|
||||
ConditionTextBlock.FontSize = topInfoFontSize;
|
||||
RangeTextBlock.FontSize = topInfoFontSize;
|
||||
CityTextBlock.FontWeight = ToVariableWeight(Lerp(520, 590, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
ConditionTextBlock.FontWeight = ToVariableWeight(Lerp(560, 630, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
RangeTextBlock.FontWeight = ToVariableWeight(Lerp(560, 620, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
var topInfoWeight = ToVariableWeight(Lerp(570, 630, Math.Clamp((scale - 0.50) / 1.2, 0, 1)));
|
||||
ConditionTextBlock.FontWeight = topInfoWeight;
|
||||
RangeTextBlock.FontWeight = topInfoWeight;
|
||||
CityTextBlock.LineHeight = cityFontSize * 1.10;
|
||||
ConditionTextBlock.LineHeight = topInfoFontSize * 1.08;
|
||||
RangeTextBlock.LineHeight = topInfoFontSize * 1.08;
|
||||
var iconSize = Math.Clamp(height * 0.116, 36, 102);
|
||||
WeatherIconImage.Width = iconSize;
|
||||
WeatherIconImage.Height = iconSize;
|
||||
ConditionTextBlock.MaxWidth = Math.Clamp(width * 0.20, 80, 240);
|
||||
RangeTextBlock.MaxWidth = Math.Clamp(width * 0.20, 80, 240);
|
||||
CityTextBlock.MaxWidth = Math.Clamp(width * 0.28, 90, 290);
|
||||
ConditionTextBlock.MaxWidth = Math.Clamp(width * 0.24, 88, 280);
|
||||
RangeTextBlock.MaxWidth = Math.Clamp(width * 0.17, 72, 210);
|
||||
CityTextBlock.MaxWidth = Math.Clamp(width * 0.30, 96, 320);
|
||||
|
||||
HourlyPanelBorder.Padding = new Thickness(0);
|
||||
HourlyPanelBorder.CornerRadius = new CornerRadius(0);
|
||||
|
||||
@@ -12,34 +12,46 @@
|
||||
CornerRadius="34"
|
||||
ClipToBounds="True"
|
||||
Padding="14">
|
||||
<Viewbox Stretch="Uniform">
|
||||
<Grid x:Name="LayoutRoot"
|
||||
Width="300"
|
||||
Height="300"
|
||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
||||
RowSpacing="8">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Grid.Row="0"
|
||||
Text="Holiday countdown"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#61697C"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,10,0,0" />
|
||||
<Grid x:Name="LayoutRoot"
|
||||
RowDefinitions="1.1*,2.3*,0.62*,0.78*,0.95*"
|
||||
RowSpacing="8">
|
||||
<TextBlock x:Name="TitleTextBlock"
|
||||
Grid.Row="0"
|
||||
Text="Holiday countdown"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#61697C"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Bottom"
|
||||
TextAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
Margin="8,0,8,0" />
|
||||
|
||||
<Viewbox Grid.Row="1"
|
||||
Stretch="Uniform"
|
||||
StretchDirection="DownOnly"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
Margin="8,0,8,0">
|
||||
<TextBlock x:Name="CountTextBlock"
|
||||
Grid.Row="1"
|
||||
Text="0"
|
||||
FontFeatures="tnum"
|
||||
FontSize="120"
|
||||
FontSize="132"
|
||||
FontWeight="Bold"
|
||||
Foreground="#0A0A0A"
|
||||
HorizontalAlignment="Center" />
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center" />
|
||||
</Viewbox>
|
||||
|
||||
<Canvas Grid.Row="2"
|
||||
Width="260"
|
||||
Height="40"
|
||||
HorizontalAlignment="Center">
|
||||
<Viewbox Grid.Row="2"
|
||||
Stretch="Uniform"
|
||||
StretchDirection="DownOnly"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
Margin="14,0,14,0">
|
||||
<Canvas Width="260"
|
||||
Height="40">
|
||||
<Path Data="M 10,16 C 68,11 192,11 250,16"
|
||||
Stroke="#1A73F0"
|
||||
StrokeThickness="12"
|
||||
@@ -60,38 +72,46 @@
|
||||
Canvas.Top="27.5"
|
||||
Opacity="0.35" />
|
||||
</Canvas>
|
||||
</Viewbox>
|
||||
|
||||
<Grid Grid.Row="3"
|
||||
ColumnDefinitions="*,Auto,*"
|
||||
Margin="8,0,8,0"
|
||||
VerticalAlignment="Center">
|
||||
<Border Grid.Column="0"
|
||||
Height="2"
|
||||
Margin="0,0,10,0"
|
||||
VerticalAlignment="Center"
|
||||
Background="#B0B9CB" />
|
||||
<TextBlock x:Name="DayUnitTextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Days"
|
||||
FontSize="56"
|
||||
Foreground="#7D869A"
|
||||
FontWeight="Medium"
|
||||
VerticalAlignment="Center" />
|
||||
<Border Grid.Column="2"
|
||||
Height="2"
|
||||
Margin="10,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Background="#B0B9CB" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="DateTextBlock"
|
||||
Grid.Row="4"
|
||||
Text="2024-10-01"
|
||||
FontSize="34"
|
||||
Foreground="#596177"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,2,0,8" />
|
||||
<Grid Grid.Row="3"
|
||||
ColumnDefinitions="*,Auto,*"
|
||||
Margin="8,0,8,0"
|
||||
VerticalAlignment="Center">
|
||||
<Border Grid.Column="0"
|
||||
Height="2"
|
||||
Margin="0,0,10,0"
|
||||
VerticalAlignment="Center"
|
||||
Background="#B0B9CB" />
|
||||
<TextBlock x:Name="DayUnitTextBlock"
|
||||
Grid.Column="1"
|
||||
Text="Days"
|
||||
FontSize="52"
|
||||
Foreground="#7D869A"
|
||||
FontWeight="Medium"
|
||||
TextAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
VerticalAlignment="Center" />
|
||||
<Border Grid.Column="2"
|
||||
Height="2"
|
||||
Margin="10,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Background="#B0B9CB" />
|
||||
</Grid>
|
||||
</Viewbox>
|
||||
|
||||
<TextBlock x:Name="DateTextBlock"
|
||||
Grid.Row="4"
|
||||
Text="2024-10-01"
|
||||
FontSize="32"
|
||||
Foreground="#596177"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
TextWrapping="NoWrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1"
|
||||
Margin="8,0,8,0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMontainDesktop.Services;
|
||||
|
||||
@@ -32,6 +33,7 @@ public partial class HolidayCalendarWidget : UserControl, IDesktopComponentWidge
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
TriggerContentRefresh();
|
||||
}
|
||||
|
||||
@@ -142,6 +144,7 @@ public partial class HolidayCalendarWidget : UserControl, IDesktopComponentWidge
|
||||
CountTextBlock.Text = "--";
|
||||
DayUnitTextBlock.Text = isZh ? "\u5929" : "Days";
|
||||
DateTextBlock.Text = "--";
|
||||
ApplyCellSize(_currentCellSize);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -195,28 +198,216 @@ public partial class HolidayCalendarWidget : UserControl, IDesktopComponentWidge
|
||||
? $"{holidayDateText} - make-up workday"
|
||||
: holidayDateText;
|
||||
}
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var scale = ResolveScale();
|
||||
var width = Bounds.Width > 1 ? Bounds.Width : 220;
|
||||
var height = Bounds.Height > 1 ? Bounds.Height : 220;
|
||||
var shortSide = Math.Min(width, height);
|
||||
var scale = ResolveScale(width, height);
|
||||
var isCompact = width < 170 || height < 170;
|
||||
var isUltraCompact = width < 130 || height < 130;
|
||||
var titleUnits = GetDisplayUnits(TitleTextBlock.Text);
|
||||
var dateUnits = GetDisplayUnits(DateTextBlock.Text);
|
||||
var titleNeedsTwoLines = isUltraCompact || titleUnits >= (isCompact ? 13 : 17);
|
||||
var dateNeedsTwoLines = isUltraCompact || dateUnits >= (isCompact ? 15 : 20);
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 15, 50));
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 7, 22));
|
||||
LayoutRoot.RowSpacing = Math.Clamp(8 * scale, 4, 14);
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(shortSide * 0.13, 10, 46));
|
||||
var padding = Math.Clamp(shortSide * 0.05, 4.5, 21);
|
||||
RootBorder.Padding = new Thickness(padding);
|
||||
LayoutRoot.RowSpacing = Math.Clamp(shortSide * 0.028, 2.2, 12);
|
||||
var rowWeights = ApplyAdaptiveRowHeights(isCompact, isUltraCompact, titleNeedsTwoLines, dateNeedsTwoLines);
|
||||
|
||||
TitleTextBlock.FontSize = Math.Clamp(24 * scale, 11, 36);
|
||||
CountTextBlock.FontSize = Math.Clamp(120 * scale, 36, 160);
|
||||
DayUnitTextBlock.FontSize = Math.Clamp(56 * scale, 16, 78);
|
||||
DateTextBlock.FontSize = Math.Clamp(34 * scale, 12, 50);
|
||||
var innerWidth = Math.Max(1, width - padding * 2);
|
||||
var innerHeight = Math.Max(1, height - padding * 2);
|
||||
var totalWeight = Math.Max(0.001, rowWeights[0] + rowWeights[1] + rowWeights[2] + rowWeights[3] + rowWeights[4]);
|
||||
var row0Height = innerHeight * (rowWeights[0] / totalWeight);
|
||||
var row1Height = innerHeight * (rowWeights[1] / totalWeight);
|
||||
var row3Height = innerHeight * (rowWeights[3] / totalWeight);
|
||||
var row4Height = innerHeight * (rowWeights[4] / totalWeight);
|
||||
var horizontalMargin = Math.Clamp(8 * scale, 4, 14);
|
||||
var titleMaxWidth = Math.Max(24, innerWidth - horizontalMargin * 2);
|
||||
var dateMaxWidth = titleMaxWidth;
|
||||
|
||||
var titlePreferred = Math.Clamp(24 * scale, 8.8, 34);
|
||||
var titleHeightCap = Math.Max(10, row0Height * 0.94);
|
||||
var titleLineCount = titleNeedsTwoLines ? 2 : 1;
|
||||
TitleTextBlock.MaxLines = titleLineCount;
|
||||
TitleTextBlock.TextWrapping = titleLineCount > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap;
|
||||
TitleTextBlock.Margin = new Thickness(horizontalMargin, 0, horizontalMargin, 0);
|
||||
TitleTextBlock.FontSize = FitTextSize(
|
||||
TitleTextBlock.Text,
|
||||
TitleTextBlock.FontWeight,
|
||||
Math.Min(titlePreferred, Math.Max(8.8, row0Height * 0.62)),
|
||||
8.6,
|
||||
titleMaxWidth,
|
||||
titleHeightCap,
|
||||
titleLineCount,
|
||||
lineHeightFactor: 1.10);
|
||||
TitleTextBlock.LineHeight = TitleTextBlock.FontSize * 1.10;
|
||||
|
||||
var digitCount = Math.Max(1, CountTextBlock.Text?.Trim().Length ?? 1);
|
||||
var digitCompression = digitCount switch
|
||||
{
|
||||
>= 5 => 0.68,
|
||||
4 => 0.8,
|
||||
3 => 0.9,
|
||||
_ => 1.0
|
||||
};
|
||||
var countCompactFactor = isUltraCompact ? 0.86 : isCompact ? 0.93 : 1.0;
|
||||
var countPreferred = Math.Clamp(132 * scale * digitCompression * countCompactFactor, 28, 170);
|
||||
var countHeightCap = Math.Max(30, row1Height * 0.96);
|
||||
CountTextBlock.FontSize = FitTextSize(
|
||||
CountTextBlock.Text,
|
||||
CountTextBlock.FontWeight,
|
||||
Math.Min(countPreferred, Math.Max(28, row1Height * 0.9)),
|
||||
24,
|
||||
titleMaxWidth,
|
||||
countHeightCap,
|
||||
maxLines: 1,
|
||||
lineHeightFactor: 1.08);
|
||||
CountTextBlock.LineHeight = CountTextBlock.FontSize * 1.08;
|
||||
|
||||
var unitCompactFactor = isUltraCompact ? 0.8 : isCompact ? 0.9 : 1.0;
|
||||
DayUnitTextBlock.FontSize = Math.Clamp(52 * scale * unitCompactFactor, 10, 72);
|
||||
DayUnitTextBlock.FontSize = Math.Min(DayUnitTextBlock.FontSize, Math.Max(10, row3Height * 0.64));
|
||||
DayUnitTextBlock.LineHeight = DayUnitTextBlock.FontSize * 1.02;
|
||||
|
||||
var dateCompactFactor = isUltraCompact ? 0.84 : isCompact ? 0.92 : 1.0;
|
||||
var datePreferred = Math.Clamp(32 * scale * dateCompactFactor, 9, 46);
|
||||
var dateHeightCap = Math.Max(10, row4Height * 0.96);
|
||||
var dateLineCount = dateNeedsTwoLines ? 2 : 1;
|
||||
DateTextBlock.MaxLines = dateLineCount;
|
||||
DateTextBlock.TextWrapping = dateLineCount > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap;
|
||||
DateTextBlock.Margin = new Thickness(horizontalMargin, 0, horizontalMargin, 0);
|
||||
DateTextBlock.FontSize = FitTextSize(
|
||||
DateTextBlock.Text,
|
||||
DateTextBlock.FontWeight,
|
||||
Math.Min(datePreferred, Math.Max(9, row4Height * 0.58)),
|
||||
8.5,
|
||||
dateMaxWidth,
|
||||
dateHeightCap,
|
||||
dateLineCount,
|
||||
lineHeightFactor: 1.12);
|
||||
DateTextBlock.LineHeight = DateTextBlock.FontSize * 1.12;
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
private double[] ApplyAdaptiveRowHeights(
|
||||
bool isCompact,
|
||||
bool isUltraCompact,
|
||||
bool titleNeedsTwoLines,
|
||||
bool dateNeedsTwoLines)
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.60, 1.95);
|
||||
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 300d, 0.58, 2.0) : 1;
|
||||
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 300d, 0.58, 2.0) : 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.05), 0.58, 1.95);
|
||||
var weights = isUltraCompact
|
||||
? new[] { 1.35, 2.55, 0.48, 0.6, 0.82 }
|
||||
: isCompact
|
||||
? new[] { 1.2, 2.45, 0.56, 0.7, 0.9 }
|
||||
: new[] { 1.1, 2.3, 0.62, 0.78, 0.95 };
|
||||
|
||||
if (titleNeedsTwoLines)
|
||||
{
|
||||
weights[0] += 0.36;
|
||||
weights[1] -= 0.21;
|
||||
weights[2] -= 0.08;
|
||||
weights[3] -= 0.07;
|
||||
}
|
||||
|
||||
if (dateNeedsTwoLines)
|
||||
{
|
||||
weights[4] += 0.42;
|
||||
weights[1] -= 0.23;
|
||||
weights[2] -= 0.10;
|
||||
weights[3] -= 0.09;
|
||||
}
|
||||
|
||||
weights[0] = Math.Max(0.92, weights[0]);
|
||||
weights[1] = Math.Max(1.45, weights[1]);
|
||||
weights[2] = Math.Max(0.34, weights[2]);
|
||||
weights[3] = Math.Max(0.44, weights[3]);
|
||||
weights[4] = Math.Max(0.72, weights[4]);
|
||||
|
||||
if (LayoutRoot.RowDefinitions.Count < 5)
|
||||
{
|
||||
return weights;
|
||||
}
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
LayoutRoot.RowDefinitions[i].Height = new GridLength(weights[i], GridUnitType.Star);
|
||||
}
|
||||
|
||||
return weights;
|
||||
}
|
||||
|
||||
private static int GetDisplayUnits(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var units = 0;
|
||||
foreach (var ch in text.Trim())
|
||||
{
|
||||
if (char.IsWhiteSpace(ch))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
units += ch > 0x7F ? 2 : 1;
|
||||
}
|
||||
|
||||
return units;
|
||||
}
|
||||
|
||||
private static double FitTextSize(
|
||||
string? text,
|
||||
FontWeight fontWeight,
|
||||
double preferredSize,
|
||||
double minSize,
|
||||
double maxWidth,
|
||||
double maxHeight,
|
||||
int maxLines,
|
||||
double lineHeightFactor)
|
||||
{
|
||||
var safeText = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
|
||||
var safeMaxWidth = Math.Max(1, maxWidth);
|
||||
var safeMaxHeight = Math.Max(1, maxHeight);
|
||||
var safeMaxLines = Math.Max(1, maxLines);
|
||||
|
||||
var probe = new TextBlock
|
||||
{
|
||||
Text = safeText,
|
||||
FontWeight = fontWeight,
|
||||
MaxLines = safeMaxLines,
|
||||
TextWrapping = safeMaxLines > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap
|
||||
};
|
||||
|
||||
for (var size = preferredSize; size >= minSize; size -= 0.5)
|
||||
{
|
||||
probe.FontSize = size;
|
||||
probe.LineHeight = size * lineHeightFactor;
|
||||
probe.Measure(new Size(safeMaxWidth, double.PositiveInfinity));
|
||||
var desired = probe.DesiredSize;
|
||||
if (desired.Width <= safeMaxWidth + 0.6 &&
|
||||
desired.Height <= safeMaxHeight + 0.6)
|
||||
{
|
||||
return size;
|
||||
}
|
||||
}
|
||||
|
||||
return minSize;
|
||||
}
|
||||
|
||||
private double ResolveScale(double width, double height)
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.56, 2.0);
|
||||
var widthScale = Math.Clamp(width / 220d, 0.5, 2.0);
|
||||
var heightScale = Math.Clamp(height / 220d, 0.5, 2.0);
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.02), 0.5, 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,8 @@ public interface IWeatherInfoAwareComponentWidget
|
||||
{
|
||||
void SetWeatherInfoService(IWeatherInfoService weatherInfoService);
|
||||
}
|
||||
|
||||
public interface IDesktopPageVisibilityAwareComponentWidget
|
||||
{
|
||||
void SetDesktopPageContext(bool isOnActivePage, bool isEditMode);
|
||||
}
|
||||
|
||||
@@ -825,10 +825,10 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
TopRowGrid.ColumnSpacing = Math.Clamp(7.5 * scaleX, 4, 13);
|
||||
|
||||
var availableHeight = Math.Max(80, innerHeight - (ContentGrid.RowSpacing * 2));
|
||||
var topZoneRatio = Math.Clamp(0.55 + ((1 - compactness) * 0.04), 0.52, 0.60);
|
||||
var bottomZoneRatio = Math.Clamp(0.29 - (compactness * 0.03), 0.24, 0.32);
|
||||
var topZoneHeight = Math.Clamp(availableHeight * topZoneRatio, 48, availableHeight - 28);
|
||||
var bottomZoneHeight = Math.Clamp(availableHeight * bottomZoneRatio, 26, availableHeight - topZoneHeight - 6);
|
||||
var topZoneRatio = Math.Clamp(0.52 + ((1 - compactness) * 0.03), 0.48, 0.56);
|
||||
var bottomZoneRatio = Math.Clamp(0.36 - (compactness * 0.02), 0.32, 0.40);
|
||||
var topZoneHeight = Math.Clamp(availableHeight * topZoneRatio, 44, availableHeight - 30);
|
||||
var bottomZoneHeight = Math.Clamp(availableHeight * bottomZoneRatio, 34, availableHeight - topZoneHeight - 6);
|
||||
if (topZoneHeight + bottomZoneHeight > availableHeight - 6)
|
||||
{
|
||||
bottomZoneHeight = Math.Max(24, availableHeight - topZoneHeight - 6);
|
||||
@@ -842,11 +842,11 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
ContentGrid.RowDefinitions[2].Height = new GridLength(bottomZoneHeight, GridUnitType.Pixel);
|
||||
}
|
||||
|
||||
var topScaleH = Math.Clamp(topZoneHeight / 112d, 0.60, 2.2);
|
||||
var topScaleH = Math.Clamp(topZoneHeight / 112d, 0.58, 2.2);
|
||||
var topScaleW = Math.Clamp(innerWidth / 288d, 0.60, 2.2);
|
||||
var topScale = Math.Clamp((topScaleH * 0.72) + (topScaleW * 0.28), 0.60, 2.2);
|
||||
var bottomScaleH = Math.Clamp(bottomZoneHeight / 94d, 0.52, 2.1);
|
||||
var bottomScale = Math.Clamp((bottomScaleH * 0.76) + (scaleX * 0.24), 0.52, 2.1);
|
||||
var topScale = Math.Clamp((topScaleH * 0.70) + (topScaleW * 0.30), 0.58, 2.2);
|
||||
var bottomScaleH = Math.Clamp(bottomZoneHeight / 80d, 0.62, 2.2);
|
||||
var bottomScale = Math.Clamp((bottomScaleH * 0.80) + (scaleX * 0.20), 0.62, 2.2);
|
||||
|
||||
var iconSize = Math.Clamp(
|
||||
Math.Max(52, topZoneHeight * 0.50) * (0.76 + (topScale * 0.24)),
|
||||
@@ -857,9 +857,9 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-5 * topScale, -12, 0), 0, 0);
|
||||
|
||||
TemperatureTextBlock.FontSize = Math.Clamp(
|
||||
Math.Max(56, topZoneHeight * 0.74) * (0.72 + (topScale * 0.28)),
|
||||
52,
|
||||
156);
|
||||
Math.Max(52, topZoneHeight * 0.69) * (0.74 + (topScale * 0.24)),
|
||||
50,
|
||||
146);
|
||||
TemperatureTextBlock.FontWeight = ToVariableWeight(310);
|
||||
TemperatureTextBlock.Margin = new Thickness(Math.Clamp(-2 * topScale, -5, 0), Math.Clamp(-8 * topScale, -14, -3), 0, 0);
|
||||
var temperatureMaxWidthLimit = Math.Max(90, innerWidth * 0.70);
|
||||
@@ -868,33 +868,57 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, ITime
|
||||
90,
|
||||
temperatureMaxWidthLimit);
|
||||
|
||||
BottomInfoStack.Spacing = Math.Clamp(1.0 * bottomScale, 0, 3);
|
||||
var bottomStackSpacing = Math.Clamp(1.2 * bottomScale, 1, 4);
|
||||
BottomInfoStack.Spacing = bottomStackSpacing;
|
||||
BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(1.8 * scaleY, 0, 4));
|
||||
BottomInfoStack.MaxHeight = Math.Max(24, bottomZoneHeight);
|
||||
BottomInfoStack.MaxHeight = Math.Max(32, bottomZoneHeight);
|
||||
|
||||
var bottomTextMaxWidth = Math.Min(innerWidth, Math.Max(48, innerWidth * 0.78));
|
||||
ConditionStack.Spacing = Math.Clamp(1.0 + (1.6 * bottomScale), 1, 5);
|
||||
var bottomTextMaxWidth = Math.Min(innerWidth, Math.Max(56, innerWidth * 0.84));
|
||||
var conditionStackSpacing = Math.Clamp(1.4 + (2.1 * bottomScale), 1.2, 7);
|
||||
ConditionStack.Spacing = conditionStackSpacing;
|
||||
ConditionStack.Margin = new Thickness(0);
|
||||
var infoFontSize = Math.Clamp(
|
||||
Math.Max(12, bottomZoneHeight * 0.30) * (0.78 + (bottomScale * 0.22)),
|
||||
12,
|
||||
34);
|
||||
var infoFontWeight = ToVariableWeight(560);
|
||||
var infoFontSizeRaw = Math.Clamp(
|
||||
Math.Max(14, bottomZoneHeight * 0.38) * (0.82 + (bottomScale * 0.24)),
|
||||
15,
|
||||
42);
|
||||
var infoFontSize = infoFontSizeRaw;
|
||||
const double infoLineHeightFactor = 1.10;
|
||||
var estimatedBottomUsedHeight =
|
||||
(infoFontSize * infoLineHeightFactor * 3) +
|
||||
conditionStackSpacing +
|
||||
bottomStackSpacing +
|
||||
2;
|
||||
if (estimatedBottomUsedHeight > bottomZoneHeight)
|
||||
{
|
||||
var shrink = Math.Clamp(bottomZoneHeight / estimatedBottomUsedHeight, 0.58, 1.0);
|
||||
infoFontSize = Math.Max(11, infoFontSize * shrink);
|
||||
conditionStackSpacing = Math.Max(0.8, conditionStackSpacing * shrink);
|
||||
bottomStackSpacing = Math.Max(0.8, bottomStackSpacing * shrink);
|
||||
ConditionStack.Spacing = conditionStackSpacing;
|
||||
BottomInfoStack.Spacing = bottomStackSpacing;
|
||||
}
|
||||
|
||||
var infoFontWeight = ToVariableWeight(590);
|
||||
ConditionTextBlock.FontSize = infoFontSize;
|
||||
ConditionTextBlock.FontWeight = infoFontWeight;
|
||||
ConditionTextBlock.LineHeight = infoFontSize * infoLineHeightFactor;
|
||||
ConditionTextBlock.MaxWidth = bottomTextMaxWidth;
|
||||
RangeTextBlock.FontSize = infoFontSize;
|
||||
RangeTextBlock.FontWeight = infoFontWeight;
|
||||
RangeTextBlock.LineHeight = infoFontSize * infoLineHeightFactor;
|
||||
RangeTextBlock.MaxWidth = bottomTextMaxWidth;
|
||||
|
||||
CityInfoBadge.Padding = new Thickness(0);
|
||||
CityInfoBadge.CornerRadius = new CornerRadius(0);
|
||||
CityInfoBadge.MaxWidth = bottomTextMaxWidth;
|
||||
LocationIcon.FontSize = Math.Clamp(
|
||||
Math.Max(8, bottomZoneHeight * 0.13) * (0.74 + (bottomScale * 0.20)),
|
||||
8,
|
||||
14);
|
||||
Math.Max(9, bottomZoneHeight * 0.16) * (0.76 + (bottomScale * 0.22)),
|
||||
9,
|
||||
18);
|
||||
LocationIcon.FontSize = Math.Min(LocationIcon.FontSize, infoFontSize * 0.72);
|
||||
CityTextBlock.FontSize = infoFontSize;
|
||||
CityTextBlock.FontWeight = infoFontWeight;
|
||||
CityTextBlock.LineHeight = infoFontSize * infoLineHeightFactor;
|
||||
CityTextBlock.MaxWidth = bottomTextMaxWidth;
|
||||
}
|
||||
|
||||
|
||||
@@ -934,6 +934,8 @@ public partial class MainWindow
|
||||
Grid.SetRowSpan(host, heightCells);
|
||||
pageGrid.Children.Add(host);
|
||||
}
|
||||
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
}
|
||||
|
||||
private void PlaceDesktopComponentOnPage(string componentId, int pageIndex, int row, int column)
|
||||
@@ -991,6 +993,7 @@ public partial class MainWindow
|
||||
pageGrid.Children.Add(host);
|
||||
|
||||
_desktopComponentPlacements.Add(placement);
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
PersistSettings();
|
||||
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
@@ -1291,6 +1294,45 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDesktopPageAwareComponentContext()
|
||||
{
|
||||
var activeDesktopPageIndex = _isSettingsOpen ? -1 : _currentDesktopSurfaceIndex;
|
||||
var isEditMode = _isComponentLibraryOpen || _isSettingsOpen;
|
||||
|
||||
foreach (var pair in _desktopPageComponentGrids)
|
||||
{
|
||||
var isOnActivePage = pair.Key == activeDesktopPageIndex;
|
||||
foreach (var host in pair.Value.Children.OfType<Border>())
|
||||
{
|
||||
if (!host.Classes.Contains(DesktopComponentHostClass))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryGetContentHost(host)?.Child is Control componentRoot)
|
||||
{
|
||||
ApplyDesktopPageContext(componentRoot, isOnActivePage, isEditMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyDesktopPageContext(Control root, bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
if (root is IDesktopPageVisibilityAwareComponentWidget awareRoot)
|
||||
{
|
||||
awareRoot.SetDesktopPageContext(isOnActivePage, isEditMode);
|
||||
}
|
||||
|
||||
foreach (var descendant in root.GetVisualDescendants())
|
||||
{
|
||||
if (descendant is IDesktopPageVisibilityAwareComponentWidget awareChild)
|
||||
{
|
||||
awareChild.SetDesktopPageContext(isOnActivePage, isEditMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Border? TryGetResizeHandle(Border host)
|
||||
{
|
||||
if (host.Child is Grid hostChrome)
|
||||
@@ -1368,6 +1410,8 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
}
|
||||
|
||||
private void ApplyDesktopEditStateToHost(Border host, bool isEditMode)
|
||||
|
||||
@@ -281,6 +281,8 @@ public partial class MainWindow
|
||||
{
|
||||
CloseLauncherFolderOverlay();
|
||||
}
|
||||
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
}
|
||||
|
||||
private void MoveSurfaceBy(int delta)
|
||||
|
||||
@@ -1769,6 +1769,7 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
_isSettingsOpen = true;
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
UpdateAdaptiveTextSystem();
|
||||
ApplyWallpaperBrush();
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
@@ -1805,6 +1806,7 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
_isSettingsOpen = false;
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
UpdateAdaptiveTextSystem();
|
||||
ApplyWallpaperBrush();
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
|
||||
Reference in New Issue
Block a user