Apply stream-quality change live by reloading at current position

Finish the Settings "Apply" behavior so changing streaming quality mid-track
switches format immediately instead of only persisting the cookie for the next
play.

- SettingsMenu reads the AudioPlayerProvider cascade and threads the player into
  StreamQualitySetting as an explicit parameter (the MudMenu panel portals to
  MudPopoverProvider, outside the cascade scope, so a [CascadingParameter] there
  lands null). StreamQualitySetting's Apply persists the cookie, then asks the
  player to reload preserving position.

- Add a "load at timestamp" path to the player rather than restart-from-0-then-
  seek (which audibly played the start and raced the just-started scheduler into
  a crash). ReloadPreservingPositionAsync loads the track in the newly-resolved
  format beginning DIRECTLY at the saved position:
    * new JS resolveStreamOffset(position) resolves the file-absolute byte offset
      with no playback/buffer state (Opus from its sidecar immediately; WAV after
      a header probe),
    * StartFromPositionAsync converges onto the existing seek/refill loop
      (RunSegmentedStreamAsync with a non-null seekPosition) so the decoder
      reinitializes for a header-less Range continuation and starts playback at
      the target,
    * ProbeHeaderAsync feeds the byte-0 segment to the decoder WITHOUT starting
      playback until the WAV header parses (bounded by 256 KB); the probe buffers
      are dropped by the continuation's clearForSeek, so nothing is audible.

- IStreamingPlayerService gains ReloadPreservingPositionAsync; the QueueService
  test fake implements it.
This commit is contained in:
daniel-c-harvey
2026-06-24 22:55:03 -04:00
parent 7adc35dd5d
commit d686fe48ce
8 changed files with 284 additions and 12 deletions
@@ -1,5 +1,6 @@
@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
@@ -31,10 +32,24 @@
</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.
private readonly List<SettingsItem> _items =
[
new SettingsItem("Streaming quality", @<StreamQualitySetting />)
];
// 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" />)
];
}
}
@@ -24,6 +24,14 @@
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 {
@@ -31,8 +39,17 @@
[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;
@@ -60,6 +77,21 @@
private async Task OnQualityChanged(StreamQuality quality)
{
_quality = quality;
await CookieService.SetStreamQualityAsync(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();
}
}
@@ -146,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)
{
@@ -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();
}
@@ -35,6 +35,17 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// 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;
@@ -163,7 +174,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
@@ -272,11 +306,23 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
return;
}
// 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;
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)
{
@@ -339,6 +385,134 @@ 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
@@ -532,6 +532,22 @@ export class AudioPlayer {
}
}
/**
* Resolve the file-absolute byte offset to begin a stream at `position`, WITHOUT requiring active
* playback or buffered audio (the "load at timestamp" entry point — Phase 18 wave 18.6 format switch).
* Unlike seek(), it has no duration guard and never routes to the within-buffer path: a fresh load has
* no scheduler window, so the answer is always "start the byte stream here". For Opus the sidecar
* resolves the offset (and captures the page landing time for the lead-trim) immediately after init; for
* WAV the header must already be parsed (feed the byte-0 segment first). Returns success:false when the
* decoder cannot yet resolve an offset (no header / no sidecar), so the caller can probe and retry.
*/
resolveStreamOffset(position: number): AudioResult {
if (!this.isStreamingMode) {
return { success: false, error: 'Not in streaming mode' };
}
return this.seekBeyondBuffer(position);
}
/**
* Get the total buffered duration (for C# to check if seek is within buffer)
*/
+10
View File
@@ -117,6 +117,16 @@ const DeepDrftAudio = {
return player?.calculateByteOffset(positionSeconds) ?? 0;
},
// "Load at timestamp" seam (Phase 18 wave 18.6 format switch). Resolve the file-absolute byte offset
// to begin a stream at `position` with no playback/buffer state — the C# load-from-position path calls
// this after initializeStreaming (Opus: sidecar resolves immediately; WAV: after a header probe) and
// then streams from the returned offset via the seek/refill loop. seekBeyondBuffer:true + byteOffset.
resolveStreamOffset: (playerId: string, position: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.resolveStreamOffset(position);
},
// Phase 21.2a back-pressure poll: the C# read loop calls this WHILE throttled to learn when
// the scheduler has drained below low-water and reading may resume. A missing player reads as
// "not paused" so a torn-down player never wedges a loop that is already exiting.
+1
View File
@@ -1252,5 +1252,6 @@ public class QueueServiceTests
public Task ClearError() => Task.CompletedTask;
public Task WarmAudioContext() => Task.CompletedTask;
public Task StageTrack(TrackDto track) => Task.CompletedTask;
public Task ReloadPreservingPositionAsync() => Task.CompletedTask;
}
}