Merge branch 'audioplayer-unified' into dev

This commit is contained in:
daniel-c-harvey
2026-06-05 14:15:07 -04:00
5 changed files with 90 additions and 89 deletions
@@ -12,64 +12,28 @@ else
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container"> <MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
<MudPaper Elevation="8" Class="player-surface pa-3"> <MudPaper Elevation="8" Class="player-surface pa-3">
@if (_isDesktop) <div class="player-layout">
{ <PlayerTransportZone IsPlaying="IsPlaying"
@* Desktop Layout *@ IsLoaded="IsLoaded"
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3" Class="player-row"> IsLoading="IsLoading"
<PlayerTransportZone IsPlaying="IsPlaying" IsStreaming="IsStreaming"
IsLoaded="IsLoaded" LoadProgress="LoadProgress"
IsLoading="IsLoading" DisplayTime="DisplayTime"
IsStreaming="IsStreaming" Duration="Duration"
LoadProgress="LoadProgress" TogglePlayPause="@TogglePlayPause"
DisplayTime="DisplayTime" Stop="@Stop"
Duration="Duration" Class="transport-zone"/>
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"
Class="controls-left"/>
<PlayerSeekZone DisplayTime="DisplayTime" <VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
Duration="Duration"
CanSeek="CanSeek"
OnSeekStart="@OnSeekStart"
OnSeekEnd="@OnSeekEnd"
OnSeekChange="@OnSeekChange"
Class="flex-grow-1"/>
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/> <PlayerSeekZone DisplayTime="DisplayTime"
</MudStack> Duration="Duration"
} CanSeek="CanSeek"
else OnSeekStart="@OnSeekStart"
{ OnSeekEnd="@OnSeekEnd"
@* Mobile Layout *@ OnSeekChange="@OnSeekChange"
<div> Class="seek-zone"/>
<div class="d-flex align-center justify-space-between mb-3"> </div>
<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>
<PlayerSeekZone DisplayTime="DisplayTime"
Duration="Duration"
CanSeek="CanSeek"
OnSeekStart="@OnSeekStart"
OnSeekEnd="@OnSeekEnd"
OnSeekChange="@OnSeekChange"
Class="flex-grow-1"/>
</div>
}
@* Minimize / close — positioned absolutely top-right *@ @* Minimize / close — positioned absolutely top-right *@
<PlayerWindowControls OnMinimize="@ToggleMinimized" OnClose="@Close"/> <PlayerWindowControls OnMinimize="@ToggleMinimized" OnClose="@Close"/>
@@ -1,20 +1,16 @@
using DeepDrftPublic.Client.Services; using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using MudBlazor; using MudBlazor;
using MudBlazor.Services;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar; namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
{ {
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[Inject] public required IBrowserViewportService BrowserViewportService { get; set; }
private bool _isMinimized = true; private bool _isMinimized = true;
private bool _isSeeking = false; private bool _isSeeking = false;
private double _seekPosition = 0; private double _seekPosition = 0;
private bool _isDesktop = true;
private Guid _viewportSubscriptionId;
private IStreamingPlayerService? _subscribedService; private IStreamingPlayerService? _subscribedService;
private bool IsLoaded => PlayerService?.IsLoaded ?? false; private bool IsLoaded => PlayerService?.IsLoaded ?? false;
@@ -132,35 +128,13 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
} }
} }
protected override async Task OnAfterRenderAsync(bool firstRender) public ValueTask DisposeAsync()
{
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()
{ {
if (_subscribedService != null) if (_subscribedService != null)
{ {
_subscribedService.StateChanged -= OnPlayerStateChanged; _subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null; _subscribedService = null;
} }
await BrowserViewportService.UnsubscribeAsync(_viewportSubscriptionId); return ValueTask.CompletedTask;
} }
} }
@@ -71,3 +71,30 @@
height: 160px; height: 160px;
} }
} }
/* Unified responsive player layout.
Wide and narrow shapes are pure CSS — no runtime breakpoint subscription.
Children are targeted by their stable classes (transport-zone, volume-controls,
seek-zone) rather than positional nth-child, since all three render a .mud-stack
root and positional selectors would be fragile. */
.player-layout {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding-right: 2.5rem; /* clear the abs-positioned PlayerWindowControls */
}
/* Wide (>= 600px): single row, seek zone grows and sits between transport and volume */
@media (min-width: 600px) {
::deep .transport-zone { order: 1; }
::deep .seek-zone { order: 2; flex-grow: 1; flex-basis: 0; }
::deep .volume-controls { order: 3; }
}
/* Narrow (< 600px): transport + volume on top row, seek full-width below */
@media (max-width: 599.98px) {
::deep .transport-zone { order: 1; }
::deep .volume-controls { order: 2; }
::deep .seek-zone { flex-basis: 100%; order: 3; }
}
@@ -1,4 +1,14 @@
/* Stable minimum width so the transport cluster doesn't reflow */ /* Stable minimum width so the transport cluster doesn't reflow */
::deep .controls-left { .transport-zone {
min-width: 200px; min-width: 180px;
}
/* Narrow (< 600px): lay the transport root horizontally (controls beside
timestamp) so it fits on one line next to VolumeControls. Scoped to the
root .transport-zone stack so the nested controls stack is untouched. */
@media (max-width: 599.98px) {
.transport-zone {
flex-direction: row !important;
align-items: center;
}
} }
@@ -15,6 +15,7 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
private double[] _spectrumData = Array.Empty<double>(); private double[] _spectrumData = Array.Empty<double>();
private bool _isAnimating = false; private bool _isAnimating = false;
private string? _playerId; private string? _playerId;
private IStreamingPlayerService? _subscribedService;
private bool IsVisible => (PlayerService?.IsPlaying ?? false) || (PlayerService?.IsPaused ?? false) || _isAnimating; private bool IsVisible => (PlayerService?.IsPlaying ?? false) || (PlayerService?.IsPaused ?? false) || _isAnimating;
@@ -25,9 +26,20 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
// Provider re-renders cascade down to children and re-run OnParametersSet. // The cascade is IsFixed, so the provider's re-renders do NOT re-run
// Pick up the player id once the cascade arrives, then drive animation // OnParametersSet here, and this component has no incoming parameters
// state from the parent's current IsPlaying — no callback wrapping needed. // that change. Subscribe to the multicast StateChanged side-channel so
// animation state stays correct independent of parent re-renders —
// notably when the bar expands 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) if (_playerId == null && PlayerService is AudioPlayerService baseService)
{ {
_playerId = baseService.PlayerId; _playerId = baseService.PlayerId;
@@ -36,6 +48,15 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
await UpdateAnimationState(); await UpdateAnimationState();
} }
private void OnPlayerStateChanged() => InvokeAsync(async () =>
{
if (_playerId == null && PlayerService is AudioPlayerService baseService)
{
_playerId = baseService.PlayerId;
}
await UpdateAnimationState();
});
private async Task UpdateAnimationState() private async Task UpdateAnimationState()
{ {
if (string.IsNullOrEmpty(_playerId) || PlayerService == null) return; if (string.IsNullOrEmpty(_playerId) || PlayerService == null) return;
@@ -93,6 +114,11 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_subscribedService != null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
await StopAnimation(); await StopAnimation();
} }
} }