Merge streaming-overhaul into dev (Opus low-data streaming, windowed streaming, HW-accel-off stabilization)
This commit is contained in:
@@ -51,7 +51,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `StreamingAudioPlayerService`: Production implementation. Chunked stream from `TrackMediaClient`, adaptive 16–64 KB buffer, early-playback, **seek-beyond-buffer** via offset request to the content API.
|
||||
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
|
||||
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
|
||||
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions, **two subsystem on/off toggles** (Phase 15), and one **Theater-Mode flag** (Phase 20): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`), `TheaterMode` (bool, default `false` — `DefaultTheaterMode`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable; the Theater observers (the three detail pages and `AudioPlayerBar`) subscribe to react to `TheaterMode`. **`CoerceTheaterMode()`**: enforces the invariant that Theater Mode cannot remain on when both subsystems are off — called from `WaveformVisualizerControls.ToggleLava`/`ToggleWaveform` **before** `NotifyChanged()` so all observers see a consistent, coerced state in the same `Changed` cycle. `TheaterMode` is a page-chrome presentation flag; the visualizer bridge ignores it. Scoped DI so state survives SPA nav within a session and resets on fresh page load. **Phase 20 Wave 2 — playing-release predicates** live in `ReleaseDetailBase` / `CutDetailBase` (not in this state holder): `IsThisReleasePlaying` (`PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey`), `IsContentHidden` (`TheaterMode && IsThisReleasePlaying`), `ShowTheaterToggle` (`(LavaEnabled || WaveformEnabled) && IsThisReleasePlaying`). Both base classes also subscribe to `IStreamingPlayerService.StateChanged` (idempotent, reference-guarded, disposed) so the predicates re-evaluate live when playback moves between releases.
|
||||
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions, **two subsystem on/off toggles** (Phase 15), and one **Theater-Mode flag** (Phase 20): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`), `TheaterMode` (bool, default `false` — `DefaultTheaterMode`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable; the Theater observers (the three detail pages and `AudioPlayerBar`) subscribe to react to `TheaterMode`. **`CoerceTheaterMode()`**: enforces the invariant that Theater Mode cannot remain on when both subsystems are off — called from `WaveformVisualizerControls.ToggleLava`/`ToggleWaveform` **before** `NotifyChanged()` so all observers see a consistent, coerced state in the same `Changed` cycle. **`ApplyCapabilityDefault(bool hardwareAccelerated)`**: one-time scoped capability default (guarded by `_capabilityDefaultApplied`; never re-applies on SPA navigation, never overrides an explicit in-session toggle). When `hardwareAccelerated` is false (positive software-renderer match from `hwAccel.ts`'s `UNMASKED_RENDERER_WEBGL` probe, or total WebGL failure) sets `LavaEnabled = false` while leaving `WaveformEnabled` at its default on, then calls `CoerceTheaterMode()` + `NotifyChanged()` once so all observers see the default in a single cycle. Called by the visualizer bridge on first interactive render once JS interop (the HW-accel probe via `detectHardwareAcceleration()` exported from `WaveformVisualizer.ts`) is available; a no-op when HW accel is present. `TheaterMode` is a page-chrome presentation flag; the visualizer bridge ignores it. Scoped DI so state survives SPA nav within a session and resets on fresh page load. **Phase 20 Wave 2 — playing-release predicates** live in `ReleaseDetailBase` / `CutDetailBase` (not in this state holder): `IsThisReleasePlaying` (`PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey`), `IsContentHidden` (`TheaterMode && IsThisReleasePlaying`), `ShowTheaterToggle` (`(LavaEnabled || WaveformEnabled) && IsThisReleasePlaying`). Both base classes also subscribe to `IStreamingPlayerService.StateChanged` (idempotent, reference-guarded, disposed) so the predicates re-evaluate live when playback moves between releases.
|
||||
- `PlayTracker`: Per-session play-session tracker (Phase 16 wave 16.1). Opens on playback start, advances a high-water position on each progress tick (from `StreamingAudioPlayerService` — not the HTTP layer, so seek-beyond-buffer re-fetches are the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3 s OR ≥5% of duration. Three-bucket classification (`partial`/`sampled`/`complete`). Emits at most one event per session via `IPlayEventSink`. No player or JS dependency — testable against a fake sink.
|
||||
- `ShareTracker`: Per-session share tracker (Phase 16 wave 16.1). Called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce. Sends via `BeaconInterop`. Scoped so debounce memory resets on fresh page load. **Wave 16.3:** injects `IAnonIdProvider`; attaches `_anonId.Current` to `ShareEventDto.AnonId` (omitted when null).
|
||||
- `BeaconInterop`: `navigator.sendBeacon` JS interop wrapper (Phase 16 wave 16.1). Fires JSON payloads to `api/event/{play,share}` fire-and-forget. Also wires a page-unload handler that flushes any pending play event when the page is torn down.
|
||||
|
||||
@@ -2,6 +2,8 @@ using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NetBlocks.Models;
|
||||
|
||||
@@ -18,13 +20,24 @@ public class TrackMediaResponse : IDisposable
|
||||
/// </summary>
|
||||
public string ContentType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The total file length in bytes, parsed from the 206 response's <c>Content-Range:
|
||||
/// bytes start-end/TOTAL</c> header (Phase 21 Direction B). Null when the server returned
|
||||
/// 200 (no Content-Range) — callers fall back to <see cref="ContentLength"/> as the total.
|
||||
/// This is the EOF boundary the segment loop advances its cursor toward, and the full
|
||||
/// logical length the JS decoder must see (so a bounded segment's small Content-Length
|
||||
/// never trips the decoder's byte-count completion early).
|
||||
/// </summary>
|
||||
public long? TotalLength { get; }
|
||||
|
||||
private readonly HttpResponseMessage _response;
|
||||
|
||||
public TrackMediaResponse(Stream stream, long contentLength, string contentType, HttpResponseMessage response)
|
||||
public TrackMediaResponse(Stream stream, long contentLength, string contentType, long? totalLength, HttpResponseMessage response)
|
||||
{
|
||||
Stream = stream;
|
||||
ContentLength = contentLength;
|
||||
ContentType = contentType;
|
||||
TotalLength = totalLength;
|
||||
_response = response;
|
||||
}
|
||||
|
||||
@@ -45,24 +58,61 @@ public class TrackMediaClient
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the WAV stream for a track via an HTTP Range request starting at a
|
||||
/// Fetches the audio stream for a track via an HTTP Range request starting at a
|
||||
/// file-absolute byte offset. <paramref name="byteOffset"/> is the position from
|
||||
/// the start of the file on disk (including the WAV header) — callers seeking into
|
||||
/// audio data must add the header size themselves. The cancellation token aborts
|
||||
/// the in-flight server connection rather than leaving the server draining bytes
|
||||
/// into a dead socket.
|
||||
/// the start of the file on disk (including any container/header bytes) — callers
|
||||
/// seeking into audio data must add the header size themselves. The cancellation
|
||||
/// token aborts the in-flight server connection rather than leaving the server
|
||||
/// draining bytes into a dead socket.
|
||||
/// <para>
|
||||
/// <paramref name="byteEnd"/> (Phase 21 Direction B) bounds the request to a single
|
||||
/// segment: when set, the Range header is <c>bytes={byteOffset}-{byteEnd}</c> (inclusive),
|
||||
/// so the browser holds at most ~one segment of raw bytes regardless of file size — the
|
||||
/// network-memory bound this phase exists for. When null the request is open-ended
|
||||
/// (<c>bytes={byteOffset}-</c>), the pre-Direction-B behaviour. Either way the response's
|
||||
/// <c>Content-Range</c> total is surfaced via <see cref="TrackMediaResponse.TotalLength"/>
|
||||
/// so the caller knows the EOF boundary and the full logical length the decoder must see.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <paramref name="format"/> selects the delivery rendering (Phase 18): the default
|
||||
/// <see cref="AudioFormat.Lossless"/> sends no <c>format</c> query param, so existing
|
||||
/// callers hit the byte-identical pre-Phase-18 endpoint; <see cref="AudioFormat.Opus"/>
|
||||
/// requests the low-data Ogg Opus artifact, which the server resolves and falls back to
|
||||
/// lossless when absent (C2). The response <see cref="TrackMediaResponse.ContentType"/>
|
||||
/// reports the format actually served, so the JS decoder dispatches on the real bytes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
|
||||
string trackId,
|
||||
long byteOffset = 0,
|
||||
long? byteEnd = null,
|
||||
AudioFormat format = AudioFormat.Lossless,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Same URL for every seek — only the Range header differs. byteOffset 0 is
|
||||
// Same URL for every fetch — only the Range header differs. byteOffset 0 is
|
||||
// not special-cased: "bytes=0-" requests the whole file from the start.
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"api/track/{trackId}");
|
||||
request.Headers.Range = new RangeHeaderValue(byteOffset, null);
|
||||
// Lossless omits the format param entirely so the request is byte-identical to
|
||||
// the pre-Phase-18 endpoint; only Opus appends ?format=opus.
|
||||
var uri = format == AudioFormat.Lossless
|
||||
? $"api/track/{trackId}"
|
||||
: $"api/track/{trackId}?format={format.ToString().ToLowerInvariant()}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
// Bounded (byteEnd set) → "bytes=start-end" so the server returns a finite 206
|
||||
// slice and the browser buffers only that segment; open-ended (byteEnd null) →
|
||||
// "bytes=start-". The server honours both via File(..., enableRangeProcessing: true),
|
||||
// which parses the full RFC 7233 range grammar and slices accordingly.
|
||||
request.Headers.Range = new RangeHeaderValue(byteOffset, byteEnd);
|
||||
|
||||
// Stream the response body incrementally instead of buffering it whole (Phase 21.4 fix).
|
||||
// In Blazor WebAssembly the HttpClient is backed by the browser fetch API; without this the
|
||||
// browser buffers the ENTIRE body before the response stream yields a byte. With Direction B
|
||||
// each request is already bounded to one segment, so the body is small regardless — but
|
||||
// streaming still lets us read it incrementally and is harmless on the SSR server-to-server
|
||||
// path (SocketsHttpHandler ignores the unknown option). Kept for both the initial and the
|
||||
// seek/refill paths since both share this method.
|
||||
request.SetBrowserResponseStreamingEnabled(true);
|
||||
|
||||
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
|
||||
var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
@@ -72,11 +122,15 @@ public class TrackMediaClient
|
||||
// Default to WAV when the server omits the header — the only format shipping
|
||||
// today — so the JS factory always receives a usable media type.
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType ?? "audio/wav";
|
||||
// Content-Range "bytes start-end/TOTAL" carries the full file length on a 206; on a 200
|
||||
// there is no Content-Range, so TotalLength is null and callers use ContentLength.
|
||||
var totalLength = response.Content.Headers.ContentRange?.Length;
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
|
||||
// TrackMediaResponse takes ownership of both stream and response;
|
||||
// do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose().
|
||||
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength, contentType, response));
|
||||
return ApiResult<TrackMediaResponse>.CreatePassResult(
|
||||
new TrackMediaResponse(stream, contentLength, contentType, totalLength, response));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -115,4 +169,33 @@ public class TrackMediaClient
|
||||
return ApiResult<WaveformProfileDto>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a track's Opus seek/setup sidecar — the combined OpusHead/OpusTags setup header plus the
|
||||
/// granule→byte seek index (Phase 18). The caller (18.5 player wiring) fetches this once on track load
|
||||
/// and parses it into the JS-side OpusSeekData before issuing any Opus seek. A 404 means no Opus
|
||||
/// artifact / sidecar exists for the track (legacy row, not backfilled, or transcode failed); callers
|
||||
/// treat that as "this track has no Opus seek data — stay on lossless" rather than an error, so it
|
||||
/// surfaces as a fail result with a stable message rather than throwing (mirrors GetWaveformProfileAsync).
|
||||
/// </summary>
|
||||
public async Task<ApiResult<byte[]>> GetOpusSidecarAsync(string trackId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.GetAsync($"api/track/{trackId}/opus/seekdata", cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return ApiResult<byte[]>.CreateFailResult("No Opus sidecar available");
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
return ApiResult<byte[]>.CreatePassResult(bytes);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ApiResult<byte[]>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Common;
|
||||
|
||||
/// <summary>
|
||||
/// The single public-site listener-settings object (Phase 18 wave 18.6, §4a). The generalized analogue of
|
||||
/// <see cref="DarkModeSettings"/>: one scoped holder for every remembered listener preference, seeded at
|
||||
/// server prerender, carried into WASM via <see cref="PersistentState"/>, and persisted to a cookie on
|
||||
/// change. Today it carries one preference — streaming quality; tomorrow dark mode (and whatever follows)
|
||||
/// folds in here as another property without disturbing the menu that reads it.
|
||||
/// <para>
|
||||
/// Built design-for-adaptability per §4a: a new preference is a new <c>[PersistentState]</c> property here
|
||||
/// plus a new <see cref="Components.SettingsItem"/> in the menu — not a rewire. Dark mode is intentionally
|
||||
/// <em>not</em> migrated in now (it keeps its own <see cref="DarkModeSettings"/> seam); this object is shaped
|
||||
/// so that consolidation is later a merge of two identical seams, not a reconciliation of two different ones.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class PublicSiteSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// The listener's streaming-quality preference. Defaults to <see cref="StreamQuality.LowData"/> (Opus,
|
||||
/// capability-gated — OQ2). Seeded from the <c>streamQuality</c> cookie at prerender; persisted on change
|
||||
/// by the client cookie service. The player reads this to decide which <c>?format=</c> to request, but
|
||||
/// the capability gate and C2 fallback still apply on top, so a <see cref="StreamQuality.LowData"/>
|
||||
/// preference never forces an unplayable stream.
|
||||
/// </summary>
|
||||
[PersistentState]
|
||||
public StreamQuality StreamQuality { get; set; } = StreamQuality.LowData;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Common;
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the public-site Settings menu (Phase 18 wave 18.6, §4a). The settings-item abstraction the
|
||||
/// menu renders instead of a hard-coded control list: a <see cref="Label"/> plus a <see cref="Control"/>
|
||||
/// fragment bound to a persisted preference. Adding a future tenant (e.g. dark mode) is appending one of
|
||||
/// these — not rewiring the menu. The control fragment owns its own binding to <see cref="PublicSiteSettings"/>
|
||||
/// and its own persistence call, so each item is self-contained and the menu stays preference-agnostic.
|
||||
/// </summary>
|
||||
public sealed record SettingsItem(string Label, RenderFragment Control);
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace DeepDrftPublic.Client.Common;
|
||||
|
||||
/// <summary>
|
||||
/// The listener's streaming-quality preference (Phase 18 wave 18.6, §4). This is the user's <em>intent</em>,
|
||||
/// not the wire format that ultimately gets served: <see cref="LowData"/> means "give me Opus if you can,"
|
||||
/// but the player still capability-gates and C2-falls-back to lossless when Opus can't play (a browser that
|
||||
/// can't decode Ogg Opus, or a track with no Opus artifact). It is therefore deliberately distinct from
|
||||
/// <c>DeepDrftModels.Enums.AudioFormat</c> (the delivery rendering resolved per request): one is the
|
||||
/// remembered preference, the other is what a given stream request actually asks for.
|
||||
/// </summary>
|
||||
public enum StreamQuality
|
||||
{
|
||||
/// <summary>Bandwidth-friendly Opus (capability-gated; the default before any choice — OQ2).</summary>
|
||||
LowData,
|
||||
|
||||
/// <summary>The lossless WAV path, always playable everywhere.</summary>
|
||||
Lossless
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@using DeepDrftPublic.Client.Common
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Controls.Settings
|
||||
@using DeepDrftPublic.Client.Services
|
||||
|
||||
@* Desktop Menu *@
|
||||
@@ -42,6 +43,7 @@
|
||||
|
||||
<div class="dd-nav-actions">
|
||||
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now ▶"/>
|
||||
<SettingsMenu />
|
||||
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -74,7 +76,8 @@
|
||||
@onclick="ToggleMobileMenu">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
|
||||
|
||||
<SettingsMenu />
|
||||
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
@code {
|
||||
private string _audioPlayerClass = "minimized";
|
||||
private const string DarkModeKey = "darkMode";
|
||||
private const string StreamQualityKey = "streamQuality";
|
||||
private bool _isDarkMode = false;
|
||||
private bool? _lastAppliedDarkMode = null;
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
@@ -49,6 +50,7 @@
|
||||
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
[Inject] public required DarkModeSettings DarkModeSettings { get; set; }
|
||||
[Inject] public required PublicSiteSettings PublicSiteSettings { get; set; }
|
||||
[Inject] public required IJSRuntime JS { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
@@ -66,8 +68,17 @@
|
||||
_isDarkMode = DarkModeSettings.IsDarkMode;
|
||||
}
|
||||
|
||||
// Restore the prerender-seeded streaming-quality preference (Phase 18 wave 18.6). Same bridge dark
|
||||
// mode uses: the server SettingsService seeded PublicSiteSettings from the streamQuality cookie, and
|
||||
// this carries it into WASM so the client boots already knowing the preference (no re-read flash, no
|
||||
// wrong default before the first stream).
|
||||
if (PersistentState.TryTakeFromJson<StreamQuality>(StreamQualityKey, out var restoredQuality))
|
||||
{
|
||||
PublicSiteSettings.StreamQuality = restoredQuality;
|
||||
}
|
||||
|
||||
// Register to persist state when prerendering completes
|
||||
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistDarkMode);
|
||||
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistState);
|
||||
}
|
||||
|
||||
// Sync dark mode class on <body> so portaled MudBlazor elements (popovers, menus, selects)
|
||||
@@ -91,9 +102,10 @@
|
||||
// Theme wrapper class for CSS targeting
|
||||
private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light";
|
||||
|
||||
private Task PersistDarkMode()
|
||||
private Task PersistState()
|
||||
{
|
||||
PersistentState.PersistAsJson(DarkModeKey, _isDarkMode);
|
||||
PersistentState.PersistAsJson(StreamQualityKey, PublicSiteSettings.StreamQuality);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,37 @@ public class AudioInteropService : IAsyncDisposable
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength, contentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probes whether this browser can stream-decode Ogg Opus via WebCodecs (<c>AudioDecoder</c> +
|
||||
/// <c>codec:'opus'</c>; Safari < 16.4 / older Firefox cannot). Phase 18 capability gate (OQ2): the
|
||||
/// player only requests Opus when this returns true, otherwise it stays on the universal lossless path
|
||||
/// (AC7 — no listener ever gets silence over a codec gap). Probe failures degrade to <c>false</c>
|
||||
/// (assume incapable) so an interop error can never silence playback.
|
||||
/// </summary>
|
||||
public async Task<bool> CanDecodeOggOpus()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<bool>("DeepDrftAudio.canDecodeOggOpus");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands the raw Opus seek/setup sidecar bytes (setup header + granule→byte seek index) to the JS player
|
||||
/// so the next Opus stream's decoder has them BEFORE init (the 18.4 set-before-init contract). The player
|
||||
/// parses and stashes them; <see cref="InitializeStreaming"/> applies them when it builds the Opus decoder.
|
||||
/// Must be called before <see cref="InitializeStreaming"/> on an Opus stream. Returns the parse result —
|
||||
/// a failure means the bytes were not a valid sidecar, and the caller falls back to lossless.
|
||||
/// </summary>
|
||||
public async Task<AudioOperationResult> SetOpusSidecar(string playerId, byte[] sidecarBytes)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setOpusSidecar", playerId, sidecarBytes);
|
||||
}
|
||||
|
||||
public async Task<StreamingResult> ProcessStreamingChunk(string playerId, byte[] audioChunk)
|
||||
{
|
||||
return await InvokeJsAsync<StreamingResult>("DeepDrftAudio.processStreamingChunk", playerId, audioChunk);
|
||||
@@ -115,6 +146,18 @@ public class AudioInteropService : IAsyncDisposable
|
||||
return await InvokeJsAsync<SeekResult>("DeepDrftAudio.seek", playerId, position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the file-absolute byte offset to begin a stream at <paramref name="position"/> with no
|
||||
/// active playback or buffered audio — the "load at timestamp" seam (Phase 18 wave 18.6 format switch).
|
||||
/// Returns <see cref="SeekResult.ByteOffset"/> on success; <see cref="AudioOperationResult.Success"/> is
|
||||
/// false when the decoder cannot yet resolve an offset (e.g. a WAV stream whose header has not been
|
||||
/// parsed), so the caller can feed header bytes and retry.
|
||||
/// </summary>
|
||||
public async Task<SeekResult> ResolveStreamOffsetAsync(string playerId, double position)
|
||||
{
|
||||
return await InvokeJsAsync<SeekResult>("DeepDrftAudio.resolveStreamOffset", playerId, position);
|
||||
}
|
||||
|
||||
// New methods for seek-beyond-buffer support
|
||||
public async Task<double> GetBufferedDuration(string playerId)
|
||||
{
|
||||
@@ -128,11 +171,42 @@ public class AudioInteropService : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 21.2a back-pressure poll: ask whether the scheduler is still over its forward
|
||||
/// high/low-water band. The read loop calls this only WHILE already throttled, to learn when it
|
||||
/// may resume reading — the steady-state loop reads the piggybacked <c>ProductionPaused</c> flag
|
||||
/// off each chunk result instead. Defaults to false on any interop failure so a torn-down player
|
||||
/// never wedges a loop that is exiting anyway.
|
||||
/// </summary>
|
||||
public async Task<bool> IsProductionPaused(string playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<bool>("DeepDrftAudio.isProductionPaused", playerId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> ReinitializeFromOffset(string playerId, long totalStreamLength, double seekPosition)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.reinitializeFromOffset", playerId, totalStreamLength, seekPosition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 21.3 / AC6 clean-failure recovery: after a window-miss refill (seek-back past the
|
||||
/// retained tail) fails its Range fetch or reinit, halt the starved scheduler and leave the
|
||||
/// player paused-but-loaded at <paramref name="seekPosition"/> so no silent false end fires and a
|
||||
/// retry is possible. Routes through <see cref="InvokeJsAsync{T}"/> so an interop failure during
|
||||
/// recovery still yields a failure result rather than throwing into the seek path.
|
||||
/// </summary>
|
||||
public async Task<AudioOperationResult> RecoverFromFailedRefill(string playerId, double seekPosition)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.recoverFromFailedRefill", playerId, seekPosition);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetVolumeAsync(string playerId, double volume)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setVolume", playerId, volume);
|
||||
@@ -388,6 +462,11 @@ public class StreamingResult : AudioOperationResult
|
||||
public bool HeaderParsed { get; set; }
|
||||
public int BufferCount { get; set; }
|
||||
public double? Duration { get; set; } // Duration in seconds calculated from WAV header
|
||||
|
||||
// Phase 21.2a back-pressure: true when the scheduler's forward decoded fill is over the
|
||||
// high-water mark and the C# read loop should stop calling ReadAsync until it drains. Read off
|
||||
// the chunk result the loop already awaits — no extra interop hop in the unthrottled steady state.
|
||||
public bool ProductionPaused { get; set; }
|
||||
}
|
||||
|
||||
public class AudioPlayerState
|
||||
|
||||
@@ -118,6 +118,16 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
IsPlaying = true;
|
||||
IsPaused = false;
|
||||
}
|
||||
else if (IsPaused)
|
||||
{
|
||||
// Play failed while the player is paused — the scheduler may be empty after a
|
||||
// failed refill (AC6 recovery). Re-issue a seek at the current position: the
|
||||
// seek path routes to seekBeyondBuffer when the scheduler is empty (Phase 21.3
|
||||
// fix), triggering a real refetch rather than returning "Streaming not ready".
|
||||
// We return early here; Seek owns its own state mutations and NotifyStateChanged.
|
||||
await Seek(CurrentTime);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
@@ -128,7 +138,7 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
{
|
||||
ErrorMessage = null;
|
||||
}
|
||||
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -298,7 +308,6 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
/// </summary>
|
||||
protected virtual void OnPlaybackEnded() { }
|
||||
|
||||
|
||||
protected async Task EnsureInitializedAsync()
|
||||
{
|
||||
if (!IsInitialized)
|
||||
|
||||
@@ -14,9 +14,7 @@ public class DarkModeCookieService(DarkModeSettings darkModeSetting, IJSRuntime
|
||||
|
||||
public async ValueTask SetDarkModeAsync(bool isDarkMode)
|
||||
{
|
||||
var expires = DateTime.UtcNow.AddDays(EXPIRY_DAYS).ToString("R");
|
||||
await js.InvokeVoidAsync("eval",
|
||||
$"document.cookie = '{COOKIE_NAME}={isDarkMode.ToString().ToLower()}; expires={expires}; path=/; SameSite=Lax'");
|
||||
await js.InvokeVoidAsync("DeepDrftSettings.setCookie", COOKIE_NAME, isDarkMode.ToString().ToLower(), EXPIRY_DAYS);
|
||||
darkModeSetting.IsDarkMode = isDarkMode;
|
||||
}
|
||||
}
|
||||
@@ -91,4 +91,16 @@ public interface IStreamingPlayerService : IPlayerService
|
||||
/// <see cref="IPlayerService.CurrentTrack"/> and notifies; performs no JS interop.
|
||||
/// </summary>
|
||||
Task StageTrack(TrackDto track);
|
||||
|
||||
/// <summary>
|
||||
/// Re-streams the current track in the freshly-resolved delivery format while preserving the
|
||||
/// listener's playback position (Phase 18 wave 18.6 — the Settings "Apply" restart). The format is
|
||||
/// re-resolved on the new load via the <c>ResolveStreamFormatAsync</c> seam, so this picks up a just-
|
||||
/// changed streaming-quality preference. A cross-format byte offset can only be resolved once the NEW
|
||||
/// format's decoder has parsed its header, so the reload runs a fresh load from byte 0 to initialize
|
||||
/// that decoder, then seeks back to the saved position through the existing seek-beyond-buffer path.
|
||||
/// No-op when no track is loaded (nothing playing to switch). Safe to call while a track is playing;
|
||||
/// a track switch during the brief restore window abandons the position restore.
|
||||
/// </summary>
|
||||
Task ReloadPreservingPositionAsync();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Clients;
|
||||
using DeepDrftPublic.Client.Common;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The production player that honours the listener's streaming-quality preference (Phase 18 wave 18.6).
|
||||
/// Extends <see cref="StreamingAudioPlayerService"/> through the single deliberately-overridable seam,
|
||||
/// <see cref="StreamingAudioPlayerService.ResolveStreamFormatAsync"/>, so the rest of the streaming stack
|
||||
/// (seek, telemetry, the seek-beyond-buffer format reuse) is inherited verbatim.
|
||||
/// <para>
|
||||
/// The override is one branch: a <see cref="StreamQuality.Lossless"/> preference returns
|
||||
/// <see cref="AudioFormat.Lossless"/> immediately; anything else falls through to <c>base</c>, which keeps
|
||||
/// the 18.5 invariants intact — the capability gate (AC7: a browser that can't decode Ogg Opus gets lossless)
|
||||
/// and the sidecar-absent → lossless fallback (C2: a legacy / un-backfilled / failed-transcode track gets
|
||||
/// lossless). So a Lossless pick always yields lossless; a Low-data pick yields Opus only when it can
|
||||
/// actually play, and lossless otherwise. No path produces an unplayable stream.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class PreferenceAwareStreamingPlayerService : StreamingAudioPlayerService
|
||||
{
|
||||
private readonly PublicSiteSettings _settings;
|
||||
|
||||
public PreferenceAwareStreamingPlayerService(
|
||||
AudioInteropService audioInterop,
|
||||
TrackMediaClient trackMediaClient,
|
||||
ILogger<StreamingAudioPlayerService> logger,
|
||||
PublicSiteSettings settings)
|
||||
: base(audioInterop, trackMediaClient, logger)
|
||||
{
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
protected override async Task<AudioFormat> ResolveStreamFormatAsync(string entryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
// Listener explicitly chose lossless — request it directly, no Opus probe / sidecar fetch needed.
|
||||
if (_settings.StreamQuality == StreamQuality.Lossless)
|
||||
{
|
||||
return AudioFormat.Lossless;
|
||||
}
|
||||
|
||||
// Low-data preference: defer to the base capability-gated resolution, which probes Opus support and
|
||||
// the sidecar's presence and degrades to lossless when either is missing. Both 18.5 invariants are
|
||||
// inherited here, not re-implemented.
|
||||
return await base.ResolveStreamFormatAsync(entryKey, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using DeepDrftPublic.Client.Common;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client-side runtime writer for public-site settings (Phase 18 wave 18.6), the analogue of
|
||||
/// <see cref="DarkModeCookieService"/>. Reads the current preference off the in-memory
|
||||
/// <see cref="PublicSiteSettings"/> (already seeded at prerender and bridged into WASM), and writes a
|
||||
/// 365-day cookie via <c>document.cookie</c> interop when the listener changes it in the Settings menu —
|
||||
/// the same durable-truth seam dark mode uses, so the choice survives the session and seeds the next visit's
|
||||
/// prerender (no flash).
|
||||
/// </summary>
|
||||
public class SettingsCookieService(PublicSiteSettings settings, IJSRuntime js) : SettingsServiceBase
|
||||
{
|
||||
private const int ExpiryDays = 365;
|
||||
|
||||
public StreamQuality GetStreamQuality() => settings.StreamQuality;
|
||||
|
||||
public async ValueTask SetStreamQualityAsync(StreamQuality quality)
|
||||
{
|
||||
if (settings.StreamQuality == quality) return;
|
||||
|
||||
await WriteCookieAsync(StreamQualityCookieName, FormatStreamQuality(quality));
|
||||
settings.StreamQuality = quality;
|
||||
}
|
||||
|
||||
private async ValueTask WriteCookieAsync(string name, string value)
|
||||
{
|
||||
await js.InvokeVoidAsync("DeepDrftSettings.setCookie", name, value, ExpiryDays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using DeepDrftPublic.Client.Common;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Shared cookie contract for the public-site settings seam (Phase 18 wave 18.6), the analogue of
|
||||
/// <see cref="DarkModeServiceBase"/>. Holds the cookie names and the (de)serialization for each preference
|
||||
/// so the server prerender-read service and the client cookie-write service agree on one wire format —
|
||||
/// the load-bearing reason this is shared rather than duplicated. Each new preference adds its cookie name
|
||||
/// and a parse/format pair here, keeping the round-trip in one place.
|
||||
/// </summary>
|
||||
public abstract class SettingsServiceBase
|
||||
{
|
||||
protected const string StreamQualityCookieName = "streamQuality";
|
||||
|
||||
/// <summary>
|
||||
/// Parses the <c>streamQuality</c> cookie value into <see cref="StreamQuality"/>, defaulting to
|
||||
/// <see cref="StreamQuality.LowData"/> (the OQ2 default) for an absent, empty, or unrecognized value so
|
||||
/// a missing/garbled cookie never produces a surprising preference.
|
||||
/// </summary>
|
||||
protected static StreamQuality ParseStreamQuality(string? cookieValue) =>
|
||||
Enum.TryParse<StreamQuality>(cookieValue, ignoreCase: true, out var parsed)
|
||||
? parsed
|
||||
: StreamQuality.LowData;
|
||||
|
||||
/// <summary>Formats a <see cref="StreamQuality"/> for cookie storage (round-trips with <see cref="ParseStreamQuality"/>).</summary>
|
||||
protected static string FormatStreamQuality(StreamQuality quality) => quality.ToString();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Clients;
|
||||
using System.Buffers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -15,6 +16,36 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
// Adaptive chunk sizing
|
||||
private const int MinBufferSize = 16 * 1024; // 16KB minimum
|
||||
private const int MaxBufferSize = 64 * 1024; // 64KB maximum
|
||||
|
||||
// Phase 21.2a back-pressure poll interval. While the scheduler is over its forward high-water
|
||||
// mark, the segment loop stops fetching the next segment and polls IsProductionPaused at this
|
||||
// cadence until the fill drains below low-water. 100 ms is well under the low-water lookahead
|
||||
// (seconds), so resume is prompt relative to the playhead — no starvation (AC3) — while keeping
|
||||
// the poll cheap. The poll honors the loop's cancellation token, so a track switch/seek during a
|
||||
// pause exits through the same drain discipline as a pause during ReadAsync (C6).
|
||||
private const int BackpressurePollMs = 100;
|
||||
|
||||
// Phase 21 Direction B — forward Range-segment size. The forward stream is fetched as a
|
||||
// sequence of bounded "bytes=cursor-(cursor+SegmentSizeBytes-1)" 206 requests, the next issued
|
||||
// only when the scheduler drains below low-water. Because each request is bounded and fully
|
||||
// consumed before the next is issued, the browser fetch holds AT MOST ~one segment of raw bytes
|
||||
// regardless of file size — this is the network-memory bound the phase exists for (the open-ended
|
||||
// single GET buffered the whole ~970 MB body in the browser even when reads were paused, the
|
||||
// 21.4 finding). 4 MB balances request overhead (a 1 GB mix is ~250 segments) against memory:
|
||||
// at the 30 s high-water mark a fast connection holds well under a segment of unplayed raw bytes,
|
||||
// so the bound is the segment size, not the decoded window. Tunable; not magic.
|
||||
private const long SegmentSizeBytes = 4 * 1024 * 1024;
|
||||
|
||||
// Phase 18 wave 18.6 — position-preserving format switch ("load at timestamp"). When the listener
|
||||
// changes streaming quality mid-track, the new-format stream is started DIRECTLY at the saved
|
||||
// position rather than from byte 0: the load resolves the byte offset for the target time in the
|
||||
// freshly-initialized decoder and streams from there (never audibly playing the start). For WAV the
|
||||
// byte-offset math needs the header, so the byte-0 segment is probed (fed to the decoder WITHOUT
|
||||
// starting playback) until the header parses; this caps that probe so a header that never appears
|
||||
// (corrupt stream) can't read unbounded. The decoder's own header-search ceiling is 256 KB, so this
|
||||
// matches it. Opus needs no probe (its sidecar resolves offsets immediately after init).
|
||||
private const int MaxHeaderProbeBytes = 256 * 1024;
|
||||
|
||||
private int _currentBufferSize = DefaultBufferSize;
|
||||
private int _consecutiveSlowReads = 0;
|
||||
|
||||
@@ -33,6 +64,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
private readonly ILogger<StreamingAudioPlayerService> _logger;
|
||||
private string? _currentTrackId;
|
||||
|
||||
// The delivery format the active load resolved to (Phase 18). Captured once per LoadTrackStreaming and
|
||||
// reused by the seek-beyond-buffer re-fetch so the Range continuation requests the SAME artifact the
|
||||
// initial stream did — a seek must never switch formats mid-track (the JS decoder, the cached setup
|
||||
// header, and the byte offsets all belong to one artifact). Defaults to Lossless until a load resolves.
|
||||
private AudioFormat _currentFormat = AudioFormat.Lossless;
|
||||
|
||||
// Phase 16 play-session telemetry (§2.1). The tracker observes the playback lifecycle and emits at
|
||||
// most one bucketed play event per session, behind the engagement floor. Attached after construction
|
||||
// by AudioPlayerProvider (the player is not DI-registered), mirroring how QueueService binds — no
|
||||
@@ -129,7 +166,30 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
private async Task LoadTrackStreaming(TrackDto track)
|
||||
/// <inheritdoc />
|
||||
public async Task ReloadPreservingPositionAsync()
|
||||
{
|
||||
// Nothing playing → nothing to switch. The new preference simply takes effect on the next play.
|
||||
if (CurrentTrack is not { } track || !IsStreamingMode) return;
|
||||
|
||||
// Capture the position to restore before the reload resets streaming state. Near the very start
|
||||
// there is nothing worth preserving — a plain restart in the new format is simpler and avoids a
|
||||
// needless seek-offset resolution.
|
||||
var resumeAt = CurrentTime;
|
||||
|
||||
await EnsureInitializedAsync();
|
||||
await _audioInterop.EnsureAudioContextReady(PlayerId);
|
||||
await NotifyTrackSelected();
|
||||
|
||||
// Reload the same track in the newly-resolved delivery format. A near-start position restarts
|
||||
// from byte 0; otherwise the load begins DIRECTLY at the saved position (no audible playback
|
||||
// from the start). LoadTrackStreaming runs the whole forward segment loop, so this is the last
|
||||
// meaningful await — the caller already fires this fire-and-forget.
|
||||
await LoadTrackStreaming(track, startPosition: resumeAt > 1.0 ? resumeAt : null);
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
private async Task LoadTrackStreaming(TrackDto track, double? startPosition = null)
|
||||
{
|
||||
// Always reset to clean state before loading new track. ResetToIdle
|
||||
// both cancels and awaits any in-flight streaming loop, so by the time
|
||||
@@ -174,22 +234,35 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
|
||||
await NotifyStateChanged();
|
||||
|
||||
// Pass the streaming token to the HTTP layer so a navigation/track switch
|
||||
// aborts the server connection instead of leaving it draining bytes.
|
||||
var mediaResult = await _trackMediaClient.GetTrackMedia(
|
||||
// Resolve the delivery format for this load BEFORE requesting bytes (Phase 18, default policy
|
||||
// OQ2). When Opus is chosen the sidecar is fetched and injected into the JS player here, ahead of
|
||||
// InitializeStreaming, honouring the 18.4 set-before-init contract. The result is captured so the
|
||||
// seek-beyond-buffer re-fetch reuses the same artifact.
|
||||
_currentFormat = await ResolveStreamFormatAsync(track.EntryKey, loadCts.Token);
|
||||
|
||||
// Direction B: fetch the FIRST bounded segment to learn the total file length and the
|
||||
// content type. The 206 Content-Range carries the total; the segment loop advances its
|
||||
// cursor toward it. The decoder is initialized with the TOTAL length (not the segment
|
||||
// length) so a bounded segment's small Content-Length never trips its byte-count
|
||||
// completion early — segment boundaries are invisible to the decoder, which sees one
|
||||
// continuous in-order byte stream. Passing the streaming token aborts the server
|
||||
// connection on a navigation/track switch instead of leaving it draining bytes.
|
||||
var firstSegment = await _trackMediaClient.GetTrackMedia(
|
||||
track.EntryKey,
|
||||
byteOffset: 0,
|
||||
byteEnd: SegmentSizeBytes - 1,
|
||||
format: _currentFormat,
|
||||
cancellationToken: loadCts.Token);
|
||||
if (!mediaResult.Success)
|
||||
if (!firstSegment.Success)
|
||||
{
|
||||
var technicalError = mediaResult.GetMessage();
|
||||
var technicalError = firstSegment.GetMessage();
|
||||
_logger.LogError("Failed to get track media for {TrackId}: {Error}",
|
||||
track.EntryKey, technicalError);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaResult.Value == null)
|
||||
if (firstSegment.Value == null)
|
||||
{
|
||||
const string technicalError = "No audio returned from server";
|
||||
_logger.LogError("No audio data returned for track {TrackId}", track.EntryKey);
|
||||
@@ -197,13 +270,22 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
return;
|
||||
}
|
||||
|
||||
using var audio = mediaResult.Value;
|
||||
// Ownership of the first segment transfers to the segment loop, which disposes it (and
|
||||
// every subsequent segment). No `using` here — a double dispose is avoided and the socket
|
||||
// is released the moment the loop finishes consuming the segment.
|
||||
var audio = firstSegment.Value;
|
||||
|
||||
// Initialize streaming mode with content length and media type (drives
|
||||
// JS format-decoder selection).
|
||||
var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, audio.ContentLength, audio.ContentType);
|
||||
// The total logical length the decoder must see. On a 206 the Content-Range carries it;
|
||||
// a 200 (server ignored Range / file ≤ one segment) has no Content-Range, so fall back to
|
||||
// the body's own Content-Length — that body IS the whole file in that case.
|
||||
var totalLength = audio.TotalLength ?? audio.ContentLength;
|
||||
|
||||
// Initialize streaming mode with the TOTAL length and media type (drives JS
|
||||
// format-decoder selection). See above: total, not segment, length.
|
||||
var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, totalLength, audio.ContentType);
|
||||
if (!streamingResult.Success)
|
||||
{
|
||||
audio.Dispose();
|
||||
var technicalError = $"Failed to initialize streaming: {streamingResult.Error}";
|
||||
_logger.LogError("Streaming initialization failed for track {TrackId}: {Error}",
|
||||
track.EntryKey, technicalError);
|
||||
@@ -211,8 +293,23 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
return;
|
||||
}
|
||||
|
||||
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, loadCts.Token);
|
||||
await _activeStreamingTask;
|
||||
if (startPosition is { } startAt)
|
||||
{
|
||||
// "Load at timestamp" (Phase 18 wave 18.6 format switch): begin the stream DIRECTLY at
|
||||
// startAt rather than byte 0, so the listener never hears the track restart from the
|
||||
// beginning. The byte-0 segment in hand is used only to parse the header for byte-offset
|
||||
// math (WAV) — Opus resolves the offset from its sidecar with no probe — and then a fresh
|
||||
// segment is fetched from the resolved offset and pumped via the shared seek/refill loop.
|
||||
await StartFromPositionAsync(track.EntryKey, audio, totalLength, startAt, loadCts.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Forward segmentation from byte 0. The first segment is already in hand; the loop pumps
|
||||
// it, then fetches subsequent bounded segments gated on the scheduler fill signal.
|
||||
_activeStreamingTask = RunSegmentedStreamAsync(
|
||||
track.EntryKey, audio, cursor: 0, totalLength, seekPosition: null, loadCts.Token);
|
||||
await _activeStreamingTask;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (loadCts.IsCancellationRequested)
|
||||
{
|
||||
@@ -233,10 +330,33 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
catch (Exception ex)
|
||||
{
|
||||
StreamingErrorHandler.LogError(_logger, ex, "LoadTrackStreaming", track.EntryKey);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
IsStreamingMode = false;
|
||||
var userError = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
|
||||
|
||||
// Mid-stream failure (playback was already underway): halt the JS scheduler into a clean
|
||||
// paused-but-loaded state exactly as the seek path does via RecoverFromFailedRefill, rather
|
||||
// than resetting to unloaded and letting the scheduler's buffered tail drain into a silent
|
||||
// false end (AC6). Apply only when this load is still the active operation — a superseding
|
||||
// seek owns state and has already replaced _streamingCancellation with its own CTS.
|
||||
if (_streamingPlaybackStarted && ReferenceEquals(_streamingCancellation, loadCts))
|
||||
{
|
||||
await RecoverFromFailedRefill(CurrentTime, userError);
|
||||
}
|
||||
else if (ReferenceEquals(_streamingCancellation, loadCts))
|
||||
{
|
||||
// First-segment failure (nothing buffered / playing yet), still the active operation:
|
||||
// the normal unload-to-error path is correct — nothing is in the scheduler to halt.
|
||||
ErrorMessage = userError;
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
IsStreamingMode = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Superseded load: a newer seek (or track switch) has already claimed _streamingCancellation
|
||||
// and owns all shared state. Writing IsLoaded/IsStreamingMode here would corrupt the live
|
||||
// operation — mirror the OCE catch's identity guard and do nothing to shared state.
|
||||
_logger.LogDebug("Generic throw on superseded load for track {TrackId} — newer operation owns state, skipping unload", track.EntryKey);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -250,6 +370,178 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begin streaming the freshly-initialized track DIRECTLY at <paramref name="startPosition"/> instead
|
||||
/// of byte 0 (Phase 18 wave 18.6 — the position-preserving format switch). The decoder has already been
|
||||
/// built by <c>InitializeStreaming</c>; this resolves the file-absolute byte offset for the target time
|
||||
/// and then converges onto the shared seek/refill loop (<see cref="RunSegmentedStreamAsync"/> with a
|
||||
/// non-null seekPosition), which reinitializes the decoder for a header-less Range continuation and
|
||||
/// starts playback at the target — so nothing is ever audibly played from the start.
|
||||
/// <para>
|
||||
/// Opus resolves the offset from its sidecar immediately; WAV needs its header, so the byte-0 segment
|
||||
/// already in hand is fed to the decoder (WITHOUT starting playback) until the header parses, then the
|
||||
/// offset resolves. The probe is bounded by <see cref="MaxHeaderProbeBytes"/>. The byte-0 segment is
|
||||
/// disposed once the header is in hand; the continuation is a fresh fetch from the resolved offset.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task StartFromPositionAsync(
|
||||
string trackId,
|
||||
TrackMediaResponse headerSegment,
|
||||
long totalLength,
|
||||
double startPosition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Resolve the byte offset for the target time. Opus answers immediately from its sidecar; WAV
|
||||
// returns failure until its header is parsed, so we probe the byte-0 segment and retry. The
|
||||
// byte-0 segment is disposed once it has served its purpose (header probe / nothing for Opus),
|
||||
// even if the probe throws, so its socket never leaks before the continuation fetch.
|
||||
SeekResult resolved;
|
||||
try
|
||||
{
|
||||
resolved = await _audioInterop.ResolveStreamOffsetAsync(PlayerId, startPosition);
|
||||
if (!resolved.Success || !resolved.SeekBeyondBuffer)
|
||||
{
|
||||
await ProbeHeaderAsync(headerSegment, cancellationToken);
|
||||
resolved = await _audioInterop.ResolveStreamOffsetAsync(PlayerId, startPosition);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
headerSegment.Dispose();
|
||||
}
|
||||
|
||||
if (!resolved.Success || !resolved.SeekBeyondBuffer)
|
||||
{
|
||||
// Could not resolve an offset even after probing — the stream is unusable for a positioned
|
||||
// start. Surface as an error rather than silently restarting from 0 (which would contradict
|
||||
// the "preserve position" contract the listener invoked). The catch in LoadTrackStreaming
|
||||
// settles the error state.
|
||||
throw new Exception(resolved.Error ?? "Could not resolve a stream offset for the requested position");
|
||||
}
|
||||
|
||||
// Fetch the FIRST bounded segment from the resolved offset and pump it through the shared loop
|
||||
// exactly as a seek-beyond-buffer does (reinit for the header-less continuation happens inside,
|
||||
// and playback starts at startPosition). Reuse the format the load already resolved to.
|
||||
var byteOffset = resolved.ByteOffset;
|
||||
var firstSegment = await _trackMediaClient.GetTrackMedia(
|
||||
trackId,
|
||||
byteOffset,
|
||||
byteEnd: byteOffset + SegmentSizeBytes - 1,
|
||||
format: _currentFormat,
|
||||
cancellationToken: cancellationToken);
|
||||
if (!firstSegment.Success || firstSegment.Value == null)
|
||||
{
|
||||
var technicalError = firstSegment.GetMessage() ?? "Failed to load audio from position";
|
||||
_logger.LogError("Failed to get track media from offset {Offset} for {TrackId}: {Error}",
|
||||
byteOffset, trackId, technicalError);
|
||||
throw new Exception(technicalError);
|
||||
}
|
||||
|
||||
var audio = firstSegment.Value;
|
||||
// The absolute EOF boundary the segment loop targets. On a 206 the Content-Range carries the file
|
||||
// total; on a 200 (single-segment tail) fall back to the offset plus this body's length.
|
||||
var continuationTotal = audio.TotalLength ?? (byteOffset + audio.ContentLength);
|
||||
|
||||
// Fresh playback-start transition for the positioned stream (it has not started yet).
|
||||
_streamingPlaybackStarted = false;
|
||||
CanStartStreaming = false;
|
||||
BufferedChunks = 0;
|
||||
// Reflect the landing position immediately so the UI seek bar shows the right spot while the
|
||||
// first post-offset buffers decode.
|
||||
CurrentTime = startPosition;
|
||||
|
||||
_activeStreamingTask = RunSegmentedStreamAsync(
|
||||
trackId, audio, cursor: byteOffset, continuationTotal, seekPosition: startPosition, cancellationToken);
|
||||
await _activeStreamingTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feed bytes from the byte-0 <paramref name="segment"/> into the decoder until its header parses
|
||||
/// (<see cref="HeaderParsed"/>), WITHOUT starting playback — the WAV byte-offset math needs the header
|
||||
/// before <see cref="AudioInteropService.ResolveStreamOffsetAsync"/> can answer. Bounded by
|
||||
/// <see cref="MaxHeaderProbeBytes"/> so a stream that never yields a header cannot read unbounded. The
|
||||
/// decoded buffers this queues are dropped by the subsequent Range-continuation reinit (clearForSeek),
|
||||
/// so nothing is audible and nothing leaks. Opus never reaches here (its offset resolves pre-probe).
|
||||
/// </summary>
|
||||
private async Task ProbeHeaderAsync(TrackMediaResponse segment, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(MaxBufferSize);
|
||||
try
|
||||
{
|
||||
var probed = 0;
|
||||
while (!HeaderParsed && probed < MaxHeaderProbeBytes)
|
||||
{
|
||||
var read = await segment.Stream.ReadAsync(buffer, 0, _currentBufferSize, cancellationToken);
|
||||
if (read <= 0) break; // segment exhausted before a header — let the caller surface the failure.
|
||||
probed += read;
|
||||
|
||||
// Slice to the exact bytes read (the pooled buffer may carry stale tail bytes).
|
||||
var chunk = buffer.AsSpan(0, read).ToArray();
|
||||
var result = await _audioInterop.ProcessStreamingChunk(PlayerId, chunk);
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new Exception($"Failed to process header probe chunk: {result.Error}");
|
||||
}
|
||||
|
||||
HeaderParsed = result.HeaderParsed;
|
||||
// Capture the once-only duration the header yields so the UI and play session have it.
|
||||
if (result.Duration.HasValue && Duration == null)
|
||||
{
|
||||
Duration = result.Duration.Value;
|
||||
_playTracker?.SetDuration(result.Duration.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves which delivery format this load should request (Phase 18 default policy, OQ2): Opus when the
|
||||
/// browser can decode Ogg Opus AND a sidecar exists for the track, otherwise lossless. When Opus is
|
||||
/// chosen the sidecar is injected into the JS player here (set-before-init, the 18.4 contract) so the
|
||||
/// decoder has its setup header + seek index before <c>InitializeStreaming</c> builds it.
|
||||
/// <para>
|
||||
/// This is the single, deliberately-overridable seam for the listener quality preference (wave 18.6).
|
||||
/// 18.6 overrides this to honour the user's "streaming quality" toggle — returning lossless when the
|
||||
/// listener picked it, and otherwise falling through to this capability-gated default. The capability
|
||||
/// gate (AC7) and the sidecar-absent → lossless fallback (C2) stay here so any override inherits both:
|
||||
/// a browser that cannot decode Opus, or a track with no sidecar, always lands on lossless and plays.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
protected virtual async Task<AudioFormat> ResolveStreamFormatAsync(string entryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
// Capability gate first (AC7): never hand Ogg Opus to a browser that cannot decode it.
|
||||
if (!await _audioInterop.CanDecodeOggOpus())
|
||||
{
|
||||
return AudioFormat.Lossless;
|
||||
}
|
||||
|
||||
// The sidecar must be present (and parseable by the JS decoder) to seek an Opus stream. Its absence
|
||||
// means the track has no Opus artifact yet (legacy / not backfilled / transcode failed) — request
|
||||
// lossless rather than Opus-without-a-sidecar (the server would C2-fall-back anyway, but asking for
|
||||
// lossless keeps the request honest and avoids a wasted Opus-then-fallback round-trip).
|
||||
var sidecar = await _trackMediaClient.GetOpusSidecarAsync(entryKey, cancellationToken);
|
||||
if (!sidecar.Success || sidecar.Value is not { Length: > 0 } sidecarBytes)
|
||||
{
|
||||
return AudioFormat.Lossless;
|
||||
}
|
||||
|
||||
// Inject BEFORE InitializeStreaming (the set-before-init contract). A parse failure here means the
|
||||
// bytes are not a usable sidecar — fall back to lossless so a malformed sidecar never breaks playback.
|
||||
var injected = await _audioInterop.SetOpusSidecar(PlayerId, sidecarBytes);
|
||||
if (!injected.Success)
|
||||
{
|
||||
_logger.LogWarning("Opus sidecar for {EntryKey} failed to parse ({Error}); falling back to lossless.",
|
||||
entryKey, injected.Error);
|
||||
return AudioFormat.Lossless;
|
||||
}
|
||||
|
||||
return AudioFormat.Opus;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches and decodes the track's waveform loudness profile, then notifies state so the
|
||||
/// seek zone re-renders with real bars. Best-effort: a 404 (no stored profile) or any other
|
||||
@@ -311,112 +603,223 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
return profile;
|
||||
}
|
||||
|
||||
private async Task StreamAudioWithEarlyPlayback(TrackMediaResponse audio, CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Phase 21 Direction B — the single segmented forward read loop, shared by the initial load and
|
||||
/// the seek/refill path (the convergence C1/C5 require: one cursor, one fetch mechanism, no forked
|
||||
/// path). It pumps the FIRST segment (already fetched by the caller), then fetches subsequent
|
||||
/// bounded <c>bytes=cursor-(cursor+SegmentSizeBytes-1)</c> 206 segments — each only AFTER the
|
||||
/// scheduler drains below low-water — until the cursor reaches <paramref name="totalLength"/>.
|
||||
/// Because each segment is bounded and fully consumed before the next is requested, the browser
|
||||
/// holds at most ~one segment of raw bytes (the network-memory bound), while the decoder sees one
|
||||
/// continuous in-order byte stream across segment boundaries (the demuxer/decoder buffer partial
|
||||
/// frames/pages across the boundary exactly as for arbitrary chunks today — no per-segment reinit).
|
||||
/// </summary>
|
||||
/// <param name="firstSegment">The already-fetched first segment (byte <paramref name="cursor"/>).
|
||||
/// Owned by this method, which disposes it; subsequent segments are fetched and disposed inline.</param>
|
||||
/// <param name="cursor">File-absolute byte offset the first segment starts at (0 for a fresh load,
|
||||
/// the resolved seek offset for a refill).</param>
|
||||
/// <param name="totalLength">Total file length in bytes — the EOF boundary the cursor advances
|
||||
/// toward. The decoder is initialized/reinitialized against this, not the per-segment length.</param>
|
||||
/// <param name="seekPosition">Non-null for a seek/refill: the decoder is reinitialized for the
|
||||
/// header-less Range continuation at this time before the first segment's bytes are fed (WAV
|
||||
/// retains its header, Opus re-applies the cached setup + lead-trim). Null for a forward load from
|
||||
/// byte 0, where the first segment carries the header and no reinit is needed.</param>
|
||||
private async Task RunSegmentedStreamAsync(
|
||||
string trackId,
|
||||
TrackMediaResponse firstSegment,
|
||||
long cursor,
|
||||
long totalLength,
|
||||
double? seekPosition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
byte[]? buffer = null;
|
||||
var segment = firstSegment;
|
||||
try
|
||||
{
|
||||
long totalBytesRead = 0;
|
||||
buffer = ArrayPool<byte>.Shared.Rent(MaxBufferSize); // Rent larger buffer to accommodate adaptive sizing
|
||||
int currentBytes;
|
||||
var readTimer = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
do
|
||||
// Seek/refill: reinitialize the active decoder for the header-less continuation ONCE,
|
||||
// before any continuation bytes are fed. Forward-from-zero (seekPosition null) skips this
|
||||
// — its first segment carries the real header the decoder parses. Done here, inside the
|
||||
// single loop, so seek and forward share the same fetch+pump mechanism (no forked path).
|
||||
if (seekPosition is { } resumeAt)
|
||||
{
|
||||
readTimer.Restart();
|
||||
currentBytes = await audio.Stream.ReadAsync(buffer, 0, _currentBufferSize, cancellationToken);
|
||||
readTimer.Stop();
|
||||
|
||||
// Adapt buffer size based on read performance
|
||||
AdaptBufferSize(currentBytes, readTimer.ElapsedMilliseconds);
|
||||
|
||||
if (currentBytes > 0)
|
||||
// The decoder byte-counts the header-less continuation against the bytes REMAINING
|
||||
// from the range start to EOF (total − cursor), not the absolute total — that is what
|
||||
// reinitializeForRangeContinuation expects (StreamDecoder.remainingByteLength). The
|
||||
// loop's own cursor still targets the absolute totalLength for EOF.
|
||||
var remainingBytes = Math.Max(0, totalLength - cursor);
|
||||
var reinitResult = await _audioInterop.ReinitializeFromOffset(PlayerId, remainingBytes, resumeAt);
|
||||
if (!reinitResult.Success)
|
||||
{
|
||||
totalBytesRead += currentBytes;
|
||||
|
||||
// Always slice to the exact number of bytes read. The pooled buffer
|
||||
// is rented at MaxBufferSize and may carry stale bytes past
|
||||
// currentBytes from a prior rental — handing the full array to JS
|
||||
// interop would serialise that garbage into the audio stream.
|
||||
var actualBuffer = buffer.AsSpan(0, currentBytes).ToArray();
|
||||
|
||||
// Process chunk for streaming
|
||||
var chunkResult = await _audioInterop.ProcessStreamingChunk(PlayerId, actualBuffer);
|
||||
if (!chunkResult.Success)
|
||||
{
|
||||
var error = $"Failed to process streaming chunk: {chunkResult.Error}";
|
||||
_logger.LogWarning("Chunk processing failed: {Error}", error);
|
||||
throw new Exception(error);
|
||||
}
|
||||
|
||||
// Update streaming state
|
||||
CanStartStreaming = chunkResult.CanStartStreaming;
|
||||
HeaderParsed = chunkResult.HeaderParsed;
|
||||
BufferedChunks = chunkResult.BufferCount;
|
||||
|
||||
// Set duration from WAV header when available (only set once)
|
||||
if (chunkResult.Duration.HasValue && Duration == null)
|
||||
{
|
||||
Duration = chunkResult.Duration.Value;
|
||||
_logger.LogInformation("Duration set from WAV header: {Duration:F2} seconds", Duration);
|
||||
// Feed the same once-only duration to the play session so it can compute the
|
||||
// completion fraction at close. Safe before/after session open — SetDuration
|
||||
// is a no-op when no session is open and idempotent otherwise.
|
||||
_playTracker?.SetDuration(chunkResult.Duration.Value);
|
||||
}
|
||||
|
||||
// Start playback as soon as we can
|
||||
if (!_streamingPlaybackStarted && CanStartStreaming)
|
||||
{
|
||||
var playbackResult = await _audioInterop.StartStreamingPlayback(PlayerId);
|
||||
if (playbackResult.Success)
|
||||
{
|
||||
_streamingPlaybackStarted = true;
|
||||
IsPlaying = true;
|
||||
IsPaused = false;
|
||||
IsLoaded = true; // Track is loaded and ready to play (even if still downloading)
|
||||
ErrorMessage = null;
|
||||
|
||||
// Open the play session exactly once per load, at the moment playback truly
|
||||
// begins (§2.1). The _sessionOpened guard keeps the SeekBeyondBuffer re-stream
|
||||
// — which re-enters this transition with _streamingPlaybackStarted reset —
|
||||
// from opening a second session for the same play. Duration may already be
|
||||
// known from a prior chunk, so re-feed it after opening.
|
||||
if (!_sessionOpened && _currentTrackId is { } trackKey)
|
||||
{
|
||||
_sessionOpened = true;
|
||||
_playTracker?.OnPlaybackStarted(trackKey);
|
||||
if (Duration is { } d)
|
||||
_playTracker?.SetDuration(d);
|
||||
}
|
||||
|
||||
await NotifyStateChanged(); // Immediate notification for critical state change
|
||||
}
|
||||
else
|
||||
{
|
||||
var technicalError = $"Failed to start streaming playback: {playbackResult.Error}";
|
||||
_logger.LogError("Failed to start playback: {Error}", technicalError);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
}
|
||||
}
|
||||
|
||||
// Update progress
|
||||
if (audio.ContentLength > 0)
|
||||
{
|
||||
LoadProgress = Math.Min(1.0, (double)totalBytesRead / audio.ContentLength);
|
||||
}
|
||||
|
||||
await ThrottledNotifyStateChanged();
|
||||
throw new Exception($"Failed to reinitialize for offset streaming: {reinitResult.Error}");
|
||||
}
|
||||
} while (currentBytes > 0);
|
||||
}
|
||||
|
||||
// Notify the JS decoder that the stream is finished. When the server omits
|
||||
// Content-Length the StreamDecoder cannot determine completion via byte counting
|
||||
// alone; this explicit signal ensures the tail-decoding path (streamComplete=true)
|
||||
// fires regardless of whether Content-Length was present.
|
||||
buffer = ArrayPool<byte>.Shared.Rent(MaxBufferSize); // larger rental to fit adaptive sizing
|
||||
var readTimer = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
// Segment loop. Each iteration fully consumes one bounded 206 body, advancing the cursor by
|
||||
// the bytes received. The next segment is fetched only when the scheduler is below
|
||||
// high-water (the inter-segment gate). EOF is the cursor reaching totalLength, or a short
|
||||
// segment (server returned fewer bytes than requested — the final slice).
|
||||
while (true)
|
||||
{
|
||||
long segmentBytesRead = 0;
|
||||
int currentBytes;
|
||||
do
|
||||
{
|
||||
readTimer.Restart();
|
||||
currentBytes = await segment.Stream.ReadAsync(buffer, 0, _currentBufferSize, cancellationToken);
|
||||
readTimer.Stop();
|
||||
|
||||
AdaptBufferSize(currentBytes, readTimer.ElapsedMilliseconds);
|
||||
|
||||
if (currentBytes > 0)
|
||||
{
|
||||
segmentBytesRead += currentBytes;
|
||||
|
||||
// Slice to the exact bytes read: the pooled buffer is rented at MaxBufferSize
|
||||
// and may carry stale bytes past currentBytes from a prior rental — handing the
|
||||
// full array to JS would serialise that garbage into the audio stream.
|
||||
var actualBuffer = buffer.AsSpan(0, currentBytes).ToArray();
|
||||
|
||||
var chunkResult = await _audioInterop.ProcessStreamingChunk(PlayerId, actualBuffer);
|
||||
if (!chunkResult.Success)
|
||||
{
|
||||
var error = $"Failed to process streaming chunk: {chunkResult.Error}";
|
||||
_logger.LogWarning("Chunk processing failed: {Error}", error);
|
||||
throw new Exception(error);
|
||||
}
|
||||
|
||||
CanStartStreaming = chunkResult.CanStartStreaming;
|
||||
HeaderParsed = chunkResult.HeaderParsed;
|
||||
BufferedChunks = chunkResult.BufferCount;
|
||||
|
||||
// Set duration from header when available (only set once)
|
||||
if (chunkResult.Duration.HasValue && Duration == null)
|
||||
{
|
||||
Duration = chunkResult.Duration.Value;
|
||||
_logger.LogInformation("Duration set from header: {Duration:F2} seconds", Duration);
|
||||
// Feed the once-only duration to the play session for the completion
|
||||
// fraction. No-op when no session is open; idempotent otherwise.
|
||||
_playTracker?.SetDuration(chunkResult.Duration.Value);
|
||||
}
|
||||
|
||||
// Start playback as soon as we can — at the min-buffer threshold, exactly as
|
||||
// before (C2: first audio is not gated on the segment boundary; the first
|
||||
// segment alone clears the threshold).
|
||||
if (!_streamingPlaybackStarted && CanStartStreaming)
|
||||
{
|
||||
await TryStartPlaybackAsync();
|
||||
}
|
||||
|
||||
// Progress against the total file length (cursor + bytes consumed so far).
|
||||
if (totalLength > 0)
|
||||
{
|
||||
LoadProgress = Math.Min(1.0, (double)(cursor + segmentBytesRead) / totalLength);
|
||||
}
|
||||
|
||||
await ThrottledNotifyStateChanged();
|
||||
|
||||
// Per-chunk back-pressure — the bound that actually holds for high-density codecs.
|
||||
// The inter-segment gate alone is matched to WAV's byte density (~24 s of audio per
|
||||
// 4 MB segment) but NOT to Opus: at 320 kbps a 4 MB segment is ~100 s of decodable
|
||||
// audio. The inner loop has the whole segment's bytes already in hand, so with no
|
||||
// network wait to pace it, it would decode the ENTIRE segment eagerly — piling tens
|
||||
// of MB of decoded f32 PCM AHEAD of a playhead that has barely moved, before the
|
||||
// inter-segment gate ever runs. With HW accel off that lookahead lives in main-
|
||||
// process RAM, and the byte ceiling cannot save us because nothing on this path
|
||||
// polls it. So drain to low-water per chunk once the scheduler is over high-water.
|
||||
//
|
||||
// Gated on _streamingPlaybackStarted so this can NEVER block first audio (C2): until
|
||||
// playback starts the playhead does not advance, so the forward fill would never
|
||||
// drain and the loop would deadlock. The 30 s high-water sits far above the
|
||||
// 6-buffer playback-start minimum, so in practice the gate is not even reached
|
||||
// before playback begins — the guard is the correctness backstop, not the common
|
||||
// case. Reads the piggybacked flag (no extra interop hop) to DECIDE to drain; the
|
||||
// drain helper then polls IsProductionPaused — the same steady-state-reads-flag /
|
||||
// throttled-state-polls split the inter-segment gate uses.
|
||||
if (_streamingPlaybackStarted && chunkResult.ProductionPaused)
|
||||
{
|
||||
await DrainBackpressureAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
} while (currentBytes > 0);
|
||||
|
||||
// Segment fully consumed; advance the cursor and release this segment's stream/socket
|
||||
// before deciding whether to fetch the next. Disposing here keeps exactly one segment's
|
||||
// raw bytes resident at a time.
|
||||
cursor += segmentBytesRead;
|
||||
segment.Dispose();
|
||||
segment = null!;
|
||||
|
||||
// EOF: cursor reached the total file length. This is the sole forward-EOF condition.
|
||||
// A short segment body (segmentBytesRead < SegmentSizeBytes) is NOT an EOF signal —
|
||||
// the inner read loop fully drains the HTTP body, so a short body means the server
|
||||
// sent fewer bytes than the bounded range we requested. While cursor < totalLength that
|
||||
// can only be a connection drop / truncated stream, NOT the file tail — route it to
|
||||
// the same clean-failure recovery as a fetch error rather than silently completing.
|
||||
var reachedTotal = totalLength > 0 && cursor >= totalLength;
|
||||
if (reachedTotal)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Guard: if the body was short but we haven't reached totalLength, the stream was
|
||||
// truncated mid-segment (connection drop / premature close). Surface as an error so
|
||||
// the scheduler is halted rather than left to drain its buffered tail into a false end.
|
||||
if (segmentBytesRead < SegmentSizeBytes)
|
||||
{
|
||||
throw new Exception(
|
||||
$"Stream truncated at byte {cursor} of {totalLength}: received {segmentBytesRead} bytes " +
|
||||
$"but expected up to {SegmentSizeBytes} and have not reached EOF");
|
||||
}
|
||||
|
||||
// Inter-segment back-pressure gate (Phase 21.2 fill signal, gating SEGMENT FETCH). Do not
|
||||
// fetch the next segment while the scheduler is over high-water; wait until it drains
|
||||
// below low-water. Because the browser only buffers bounded segments and we hold off
|
||||
// requesting the next one, raw network memory stays at ~one segment. Shares the same
|
||||
// drain helper as the per-chunk gate above. No _streamingPlaybackStarted guard is needed
|
||||
// here (unlike the per-chunk gate): reaching this point means a full segment was consumed,
|
||||
// which is ~24 s (WAV) / ~100 s (Opus) of audio — far past the 6-buffer playback-start
|
||||
// minimum — so playback is always running by now and the fill can drain. A file that fits
|
||||
// in one segment hits EOF and breaks above, never reaching this gate.
|
||||
await DrainBackpressureAsync(cancellationToken);
|
||||
|
||||
// Fetch the next bounded segment. The end offset is clamped implicitly by the server
|
||||
// (a request past EOF yields the available tail as a short slice, caught above).
|
||||
var nextEnd = cursor + SegmentSizeBytes - 1;
|
||||
var nextResult = await _trackMediaClient.GetTrackMedia(
|
||||
trackId,
|
||||
byteOffset: cursor,
|
||||
byteEnd: nextEnd,
|
||||
format: _currentFormat,
|
||||
cancellationToken: cancellationToken);
|
||||
if (!nextResult.Success || nextResult.Value == null)
|
||||
{
|
||||
var technicalError = nextResult.GetMessage() ?? "Failed to fetch next stream segment";
|
||||
_logger.LogError("Failed to fetch segment at offset {Offset} for {TrackId}: {Error}",
|
||||
cursor, trackId, technicalError);
|
||||
throw new Exception(technicalError);
|
||||
}
|
||||
segment = nextResult.Value;
|
||||
}
|
||||
|
||||
// Notify the JS decoder that the stream is finished. The decoder marks completion by byte
|
||||
// count against the total it was initialized with; this explicit signal flushes the
|
||||
// residual tail and covers the (rare) case where the total was unknown.
|
||||
await _audioInterop.MarkStreamCompleteAsync(PlayerId);
|
||||
|
||||
// Mark as fully loaded
|
||||
// Complete-without-start fallback: if the track's total decodable audio never crossed the
|
||||
// start threshold (e.g. total Opus audio < 1s lead, or WAV < 6 buffers), the in-loop
|
||||
// CanStartStreaming check never fired and _streamingPlaybackStarted is still false. Now that
|
||||
// streamComplete is set on the JS scheduler, calling StartStreamingPlayback lets it drain
|
||||
// the accumulated buffers and fires onPlaybackEnded exactly once — same transition the
|
||||
// normal path uses, so session/_sessionOpened/Duration handling is identical.
|
||||
if (!_streamingPlaybackStarted)
|
||||
{
|
||||
await TryStartPlaybackAsync();
|
||||
}
|
||||
|
||||
LoadProgress = 1.0;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
@@ -427,7 +830,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StreamingErrorHandler.LogError(_logger, ex, "StreamAudioWithEarlyPlayback");
|
||||
StreamingErrorHandler.LogError(_logger, ex, "RunSegmentedStreamAsync");
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
@@ -437,6 +840,8 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Release the last segment (if a fetch failed mid-loop it may still be held) and the buffer.
|
||||
segment?.Dispose();
|
||||
if (buffer != null)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
@@ -444,6 +849,46 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call <c>StartStreamingPlayback</c> on the JS player and apply the resulting state transitions.
|
||||
/// This is the single playback-start transition shared by the in-loop threshold path and the
|
||||
/// completion-path fallback — both callers set the guard and apply session/Duration handling
|
||||
/// identically so neither path diverges.
|
||||
/// </summary>
|
||||
private async Task TryStartPlaybackAsync()
|
||||
{
|
||||
var playbackResult = await _audioInterop.StartStreamingPlayback(PlayerId);
|
||||
if (playbackResult.Success)
|
||||
{
|
||||
_streamingPlaybackStarted = true;
|
||||
IsPlaying = true;
|
||||
IsPaused = false;
|
||||
IsLoaded = true; // loaded and ready, even while still downloading
|
||||
ErrorMessage = null;
|
||||
|
||||
// Open the play session exactly once per load, at the moment playback
|
||||
// truly begins (§2.1). The _sessionOpened guard keeps a seek/refill
|
||||
// re-stream — which re-enters this transition with
|
||||
// _streamingPlaybackStarted reset — from opening a second session for
|
||||
// the same play. Duration may already be known, so re-feed it.
|
||||
if (!_sessionOpened && _currentTrackId is { } trackKey)
|
||||
{
|
||||
_sessionOpened = true;
|
||||
_playTracker?.OnPlaybackStarted(trackKey);
|
||||
if (Duration is { } d)
|
||||
_playTracker?.SetDuration(d);
|
||||
}
|
||||
|
||||
await NotifyStateChanged(); // immediate — critical state change
|
||||
}
|
||||
else
|
||||
{
|
||||
var technicalError = $"Failed to start streaming playback: {playbackResult.Error}";
|
||||
_logger.LogError("Failed to start playback: {Error}", technicalError);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In streaming mode, Stop fully resets to Idle state since audio data is consumed.
|
||||
/// This is equivalent to Unload for streaming playback.
|
||||
@@ -515,6 +960,10 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture into a non-null local: _currentTrackId is the field a track-switch could clear, but
|
||||
// this seek operates against the track loaded NOW; the segment loop needs a stable id.
|
||||
var trackId = _currentTrackId;
|
||||
|
||||
IsSeekingBeyondBuffer = true;
|
||||
|
||||
// Cancel the current streaming loop AND wait for it to fully exit before
|
||||
@@ -538,46 +987,68 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
await DrainActiveStreamingTaskAsync();
|
||||
oldCts?.Dispose();
|
||||
|
||||
// Single-writer discipline (C6/AC8): all three failure exits must share the same guard.
|
||||
// TrackMediaClient.GetTrackMedia swallows OperationCanceledException and returns
|
||||
// Success==false, so a superseded seek lands in the media-fetch-fail branch below
|
||||
// rather than in the OCE catch. Without the guard those branches would call
|
||||
// RecoverFromFailedRefill — running clearForSeek + setPlaybackOffset against the player
|
||||
// state the NEWER seek now owns. A local predicate keeps all three exits symmetric so a
|
||||
// future exit cannot forget the check.
|
||||
bool IsStillActiveSeek() => ReferenceEquals(_streamingCancellation, seekCts);
|
||||
|
||||
try
|
||||
{
|
||||
// Update UI immediately
|
||||
CurrentTime = seekPosition;
|
||||
await NotifyStateChanged();
|
||||
|
||||
// Request new stream from offset
|
||||
var mediaResult = await _trackMediaClient.GetTrackMedia(
|
||||
_currentTrackId,
|
||||
// Request the FIRST bounded segment from the resolved offset (Direction B — converged with
|
||||
// the forward path). Reuse the format the initial load resolved to (_currentFormat): an
|
||||
// Opus seek must come back as Opus bytes so the cached setup header + page-aligned
|
||||
// byteOffset (resolved JS-side from the Opus seek index) match the continuation; WAV resolves
|
||||
// its offset from the header — one seam, format-appropriate math (AC9 / §3.4a C). The
|
||||
// segment loop then continues forward segmentation from this offset exactly as a fresh load
|
||||
// does from 0 — no forked fetch path (C1/C5).
|
||||
var firstSegment = await _trackMediaClient.GetTrackMedia(
|
||||
trackId,
|
||||
byteOffset,
|
||||
byteEnd: byteOffset + SegmentSizeBytes - 1,
|
||||
format: _currentFormat,
|
||||
cancellationToken: seekCts.Token);
|
||||
if (!mediaResult.Success || mediaResult.Value == null)
|
||||
if (!firstSegment.Success || firstSegment.Value == null)
|
||||
{
|
||||
var technicalError = mediaResult.GetMessage() ?? "Failed to load audio from position";
|
||||
var technicalError = firstSegment.GetMessage() ?? "Failed to load audio from position";
|
||||
_logger.LogError("Failed to get track media from offset {Offset}: {Error}", byteOffset, technicalError);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
IsSeekingBeyondBuffer = false;
|
||||
// Guard: a superseded seek must NOT touch shared state. The newer seek owns teardown.
|
||||
if (IsStillActiveSeek())
|
||||
{
|
||||
await RecoverFromFailedRefill(seekPosition, StreamingErrorHandler.GetUserFriendlyMessage(technicalError));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Media-fetch failed on superseded seek to {Position} — newer seek owns state, skipping recovery", seekPosition);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
using var audio = mediaResult.Value;
|
||||
var audio = firstSegment.Value;
|
||||
// The absolute EOF boundary the segment loop's cursor targets. On a 206 the Content-Range
|
||||
// carries the file total; on a 200 (single-segment file) fall back to cursor + body length.
|
||||
var totalLength = audio.TotalLength ?? (byteOffset + audio.ContentLength);
|
||||
|
||||
// Reinitialize JS player for offset streaming
|
||||
var reinitResult = await _audioInterop.ReinitializeFromOffset(PlayerId, audio.ContentLength, seekPosition);
|
||||
if (!reinitResult.Success)
|
||||
{
|
||||
_logger.LogError("Failed to reinitialize for offset streaming: {Error}", reinitResult.Error);
|
||||
ErrorMessage = "Failed to seek to position";
|
||||
IsSeekingBeyondBuffer = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset streaming state for new stream
|
||||
// Reset streaming state for the new stream. The decoder reinit for the header-less
|
||||
// continuation happens INSIDE RunSegmentedStreamAsync (seekPosition non-null), so seek and
|
||||
// forward share one fetch+pump+reinit mechanism. A reinit failure there throws and lands in
|
||||
// the catch below, which recovers when still the active seek — the same clean-failure path
|
||||
// (AC6) the old explicit reinit branch had, now unified with the fetch-failure path.
|
||||
_streamingPlaybackStarted = false;
|
||||
CanStartStreaming = false;
|
||||
HeaderParsed = false;
|
||||
BufferedChunks = 0;
|
||||
|
||||
// Stream audio from offset
|
||||
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, seekCts.Token);
|
||||
// Stream from offset via the shared segment loop. Ownership of `audio` transfers to it.
|
||||
_activeStreamingTask = RunSegmentedStreamAsync(
|
||||
trackId, audio, cursor: byteOffset, totalLength, seekPosition, seekCts.Token);
|
||||
await _activeStreamingTask;
|
||||
|
||||
IsSeekingBeyondBuffer = false;
|
||||
@@ -588,22 +1059,65 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
// still the active seek — if _streamingCancellation has been replaced, a
|
||||
// newer seek is in progress and owns the flag.
|
||||
_logger.LogDebug("Seek beyond buffer cancelled");
|
||||
if (ReferenceEquals(_streamingCancellation, seekCts))
|
||||
if (IsStillActiveSeek())
|
||||
{
|
||||
IsSeekingBeyondBuffer = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A refill fetch can fail deep into a long mix (the listener didn't initiate it). Recover
|
||||
// into a clean paused-but-loaded state (AC6) rather than leaving the starved scheduler to
|
||||
// fire a silent false end. Only when we are still the active seek — a superseding seek owns
|
||||
// the state and the OCE catch above handles its own teardown.
|
||||
_logger.LogError(ex, "Error during seek beyond buffer to position {Position}", seekPosition);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
|
||||
IsSeekingBeyondBuffer = false;
|
||||
await NotifyStateChanged();
|
||||
if (IsStillActiveSeek())
|
||||
{
|
||||
await RecoverFromFailedRefill(seekPosition, StreamingErrorHandler.GetUserFriendlyMessage(ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single method to reset all state - called by both Stop and Unload.
|
||||
/// Clean-failure recovery for a window-miss refill (Phase 21.3 / AC6). A backward seek past the
|
||||
/// retained tail re-fetches via the existing Range path; that mid-stream fetch the listener did not
|
||||
/// initiate can fail deep into a long mix. When it does, the pre-seek loop has already been
|
||||
/// cancelled and drained, but the JS scheduler is still holding stale pre-seek buffers and still
|
||||
/// "playing" — left alone it drains them and fires a silent false end (the wedged/starved state AC6
|
||||
/// forbids). This halts the scheduler into a paused-but-loaded state at <paramref name="seekPosition"/>,
|
||||
/// surfaces a clear error, and leaves the track loaded so the listener can retry the seek or pick
|
||||
/// another track. Mirrors <c>PlaybackScheduler.playFromPosition</c>'s end-of-buffer recovery: stop
|
||||
/// pretending to play.
|
||||
/// </summary>
|
||||
private async Task RecoverFromFailedRefill(double seekPosition, string userFacingError)
|
||||
{
|
||||
// Halt the starved scheduler JS-side (stop sources, drop stale buffers, anchor at the target).
|
||||
// Best-effort: if even this interop fails the player is no worse off, and we still surface the
|
||||
// error and settle C# state below.
|
||||
var recovered = await _audioInterop.RecoverFromFailedRefill(PlayerId, seekPosition);
|
||||
if (!recovered.Success)
|
||||
{
|
||||
_logger.LogWarning("Refill-failure recovery interop did not succeed: {Error}", recovered.Error);
|
||||
}
|
||||
|
||||
// Settle C# into the matching recoverable state: not playing, paused at the target, still loaded
|
||||
// and still in streaming mode. IsLoaded = true and IsStreamingMode = true are both load-bearing —
|
||||
// the "paused-but-loaded" contract lets the listener retry the seek (Seek early-returns when
|
||||
// !IsLoaded || !IsStreamingMode), resume via TogglePlayPause, or pick another track. Resetting
|
||||
// either to false would wedge at least one of the three retry routes (AC6 / Phase 21.3).
|
||||
ErrorMessage = userFacingError;
|
||||
IsPlaying = false;
|
||||
IsPaused = true;
|
||||
IsLoaded = true;
|
||||
IsStreamingMode = true;
|
||||
CurrentTime = seekPosition;
|
||||
IsSeekingBeyondBuffer = false;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single method to reset all state - called by both Stop and Unload, and as the prologue of a new
|
||||
/// load.
|
||||
/// </summary>
|
||||
private async Task ResetToIdle()
|
||||
{
|
||||
@@ -653,6 +1167,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
_streamingPlaybackStarted = false;
|
||||
IsSeekingBeyondBuffer = false;
|
||||
_currentTrackId = null;
|
||||
_currentFormat = AudioFormat.Lossless;
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
@@ -691,6 +1206,27 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Block the segment loop while the scheduler's decoded forward fill is over high-water, resuming
|
||||
/// once it drains below low-water (Phase 21.2 hysteresis). Shared by the per-chunk gate (inside a
|
||||
/// segment) and the inter-segment gate so both honor identical drain discipline — a guard present on
|
||||
/// one path and absent on the other would let one path overshoot the memory bound.
|
||||
/// <para>
|
||||
/// The poll awaits on <paramref name="cancellationToken"/>, so a track switch/seek mid-wait throws
|
||||
/// OCE and unwinds through the existing drain discipline (C6). UC5: a user pause freezes the playhead
|
||||
/// so the fill never drains on its own — hold here until playback resumes (IsPaused clears) OR the
|
||||
/// fill drains. Returns immediately when nothing is throttled (the steady-state common case).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task DrainBackpressureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (IsPaused || await _audioInterop.IsProductionPaused(PlayerId))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await Task.Delay(BackpressurePollMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ThrottledNotifyStateChanged()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
@@ -160,6 +160,47 @@ public sealed class WaveformVisualizerControlState
|
||||
/// </summary>
|
||||
public event Action? Changed;
|
||||
|
||||
// Whether the one-time, capability-driven default has been applied this session. The default-set
|
||||
// (lava off when the browser has no WebGL hardware acceleration) must run exactly once — on the
|
||||
// first interactive render, before the listener has touched a toggle — so it sets the *initial
|
||||
// default* and never clobbers a later explicit in-session toggle. Scoped with the rest of this
|
||||
// state, so it survives SPA navigation (a remounted visualizer does not re-apply) and resets on a
|
||||
// fresh page load (F5 re-probes).
|
||||
private bool _capabilityDefaultApplied;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the hardware-capability default exactly once per session: when the browser reports no
|
||||
/// WebGL hardware acceleration, the lava subsystem (the expensive, main-thread software-rendered
|
||||
/// part that starves audio decode) defaults <c>off</c> while the waveform stays <c>on</c>. With
|
||||
/// acceleration present this is a no-op — lava keeps its <see cref="DefaultLavaEnabled"/> on-state.
|
||||
///
|
||||
/// <para>
|
||||
/// Idempotent and guarded: only the FIRST call this session has any effect, so it sets the initial
|
||||
/// default and never overrides a listener's explicit toggle (the control remains fully functional —
|
||||
/// a user on a software renderer may re-enable lava at their own risk). Mutates, coerces
|
||||
/// Theater Mode, then raises <see cref="Changed"/> once so the controls UI, the visualizer bridge,
|
||||
/// and the Theater observers all reflect the default in a single cycle. Called by the visualizer
|
||||
/// bridge on first interactive render, once JS interop (the probe) is available.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="hardwareAccelerated">
|
||||
/// The probe result — <c>true</c> when WebGL hardware acceleration is present (or the renderer is
|
||||
/// unknown/masked, favoring the common case), <c>false</c> only on a positive software-renderer
|
||||
/// match or total WebGL failure.
|
||||
/// </param>
|
||||
public void ApplyCapabilityDefault(bool hardwareAccelerated)
|
||||
{
|
||||
if (_capabilityDefaultApplied) return;
|
||||
_capabilityDefaultApplied = true;
|
||||
|
||||
// Accelerated (or unknown): keep the as-shipped defaults — no observer churn.
|
||||
if (hardwareAccelerated) return;
|
||||
|
||||
LavaEnabled = false; // the expensive subsystem off; WaveformEnabled stays at its default (true).
|
||||
CoerceTheaterMode();
|
||||
NotifyChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enforces the Theater-Mode invariant: Theater Mode cannot remain on when both visualizer
|
||||
/// subsystems are off (there is nothing to go to theater FOR). Call this after mutating
|
||||
|
||||
@@ -14,6 +14,13 @@ public static class Startup
|
||||
services.AddScoped<DarkModeSettings>();
|
||||
services.AddScoped<DarkModeCookieService>();
|
||||
|
||||
// Public-site listener settings (Phase 18 wave 18.6). PublicSiteSettings is the generalized,
|
||||
// prerender-seeded preference object (today: streaming quality); SettingsCookieService writes the
|
||||
// 365-day cookie at runtime. Same scoped lifetime + cookie seam as the dark-mode pair above, so the
|
||||
// preference survives SPA nav within a session and seeds the next visit's prerender.
|
||||
services.AddScoped<PublicSiteSettings>();
|
||||
services.AddScoped<SettingsCookieService>();
|
||||
|
||||
// Track Client. The HTTP-backed ITrackDataService is used by both WASM and SSR
|
||||
// prerender — both call DeepDrftAPI over the "DeepDrft.API" client.
|
||||
services.AddScoped<TrackClient>();
|
||||
|
||||
Reference in New Issue
Block a user