diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor index dae7314..a7b6bc0 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor @@ -1,10 +1,9 @@ @if (_isMinimized) {
- +
} else diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor new file mode 100644 index 0000000..5bd1fba --- /dev/null +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor @@ -0,0 +1,8 @@ +@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar + +
+ +
diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.cs new file mode 100644 index 0000000..6a04bcb --- /dev/null +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.cs @@ -0,0 +1,150 @@ +using DeepDrftPublic.Client.Services; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +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; } + [Parameter] public Size Size { get; set; } = Size.Large; + [Parameter] public Color Color { get; set; } = Color.Primary; + + // 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(); + } +} diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.css b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.css new file mode 100644 index 0000000..63c706b --- /dev/null +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab.razor.css @@ -0,0 +1,27 @@ +:root { + --lmf-green: #2ECC71; + --lmf-yellow: #F4C430; + --lmf-orange: #FF6B35; +} + +/* The wrapper div is the isolation anchor; MudFab wraps the glyph in its own + markup, so the band tint reaches the rendered SVG via :deep(). currentColor + on the idle (no-band) state inherits MudFab's Color.Primary contrast text. */ +.lmf-root :deep(svg) { + transition: color 120ms ease-out; +} + +.lmf-green :deep(svg) { + color: var(--lmf-green); + filter: drop-shadow(0 0 6px rgba(46, 204, 113, 0.45)); +} + +.lmf-yellow :deep(svg) { + color: var(--lmf-yellow); + filter: drop-shadow(0 0 6px rgba(244, 196, 48, 0.45)); +} + +.lmf-orange :deep(svg) { + color: var(--lmf-orange); + filter: drop-shadow(0 0 6px rgba(255, 107, 53, 0.45)); +}