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
@@ -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