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:
@@ -1,5 +1,6 @@
|
|||||||
@using DeepDrftPublic.Client.Common
|
@using DeepDrftPublic.Client.Common
|
||||||
@using DeepDrftPublic.Client.Controls.Settings
|
@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
|
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>
|
</div>
|
||||||
|
|
||||||
@code {
|
@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
|
// 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.
|
// fragment — the menu body above renders whatever is here without knowing what each item is. The item
|
||||||
private readonly List<SettingsItem> _items =
|
// 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.
|
||||||
new SettingsItem("Streaming quality", @<StreamQualitySetting />)
|
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.
|
This browser can't decode Opus — you'll stream lossless.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<div class="d-flex flex-align-right">
|
||||||
|
<MudButton Disabled="!IsApplyEnabled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
OnClick="@ApplyStreamQualitySetting">
|
||||||
|
APPLY
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -31,8 +39,17 @@
|
|||||||
[Inject] public required SettingsCookieService CookieService { get; set; }
|
[Inject] public required SettingsCookieService CookieService { get; set; }
|
||||||
[Inject] public required AudioInteropService AudioInterop { 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;
|
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.
|
// Null until the capability probe runs (post-render JS interop). false → can decode Opus; true → cannot.
|
||||||
private bool _opusUnavailable;
|
private bool _opusUnavailable;
|
||||||
|
|
||||||
@@ -60,6 +77,21 @@
|
|||||||
private async Task OnQualityChanged(StreamQuality quality)
|
private async Task OnQualityChanged(StreamQuality quality)
|
||||||
{
|
{
|
||||||
_quality = 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);
|
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
|
// New methods for seek-beyond-buffer support
|
||||||
public async Task<double> GetBufferedDuration(string playerId)
|
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.
|
/// <see cref="IPlayerService.CurrentTrack"/> and notifies; performs no JS interop.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task StageTrack(TrackDto track);
|
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,
|
// 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.
|
// so the bound is the segment size, not the decoded window. Tunable; not magic.
|
||||||
private const long SegmentSizeBytes = 4 * 1024 * 1024;
|
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 _currentBufferSize = DefaultBufferSize;
|
||||||
private int _consecutiveSlowReads = 0;
|
private int _consecutiveSlowReads = 0;
|
||||||
|
|
||||||
@@ -163,7 +174,30 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
await NotifyStateChanged();
|
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
|
// Always reset to clean state before loading new track. ResetToIdle
|
||||||
// both cancels and awaits any in-flight streaming loop, so by the time
|
// both cancels and awaits any in-flight streaming loop, so by the time
|
||||||
@@ -272,11 +306,23 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward segmentation from byte 0. The first segment is already in hand; the loop pumps
|
if (startPosition is { } startAt)
|
||||||
// it, then fetches subsequent bounded segments gated on the scheduler fill signal.
|
{
|
||||||
_activeStreamingTask = RunSegmentedStreamAsync(
|
// "Load at timestamp" (Phase 18 wave 18.6 format switch): begin the stream DIRECTLY at
|
||||||
track.EntryKey, audio, cursor: 0, totalLength, seekPosition: null, loadCts.Token);
|
// startAt rather than byte 0, so the listener never hears the track restart from the
|
||||||
await _activeStreamingTask;
|
// 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)
|
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>
|
/// <summary>
|
||||||
/// Resolves which delivery format this load should request (Phase 18 default policy, OQ2): Opus when the
|
/// 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
|
/// 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)
|
* Get the total buffered duration (for C# to check if seek is within buffer)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -117,6 +117,16 @@ const DeepDrftAudio = {
|
|||||||
return player?.calculateByteOffset(positionSeconds) ?? 0;
|
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
|
// 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
|
// 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.
|
// "not paused" so a torn-down player never wedges a loop that is already exiting.
|
||||||
|
|||||||
@@ -1252,5 +1252,6 @@ public class QueueServiceTests
|
|||||||
public Task ClearError() => Task.CompletedTask;
|
public Task ClearError() => Task.CompletedTask;
|
||||||
public Task WarmAudioContext() => Task.CompletedTask;
|
public Task WarmAudioContext() => Task.CompletedTask;
|
||||||
public Task StageTrack(TrackDto track) => Task.CompletedTask;
|
public Task StageTrack(TrackDto track) => Task.CompletedTask;
|
||||||
|
public Task ReloadPreservingPositionAsync() => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user