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">
<MudPaper Elevation="8" Class="player-surface pa-3">
@if (_isDesktop)
{
@* Desktop Layout *@
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3" Class="player-row">
<PlayerTransportZone IsPlaying="IsPlaying"
IsLoaded="IsLoaded"
IsLoading="IsLoading"
IsStreaming="IsStreaming"
LoadProgress="LoadProgress"
DisplayTime="DisplayTime"
Duration="Duration"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"
Class="controls-left"/>
<div class="player-layout">
<PlayerTransportZone IsPlaying="IsPlaying"
IsLoaded="IsLoaded"
IsLoading="IsLoading"
IsStreaming="IsStreaming"
LoadProgress="LoadProgress"
DisplayTime="DisplayTime"
Duration="Duration"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"
Class="transport-zone"/>
<PlayerSeekZone DisplayTime="DisplayTime"
Duration="Duration"
CanSeek="CanSeek"
OnSeekStart="@OnSeekStart"
OnSeekEnd="@OnSeekEnd"
OnSeekChange="@OnSeekChange"
Class="flex-grow-1"/>
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
</MudStack>
}
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>
<PlayerSeekZone DisplayTime="DisplayTime"
Duration="Duration"
CanSeek="CanSeek"
OnSeekStart="@OnSeekStart"
OnSeekEnd="@OnSeekEnd"
OnSeekChange="@OnSeekChange"
Class="flex-grow-1"/>
</div>
}
<PlayerSeekZone DisplayTime="DisplayTime"
Duration="Duration"
CanSeek="CanSeek"
OnSeekStart="@OnSeekStart"
OnSeekEnd="@OnSeekEnd"
OnSeekChange="@OnSeekChange"
Class="seek-zone"/>
</div>
@* Minimize / close — positioned absolutely top-right *@
<PlayerWindowControls OnMinimize="@ToggleMinimized" OnClose="@Close"/>
@@ -1,20 +1,16 @@
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using MudBlazor;
using MudBlazor.Services;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
{
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[Inject] public required IBrowserViewportService BrowserViewportService { get; set; }
private bool _isMinimized = true;
private bool _isSeeking = false;
private double _seekPosition = 0;
private bool _isDesktop = true;
private Guid _viewportSubscriptionId;
private IStreamingPlayerService? _subscribedService;
private bool IsLoaded => PlayerService?.IsLoaded ?? false;
@@ -132,35 +128,13 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
}
}
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()
public ValueTask DisposeAsync()
{
if (_subscribedService != null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
await BrowserViewportService.UnsubscribeAsync(_viewportSubscriptionId);
return ValueTask.CompletedTask;
}
}
@@ -71,3 +71,30 @@
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 */
::deep .controls-left {
min-width: 200px;
.transport-zone {
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 bool _isAnimating = false;
private string? _playerId;
private IStreamingPlayerService? _subscribedService;
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()
{
// Provider re-renders cascade down to children and re-run OnParametersSet.
// Pick up the player id once the cascade arrives, then drive animation
// state from the parent's current IsPlaying — no callback wrapping needed.
// The cascade is IsFixed, so the provider's re-renders do NOT re-run
// OnParametersSet here, and this component has no incoming parameters
// 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)
{
_playerId = baseService.PlayerId;
@@ -36,6 +48,15 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
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;
@@ -93,6 +114,11 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
public async ValueTask DisposeAsync()
{
if (_subscribedService != null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
await StopAnimation();
}
}