diff --git a/LanMountainDesktop/Views/Components/BrowserWidget.axaml.cs b/LanMountainDesktop/Views/Components/BrowserWidget.axaml.cs index 96ef55f..6fc4b51 100644 --- a/LanMountainDesktop/Views/Components/BrowserWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/BrowserWidget.axaml.cs @@ -1,4 +1,4 @@ -using System; +using System; using Avalonia; using Avalonia.Controls; using Avalonia.Input; @@ -12,7 +12,7 @@ using WebViewCore.Events; namespace LanMountainDesktop.Views.Components; public partial class BrowserWidget : UserControl, IDesktopComponentWidget - , IDesktopPageVisibilityAwareComponentWidget + , IDesktopPageVisibilityAwareComponentWidget, IDisposable { private static readonly Uri DefaultHomeUri = new("https://www.bing.com"); private double _currentCellSize = 48; @@ -22,6 +22,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget private bool _isEditMode; private bool _isWebViewActive = true; private readonly WebView2RuntimeAvailability _runtimeAvailability; + private bool _isDisposed; public BrowserWidget() { @@ -48,6 +49,26 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget NavigateTo(DefaultHomeUri); } + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + SizeChanged -= OnSizeChanged; + ActualThemeVariantChanged -= OnActualThemeVariantChanged; + AttachedToVisualTree -= OnAttachedToVisualTree; + DetachedFromVisualTree -= OnDetachedFromVisualTree; + + if (_runtimeAvailability.IsAvailable) + { + BrowserWebView.NavigationStarting -= OnBrowserWebViewNavigationStarting; + } + } + public void ApplyCellSize(double cellSize) { _currentCellSize = Math.Max(1, cellSize); diff --git a/LanMountainDesktop/Views/Components/DailyPoetryWidget.axaml b/LanMountainDesktop/Views/Components/DailyPoetryWidget.axaml index 6ea22b5..a8b036b 100644 --- a/LanMountainDesktop/Views/Components/DailyPoetryWidget.axaml +++ b/LanMountainDesktop/Views/Components/DailyPoetryWidget.axaml @@ -1,4 +1,4 @@ - - + - + + - + + - + diff --git a/LanMountainDesktop/Views/Components/DailyPoetryWidget.axaml.cs b/LanMountainDesktop/Views/Components/DailyPoetryWidget.axaml.cs index 98d7a4a..eec7838 100644 --- a/LanMountainDesktop/Views/Components/DailyPoetryWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/DailyPoetryWidget.axaml.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -45,8 +45,8 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I private const double BaseCellSize = 48d; private const int BaseWidthCells = 4; private const int BaseHeightCells = 2; - private const double MinPoetryFontSize = 12; - private const double MinAuthorFontSize = 10.5; + private const double MinPoetryFontSize = 8; + private const double MinAuthorFontSize = 7; private readonly record struct TextFitResult(double FontSize, FontWeight FontWeight, double LineHeight); @@ -109,7 +109,6 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I 0, 0); - AuthorPanel.Margin = new Thickness(0, Math.Clamp(5 * scale, 2, 10), Math.Clamp(4 * scale, 2, 8), 0); AuthorAccent.Width = Math.Clamp(6 * scale, 3.2, 9.5); AuthorAccent.Height = Math.Clamp(24 * scale, 12, 34); AuthorAccent.Margin = new Thickness(0, 0, Math.Clamp(8 * scale, 4, 13), 0); @@ -351,11 +350,6 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I AuthorTextBlock.Foreground = CreateBrush("#F4D7A7"); AuthorAccent.Background = CreateBrush("#63F2AF90"); - AuthorPanel.Margin = new Thickness( - 0, - Math.Clamp(6 * scale, 2, 10), - Math.Clamp(6 * scale, 2, 10), - Math.Clamp(1 * scale, 0, 3)); DayDecorationCanvas.IsVisible = false; RefreshButton.IsVisible = true; @@ -380,11 +374,6 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I AuthorTextBlock.Foreground = CreateBrush("#272D38"); AuthorAccent.Background = CreateBrush("#C8090D"); - AuthorPanel.Margin = new Thickness( - 0, - Math.Clamp(6 * scale, 2, 10), - Math.Clamp(6 * scale, 2, 10), - Math.Clamp(2 * scale, 0, 4)); DayDecorationCanvas.IsVisible = true; RefreshButton.IsVisible = true; @@ -475,83 +464,19 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I DayDecorationCanvas.IsVisible = showDayDecorations; RefreshButton.IsVisible = true; - var refreshReservedWidth = RefreshButton.Width + Math.Clamp(8 * scale, 5, 14); - var decorationReservedWidth = showDayDecorations - ? Math.Clamp(innerWidth * 0.24, 34, 96) - : 0; - var quoteReservedWidth = QuoteMarkTextBlock.IsVisible - ? Math.Clamp(10 * scale, 5, 16) - : 0; - var poemReservedRight = Math.Max(refreshReservedWidth, decorationReservedWidth); - var poemWidth = innerWidth - poemReservedRight - quoteReservedWidth; - var poemMinWidth = Math.Max(66, innerWidth * 0.56); - if (poemWidth < poemMinWidth) - { - poemWidth = poemMinWidth; - } - poemWidth = Math.Min(Math.Max(64, poemWidth), innerWidth); + var refreshButtonWidth = 42 + Math.Clamp(8 * scale, 5, 14); + var quoteMarkWidth = QuoteMarkTextBlock.IsVisible ? Math.Clamp(10 * scale, 5, 16) : 0; + + var poemWidth = innerWidth - quoteMarkWidth - Math.Clamp(12 * scale, 6, 20); + poemWidth = Math.Min(Math.Max(64, poemWidth), innerWidth - Math.Clamp(16 * scale, 8, 24)); - var authorMaxLines = innerWidth < Math.Max(_currentCellSize * 5.2, 252) ? 2 : 1; - var authorUnitsTarget = authorMaxLines == 1 ? 20 : 12; - var authorWidth = Math.Max(72, Math.Min(innerWidth * (isNightMode ? 0.5 : 0.56), innerWidth - 8)); - var authorPrepared = PrepareAuthorText(_authorRawText, authorUnitsTarget, authorMaxLines); - var authorPreferredFontSize = Math.Clamp((isNightMode ? 25 : 23) * scale, 12, 34); - var authorMinFontSize = Math.Clamp(authorPreferredFontSize * 0.72, MinAuthorFontSize, authorPreferredFontSize); - var authorMinWeight = isNightMode ? 500 : 470; - var authorMaxWeight = isNightMode ? 650 : 600; - authorPrepared = EnsureTextFitsAtMinSize( - preparedText: authorPrepared, - sourceText: _authorRawText, - targetUnits: authorUnitsTarget, - maxLines: authorMaxLines, - maxWidth: authorWidth, - maxHeight: Math.Max(20, innerHeight * (authorMaxLines > 1 ? 0.38 : 0.28)), - minFontSize: authorMinFontSize, - minFontWeight: ToVariableWeight(authorMinWeight), - lineHeightFactor: 1.12); - - var authorFit = FitTextStable( - authorPrepared, - authorWidth, - Math.Max(20, innerHeight * (authorMaxLines > 1 ? 0.38 : 0.28)), - minFontSize: authorMinFontSize, - maxFontSize: Math.Clamp(authorPreferredFontSize * 1.15, authorMinFontSize, 42), - maxLines: authorMaxLines, - lineHeightFactor: 1.12, - minWeight: authorMinWeight, - maxWeight: authorMaxWeight); - - AuthorTextBlock.Text = authorPrepared; - AuthorTextBlock.TextWrapping = authorMaxLines > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap; - AuthorTextBlock.MaxLines = authorMaxLines; - AuthorTextBlock.MaxWidth = authorWidth; - AuthorTextBlock.FontSize = authorFit.FontSize; - AuthorTextBlock.LineHeight = authorFit.LineHeight; - AuthorTextBlock.FontWeight = authorFit.FontWeight; - AuthorPanel.MaxWidth = authorWidth + AuthorAccent.Width + AuthorAccent.Margin.Right + Math.Clamp(4 * scale, 2, 8); - - var authorMeasured = MeasureTextSize( - authorPrepared, - authorFit.FontSize, - authorFit.FontWeight, - authorWidth, - authorFit.LineHeight); - var authorHeight = Math.Min(authorMeasured.Height, authorFit.LineHeight * authorMaxLines); - var authorBlockHeight = Math.Max(authorHeight, AuthorAccent.Height) + - AuthorPanel.Margin.Top + - AuthorPanel.Margin.Bottom + - Math.Clamp(4 * scale, 2, 8); - - var poemMaxLines = innerHeight < _currentCellSize * 1.58 - ? 4 - : innerHeight < _currentCellSize * 2.05 - ? 3 - : 2; + var poemMaxLines = 2; var poemUnitsTarget = EstimateTargetUnitsPerLine(poemWidth, scale, isNightMode); var poemPrepared = PreparePoetryText(_poetryRawText, poemUnitsTarget, poemMaxLines); - var poemHeight = Math.Max(30, innerHeight - authorBlockHeight); - var poemPreferredFontSize = Math.Clamp((isNightMode ? 34 : 32) * scale, 16, 56); - var poemMinFontSize = Math.Clamp(poemPreferredFontSize * 0.72, MinPoetryFontSize, poemPreferredFontSize); + + var availablePoemHeight = innerHeight * 0.72; + var poemPreferredFontSize = Math.Clamp((isNightMode ? 34 : 32) * scale, 14, 56); + var poemMinFontSize = Math.Clamp(poemPreferredFontSize * 0.65, MinPoetryFontSize, poemPreferredFontSize); var poemMinWeight = isNightMode ? 540 : 500; var poemMaxWeight = isNightMode ? 760 : 680; poemPrepared = EnsureTextFitsAtMinSize( @@ -560,19 +485,19 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I targetUnits: poemUnitsTarget, maxLines: poemMaxLines, maxWidth: poemWidth, - maxHeight: poemHeight, + maxHeight: availablePoemHeight, minFontSize: poemMinFontSize, minFontWeight: ToVariableWeight(poemMinWeight), - lineHeightFactor: 1.1); + lineHeightFactor: 1.12); var poemFit = FitTextStable( poemPrepared, poemWidth, - poemHeight, + availablePoemHeight, minFontSize: poemMinFontSize, maxFontSize: Math.Clamp(poemPreferredFontSize * 1.20, poemMinFontSize, 62), maxLines: poemMaxLines, - lineHeightFactor: 1.10, + lineHeightFactor: 1.12, minWeight: poemMinWeight, maxWeight: poemMaxWeight); @@ -582,6 +507,43 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I PoetryContentTextBlock.FontSize = poemFit.FontSize; PoetryContentTextBlock.LineHeight = poemFit.LineHeight; PoetryContentTextBlock.FontWeight = poemFit.FontWeight; + + var authorWidth = Math.Max(72, Math.Min(innerWidth * (isNightMode ? 0.5 : 0.56), innerWidth - 8)); + var authorUnitsTarget = 20; + var authorPrepared = PrepareAuthorText(_authorRawText, authorUnitsTarget, 1); + var authorPreferredFontSize = Math.Clamp((isNightMode ? 25 : 23) * scale, 10, 34); + var authorMinFontSize = Math.Clamp(authorPreferredFontSize * 0.65, MinAuthorFontSize, authorPreferredFontSize); + var authorMinWeight = isNightMode ? 500 : 470; + var authorMaxWeight = isNightMode ? 650 : 600; + authorPrepared = EnsureTextFitsAtMinSize( + preparedText: authorPrepared, + sourceText: _authorRawText, + targetUnits: authorUnitsTarget, + maxLines: 1, + maxWidth: authorWidth, + maxHeight: AuthorAccent.Height, + minFontSize: authorMinFontSize, + minFontWeight: ToVariableWeight(authorMinWeight), + lineHeightFactor: 1.12); + + var authorFit = FitTextStable( + authorPrepared, + authorWidth, + AuthorAccent.Height, + minFontSize: authorMinFontSize, + maxFontSize: Math.Clamp(authorPreferredFontSize * 1.15, authorMinFontSize, 42), + maxLines: 1, + lineHeightFactor: 1.12, + minWeight: authorMinWeight, + maxWeight: authorMaxWeight); + + AuthorTextBlock.Text = authorPrepared; + AuthorTextBlock.TextWrapping = TextWrapping.NoWrap; + AuthorTextBlock.MaxLines = 1; + AuthorTextBlock.MaxWidth = authorWidth; + AuthorTextBlock.FontSize = authorFit.FontSize; + AuthorTextBlock.LineHeight = authorFit.LineHeight; + AuthorTextBlock.FontWeight = authorFit.FontWeight; } private void UpdateRefreshButtonState() diff --git a/LanMountainDesktop/Views/Components/RecordingWidget.axaml.cs b/LanMountainDesktop/Views/Components/RecordingWidget.axaml.cs index 37ef82b..b968868 100644 --- a/LanMountainDesktop/Views/Components/RecordingWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/RecordingWidget.axaml.cs @@ -15,7 +15,7 @@ using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.Components; -public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget +public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IDisposable { private const int WaveBarCount = 22; @@ -38,6 +38,7 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDe private bool _isOnActivePage = true; private bool _pausedStudyMonitoringForRecording; private bool _isNightVisual = true; + private bool _isDisposed; public RecordingWidget() { @@ -612,4 +613,23 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDe return (false, path.LocalPath); } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + _uiTimer.Stop(); + _uiTimer.Tick -= OnUiTick; + AttachedToVisualTree -= OnAttachedToVisualTree; + DetachedFromVisualTree -= OnDetachedFromVisualTree; + SizeChanged -= OnSizeChanged; + ActualThemeVariantChanged -= OnActualThemeVariantChanged; + + _audioRecorderService.Dispose(); + } } diff --git a/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs index 234bd31..aa85f1d 100644 --- a/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; using Avalonia; using Avalonia.Controls; @@ -9,7 +9,7 @@ using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.Components; -public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget +public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IDisposable { private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault(); @@ -27,6 +27,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg private string _languageCode = "zh-CN"; private bool _isAttached; private bool _isOnActivePage = true; + private bool _isDisposed; private IDisposable? _monitoringLease; public StudyEnvironmentWidget() @@ -329,4 +330,23 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg return CreateBrush(fallbackHex); } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + _uiTimer.Stop(); + _uiTimer.Tick -= OnUiTimerTick; + AttachedToVisualTree -= OnAttachedToVisualTree; + DetachedFromVisualTree -= OnDetachedFromVisualTree; + SizeChanged -= OnSizeChanged; + + _monitoringLease?.Dispose(); + _monitoringLease = null; + } } diff --git a/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs b/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs index 059fb26..d8ff30c 100644 --- a/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs +++ b/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs @@ -14,10 +14,10 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control private static readonly Pen GridPen = new(GridBrush, 1); private static readonly Pen AxisPen = new(AxisBrush, 1.1); - private static readonly IBrush QuietPointBrush = new SolidColorBrush(Color.Parse("#FF34D399")); - private static readonly IBrush NormalPointBrush = new SolidColorBrush(Color.Parse("#FF60A5FA")); - private static readonly IBrush NoisyPointBrush = new SolidColorBrush(Color.Parse("#FFF59E0B")); - private static readonly IBrush ExtremePointBrush = new SolidColorBrush(Color.Parse("#FFEF4444")); + private static readonly IBrush QuietBrush = new SolidColorBrush(Color.Parse("#FF34D399")); + private static readonly IBrush NormalBrush = new SolidColorBrush(Color.Parse("#FF60A5FA")); + private static readonly IBrush NoisyBrush = new SolidColorBrush(Color.Parse("#FFF59E0B")); + private static readonly IBrush ExtremeBrush = new SolidColorBrush(Color.Parse("#FFEF4444")); private IReadOnlyList _points = Array.Empty(); private double _baselineDb = 45; @@ -47,34 +47,102 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control DrawGrid(context, plot); - if (_points.Count == 0) + if (_points.Count < 2) { return; } + DrawElectronCloud(context, plot); + } + + private void DrawElectronCloud(DrawingContext context, Rect plot) + { var start = _points[0].Timestamp; var end = _points[^1].Timestamp; var totalTicks = Math.Max(1, (end - start).Ticks); - var maxRenderPoints = Math.Clamp((int)Math.Floor(plot.Width * 1.5), 80, 520); - var step = Math.Max(1, _points.Count / Math.Max(1, maxRenderPoints)); - var radius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 88d, 1.4, 3.8); - - for (var i = 0; i < _points.Count; i += step) + var pointCount = _points.Count; + var cloudLayers = 8; + var baseRadius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 45d, 3, 12); + + var sortedPoints = new List<(double X, double Y, NoiseDistributionLevel Level)>(); + for (var i = 0; i < pointCount; i++) { var point = _points[i]; - var level = ResolveLevel(point.DisplayDb, _baselineDb); var x = MapX(plot, point.Timestamp, start, totalTicks); - var y = MapY(plot, level, point.Timestamp); - context.DrawEllipse(GetLevelBrush(level), pen: null, center: new Point(x, y), radiusX: radius, radiusY: radius); + var y = MapYContinuous(plot, point.DisplayDb); + var level = ResolveLevel(point.DisplayDb, _baselineDb); + sortedPoints.Add((x, y, level)); + } + + sortedPoints.Sort((a, b) => a.X.CompareTo(b.X)); + + for (var layer = cloudLayers - 1; layer >= 0; layer--) + { + var layerRatio = (double)layer / (cloudLayers - 1); + var layerRadius = baseRadius * (1.2 + layerRatio * 0.8); + var layerAlpha = (byte)(40 + layerRatio * 25); + + foreach (var pt in sortedPoints) + { + var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha); + var jitterX = ComputeJitter(pt.X * 1000 + layer) * layerRadius * 0.3; + var jitterY = ComputeJitter(pt.Y * 1000 + layer) * layerRadius * 0.3; + + context.DrawEllipse( + brush, + pen: null, + center: new Point(pt.X + jitterX, pt.Y + jitterY), + radiusX: layerRadius, + radiusY: layerRadius * 0.7); + } + } + + var glowLayers = 5; + for (var layer = glowLayers - 1; layer >= 0; layer--) + { + var layerRatio = (double)layer / (glowLayers - 1); + var layerRadius = baseRadius * (0.8 + layerRatio * 0.6); + var layerAlpha = (byte)(20 + layerRatio * 15); + + foreach (var pt in sortedPoints) + { + var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha); + context.DrawEllipse( + brush, + pen: null, + center: new Point(pt.X, pt.Y), + radiusX: layerRadius, + radiusY: layerRadius * 0.6); + } } - // Ensure latest point is always visible. var latest = _points[^1]; - var latestLevel = ResolveLevel(latest.DisplayDb, _baselineDb); var latestX = MapX(plot, latest.Timestamp, start, totalTicks); - var latestY = MapY(plot, latestLevel, latest.Timestamp); - context.DrawEllipse(GetLevelBrush(latestLevel), pen: new Pen(Brushes.White, 1), center: new Point(latestX, latestY), radiusX: radius + 0.8, radiusY: radius + 0.8); + var latestY = MapYContinuous(plot, latest.DisplayDb); + var latestLevel = ResolveLevel(latest.DisplayDb, _baselineDb); + + for (var i = 3; i >= 0; i--) + { + var radius = baseRadius * (1.5 + i * 0.8); + var alpha = (byte)(30 - i * 6); + var glowBrush = GetLevelBrushWithAlpha(latestLevel, alpha); + context.DrawEllipse(glowBrush, null, new Point(latestX, latestY), radius, radius * 0.6); + } + + context.DrawEllipse( + GetLevelBrush(latestLevel), + new Pen(Brushes.White, 1.5), + new Point(latestX, latestY), + baseRadius + 1, + baseRadius * 0.7 + 1); + + context.DrawEllipse( + Brushes.White, + null, + new Point(latestX, latestY), + 2, + 2); } private static void DrawGrid(DrawingContext context, Rect plot) @@ -103,34 +171,28 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control return plot.Left + plot.Width * (offsetTicks / (double)totalTicks); } - private static double MapY(Rect plot, NoiseDistributionLevel level, DateTimeOffset timestamp) + private double MapYContinuous(Rect plot, double displayDb) { - // 4 bands: quiet(bottom) -> extreme(top). Add deterministic jitter in each band. - var bandHeight = plot.Height / 4d; - var levelIndex = level switch - { - NoiseDistributionLevel.Quiet => 0, - NoiseDistributionLevel.Normal => 1, - NoiseDistributionLevel.Noisy => 2, - NoiseDistributionLevel.Extreme => 3, - _ => 1 - }; + var minDb = _baselineDb - 5; + var maxDb = _baselineDb + 25; + var dbRange = maxDb - minDb; + if (dbRange <= 0) dbRange = 30; - var centerY = plot.Bottom - ((levelIndex + 0.5) * bandHeight); - var jitter = ComputeJitter(timestamp.Ticks) * bandHeight * 0.26; - return Math.Clamp(centerY + jitter, plot.Top + 1.5, plot.Bottom - 1.5); + var normalizedDb = (displayDb - minDb) / dbRange; + normalizedDb = Math.Clamp(normalizedDb, 0, 1); + + return plot.Bottom - (normalizedDb * plot.Height); } - private static double ComputeJitter(long ticks) + private static double ComputeJitter(double value) { - // Deterministic pseudo-random value in [-1, 1] to avoid overlap without animation noise. - var value = (ulong)ticks; - value ^= value >> 33; - value *= 0xff51afd7ed558ccdUL; - value ^= value >> 33; - value *= 0xc4ceb9fe1a85ec53UL; - value ^= value >> 33; - var normalized = (value & 0xFFFF) / 65535d; + var hash = (ulong)(value * 1000000); + hash ^= hash >> 33; + hash *= 0xff51afd7ed558ccdUL; + hash ^= hash >> 33; + hash *= 0xc4ceb9fe1a85ec53UL; + hash ^= hash >> 33; + var normalized = (hash & 0xFFFF) / 65535d; return (normalized * 2d) - 1d; } @@ -162,11 +224,23 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control { return level switch { - NoiseDistributionLevel.Quiet => QuietPointBrush, - NoiseDistributionLevel.Normal => NormalPointBrush, - NoiseDistributionLevel.Noisy => NoisyPointBrush, - NoiseDistributionLevel.Extreme => ExtremePointBrush, - _ => NormalPointBrush + NoiseDistributionLevel.Quiet => QuietBrush, + NoiseDistributionLevel.Normal => NormalBrush, + NoiseDistributionLevel.Noisy => NoisyBrush, + NoiseDistributionLevel.Extreme => ExtremeBrush, + _ => NormalBrush + }; + } + + private static IBrush GetLevelBrushWithAlpha(NoiseDistributionLevel level, byte alpha) + { + return level switch + { + NoiseDistributionLevel.Quiet => new SolidColorBrush(Color.FromArgb(alpha, 0x34, 0xD3, 0x99)), + NoiseDistributionLevel.Normal => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA)), + NoiseDistributionLevel.Noisy => new SolidColorBrush(Color.FromArgb(alpha, 0xF5, 0x9E, 0x0B)), + NoiseDistributionLevel.Extreme => new SolidColorBrush(Color.FromArgb(alpha, 0xEF, 0x44, 0x44)), + _ => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA)) }; } } diff --git a/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs index a2bf6a8..b171f4f 100644 --- a/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -12,7 +12,7 @@ using LanMountainDesktop.Theme; namespace LanMountainDesktop.Views.Components; -public partial class StudyNoiseDistributionWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget +public partial class StudyNoiseDistributionWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IDisposable { private static readonly Color[] ValueColorCandidates = { @@ -46,13 +46,14 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone private readonly LocalizationService _localizationService = new(); private readonly DispatcherTimer _uiTimer = new() { - Interval = TimeSpan.FromMilliseconds(250) + Interval = TimeSpan.FromMilliseconds(100) }; private double _currentCellSize = 48; private string _languageCode = "zh-CN"; private bool _isAttached; private bool _isOnActivePage = true; + private bool _isDisposed; private bool _isCompactMode; private bool _isUltraCompactMode; private IDisposable? _monitoringLease; @@ -604,6 +605,25 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone { return _localizationService.GetString(_languageCode, key, fallback); } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + _uiTimer.Stop(); + _uiTimer.Tick -= OnUiTimerTick; + AttachedToVisualTree -= OnAttachedToVisualTree; + DetachedFromVisualTree -= OnDetachedFromVisualTree; + SizeChanged -= OnSizeChanged; + + _monitoringLease?.Dispose(); + _monitoringLease = null; + } } diff --git a/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs index 9a49223..6a6478f 100644 --- a/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudySessionControlWidget.axaml.cs @@ -11,7 +11,7 @@ using Material.Icons; namespace LanMountainDesktop.Views.Components; -public partial class StudySessionControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget +public partial class StudySessionControlWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IDisposable { private static readonly Color[] PrimaryColorCandidates = { @@ -61,6 +61,7 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW private string _languageCode = "zh-CN"; private bool _isAttached; private bool _isOnActivePage = true; + private bool _isDisposed; private bool _isCompactMode; private bool _isUltraCompactMode; private IDisposable? _monitoringLease; @@ -468,4 +469,20 @@ public partial class StudySessionControlWidget : UserControl, IDesktopComponentW { return _localizationService.GetString(_languageCode, key, fallback); } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + _uiTimer.Stop(); + _uiTimer.Tick -= OnUiTimerTick; + AttachedToVisualTree -= OnAttachedToVisualTree; + DetachedFromVisualTree -= OnDetachedFromVisualTree; + SizeChanged -= OnSizeChanged; + } } diff --git a/LanMountainDesktop/Views/Components/WeatherClockWidget.axaml.cs b/LanMountainDesktop/Views/Components/WeatherClockWidget.axaml.cs index 307b5d1..14fef34 100644 --- a/LanMountainDesktop/Views/Components/WeatherClockWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/WeatherClockWidget.axaml.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -69,6 +69,7 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, AttachedToVisualTree += OnAttachedToVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree; SizeChanged += OnSizeChanged; + ActualThemeVariantChanged += OnActualThemeVariantChanged; InitializeDialIfNeeded(); InitializeHandsIfNeeded(); @@ -238,6 +239,12 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, ApplyCellSize(_currentCellSize); } + private void OnActualThemeVariantChanged(object? sender, EventArgs e) + { + _isNightModeApplied = null; + ApplyModeVisualIfNeeded(); + } + private void OnClockTimerTick(object? sender, EventArgs e) { UpdateClockVisual(); diff --git a/LanMountainDesktop/packaging/linux/install.sh b/LanMountainDesktop/packaging/linux/install.sh index 60ff10e..f8d0514 100644 --- a/LanMountainDesktop/packaging/linux/install.sh +++ b/LanMountainDesktop/packaging/linux/install.sh @@ -11,6 +11,74 @@ ICONS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps" DESKTOP_TARGET="$APPLICATIONS_DIR/LanMountainDesktop.desktop" ICON_TARGET="$ICONS_DIR/lanmountaindesktop.png" +check_audio_dependencies() { + MISSING_DEPS="" + + if command -v dpkg >/dev/null 2>&1; then + if ! dpkg -s libportaudio2 >/dev/null 2>&1; then + MISSING_DEPS="$MISSING_DEPS libportaudio2" + fi + if ! dpkg -s libasound2 >/dev/null 2>&1; then + MISSING_DEPS="$MISSING_DEPS libasound2" + fi + elif command -v rpm >/dev/null 2>&1; then + if ! rpm -q portaudio-libs >/dev/null 2>&1; then + MISSING_DEPS="$MISSING_DEPS portaudio-libs" + fi + if ! rpm -q alsa-lib >/dev/null 2>&1; then + MISSING_DEPS="$MISSING_DEPS alsa-lib" + fi + elif command -v pacman >/dev/null 2>&1; then + if ! pacman -Q portaudio >/dev/null 2>&1; then + MISSING_DEPS="$MISSING_DEPS portaudio" + fi + if ! pacman -Q alsa-lib >/dev/null 2>&1; then + MISSING_DEPS="$MISSING_DEPS alsa-lib" + fi + elif command -v apk >/dev/null 2>&1; then + if ! apk -e info portaudio >/dev/null 2>&1; then + MISSING_DEPS="$MISSING_DEPS portaudio" + fi + if ! apk -e info alsa-lib >/dev/null 2>&1; then + MISSING_DEPS="$MISSING_DEPS alsa-lib" + fi + fi + + if [ -n "$MISSING_DEPS" ]; then + return 1 + fi + return 0 +} + +install_audio_dependencies() { + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y libportaudio2 libasound2 + elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y portaudio-libs alsa-lib + elif command -v yum >/dev/null 2>&1; then + sudo yum install -y portaudio-libs alsa-lib + elif command -v pacman >/dev/null 2>&1; then + sudo pacman -S --noconfirm portaudio alsa-lib + elif command -v apk >/dev/null 2>&1; then + sudo apk add portaudio alsa-lib + else + printf '%s\n' "Warning: Could not detect package manager. Please install audio dependencies manually:" + printf '%s\n' " - libportaudio2 (or portaudio-libs/portaudio)" + printf '%s\n' " - libasound2 (or alsa-lib)" + fi +} + +if ! check_audio_dependencies; then + printf '%s\n' "Installing audio dependencies for recording features..." + install_audio_dependencies + + if ! check_audio_dependencies; then + printf '%s\n' "Warning: Audio dependencies may not be installed correctly." + printf '%s\n' "Recording and study monitoring features may not work properly." + fi +fi + mkdir -p "$APPLICATIONS_DIR" "$ICONS_DIR" cp "$ICON_SOURCE" "$ICON_TARGET" diff --git a/run.md b/run.md index 5a49597..9f13479 100644 --- a/run.md +++ b/run.md @@ -26,3 +26,29 @@ dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj - 启动失败提示 SDK 版本不匹配:确认 `dotnet --info` 中已安装 .NET 10 SDK。 - 桌面端视频相关能力异常:优先在 Windows 环境下验证。 - 配置重置:删除 `%LOCALAPPDATA%\LanMountainDesktop\settings.json` 后重启应用。 + +## 6. Linux 音频功能依赖 + +如果在 Linux 上使用录音机组件或自习监测组件,需要安装以下音频库: + +### Debian/Ubuntu +```bash +sudo apt install libportaudio2 libasound2 +``` + +### Fedora/RHEL +```bash +sudo dnf install portaudio-libs alsa-lib +``` + +### Arch Linux +```bash +sudo pacman -S portaudio alsa-lib +``` + +### Alpine Linux +```bash +sudo apk add portaudio alsa-lib +``` + +> 注:如果未安装这些依赖,录音和自习监测功能将不可用,但应用其他功能可以正常运行。