using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.Styling; using Avalonia.Threading; using LanMountainDesktop.Models; using LanMountainDesktop.Services; namespace LanMountainDesktop.Views.Components; public partial class RecordingWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IDisposable { private const int WaveBarCount = 22; private readonly DispatcherTimer _uiTimer = new() { Interval = TimeSpan.FromMilliseconds(96) }; private readonly IAudioRecorderService _audioRecorderService = AudioRecorderServiceFactory.CreateRecorder(); private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings; private readonly LocalizationService _localizationService = new(); private readonly List _waveBars = []; private readonly double[] _waveLevels = new double[WaveBarCount]; private string _languageCode = "zh-CN"; private string _lastSavedFilePath = string.Empty; private double _currentCellSize = 48; private bool _isAttached; private bool _isOnActivePage = true; private bool _pausedStudyMonitoringForRecording; private bool _isNightVisual = true; private bool _isDisposed; public RecordingWidget() { InitializeComponent(); _uiTimer.Tick += OnUiTick; AttachedToVisualTree += OnAttachedToVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree; SizeChanged += OnSizeChanged; ActualThemeVariantChanged += OnActualThemeVariantChanged; InitializeWaveBars(); ReloadLanguageCode(); ApplyCellSize(_currentCellSize); RefreshVisual(); } public void ApplyCellSize(double cellSize) { _currentCellSize = Math.Max(1, cellSize); var rawScale = ResolveScale(); var chromeScale = Math.Clamp(rawScale, 0.62, 2.0); var contentScale = Math.Clamp(rawScale, 0.74, 1.0); var rootRadius = ComponentChromeCornerRadiusHelper.Scale(34 * chromeScale, 16, 56); RootBorder.CornerRadius = rootRadius; RootBorder.Padding = new Thickness(0); RecorderCardBorder.CornerRadius = rootRadius; RecorderContentGrid.Margin = new Thickness( ComponentChromeCornerRadiusHelper.SafeValue(24 * contentScale, 14, 26), ComponentChromeCornerRadiusHelper.SafeValue(18 * contentScale, 10, 22), ComponentChromeCornerRadiusHelper.SafeValue(24 * contentScale, 14, 26), ComponentChromeCornerRadiusHelper.SafeValue(18 * contentScale, 10, 24)); var sideButtonSize = Math.Clamp(54 * contentScale, 34, 58); DiscardButtonBorder.Width = sideButtonSize; DiscardButtonBorder.Height = sideButtonSize; DiscardButtonBorder.CornerRadius = new CornerRadius(sideButtonSize / 2d); DiscardIcon.FontSize = Math.Clamp(20 * contentScale, 14, 22); SaveButtonBorder.Width = sideButtonSize; SaveButtonBorder.Height = sideButtonSize; SaveButtonBorder.CornerRadius = new CornerRadius(sideButtonSize / 2d); SaveIcon.FontSize = Math.Clamp(22 * contentScale, 15, 24); var centerButtonSize = Math.Clamp(68 * contentScale, 42, 72); RecordToggleButtonBorder.Width = centerButtonSize; RecordToggleButtonBorder.Height = centerButtonSize; RecordToggleButtonBorder.CornerRadius = new CornerRadius(centerButtonSize / 2d); var centerIconSize = Math.Clamp(20 * contentScale, 14, 24); PauseGlyphIcon.FontSize = centerIconSize; PlayGlyphIcon.FontSize = centerIconSize; var recordDotSize = Math.Clamp(15 * contentScale, 10, 16); RecordDot.Width = recordDotSize; RecordDot.Height = recordDotSize; WaveformRowGrid.Margin = new Thickness(0, Math.Clamp(12 * contentScale, 6, 16), 0, 0); CenterNeedle.Height = Math.Clamp(32 * contentScale, 18, 34); FutureLine.Margin = new Thickness(Math.Clamp(4 * contentScale, 2, 6), 0, 0, 0); FutureLine.Height = Math.Clamp(2 * contentScale, 1, 3); ControlButtonsGrid.Margin = new Thickness(0, Math.Clamp(16 * contentScale, 8, 20), 0, 0); ControlButtonsGrid.ColumnSpacing = Math.Clamp(16 * contentScale, 8, 16); HintTextBlock.Margin = new Thickness(0, Math.Clamp(8 * contentScale, 4, 10), 0, 0); WaveformBarsPanel.Spacing = Math.Clamp(3 * contentScale, 1.6, 3.4); TitleTextBlock.FontSize = Math.Clamp(19 * contentScale, 12, 20); TimerTextBlock.FontSize = Math.Clamp(66 * contentScale, 34, 66); HintTextBlock.FontSize = Math.Clamp(13 * contentScale, 9, 13); UpdateWaveformVisual(); } public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) { _ = isEditMode; var wasOnActivePage = _isOnActivePage; _isOnActivePage = isOnActivePage; UpdateUiTimerState(); if (!wasOnActivePage && _isOnActivePage && _isAttached) { ReloadLanguageCode(); RefreshVisual(); } } private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = true; UpdateUiTimerState(); ReloadLanguageCode(); RefreshVisual(); } private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = false; UpdateUiTimerState(); var snapshot = _audioRecorderService.GetSnapshot(); if (snapshot.State is not AudioRecorderRuntimeState.Recording and not AudioRecorderRuntimeState.Paused) { ResumeStudyMonitoringIfNeeded(); } } private void OnSizeChanged(object? sender, SizeChangedEventArgs e) { ApplyCellSize(_currentCellSize); } private void OnActualThemeVariantChanged(object? sender, EventArgs e) { _isNightVisual = ResolveNightMode(); ApplyNightModeVisual(); } private bool ResolveNightMode() { 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 true; } 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 r = ToLinear(color.R / 255d); var g = ToLinear(color.G / 255d); var b = ToLinear(color.B / 255d); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } private void ApplyNightModeVisual() { RootBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#ECEFF3")); RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#D9DEE7")); TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#11151D")); TimerTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#A4A9B2")); FutureLine.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#A3A8B3")); DiscardButtonBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F8FAFD")); DiscardButtonBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4451") : Color.Parse("#E0E5EC")); DiscardIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#141922")); SaveButtonBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F8FAFD")); SaveButtonBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4451") : Color.Parse("#E0E5EC")); SaveIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#141922")); HintTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#7A818E")); } private void OnUiTick(object? sender, EventArgs e) { if (!_isAttached || !_isOnActivePage) { return; } RefreshVisual(); } private void UpdateUiTimerState() { if (_isAttached && _isOnActivePage) { if (!_uiTimer.IsEnabled) { _uiTimer.Start(); } return; } _uiTimer.Stop(); } private void OnDiscardButtonPointerPressed(object? sender, PointerPressedEventArgs e) { if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { return; } _audioRecorderService.Discard(); ResumeStudyMonitoringIfNeeded(); RefreshVisual(); e.Handled = true; } private void OnRecordToggleButtonPointerPressed(object? sender, PointerPressedEventArgs e) { if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { return; } var snapshot = _audioRecorderService.GetSnapshot(); if (!snapshot.IsSupported) { RefreshVisual(); e.Handled = true; return; } if (snapshot.State == AudioRecorderRuntimeState.Recording) { _audioRecorderService.Pause(); } else { _ = TryStartRecordingWithMonitoringHandoff(); } RefreshVisual(); e.Handled = true; } private async void OnSaveButtonPointerPressed(object? sender, PointerPressedEventArgs e) { if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { return; } var snapshot = _audioRecorderService.GetSnapshot(); if (!snapshot.IsSupported) { RefreshVisual(); e.Handled = true; return; } if (snapshot.State == AudioRecorderRuntimeState.Recording) { _audioRecorderService.Pause(); } var (wasCancelled, outputPath) = await PickSavePathAsync(); if (wasCancelled) { RefreshVisual(); e.Handled = true; return; } _ = _audioRecorderService.StopAndSave(outputPath); ResumeStudyMonitoringIfNeeded(); RefreshVisual(); e.Handled = true; } private void RefreshVisual() { var snapshot = _audioRecorderService.GetSnapshot(); if (_pausedStudyMonitoringForRecording && snapshot.State is AudioRecorderRuntimeState.Ready or AudioRecorderRuntimeState.Error or AudioRecorderRuntimeState.Unsupported) { ResumeStudyMonitoringIfNeeded(); snapshot = _audioRecorderService.GetSnapshot(); } TitleTextBlock.Text = L("recording.widget.title", "Recorder"); TimerTextBlock.Text = FormatDuration(snapshot.Duration); if (snapshot.State == AudioRecorderRuntimeState.Recording) { PushWaveLevel(snapshot.InputLevel); } else if (snapshot.State != AudioRecorderRuntimeState.Paused) { ClearWaveLevels(); } UpdateWaveformVisual(); ApplyControlState(snapshot); } private void ApplyControlState(AudioRecorderSnapshot snapshot) { var isSupported = snapshot.IsSupported; var canFinalize = snapshot.State == AudioRecorderRuntimeState.Recording || snapshot.State == AudioRecorderRuntimeState.Paused; var isReady = snapshot.State == AudioRecorderRuntimeState.Ready; TitleTextBlock.IsVisible = false; DiscardButtonBorder.IsVisible = canFinalize; SaveButtonBorder.IsVisible = canFinalize; DiscardButtonBorder.IsHitTestVisible = isSupported && canFinalize; SaveButtonBorder.IsHitTestVisible = isSupported && canFinalize; RecordToggleButtonBorder.IsHitTestVisible = isSupported; DiscardButtonBorder.Opacity = DiscardButtonBorder.IsHitTestVisible ? 1 : 0.42; SaveButtonBorder.Opacity = SaveButtonBorder.IsHitTestVisible ? 1 : 0.42; RecordToggleButtonBorder.Opacity = RecordToggleButtonBorder.IsHitTestVisible ? 1 : 0.54; if (!isSupported) { TimerTextBlock.Foreground = CreateBrush(_isNightVisual ? "#A8B1C2" : "#B2B7C0"); } else if (isReady) { TimerTextBlock.Foreground = CreateBrush(_isNightVisual ? "#A8B1C2" : "#A4A9B2"); } else { TimerTextBlock.Foreground = CreateBrush(_isNightVisual ? "#E8EAED" : "#151922"); } HintTextBlock.IsVisible = !isReady || !isSupported; RecordDot.IsVisible = snapshot.State == AudioRecorderRuntimeState.Ready; PauseGlyphIcon.IsVisible = snapshot.State == AudioRecorderRuntimeState.Recording; PlayGlyphIcon.IsVisible = snapshot.State == AudioRecorderRuntimeState.Paused; if (!isSupported) { HintTextBlock.Text = L("recording.widget.hint.unsupported", "Microphone is unavailable"); return; } if (snapshot.State == AudioRecorderRuntimeState.Recording) { HintTextBlock.Text = L("recording.widget.hint.recording", "Recording"); return; } if (snapshot.State == AudioRecorderRuntimeState.Paused) { HintTextBlock.Text = L("recording.widget.hint.paused", "Paused"); return; } if (snapshot.State == AudioRecorderRuntimeState.Error) { HintTextBlock.Text = string.IsNullOrWhiteSpace(snapshot.LastError) ? L("recording.widget.hint.error", "Recording failed") : snapshot.LastError; return; } if (!string.IsNullOrWhiteSpace(snapshot.LastSavedFilePath) && !string.Equals(snapshot.LastSavedFilePath, _lastSavedFilePath, StringComparison.OrdinalIgnoreCase)) { _lastSavedFilePath = snapshot.LastSavedFilePath; } if (!string.IsNullOrWhiteSpace(_lastSavedFilePath)) { var fileName = Path.GetFileName(_lastSavedFilePath); HintTextBlock.Text = string.Format( CultureInfo.InvariantCulture, L("recording.widget.hint.saved_format", "Saved {0}"), fileName); return; } HintTextBlock.Text = L("recording.widget.hint.ready", "Tap red button to record"); } private bool TryStartRecordingWithMonitoringHandoff() { if (_audioRecorderService.StartOrResume()) { return true; } if (!TryPauseStudyMonitoringForRecording()) { return false; } if (_audioRecorderService.StartOrResume()) { return true; } ResumeStudyMonitoringIfNeeded(); return false; } private bool TryPauseStudyMonitoringForRecording() { if (_pausedStudyMonitoringForRecording) { return true; } var snapshot = _studyAnalyticsService.GetSnapshot(); if (snapshot.State != StudyAnalyticsRuntimeState.Running) { return false; } if (!_studyAnalyticsService.PauseMonitoring()) { return false; } _pausedStudyMonitoringForRecording = true; return true; } private void ResumeStudyMonitoringIfNeeded() { if (!_pausedStudyMonitoringForRecording) { return; } _pausedStudyMonitoringForRecording = false; _ = _studyAnalyticsService.StartOrResumeMonitoring(); } private void InitializeWaveBars() { if (_waveBars.Count > 0) { return; } for (var i = 0; i < WaveBarCount; i++) { var bar = new Border { Width = 3, Height = 6, CornerRadius = new CornerRadius(1.5), Background = CreateBrush("#121722"), Opacity = 0.24, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center }; _waveBars.Add(bar); WaveformBarsPanel.Children.Add(bar); } } private void PushWaveLevel(double level) { for (var i = 0; i < _waveLevels.Length - 1; i++) { _waveLevels[i] = _waveLevels[i + 1]; } var previous = _waveLevels[^2]; var target = Math.Clamp(level, 0, 1); _waveLevels[^1] = Math.Clamp((previous * 0.35) + (target * 0.65), 0, 1); } private void ClearWaveLevels() { Array.Fill(_waveLevels, 0); } private void UpdateWaveformVisual() { var scale = Math.Clamp(ResolveScale(), 0.74, 1.0); var barWidth = Math.Clamp(3 * scale, 1.8, 3.2); for (var i = 0; i < _waveBars.Count; i++) { var bar = _waveBars[i]; var eased = Math.Pow(Math.Clamp(_waveLevels[i], 0, 1), 0.62); bar.Width = barWidth; bar.Height = Math.Clamp((4 + (eased * 24)) * scale, 3, 30); bar.CornerRadius = new CornerRadius(Math.Clamp(barWidth / 2d, 1, 2)); bar.Opacity = Math.Clamp(0.20 + (eased * 0.82), 0.20, 1.0); } } private void ReloadLanguageCode() { try { var snapshot = _settingsService.Load(); _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); } catch { _languageCode = "zh-CN"; } } private string L(string key, string fallback) { return _localizationService.GetString(_languageCode, key, fallback); } private double ResolveScale() { var cellScale = Math.Clamp(_currentCellSize / 44d, 0.60, 2.0); var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * 2), 0.60, 2.0) : 1; var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * 2), 0.60, 2.0) : 1; return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale) * 1.02), 0.58, 2.04); } private static string FormatDuration(TimeSpan duration) { if (duration.TotalHours >= 1) { return duration.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture); } return duration.ToString(@"mm\:ss", CultureInfo.InvariantCulture); } private static IBrush CreateBrush(string colorHex) { return new SolidColorBrush(Color.Parse(colorHex)); } private async Task<(bool WasCancelled, string? OutputPath)> PickSavePathAsync() { var suggestedName = $"recording_{DateTime.Now:yyyyMMdd_HHmmss}.wav"; var topLevel = TopLevel.GetTopLevel(this); var storageProvider = topLevel?.StorageProvider; if (storageProvider is null) { return (false, null); } var saveFile = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = L("recording.widget.save_picker_title", "Save recording"), SuggestedFileName = suggestedName, DefaultExtension = "wav", FileTypeChoices = [ new FilePickerFileType(L("recording.widget.save_picker_type", "WAV audio")) { Patterns = ["*.wav"], MimeTypes = ["audio/wav", "audio/x-wav"] } ] }); if (saveFile is null) { return (true, null); } var path = saveFile.Path; if (path is null || !path.IsFile) { return (true, null); } 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(); } }