Spectrum Visualizer for player & Layout

This commit is contained in:
daniel-c-harvey
2025-12-07 11:18:32 -05:00
parent c5fdf12ef4
commit 75456a59ce
16 changed files with 712 additions and 110 deletions
@@ -15,81 +15,92 @@ else
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
<div class="player-backdrop pa-3">
@* Desktop Layout *@
<div class="d-none d-md-flex align-center gap-3">
<div class="controls-left d-flex flex-column align-center gap-2">
<div class="d-flex align-center gap-1">
<PlayerControls IsPlaying="IsPlaying"
IsLoaded="IsLoaded"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"/>
@if (IsLoading && !IsStreaming)
{
<MudProgressCircular Color="Color.Tertiary"
Size="Size.Small"
Max="1D"
Value="@LoadProgress"
Indeterminate="@(LoadProgress == 0)"/>
}
@if (_isDesktop)
{
@* Desktop Layout *@
<div class="d-flex align-center gap-3">
<div class="controls-left d-flex flex-column align-center gap-2">
<div class="d-flex align-center gap-1">
<PlayerControls IsPlaying="IsPlaying"
IsLoaded="IsLoaded"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"/>
@if (IsLoading && !IsStreaming)
{
<MudProgressCircular Color="Color.Tertiary"
Size="Size.Small"
Max="1D"
Value="@LoadProgress"
Indeterminate="@(LoadProgress == 0)"/>
}
</div>
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
</div>
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
</div>
<div class="seekbar-flex mx-3"
@onpointerdown="OnSeekStart"
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
@onpointerleave="@(() => { if (_isSeeking) OnSeekEnd(_seekPosition); })">
<MudSlider T="double"
Min="0"
Max="@(Duration ?? 0D)"
Step="0.1"
Value="@DisplayTime"
ValueChanged="@OnSeekChange"
Immediate="true"
Disabled="@(!CanSeek)"/>
</div>
<div class="volume-right">
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
</div>
</div>
@* Mobile Layout *@
<div class="d-md-none">
<div class="d-flex align-center justify-space-between mb-3">
<div class="d-flex align-center gap-2">
<PlayerControls IsPlaying="IsPlaying"
IsLoaded="IsLoaded"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"/>
@if (IsLoading && !IsStreaming)
{
<MudProgressCircular Color="Color.Tertiary"
Size="Size.Small"
Max="1D"
Value="@LoadProgress"
Indeterminate="@(LoadProgress == 0)"/>
}
<div class="d-flex flex-column flex-grow-1">
<div class="seekbar-flex mx-3"
@onpointerdown="OnSeekStart"
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
@onpointerleave="@(() => { if (_isSeeking) OnSeekEnd(_seekPosition); })">
<MudSlider T="double"
Min="0"
Max="@(Duration ?? 0D)"
Step="0.1"
Value="@DisplayTime"
ValueChanged="@OnSeekChange"
Immediate="true"
Disabled="@(!CanSeek)"/>
</div>
<SpectrumVisualizer />
</div>
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
</div>
<div @onpointerdown="OnSeekStart"
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
@onpointerleave="@(() => { if (_isSeeking) OnSeekEnd(_seekPosition); })">
<MudSlider T="double"
Min="0"
Max="@(Duration ?? 0D)"
Step="0.1"
Value="@DisplayTime"
ValueChanged="@OnSeekChange"
Immediate="true"
Disabled="@(!CanSeek)"/>
</div>
</div>
@* Control Buttons - positioned absolutely like original *@
<div class="volume-right">
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
</div>
</div>
}
else
{
@* Mobile Layout *@
<div>
<div class="d-flex align-center justify-space-between mb-3">
<div class="d-flex align-center gap-2">
<PlayerControls IsPlaying="IsPlaying"
IsLoaded="IsLoaded"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"/>
@if (IsLoading && !IsStreaming)
{
<MudProgressCircular Color="Color.Tertiary"
Size="Size.Small"
Max="1D"
Value="@LoadProgress"
Indeterminate="@(LoadProgress == 0)"/>
}
</div>
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
</div>
<div class="d-flex flex-column flex-grow-1">
<div @onpointerdown="OnSeekStart"
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
@onpointerleave="@(() => { if (_isSeeking) OnSeekEnd(_seekPosition); })">
<MudSlider T="double"
Min="0"
Max="@(Duration ?? 0D)"
Step="0.1"
Value="@DisplayTime"
ValueChanged="@OnSeekChange"
Immediate="true"
Disabled="@(!CanSeek)"/>
</div>
<SpectrumVisualizer />
</div>
</div>
}
@* Control Buttons - positioned absolutely like original *@
<div class="player-controls d-flex align-center justify-center gap-1">
<MudIconButton Icon="@Icons.Material.Filled.Minimize"
Color="Color.Secondary"
@@ -1,16 +1,20 @@
using DeepDrftWeb.Client.Services;
using Microsoft.AspNetCore.Components;
using MudBlazor;
using MudBlazor.Services;
namespace DeepDrftWeb.Client.Controls.AudioPlayerBar;
public partial class AudioPlayerBar : ComponentBase
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
{
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
[Inject] private IBrowserViewportService BrowserViewportService { get; set; } = default!;
private bool _isMinimized = true;
private bool _isSeeking = false;
private double _seekPosition = 0;
private bool _isDesktop = true;
private Guid _viewportSubscriptionId;
private bool IsLoaded => PlayerService.IsLoaded;
private bool IsLoading => PlayerService.IsLoading;
@@ -132,4 +136,31 @@ public partial class AudioPlayerBar : ComponentBase
{
return IsPlaying ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var breakpoint = await BrowserViewportService.GetCurrentBreakpointAsync();
_isDesktop = breakpoint >= Breakpoint.Sm;
_viewportSubscriptionId = Guid.NewGuid();
await BrowserViewportService.SubscribeAsync(
_viewportSubscriptionId,
args =>
{
_isDesktop = args.Breakpoint >= Breakpoint.Sm;
InvokeAsync(StateHasChanged);
},
new ResizeOptions { NotifyOnBreakpointOnly = true },
fireImmediately: true);
StateHasChanged();
}
}
public async ValueTask DisposeAsync()
{
await BrowserViewportService.UnsubscribeAsync(_viewportSubscriptionId);
}
}
@@ -93,6 +93,12 @@
min-width: 200px;
}
.seekbar-visualizer-container {
flex: 1;
display: flex;
flex-direction: column;
}
.seekbar-flex {
flex: 1;
}
@@ -0,0 +1,12 @@
@namespace DeepDrftWeb.Client.Controls.AudioPlayerBar
<div class="spectrum-container @(IsVisible ? "" : "hidden")">
<div class="spectrum-bars">
@for (int i = 0; i < BucketCount; i++)
{
var index = i;
var height = GetBarHeight(index);
<div class="spectrum-bar" style="--bar-height: @(height.ToString("F1"))%;"></div>
}
</div>
</div>
@@ -0,0 +1,114 @@
using DeepDrftWeb.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftWeb.Client.Controls.AudioPlayerBar;
public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
{
[Inject] public required AudioInteropService AudioInterop { get; set; }
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
[Parameter] public int BucketCount { get; set; } = 32;
private readonly string _instanceId = Guid.NewGuid().ToString();
private double[] _spectrumData = Array.Empty<double>();
private bool _isAnimating = false;
private string? _playerId;
private EventCallback? _originalOnStateChanged;
private bool IsVisible => PlayerService.IsPlaying || PlayerService.IsPaused || _isAnimating;
protected override void OnInitialized()
{
_spectrumData = new double[BucketCount];
// Get the player ID from the service
if (PlayerService is AudioPlayerService baseService)
{
_playerId = baseService.PlayerId;
}
// Chain into the existing OnStateChanged callback to detect play/pause
_originalOnStateChanged = PlayerService.OnStateChanged;
PlayerService.OnStateChanged = new EventCallback(this, async () =>
{
// Call original callback first
if (_originalOnStateChanged.HasValue)
{
await _originalOnStateChanged.Value.InvokeAsync();
}
// Then update our animation state
await UpdateAnimationState();
});
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Initial check in case already playing
await UpdateAnimationState();
}
}
private async Task UpdateAnimationState()
{
if (string.IsNullOrEmpty(_playerId)) 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;
await AudioInterop.StartSpectrumAnimationAsync(_playerId, _instanceId, OnSpectrumData);
}
private async Task StopAnimation()
{
if (!_isAnimating || string.IsNullOrEmpty(_playerId)) return;
_isAnimating = false;
await AudioInterop.StopSpectrumAnimationAsync(_playerId, _instanceId);
// Clear the display
Array.Clear(_spectrumData);
await InvokeAsync(StateHasChanged);
}
private Task OnSpectrumData(double[] data)
{
if (data.Length > 0)
{
_spectrumData = data;
InvokeAsync(StateHasChanged);
}
return Task.CompletedTask;
}
private double GetBarHeight(int index)
{
if (index >= _spectrumData.Length) return 0;
// Scale to 0-100 percentage, with minimum height for visual appeal
var value = _spectrumData[index];
return Math.Max(2, value * 100);
}
public async ValueTask DisposeAsync()
{
await StopAnimation();
}
}
@@ -0,0 +1,48 @@
.spectrum-container {
width: 100%;
height: 40px;
opacity: 1;
transition: opacity 0.3s ease;
overflow: hidden;
}
.spectrum-container.hidden {
opacity: 0;
}
.spectrum-bars {
display: flex;
justify-content: space-between;
align-items: flex-end;
height: 100%;
gap: 2px;
padding: 0 4px;
}
.spectrum-bar {
flex: 1;
margin: 0 auto;
max-width: 8px;
min-width: 4px;
height: var(--bar-height, 2%);
min-height: 2px;
background: var(--deepdrft-theme-secondary, #8A2BE2);
border-radius: 2px 2px 0 0;
transition: height 0.05s ease-out;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.spectrum-container {
height: 32px;
}
.spectrum-bars {
gap: 1px;
}
.spectrum-bar {
max-width: 8px;
min-width: 3px;
}
}
@@ -1,5 +1,5 @@
<MudContainer MaxWidth="MaxWidth.Large" Class="tracks-gallery-container">
<MudGrid Spacing="3" Justify="Justify.Center">
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var track in Tracks)
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
+2 -2
View File
@@ -6,7 +6,7 @@
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout>
<MudLayout Style="display: flex; flex-direction: column; min-height: 100vh">
<AudioPlayerProvider>
<MudAppBar Elevation="_themeManager.AppBarElevation">
<MudAvatar Class="mr-2">
@@ -16,7 +16,7 @@
<MudSpacer/>
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
</MudAppBar>
<MudMainContent Class="pt-16 deepdrft-layout-with-overlay-player">
<MudMainContent Class="flex-grow-1 pt-16 pb-8">
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
@Body
</MudContainer>
@@ -0,0 +1,20 @@
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
@@ -1,28 +1,23 @@
.tracks-page-wrapper {
display: flex;
flex-direction: column;
height: calc(100dvh - 80px); /* Subtract app bar height (pt-16 = 4rem = 64px) */
/*margin: -16px; !* Counteract MudMainContent padding *!*/
padding-top: 16px; /* Restore top padding for spacing */
}
.tracks-view-container {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
flex: 1;
padding: 0 16px; /* Horizontal padding only */
}
.tracks-content {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-grow: 1;
padding-top: 16px;
}
.tracks-footer {
flex: 0 0 auto;
flex: 0 0;
padding: 8px 0;
display: flex;
flex-direction: column;
@@ -5,7 +5,7 @@ namespace DeepDrftWeb.Client.Services;
public class AudioInteropService : IAsyncDisposable
{
private readonly IJSRuntime _jsRuntime;
private readonly Dictionary<string, DotNetObjectReference<AudioPlayerCallback>> _callbacks = new();
private readonly Dictionary<string, IDisposable> _callbacks = new();
public AudioInteropService(IJSRuntime jsRuntime)
{
@@ -153,10 +153,66 @@ public class AudioInteropService : IAsyncDisposable
public async Task<AudioOperationResult> SetOnEndCallbackAsync(string playerId, Func<Task> callback)
{
return await SetCallbackAsync(playerId, "_end", "setOnEndCallback", "OnEndCallback",
return await SetCallbackAsync(playerId, "_end", "setOnEndCallback", "OnEndCallback",
wrapper => wrapper.OnEnd = callback);
}
// Spectrum analyzer methods
public async Task<double[]?> GetSpectrumDataAsync(string playerId)
{
try
{
return await _jsRuntime.InvokeAsync<double[]>("DeepDrftAudio.getSpectrumData", playerId);
}
catch
{
return null;
}
}
public async Task<AudioOperationResult> SetSpectrumHighPassAsync(string playerId, double freq)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumHighPass", playerId, freq);
}
public async Task<AudioOperationResult> SetSpectrumLowPassAsync(string playerId, double freq)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumLowPass", playerId, freq);
}
public async Task<AudioOperationResult> SetSpectrumSlopeAsync(string playerId, double dbPerDecade)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumSlope", playerId, dbPerDecade);
}
public async Task<AudioOperationResult> StartSpectrumAnimationAsync(string playerId, string callbackId, Func<double[], Task> callback)
{
try
{
var callbackWrapper = new SpectrumCallback { OnData = callback };
var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper);
_callbacks[playerId + "_spectrum_" + callbackId] = dotNetObjectRef;
return await _jsRuntime.InvokeAsync<AudioOperationResult>(
"DeepDrftAudio.startSpectrumAnimation",
playerId, callbackId, dotNetObjectRef, "OnSpectrumDataCallback");
}
catch (Exception ex)
{
return new AudioOperationResult { Success = false, Error = ex.Message };
}
}
public async Task<AudioOperationResult> StopSpectrumAnimationAsync(string playerId, string callbackId)
{
var key = playerId + "_spectrum_" + callbackId;
if (_callbacks.TryGetValue(key, out var callback))
{
callback?.Dispose();
_callbacks.Remove(key);
}
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stopSpectrumAnimation", playerId, callbackId);
}
public async Task<AudioOperationResult> DisposePlayerAsync(string playerId)
{
@@ -243,6 +299,18 @@ public class AudioPlayerCallback
}
}
public class SpectrumCallback
{
public Func<double[], Task>? OnData { get; set; }
[JSInvokable]
public async Task OnSpectrumDataCallback(double[] data)
{
if (OnData != null)
await OnData(data);
}
}
public class AudioOperationResult
{
public bool Success { get; set; }