d686fe48ce
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.
106 lines
4.8 KiB
C#
106 lines
4.8 KiB
C#
using DeepDrftModels.DTOs;
|
|
using Microsoft.AspNetCore.Components;
|
|
using NetBlocks.Models;
|
|
|
|
namespace DeepDrftPublic.Client.Services;
|
|
|
|
public interface IPlayerService
|
|
{
|
|
// State properties
|
|
bool IsInitialized { get; }
|
|
bool IsLoaded { get; }
|
|
bool IsLoading { get; }
|
|
bool IsPlaying { get; }
|
|
bool IsPaused { get; }
|
|
double CurrentTime { get; }
|
|
double? Duration { get; }
|
|
double Volume { get; }
|
|
double LoadProgress { get; }
|
|
string? ErrorMessage { get; }
|
|
TrackDto? CurrentTrack { get; }
|
|
|
|
/// <summary>
|
|
/// Normalized loudness profile for the current track, each value in [0, 1], or null when no
|
|
/// profile is available (no track loaded, or the track has no stored profile). The seek zone
|
|
/// renders this as a waveform; a null profile drives the flat-but-seekable fallback. Fetched on
|
|
/// track select and cleared on unload/stop; <see cref="StateChanged"/> fires once it arrives.
|
|
/// </summary>
|
|
double[]? WaveformProfile { get; }
|
|
|
|
// Events for UI updates
|
|
EventCallback? OnStateChanged { get; set; }
|
|
EventCallback? OnTrackSelected { get; set; }
|
|
|
|
/// <summary>
|
|
/// Multicast side-channel for state changes. The provider owns the single
|
|
/// <see cref="OnStateChanged"/> EventCallback (it drives the provider re-render);
|
|
/// cascade consumers that read state directly off this service — and so are not
|
|
/// re-rendered by the provider's render when the cascade is <c>IsFixed</c> —
|
|
/// subscribe here to re-render themselves. Fires on the same cadence as
|
|
/// <see cref="OnStateChanged"/> (throttled to ~10/s during streaming).
|
|
/// </summary>
|
|
event Action? StateChanged;
|
|
|
|
/// <summary>
|
|
/// Raised once when the current track reaches its natural end of playback (the JS
|
|
/// end-of-stream callback), distinct from a stop/unload/track-switch. This is the single
|
|
/// hook the play-queue subscribes to in order to auto-advance to the next track. It does
|
|
/// NOT fire when playback is stopped, the track is switched, or the player is unloaded —
|
|
/// only on organic completion — so an orchestrator can treat it as "advance the queue."
|
|
/// </summary>
|
|
event Action? TrackEnded;
|
|
|
|
// Control methods
|
|
Task InitializeAsync();
|
|
Task SelectTrack(TrackDto track);
|
|
Task Stop();
|
|
Task Unload();
|
|
Task TogglePlayPause();
|
|
Task Seek(double position);
|
|
Task SetVolume(double volume);
|
|
Task ClearError();
|
|
}
|
|
|
|
public interface IStreamingPlayerService : IPlayerService
|
|
{
|
|
// Streaming state properties
|
|
bool IsStreamingMode { get; }
|
|
bool CanStartStreaming { get; }
|
|
bool HeaderParsed { get; }
|
|
int BufferedChunks { get; }
|
|
|
|
// Streaming control methods
|
|
Task SelectTrackStreaming(TrackDto track);
|
|
|
|
/// <summary>
|
|
/// Initializes the player (if needed) and resumes the AudioContext. Call this synchronously at
|
|
/// the very start of a user-gesture handler — before any <c>await</c> on network I/O — so the
|
|
/// gesture is still "active" when the context resumes. Safari refuses to start a suspended
|
|
/// AudioContext once the originating gesture has been consumed by an intervening await
|
|
/// (e.g. fetching which track to play), so warming here and streaming after is load-bearing.
|
|
/// <see cref="SelectTrackStreaming"/> also resumes the context, but only after its own internal
|
|
/// awaits — too late for a handler that must first fetch the track to play.
|
|
/// </summary>
|
|
Task WarmAudioContext();
|
|
|
|
/// <summary>
|
|
/// Stages a track as the current track without touching the audio context or starting the
|
|
/// stream. Used by the embed player, where there is no user gesture on initial load: the track
|
|
/// is shown as ready, and the first play click (a genuine gesture) calls
|
|
/// <see cref="SelectTrackStreaming"/> so the browser allows the AudioContext to start. Sets
|
|
/// <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();
|
|
} |