Merge branch 'level-meter-fab' into dev
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
@if (_isMinimized)
|
||||
{
|
||||
<div class="minimized-dock">
|
||||
<MudFab Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.MusicNote"
|
||||
Size="Size.Large"
|
||||
OnClick="@ToggleMinimized"/>
|
||||
<LevelMeterFab Size="Size.Large"
|
||||
Color="Color.Primary"
|
||||
OnClick="@ToggleMinimized" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||
|
||||
<div class="lmf-root @_bandClass">
|
||||
<MudFab Color="Color"
|
||||
StartIcon="@Icons.Material.Filled.MusicNote"
|
||||
Size="Size"
|
||||
OnClick="OnClick"/>
|
||||
</div>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user