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));
+}