using DeepDrftPublic.Client.Services; using Microsoft.AspNetCore.Components; namespace DeepDrftPublic.Client.Controls.AudioPlayerBar; public partial class LevelMeterFab : ComponentBase, IAsyncDisposable { [Inject] public required AudioInteropService AudioInterop { get; set; } [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } [Parameter] public EventCallback OnClick { get; set; } // Level-data reduction tuning (see PLAN-level-meter-fab.md §2/§4). The three // band boundaries below are the spec contract; the attack/release coefficients // and silence floor are by-ear tuning values. private const double AttackCoefficient = 0.6; // fast rise toward a louder reading private const double ReleaseCoefficient = 0.15; // slow decay so the tint doesn't strobe private const double SilenceFloorDb = -80.0; // matches the analyzer's normalization window private const double GreenCeilingDb = -18.0; // ≤ this → green private const double YellowCeilingDb = -6.0; // ≤ this → yellow; above → orange private readonly string _instanceId = Guid.NewGuid().ToString(); private bool _isAnimating; private string? _playerId; private IStreamingPlayerService? _subscribedService; private double _smoothedDb = SilenceFloorDb; private string _bandClass = string.Empty; protected override async Task OnParametersSetAsync() { // The cascade is IsFixed, so the provider's re-renders do NOT re-run // OnParametersSet here. Subscribe to the multicast StateChanged side-channel // so animation state stays correct independent of parent re-renders — // notably when the bar minimizes while a track is already playing. if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService)) { if (_subscribedService != null) _subscribedService.StateChanged -= OnPlayerStateChanged; PlayerService.StateChanged += OnPlayerStateChanged; _subscribedService = PlayerService; } if (_playerId == null && PlayerService is AudioPlayerService baseService) { _playerId = baseService.PlayerId; } await UpdateAnimationState(); } private void OnPlayerStateChanged() => InvokeAsync(async () => { if (_playerId == null && PlayerService is AudioPlayerService baseService) { _playerId = baseService.PlayerId; } await UpdateAnimationState(); }); private async Task UpdateAnimationState() { if (string.IsNullOrEmpty(_playerId) || PlayerService == null) return; var shouldAnimate = PlayerService.IsPlaying; if (shouldAnimate && !_isAnimating) { await StartAnimation(); } else if (!shouldAnimate && _isAnimating) { await StopAnimation(); } } private async Task StartAnimation() { if (_isAnimating || string.IsNullOrEmpty(_playerId)) return; _isAnimating = true; _smoothedDb = SilenceFloorDb; await AudioInterop.StartSpectrumAnimationAsync(_playerId, _instanceId, OnLevelData); } private async Task StopAnimation() { if (!_isAnimating || string.IsNullOrEmpty(_playerId)) return; _isAnimating = false; await AudioInterop.StopSpectrumAnimationAsync(_playerId, _instanceId); // Revert to idle untinted; CSS eases the color back over 120ms. if (_bandClass.Length > 0) { _bandClass = string.Empty; await InvokeAsync(StateHasChanged); } } private Task OnLevelData(double[] buckets) { if (buckets.Length == 0) return Task.CompletedTask; // Peak reads more responsively than mean for a "hot signal" indicator. var peak = 0.0; foreach (var bucket in buckets) { if (bucket > peak) peak = bucket; } // Inverse of SpectrumAnalyzer's normalization: value = (clampedDb + 80) / 80. var instantDb = peak * 80.0 - 80.0; // Attack-fast / release-slow envelope so the tint doesn't strobe at 30fps. var coefficient = instantDb > _smoothedDb ? AttackCoefficient : ReleaseCoefficient; _smoothedDb += (instantDb - _smoothedDb) * coefficient; var band = BandFor(_smoothedDb); if (band != _bandClass) { _bandClass = band; InvokeAsync(StateHasChanged); } return Task.CompletedTask; } private static string BandFor(double db) => db switch { <= GreenCeilingDb => "lmf-green", <= YellowCeilingDb => "lmf-yellow", _ => "lmf-orange" }; public async ValueTask DisposeAsync() { if (_subscribedService != null) { _subscribedService.StateChanged -= OnPlayerStateChanged; _subscribedService = null; } await StopAnimation(); } }