From d686fe48cea34a478dc4aad00084f53736644c91 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 24 Jun 2026 22:55:03 -0400 Subject: [PATCH] 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. --- .../Controls/Settings/SettingsMenu.razor | 25 ++- .../Settings/StreamQualitySetting.razor | 34 +++- .../Services/AudioInteropService.cs | 12 ++ .../Services/IPlayerService.cs | 12 ++ .../Services/StreamingAudioPlayerService.cs | 186 +++++++++++++++++- DeepDrftPublic/Interop/audio/AudioPlayer.ts | 16 ++ DeepDrftPublic/Interop/audio/index.ts | 10 + DeepDrftTests/QueueServiceTests.cs | 1 + 8 files changed, 284 insertions(+), 12 deletions(-) diff --git a/DeepDrftPublic.Client/Controls/Settings/SettingsMenu.razor b/DeepDrftPublic.Client/Controls/Settings/SettingsMenu.razor index 9fd1a60..0e6d884 100644 --- a/DeepDrftPublic.Client/Controls/Settings/SettingsMenu.razor +++ b/DeepDrftPublic.Client/Controls/Settings/SettingsMenu.razor @@ -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 @@ @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 + // (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 _items = - [ - new SettingsItem("Streaming quality", @) - ]; + // 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 _items; + + public SettingsMenu() + { + _items = + [ + new SettingsItem("Streaming quality", @) + ]; + } } diff --git a/DeepDrftPublic.Client/Controls/Settings/StreamQualitySetting.razor b/DeepDrftPublic.Client/Controls/Settings/StreamQualitySetting.razor index 4fcff26..1dbdf19 100644 --- a/DeepDrftPublic.Client/Controls/Settings/StreamQualitySetting.razor +++ b/DeepDrftPublic.Client/Controls/Settings/StreamQualitySetting.razor @@ -24,6 +24,14 @@ This browser can't decode Opus — you'll stream lossless. } +
+ + APPLY + +
@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 — 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(); + } + } diff --git a/DeepDrftPublic.Client/Services/AudioInteropService.cs b/DeepDrftPublic.Client/Services/AudioInteropService.cs index 92e0fc7..e90c353 100644 --- a/DeepDrftPublic.Client/Services/AudioInteropService.cs +++ b/DeepDrftPublic.Client/Services/AudioInteropService.cs @@ -146,6 +146,18 @@ public class AudioInteropService : IAsyncDisposable return await InvokeJsAsync("DeepDrftAudio.seek", playerId, position); } + /// + /// Resolve the file-absolute byte offset to begin a stream at with no + /// active playback or buffered audio — the "load at timestamp" seam (Phase 18 wave 18.6 format switch). + /// Returns on 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. + /// + public async Task ResolveStreamOffsetAsync(string playerId, double position) + { + return await InvokeJsAsync("DeepDrftAudio.resolveStreamOffset", playerId, position); + } + // New methods for seek-beyond-buffer support public async Task GetBufferedDuration(string playerId) { diff --git a/DeepDrftPublic.Client/Services/IPlayerService.cs b/DeepDrftPublic.Client/Services/IPlayerService.cs index c0ff051..33ffc56 100644 --- a/DeepDrftPublic.Client/Services/IPlayerService.cs +++ b/DeepDrftPublic.Client/Services/IPlayerService.cs @@ -91,4 +91,16 @@ public interface IStreamingPlayerService : IPlayerService /// and notifies; performs no JS interop. /// Task StageTrack(TrackDto track); + + /// + /// 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 ResolveStreamFormatAsync 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. + /// + Task ReloadPreservingPositionAsync(); } \ No newline at end of file diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs index 5722513..27ee3b8 100644 --- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs @@ -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) + /// + 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 } } + /// + /// Begin streaming the freshly-initialized track DIRECTLY at instead + /// of byte 0 (Phase 18 wave 18.6 — the position-preserving format switch). The decoder has already been + /// built by InitializeStreaming; this resolves the file-absolute byte offset for the target time + /// and then converges onto the shared seek/refill loop ( 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. + /// + /// 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 . The byte-0 segment is + /// disposed once the header is in hand; the continuation is a fresh fetch from the resolved offset. + /// + /// + 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; + } + + /// + /// Feed bytes from the byte-0 into the decoder until its header parses + /// (), WITHOUT starting playback — the WAV byte-offset math needs the header + /// before can answer. Bounded by + /// 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). + /// + private async Task ProbeHeaderAsync(TrackMediaResponse segment, CancellationToken cancellationToken) + { + var buffer = ArrayPool.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.Shared.Return(buffer); + } + } + /// /// 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 diff --git a/DeepDrftPublic/Interop/audio/AudioPlayer.ts b/DeepDrftPublic/Interop/audio/AudioPlayer.ts index d33572f..16f476f 100644 --- a/DeepDrftPublic/Interop/audio/AudioPlayer.ts +++ b/DeepDrftPublic/Interop/audio/AudioPlayer.ts @@ -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) */ diff --git a/DeepDrftPublic/Interop/audio/index.ts b/DeepDrftPublic/Interop/audio/index.ts index 731abe8..a1a0c91 100644 --- a/DeepDrftPublic/Interop/audio/index.ts +++ b/DeepDrftPublic/Interop/audio/index.ts @@ -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. diff --git a/DeepDrftTests/QueueServiceTests.cs b/DeepDrftTests/QueueServiceTests.cs index 397d0ab..39d2dd1 100644 --- a/DeepDrftTests/QueueServiceTests.cs +++ b/DeepDrftTests/QueueServiceTests.cs @@ -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; } }