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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user