Merge streaming-overhaul into dev (Opus low-data streaming, windowed streaming, HW-accel-off stabilization)

This commit is contained in:
daniel-c-harvey
2026-06-26 11:14:59 -04:00
97 changed files with 10086 additions and 591 deletions
@@ -1,3 +1,4 @@
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.Clients;
using Microsoft.AspNetCore.Components;
@@ -13,6 +14,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
[Inject] public required BeaconInterop Beacon { get; set; }
[Inject] public required IPlayEventSink PlayEventSink { get; set; }
[Inject] public required IAnonIdProvider AnonId { get; set; }
[Inject] public required PublicSiteSettings Settings { get; set; }
private IStreamingPlayerService? _audioPlayerService;
private QueueService? _queueService;
@@ -26,7 +28,12 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
// EnsureInitializedAsync — that path is correct because audio contexts
// require a user gesture anyway. Initializing eagerly here causes 4+
// SignalR round-trips before any content is stable.
var player = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger);
// Construct the preference-aware player (Phase 18 wave 18.6): it honours the listener's streaming-
// quality choice via the ResolveStreamFormatAsync seam while inheriting the 18.5 capability gate and
// C2 fallback. PublicSiteSettings is scoped data (already prerender-seeded + WASM-bridged), so passing
// it through the constructor is cheap and carries no lifecycle — the telemetry tracker still binds
// post-construction below, exactly as before.
var player = new PreferenceAwareStreamingPlayerService(AudioInterop, TrackMediaClient, Logger, Settings);
// Phase 16: bind the play-session tracker to the player after construction, the same way the
// queue binds — the player is built with `new`, not DI, so threading telemetry through its
@@ -0,0 +1,55 @@
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Client.Controls.Settings
@using DeepDrftPublic.Client.Services
@*
The public-site Settings menu (Phase 18 wave 18.6, §4a). An app-bar trigger opening a MudMenu that renders
a settings-item list — NOT a hard-coded control stack. Each entry is a SettingsItem (label + a control
fragment bound to a persisted preference), so a future tenant (dark mode) plugs in as a new list entry, not
a menu rewire. Today the list holds one item: the streaming-quality toggle.
The MudMenu items carry OnClick="@(() => {})" + OnTouch so a click inside a control row does not dismiss the
menu (MudMenu auto-closes on item activation otherwise), keeping the radio group usable.
*@
<div class="dd-accent-icon">
<MudMenu Icon="@Icons.Material.Filled.Settings"
Color="Color.Inherit"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight"
AriaLabel="Settings"
Class="dd-settings-menu">
<div class="dd-settings-panel">
<div class="dd-settings-heading">Settings</div>
@foreach (var item in _items)
{
<div class="dd-settings-item">
<div class="dd-settings-item-label">@item.Label</div>
@item.Control
</div>
}
</div>
</MudMenu>
</div>
@code {
// The active player, cascaded by AudioPlayerProvider. SettingsMenu sits in the app bar inside the
// provider, so it receives the cascade here — but the MudMenu PANEL content below is portaled to
// <MudPopoverProvider> (outside the provider), so a cascade cannot reach it. We thread the player into
// the setting as an explicit parameter instead: an explicit value captured in the item fragment flows
// into portaled content fine, where a [CascadingParameter] would land null.
[CascadingParameter] public IStreamingPlayerService? Player { get; set; }
// The settings-item list. Built once; adding a preference is appending one SettingsItem with its control
// fragment — the menu body above renders whatever is here without knowing what each item is. The item
// fragment reads Player at render time (when the menu opens), so it picks up the cascaded instance even
// though the list itself is initialized before the cascade is set.
private readonly List<SettingsItem> _items;
public SettingsMenu()
{
_items =
[
new SettingsItem("Streaming quality", @<StreamQualitySetting Player="@Player" />)
];
}
}
@@ -0,0 +1,97 @@
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Client.Services
@*
The streaming-quality control (Phase 18 wave 18.6, §4) — the first occupant of the Settings menu. Binds
the listener's choice to PublicSiteSettings.StreamQuality and persists it via the cookie seam. Honest
capability gate (OQ2 / AC7): on a browser that cannot decode Ogg Opus the Low-data option still selects,
but a note tells the listener the effective stream is lossless — we never let the choice silently imply a
format that can't play.
*@
<div class="dd-setting-control">
<MudRadioGroup T="StreamQuality" Value="_quality" ValueChanged="OnQualityChanged">
<MudRadio T="StreamQuality" Value="StreamQuality.LowData" Color="Color.Primary" Dense="true">
Low-data (Opus)
</MudRadio>
<MudRadio T="StreamQuality" Value="StreamQuality.Lossless" Color="Color.Primary" Dense="true">
Lossless (WAV)
</MudRadio>
</MudRadioGroup>
@if (_opusUnavailable && _quality == StreamQuality.LowData)
{
<div class="dd-setting-note">
This browser can't decode Opus — you'll stream lossless.
</div>
}
<div class="d-flex flex-align-right">
<MudButton Disabled="!IsApplyEnabled"
Color="Color.Primary"
Variant="Variant.Filled"
OnClick="@ApplyStreamQualitySetting">
APPLY
</MudButton>
</div>
</div>
@code {
[Inject] public required PublicSiteSettings Settings { get; set; }
[Inject] public required SettingsCookieService CookieService { get; set; }
[Inject] public required AudioInteropService AudioInterop { get; set; }
// The active player, threaded in from SettingsMenu (which reads it off the AudioPlayerProvider cascade).
// It is an explicit [Parameter], NOT a [CascadingParameter], because this control renders inside the
// MudMenu panel, which MudBlazor portals to <MudPopoverProvider> — outside AudioPlayerProvider's cascade
// scope, so a cascade would land null here. Null during prerender or when no provider is present — Apply
// then just persists the preference, with no live track to restart.
[Parameter] public IStreamingPlayerService? Player { get; set; }
private StreamQuality _quality;
public bool IsApplyEnabled => _quality != Settings.StreamQuality;
// Null until the capability probe runs (post-render JS interop). false → can decode Opus; true → cannot.
private bool _opusUnavailable;
protected override void OnInitialized()
{
// Read the current preference (already seeded at prerender + bridged into WASM).
_quality = Settings.StreamQuality;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
// Capability probe is JS interop — only valid once interactive. Surfaces the honest note when the
// browser can't decode Ogg Opus, so a Low-data pick reads as "effectively lossless" rather than
// silently failing. The player applies the same gate independently; this is purely the UI honesty.
var canDecodeOpus = await AudioInterop.CanDecodeOggOpus();
if (canDecodeOpus == _opusUnavailable)
{
_opusUnavailable = !canDecodeOpus;
StateHasChanged();
}
}
private async Task OnQualityChanged(StreamQuality quality)
{
_quality = quality;
}
private async Task ApplyStreamQualitySetting(MouseEventArgs arg)
{
// Persist the choice first so the cookie + in-memory PublicSiteSettings.StreamQuality both reflect
// the new value BEFORE the restart: the reload re-resolves the delivery format via
// PreferenceAwareStreamingPlayerService, which reads PublicSiteSettings.StreamQuality fresh.
await CookieService.SetStreamQualityAsync(_quality);
// Switch the currently-playing track to the new format immediately, preserving the listener's
// position. No-op inside the player when nothing is loaded — the new preference then simply
// applies to the next track played. Fire-and-forget: the reload runs the streaming loop for the
// life of the track, so awaiting it would pin this handler open; UI updates flow through the
// player's state notifications, not this await.
_ = Player?.ReloadPreservingPositionAsync();
}
}
@@ -265,6 +265,24 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
return;
}
// Apply the hardware-capability default ONCE per session before seeding the controls: if the
// browser has no WebGL hardware acceleration, the lava subsystem (which software-renders on
// the main thread and starves audio decode) defaults off while the waveform stays on. The
// probe lives in JS (it needs a real WebGL context); the scoped state guards the one-time
// application, so a remounted visualizer never re-applies and a later explicit toggle is
// never clobbered. Sequenced before PushControlsAsync so the seed already carries the
// corrected enables; ApplyCapabilityDefault also raises Changed for the controls UI.
try
{
var hardwareAccelerated = await _module.InvokeAsync<bool>("detectHardwareAcceleration");
ControlState.ApplyCapabilityDefault(hardwareAccelerated);
}
catch (JSException ex)
{
// A probe failure must not regress the HW-accel majority: leave the defaults (lava on).
Logger.LogWarning(ex, "WaveformVisualizer: hardware-acceleration probe failed; leaving lava at its default.");
}
// Seed the module with the current state now that it exists. All control values (the eight
// dials + the two Phase 15 subsystem enables) come from the shared (session-persisted) state,
// so a mix opened mid-session seeds the module with the knob/toggle positions the listener