Merge Phase 18.6 Track A (public Settings menu + streaming-quality toggle) into streaming-overhaul
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace DeepDrftPublic.Client.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The single public-site listener-settings object (Phase 18 wave 18.6, §4a). The generalized analogue of
|
||||||
|
/// <see cref="DarkModeSettings"/>: one scoped holder for every remembered listener preference, seeded at
|
||||||
|
/// server prerender, carried into WASM via <see cref="PersistentState"/>, and persisted to a cookie on
|
||||||
|
/// change. Today it carries one preference — streaming quality; tomorrow dark mode (and whatever follows)
|
||||||
|
/// folds in here as another property without disturbing the menu that reads it.
|
||||||
|
/// <para>
|
||||||
|
/// Built design-for-adaptability per §4a: a new preference is a new <c>[PersistentState]</c> property here
|
||||||
|
/// plus a new <see cref="Components.SettingsItem"/> in the menu — not a rewire. Dark mode is intentionally
|
||||||
|
/// <em>not</em> migrated in now (it keeps its own <see cref="DarkModeSettings"/> seam); this object is shaped
|
||||||
|
/// so that consolidation is later a merge of two identical seams, not a reconciliation of two different ones.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class PublicSiteSettings
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The listener's streaming-quality preference. Defaults to <see cref="StreamQuality.LowData"/> (Opus,
|
||||||
|
/// capability-gated — OQ2). Seeded from the <c>streamQuality</c> cookie at prerender; persisted on change
|
||||||
|
/// by the client cookie service. The player reads this to decide which <c>?format=</c> to request, but
|
||||||
|
/// the capability gate and C2 fallback still apply on top, so a <see cref="StreamQuality.LowData"/>
|
||||||
|
/// preference never forces an unplayable stream.
|
||||||
|
/// </summary>
|
||||||
|
[PersistentState]
|
||||||
|
public StreamQuality StreamQuality { get; set; } = StreamQuality.LowData;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace DeepDrftPublic.Client.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One entry in the public-site Settings menu (Phase 18 wave 18.6, §4a). The settings-item abstraction the
|
||||||
|
/// menu renders instead of a hard-coded control list: a <see cref="Label"/> plus a <see cref="Control"/>
|
||||||
|
/// fragment bound to a persisted preference. Adding a future tenant (e.g. dark mode) is appending one of
|
||||||
|
/// these — not rewiring the menu. The control fragment owns its own binding to <see cref="PublicSiteSettings"/>
|
||||||
|
/// and its own persistence call, so each item is self-contained and the menu stays preference-agnostic.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SettingsItem(string Label, RenderFragment Control);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace DeepDrftPublic.Client.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The listener's streaming-quality preference (Phase 18 wave 18.6, §4). This is the user's <em>intent</em>,
|
||||||
|
/// not the wire format that ultimately gets served: <see cref="LowData"/> means "give me Opus if you can,"
|
||||||
|
/// but the player still capability-gates and C2-falls-back to lossless when Opus can't play (a browser that
|
||||||
|
/// can't decode Ogg Opus, or a track with no Opus artifact). It is therefore deliberately distinct from
|
||||||
|
/// <c>DeepDrftModels.Enums.AudioFormat</c> (the delivery rendering resolved per request): one is the
|
||||||
|
/// remembered preference, the other is what a given stream request actually asks for.
|
||||||
|
/// </summary>
|
||||||
|
public enum StreamQuality
|
||||||
|
{
|
||||||
|
/// <summary>Bandwidth-friendly Opus (capability-gated; the default before any choice — OQ2).</summary>
|
||||||
|
LowData,
|
||||||
|
|
||||||
|
/// <summary>The lossless WAV path, always playable everywhere.</summary>
|
||||||
|
Lossless
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using DeepDrftPublic.Client.Common;
|
||||||
using DeepDrftPublic.Client.Services;
|
using DeepDrftPublic.Client.Services;
|
||||||
using DeepDrftPublic.Client.Clients;
|
using DeepDrftPublic.Client.Clients;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
@@ -13,6 +14,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
|||||||
[Inject] public required BeaconInterop Beacon { get; set; }
|
[Inject] public required BeaconInterop Beacon { get; set; }
|
||||||
[Inject] public required IPlayEventSink PlayEventSink { get; set; }
|
[Inject] public required IPlayEventSink PlayEventSink { get; set; }
|
||||||
[Inject] public required IAnonIdProvider AnonId { get; set; }
|
[Inject] public required IAnonIdProvider AnonId { get; set; }
|
||||||
|
[Inject] public required PublicSiteSettings Settings { get; set; }
|
||||||
|
|
||||||
private IStreamingPlayerService? _audioPlayerService;
|
private IStreamingPlayerService? _audioPlayerService;
|
||||||
private QueueService? _queueService;
|
private QueueService? _queueService;
|
||||||
@@ -26,7 +28,12 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
|||||||
// EnsureInitializedAsync — that path is correct because audio contexts
|
// EnsureInitializedAsync — that path is correct because audio contexts
|
||||||
// require a user gesture anyway. Initializing eagerly here causes 4+
|
// require a user gesture anyway. Initializing eagerly here causes 4+
|
||||||
// SignalR round-trips before any content is stable.
|
// SignalR round-trips before any content is stable.
|
||||||
var player = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger);
|
// Construct the preference-aware player (Phase 18 wave 18.6): it honours the listener's streaming-
|
||||||
|
// quality choice via the ResolveStreamFormatAsync seam while inheriting the 18.5 capability gate and
|
||||||
|
// C2 fallback. PublicSiteSettings is scoped data (already prerender-seeded + WASM-bridged), so passing
|
||||||
|
// it through the constructor is cheap and carries no lifecycle — the telemetry tracker still binds
|
||||||
|
// post-construction below, exactly as before.
|
||||||
|
var player = new PreferenceAwareStreamingPlayerService(AudioInterop, TrackMediaClient, Logger, Settings);
|
||||||
|
|
||||||
// Phase 16: bind the play-session tracker to the player after construction, the same way the
|
// Phase 16: bind the play-session tracker to the player after construction, the same way the
|
||||||
// queue binds — the player is built with `new`, not DI, so threading telemetry through its
|
// queue binds — the player is built with `new`, not DI, so threading telemetry through its
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@using DeepDrftPublic.Client.Common
|
||||||
|
@using DeepDrftPublic.Client.Controls.Settings
|
||||||
|
|
||||||
|
@*
|
||||||
|
The public-site Settings menu (Phase 18 wave 18.6, §4a). An app-bar trigger opening a MudMenu that renders
|
||||||
|
a settings-item list — NOT a hard-coded control stack. Each entry is a SettingsItem (label + a control
|
||||||
|
fragment bound to a persisted preference), so a future tenant (dark mode) plugs in as a new list entry, not
|
||||||
|
a menu rewire. Today the list holds one item: the streaming-quality toggle.
|
||||||
|
|
||||||
|
The MudMenu items carry OnClick="@(() => {})" + OnTouch so a click inside a control row does not dismiss the
|
||||||
|
menu (MudMenu auto-closes on item activation otherwise), keeping the radio group usable.
|
||||||
|
*@
|
||||||
|
<div class="dd-accent-icon">
|
||||||
|
<MudMenu Icon="@Icons.Material.Filled.Settings"
|
||||||
|
Color="Color.Inherit"
|
||||||
|
AnchorOrigin="Origin.BottomRight"
|
||||||
|
TransformOrigin="Origin.TopRight"
|
||||||
|
AriaLabel="Settings"
|
||||||
|
Class="dd-settings-menu">
|
||||||
|
<div class="dd-settings-panel">
|
||||||
|
<div class="dd-settings-heading">Settings</div>
|
||||||
|
@foreach (var item in _items)
|
||||||
|
{
|
||||||
|
<div class="dd-settings-item">
|
||||||
|
<div class="dd-settings-item-label">@item.Label</div>
|
||||||
|
@item.Control
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// 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<SettingsItem> _items =
|
||||||
|
[
|
||||||
|
new SettingsItem("Streaming quality", @<StreamQualitySetting />)
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
@using DeepDrftPublic.Client.Common
|
||||||
|
@using DeepDrftPublic.Client.Services
|
||||||
|
|
||||||
|
@*
|
||||||
|
The streaming-quality control (Phase 18 wave 18.6, §4) — the first occupant of the Settings menu. Binds
|
||||||
|
the listener's choice to PublicSiteSettings.StreamQuality and persists it via the cookie seam. Honest
|
||||||
|
capability gate (OQ2 / AC7): on a browser that cannot decode Ogg Opus the Low-data option still selects,
|
||||||
|
but a note tells the listener the effective stream is lossless — we never let the choice silently imply a
|
||||||
|
format that can't play.
|
||||||
|
*@
|
||||||
|
<div class="dd-setting-control">
|
||||||
|
<MudRadioGroup T="StreamQuality" Value="_quality" ValueChanged="OnQualityChanged">
|
||||||
|
<MudRadio T="StreamQuality" Value="StreamQuality.LowData" Color="Color.Primary" Dense="true">
|
||||||
|
Low-data (Opus)
|
||||||
|
</MudRadio>
|
||||||
|
<MudRadio T="StreamQuality" Value="StreamQuality.Lossless" Color="Color.Primary" Dense="true">
|
||||||
|
Lossless (WAV)
|
||||||
|
</MudRadio>
|
||||||
|
</MudRadioGroup>
|
||||||
|
|
||||||
|
@if (_opusUnavailable && _quality == StreamQuality.LowData)
|
||||||
|
{
|
||||||
|
<div class="dd-setting-note">
|
||||||
|
This browser can't decode Opus — you'll stream lossless.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Inject] public required PublicSiteSettings Settings { get; set; }
|
||||||
|
[Inject] public required SettingsCookieService CookieService { get; set; }
|
||||||
|
[Inject] public required AudioInteropService AudioInterop { get; set; }
|
||||||
|
|
||||||
|
private StreamQuality _quality;
|
||||||
|
|
||||||
|
// Null until the capability probe runs (post-render JS interop). false → can decode Opus; true → cannot.
|
||||||
|
private bool _opusUnavailable;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
// Read the current preference (already seeded at prerender + bridged into WASM).
|
||||||
|
_quality = Settings.StreamQuality;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender) return;
|
||||||
|
|
||||||
|
// Capability probe is JS interop — only valid once interactive. Surfaces the honest note when the
|
||||||
|
// browser can't decode Ogg Opus, so a Low-data pick reads as "effectively lossless" rather than
|
||||||
|
// silently failing. The player applies the same gate independently; this is purely the UI honesty.
|
||||||
|
var canDecodeOpus = await AudioInterop.CanDecodeOggOpus();
|
||||||
|
if (canDecodeOpus == _opusUnavailable)
|
||||||
|
{
|
||||||
|
_opusUnavailable = !canDecodeOpus;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnQualityChanged(StreamQuality quality)
|
||||||
|
{
|
||||||
|
_quality = quality;
|
||||||
|
await CookieService.SetStreamQualityAsync(quality);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@using DeepDrftPublic.Client.Common
|
@using DeepDrftPublic.Client.Common
|
||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
|
@using DeepDrftPublic.Client.Controls.Settings
|
||||||
@using DeepDrftPublic.Client.Services
|
@using DeepDrftPublic.Client.Services
|
||||||
|
|
||||||
@* Desktop Menu *@
|
@* Desktop Menu *@
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
|
|
||||||
<div class="dd-nav-actions">
|
<div class="dd-nav-actions">
|
||||||
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now ▶"/>
|
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now ▶"/>
|
||||||
|
<SettingsMenu />
|
||||||
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
|
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -74,7 +76,8 @@
|
|||||||
@onclick="ToggleMobileMenu">
|
@onclick="ToggleMobileMenu">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<SettingsMenu />
|
||||||
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
|
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
@code {
|
@code {
|
||||||
private string _audioPlayerClass = "minimized";
|
private string _audioPlayerClass = "minimized";
|
||||||
private const string DarkModeKey = "darkMode";
|
private const string DarkModeKey = "darkMode";
|
||||||
|
private const string StreamQualityKey = "streamQuality";
|
||||||
private bool _isDarkMode = false;
|
private bool _isDarkMode = false;
|
||||||
private bool? _lastAppliedDarkMode = null;
|
private bool? _lastAppliedDarkMode = null;
|
||||||
private PersistingComponentStateSubscription _persistingSubscription;
|
private PersistingComponentStateSubscription _persistingSubscription;
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
|
|
||||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||||
[Inject] public required DarkModeSettings DarkModeSettings { get; set; }
|
[Inject] public required DarkModeSettings DarkModeSettings { get; set; }
|
||||||
|
[Inject] public required PublicSiteSettings PublicSiteSettings { get; set; }
|
||||||
[Inject] public required IJSRuntime JS { get; set; }
|
[Inject] public required IJSRuntime JS { get; set; }
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
@@ -66,8 +68,17 @@
|
|||||||
_isDarkMode = DarkModeSettings.IsDarkMode;
|
_isDarkMode = DarkModeSettings.IsDarkMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore the prerender-seeded streaming-quality preference (Phase 18 wave 18.6). Same bridge dark
|
||||||
|
// mode uses: the server SettingsService seeded PublicSiteSettings from the streamQuality cookie, and
|
||||||
|
// this carries it into WASM so the client boots already knowing the preference (no re-read flash, no
|
||||||
|
// wrong default before the first stream).
|
||||||
|
if (PersistentState.TryTakeFromJson<StreamQuality>(StreamQualityKey, out var restoredQuality))
|
||||||
|
{
|
||||||
|
PublicSiteSettings.StreamQuality = restoredQuality;
|
||||||
|
}
|
||||||
|
|
||||||
// Register to persist state when prerendering completes
|
// Register to persist state when prerendering completes
|
||||||
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistDarkMode);
|
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistState);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync dark mode class on <body> so portaled MudBlazor elements (popovers, menus, selects)
|
// Sync dark mode class on <body> so portaled MudBlazor elements (popovers, menus, selects)
|
||||||
@@ -91,9 +102,10 @@
|
|||||||
// Theme wrapper class for CSS targeting
|
// Theme wrapper class for CSS targeting
|
||||||
private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light";
|
private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light";
|
||||||
|
|
||||||
private Task PersistDarkMode()
|
private Task PersistState()
|
||||||
{
|
{
|
||||||
PersistentState.PersistAsJson(DarkModeKey, _isDarkMode);
|
PersistentState.PersistAsJson(DarkModeKey, _isDarkMode);
|
||||||
|
PersistentState.PersistAsJson(StreamQualityKey, PublicSiteSettings.StreamQuality);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ public class AudioInteropService : IAsyncDisposable
|
|||||||
/// stays on the universal lossless path (AC7 — no listener ever gets silence over a codec gap). Probe
|
/// stays on the universal lossless path (AC7 — no listener ever gets silence over a codec gap). Probe
|
||||||
/// failures degrade to <c>false</c> (assume incapable) so an interop error can never silence playback.
|
/// failures degrade to <c>false</c> (assume incapable) so an interop error can never silence playback.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<bool> CanDecodeOggOpus(string playerId)
|
public async Task<bool> CanDecodeOggOpus()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ public class DarkModeCookieService(DarkModeSettings darkModeSetting, IJSRuntime
|
|||||||
|
|
||||||
public async ValueTask SetDarkModeAsync(bool isDarkMode)
|
public async ValueTask SetDarkModeAsync(bool isDarkMode)
|
||||||
{
|
{
|
||||||
var expires = DateTime.UtcNow.AddDays(EXPIRY_DAYS).ToString("R");
|
await js.InvokeVoidAsync("DeepDrftSettings.setCookie", COOKIE_NAME, isDarkMode.ToString().ToLower(), EXPIRY_DAYS);
|
||||||
await js.InvokeVoidAsync("eval",
|
|
||||||
$"document.cookie = '{COOKIE_NAME}={isDarkMode.ToString().ToLower()}; expires={expires}; path=/; SameSite=Lax'");
|
|
||||||
darkModeSetting.IsDarkMode = isDarkMode;
|
darkModeSetting.IsDarkMode = isDarkMode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using DeepDrftModels.Enums;
|
||||||
|
using DeepDrftPublic.Client.Clients;
|
||||||
|
using DeepDrftPublic.Client.Common;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace DeepDrftPublic.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The production player that honours the listener's streaming-quality preference (Phase 18 wave 18.6).
|
||||||
|
/// Extends <see cref="StreamingAudioPlayerService"/> through the single deliberately-overridable seam,
|
||||||
|
/// <see cref="StreamingAudioPlayerService.ResolveStreamFormatAsync"/>, so the rest of the streaming stack
|
||||||
|
/// (seek, telemetry, the seek-beyond-buffer format reuse) is inherited verbatim.
|
||||||
|
/// <para>
|
||||||
|
/// The override is one branch: a <see cref="StreamQuality.Lossless"/> preference returns
|
||||||
|
/// <see cref="AudioFormat.Lossless"/> immediately; anything else falls through to <c>base</c>, which keeps
|
||||||
|
/// the 18.5 invariants intact — the capability gate (AC7: a browser that can't decode Ogg Opus gets lossless)
|
||||||
|
/// and the sidecar-absent → lossless fallback (C2: a legacy / un-backfilled / failed-transcode track gets
|
||||||
|
/// lossless). So a Lossless pick always yields lossless; a Low-data pick yields Opus only when it can
|
||||||
|
/// actually play, and lossless otherwise. No path produces an unplayable stream.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class PreferenceAwareStreamingPlayerService : StreamingAudioPlayerService
|
||||||
|
{
|
||||||
|
private readonly PublicSiteSettings _settings;
|
||||||
|
|
||||||
|
public PreferenceAwareStreamingPlayerService(
|
||||||
|
AudioInteropService audioInterop,
|
||||||
|
TrackMediaClient trackMediaClient,
|
||||||
|
ILogger<StreamingAudioPlayerService> logger,
|
||||||
|
PublicSiteSettings settings)
|
||||||
|
: base(audioInterop, trackMediaClient, logger)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<AudioFormat> ResolveStreamFormatAsync(string entryKey, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Listener explicitly chose lossless — request it directly, no Opus probe / sidecar fetch needed.
|
||||||
|
if (_settings.StreamQuality == StreamQuality.Lossless)
|
||||||
|
{
|
||||||
|
return AudioFormat.Lossless;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low-data preference: defer to the base capability-gated resolution, which probes Opus support and
|
||||||
|
// the sidecar's presence and degrades to lossless when either is missing. Both 18.5 invariants are
|
||||||
|
// inherited here, not re-implemented.
|
||||||
|
return await base.ResolveStreamFormatAsync(entryKey, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using DeepDrftPublic.Client.Common;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace DeepDrftPublic.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side runtime writer for public-site settings (Phase 18 wave 18.6), the analogue of
|
||||||
|
/// <see cref="DarkModeCookieService"/>. Reads the current preference off the in-memory
|
||||||
|
/// <see cref="PublicSiteSettings"/> (already seeded at prerender and bridged into WASM), and writes a
|
||||||
|
/// 365-day cookie via <c>document.cookie</c> interop when the listener changes it in the Settings menu —
|
||||||
|
/// the same durable-truth seam dark mode uses, so the choice survives the session and seeds the next visit's
|
||||||
|
/// prerender (no flash).
|
||||||
|
/// </summary>
|
||||||
|
public class SettingsCookieService(PublicSiteSettings settings, IJSRuntime js) : SettingsServiceBase
|
||||||
|
{
|
||||||
|
private const int ExpiryDays = 365;
|
||||||
|
|
||||||
|
public StreamQuality GetStreamQuality() => settings.StreamQuality;
|
||||||
|
|
||||||
|
public async ValueTask SetStreamQualityAsync(StreamQuality quality)
|
||||||
|
{
|
||||||
|
if (settings.StreamQuality == quality) return;
|
||||||
|
|
||||||
|
await WriteCookieAsync(StreamQualityCookieName, FormatStreamQuality(quality));
|
||||||
|
settings.StreamQuality = quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteCookieAsync(string name, string value)
|
||||||
|
{
|
||||||
|
await js.InvokeVoidAsync("DeepDrftSettings.setCookie", name, value, ExpiryDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using DeepDrftPublic.Client.Common;
|
||||||
|
|
||||||
|
namespace DeepDrftPublic.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared cookie contract for the public-site settings seam (Phase 18 wave 18.6), the analogue of
|
||||||
|
/// <see cref="DarkModeServiceBase"/>. Holds the cookie names and the (de)serialization for each preference
|
||||||
|
/// so the server prerender-read service and the client cookie-write service agree on one wire format —
|
||||||
|
/// the load-bearing reason this is shared rather than duplicated. Each new preference adds its cookie name
|
||||||
|
/// and a parse/format pair here, keeping the round-trip in one place.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class SettingsServiceBase
|
||||||
|
{
|
||||||
|
protected const string StreamQualityCookieName = "streamQuality";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the <c>streamQuality</c> cookie value into <see cref="StreamQuality"/>, defaulting to
|
||||||
|
/// <see cref="StreamQuality.LowData"/> (the OQ2 default) for an absent, empty, or unrecognized value so
|
||||||
|
/// a missing/garbled cookie never produces a surprising preference.
|
||||||
|
/// </summary>
|
||||||
|
protected static StreamQuality ParseStreamQuality(string? cookieValue) =>
|
||||||
|
Enum.TryParse<StreamQuality>(cookieValue, ignoreCase: true, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: StreamQuality.LowData;
|
||||||
|
|
||||||
|
/// <summary>Formats a <see cref="StreamQuality"/> for cookie storage (round-trips with <see cref="ParseStreamQuality"/>).</summary>
|
||||||
|
protected static string FormatStreamQuality(StreamQuality quality) => quality.ToString();
|
||||||
|
}
|
||||||
@@ -280,7 +280,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
protected virtual async Task<AudioFormat> ResolveStreamFormatAsync(string entryKey, CancellationToken cancellationToken)
|
protected virtual async Task<AudioFormat> ResolveStreamFormatAsync(string entryKey, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Capability gate first (AC7): never hand Ogg Opus to a browser that cannot decode it.
|
// Capability gate first (AC7): never hand Ogg Opus to a browser that cannot decode it.
|
||||||
if (!await _audioInterop.CanDecodeOggOpus(PlayerId))
|
if (!await _audioInterop.CanDecodeOggOpus())
|
||||||
{
|
{
|
||||||
return AudioFormat.Lossless;
|
return AudioFormat.Lossless;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ public static class Startup
|
|||||||
services.AddScoped<DarkModeSettings>();
|
services.AddScoped<DarkModeSettings>();
|
||||||
services.AddScoped<DarkModeCookieService>();
|
services.AddScoped<DarkModeCookieService>();
|
||||||
|
|
||||||
|
// Public-site listener settings (Phase 18 wave 18.6). PublicSiteSettings is the generalized,
|
||||||
|
// prerender-seeded preference object (today: streaming quality); SettingsCookieService writes the
|
||||||
|
// 365-day cookie at runtime. Same scoped lifetime + cookie seam as the dark-mode pair above, so the
|
||||||
|
// preference survives SPA nav within a session and seeds the next visit's prerender.
|
||||||
|
services.AddScoped<PublicSiteSettings>();
|
||||||
|
services.AddScoped<SettingsCookieService>();
|
||||||
|
|
||||||
// Track Client. The HTTP-backed ITrackDataService is used by both WASM and SSR
|
// Track Client. The HTTP-backed ITrackDataService is used by both WASM and SSR
|
||||||
// prerender — both call DeepDrftAPI over the "DeepDrft.API" client.
|
// prerender — both call DeepDrftAPI over the "DeepDrft.API" client.
|
||||||
services.AddScoped<TrackClient>();
|
services.AddScoped<TrackClient>();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
<script src=@Assets["_content/MudBlazor/MudBlazor.min.js"]></script>
|
<script src=@Assets["_content/MudBlazor/MudBlazor.min.js"]></script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
import('./js/settings/settings.js');
|
||||||
import('./js/audio/index.js');
|
import('./js/audio/index.js');
|
||||||
import('./js/telemetry/beacon.js');
|
import('./js/telemetry/beacon.js');
|
||||||
import('./js/telemetry/anonid.js');
|
import('./js/telemetry/anonid.js');
|
||||||
@@ -34,11 +35,13 @@
|
|||||||
@code {
|
@code {
|
||||||
|
|
||||||
[Inject] public required DarkModeService DarkModeService { get; set; }
|
[Inject] public required DarkModeService DarkModeService { get; set; }
|
||||||
|
[Inject] public required SettingsService SettingsService { get; set; }
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
base.OnInitialized();
|
base.OnInitialized();
|
||||||
DarkModeService.CheckDarkMode();
|
DarkModeService.CheckDarkMode();
|
||||||
|
SettingsService.CheckSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Listener-settings interop (Phase 18 wave 18.6). A safe, eval-free cookie helper for persisting
|
||||||
|
* public-site preferences (streaming quality, and any future setting added under PublicSiteSettings).
|
||||||
|
* The 365-day durable-truth seam dark mode uses — same mechanism, no eval.
|
||||||
|
*
|
||||||
|
* Exposed on window.DeepDrftSettings; imported once in App.razor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DeepDrftSettings = {
|
||||||
|
/**
|
||||||
|
* Write a cookie with the given name, value, and lifetime. Equivalent to the browser's
|
||||||
|
* document.cookie assignment but without building JS via string interpolation or eval.
|
||||||
|
* Path is always "/"; SameSite is always "Lax" — matches the dark-mode cookie semantics.
|
||||||
|
*/
|
||||||
|
setCookie: (name: string, value: string, days: number): void => {
|
||||||
|
const expires = new Date();
|
||||||
|
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
document.cookie =
|
||||||
|
`${encodeURIComponent(name)}=${encodeURIComponent(value)}` +
|
||||||
|
`; expires=${expires.toUTCString()}` +
|
||||||
|
`; path=/; SameSite=Lax`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
DeepDrftSettings: typeof DeepDrftSettings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DeepDrftSettings = DeepDrftSettings;
|
||||||
|
|
||||||
|
export { DeepDrftSettings };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using DeepDrftPublic.Client.Common;
|
||||||
|
using DeepDrftPublic.Client.Services;
|
||||||
|
|
||||||
|
namespace DeepDrftPublic.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side prerender reader for public-site listener settings (Phase 18 wave 18.6), the sibling of
|
||||||
|
/// <see cref="DarkModeService"/>. Reads each preference's cookie via <see cref="IHttpContextAccessor"/>
|
||||||
|
/// during prerender and seeds the scoped <see cref="PublicSiteSettings"/>, which <c>MainLayout</c> then
|
||||||
|
/// rounds through <c>PersistentComponentState</c> into WASM — so the first paint already reflects the
|
||||||
|
/// listener's choice with no wrong-default flash (the streaming-quality analogue of the wrong-theme fix).
|
||||||
|
/// Inherits the shared cookie names + parsers from <see cref="SettingsServiceBase"/> so the server read and
|
||||||
|
/// the client write agree on one wire format.
|
||||||
|
/// </summary>
|
||||||
|
public class SettingsService(PublicSiteSettings settings, IHttpContextAccessor httpAccessor) : SettingsServiceBase
|
||||||
|
{
|
||||||
|
public void CheckSettings()
|
||||||
|
{
|
||||||
|
var cookies = httpAccessor.HttpContext?.Request.Cookies;
|
||||||
|
if (cookies is null) return;
|
||||||
|
|
||||||
|
cookies.TryGetValue(StreamQualityCookieName, out var streamQuality);
|
||||||
|
settings.StreamQuality = ParseStreamQuality(streamQuality);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,5 +11,10 @@ public static class Startup
|
|||||||
builder.Services
|
builder.Services
|
||||||
.AddHttpContextAccessor()
|
.AddHttpContextAccessor()
|
||||||
.AddScoped<DarkModeService>();
|
.AddScoped<DarkModeService>();
|
||||||
|
|
||||||
|
// Server prerender read for public-site listener settings (Phase 18 wave 18.6), sibling to
|
||||||
|
// DarkModeService. PublicSiteSettings itself is registered in the client Startup (shared by SSR and
|
||||||
|
// WASM); this seeds it from the streamQuality cookie during prerender.
|
||||||
|
builder.Services.AddScoped<SettingsService>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -463,6 +463,46 @@ h2, h3, h4, h5, h6,
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Public-site Settings menu (Phase 18 wave 18.6). The MudMenu body renders inside .mud-popover, which
|
||||||
|
already re-points --mud-palette-surface to the theme-aware --deepdrft-popover-surface (see above), so the
|
||||||
|
panel inherits the correct surface + text in both themes with no dark override. These rules are layout
|
||||||
|
only: padding, the section heading, and each settings item's label/control stacking. Scoped via the
|
||||||
|
global stylesheet (not CSS isolation) because the menu body portals out of the component's DOM scope. */
|
||||||
|
.dd-settings-panel {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 320px;
|
||||||
|
color: var(--deepdrft-page-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-settings-heading {
|
||||||
|
font-family: "DM Sans", sans-serif;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-settings-item + .dd-settings-item {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--deepdrft-popover-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-settings-item-label {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The honest capability note under the quality control (OQ2 / AC7). */
|
||||||
|
.dd-setting-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.deepdrft-share-embed-field .mud-input-slot {
|
.deepdrft-share-embed-field .mud-input-slot {
|
||||||
font-family: var(--deepdrft-font-mono) !important;
|
font-family: var(--deepdrft-font-mono) !important;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
using System.Net;
|
||||||
|
using DeepDrftModels.Enums;
|
||||||
|
using DeepDrftPublic.Client.Clients;
|
||||||
|
using DeepDrftPublic.Client.Common;
|
||||||
|
using DeepDrftPublic.Client.Services;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace DeepDrftTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the critical invariant on
|
||||||
|
/// <see cref="PreferenceAwareStreamingPlayerService.ResolveStreamFormatAsync"/> (Phase 18 wave 18.6):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>A <see cref="StreamQuality.Lossless"/> preference returns <see cref="AudioFormat.Lossless"/>
|
||||||
|
/// immediately — without probing Opus capability or fetching the sidecar.</item>
|
||||||
|
/// <item>Any other preference (currently only <see cref="StreamQuality.LowData"/>) delegates to
|
||||||
|
/// <c>base.ResolveStreamFormatAsync</c>, so the AC7 capability gate and the C2 sidecar-absent →
|
||||||
|
/// lossless fallback from Phase 18.5 are inherited, not bypassed.</item>
|
||||||
|
/// </list>
|
||||||
|
/// The test infra (FakeJsRuntime, StubSidecarHandler, SingleClientFactory) mirrors
|
||||||
|
/// <see cref="OpusFormatSelectionTests"/> exactly so both test classes exercise the same seam with
|
||||||
|
/// the same fake boundary.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public class PreferenceAwareStreamingPlayerServiceTests
|
||||||
|
{
|
||||||
|
// Scriptable JS runtime: tracks whether the Opus-capability probe was called so tests can assert
|
||||||
|
// the Lossless branch never touches it.
|
||||||
|
private sealed class FakeJsRuntime : IJSRuntime
|
||||||
|
{
|
||||||
|
private readonly bool _canDecode;
|
||||||
|
private readonly bool _sidecarParseSucceeds;
|
||||||
|
|
||||||
|
public FakeJsRuntime(bool canDecode = true, bool sidecarParseSucceeds = true)
|
||||||
|
{
|
||||||
|
_canDecode = canDecode;
|
||||||
|
_sidecarParseSucceeds = sidecarParseSucceeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CanDecodeCallCount { get; private set; }
|
||||||
|
public int SetSidecarCallCount { get; private set; }
|
||||||
|
|
||||||
|
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||||
|
{
|
||||||
|
if (identifier == "DeepDrftAudio.canDecodeOggOpus")
|
||||||
|
{
|
||||||
|
CanDecodeCallCount++;
|
||||||
|
return ValueTask.FromResult((TValue)(object)_canDecode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (identifier == "DeepDrftAudio.setOpusSidecar")
|
||||||
|
{
|
||||||
|
SetSidecarCallCount++;
|
||||||
|
var result = new AudioOperationResult
|
||||||
|
{
|
||||||
|
Success = _sidecarParseSucceeds,
|
||||||
|
Error = _sidecarParseSucceeds ? null : "Invalid Opus sidecar blob",
|
||||||
|
};
|
||||||
|
return ValueTask.FromResult((TValue)(object)result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueTask.FromResult<TValue>(default!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
|
||||||
|
=> InvokeAsync<TValue>(identifier, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a configured status (with an optional body) for sidecar requests; anything else 404s.
|
||||||
|
private sealed class StubSidecarHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly HttpStatusCode _status;
|
||||||
|
private readonly byte[] _body;
|
||||||
|
|
||||||
|
public StubSidecarHandler(HttpStatusCode status, byte[]? body = null)
|
||||||
|
{
|
||||||
|
_status = status;
|
||||||
|
_body = body ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = new HttpResponseMessage(_status);
|
||||||
|
if (_status == HttpStatusCode.OK)
|
||||||
|
response.Content = new ByteArrayContent(_body);
|
||||||
|
return Task.FromResult(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SingleClientFactory : IHttpClientFactory
|
||||||
|
{
|
||||||
|
private readonly HttpMessageHandler _handler;
|
||||||
|
public SingleClientFactory(HttpMessageHandler handler) => _handler = handler;
|
||||||
|
public HttpClient CreateClient(string name) =>
|
||||||
|
new(_handler, disposeHandler: false) { BaseAddress = new Uri("https://content.test/") };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposes the protected seam for direct assertion.
|
||||||
|
private sealed class TestablePreferencePlayer : PreferenceAwareStreamingPlayerService
|
||||||
|
{
|
||||||
|
public TestablePreferencePlayer(
|
||||||
|
AudioInteropService interop,
|
||||||
|
TrackMediaClient media,
|
||||||
|
PublicSiteSettings settings)
|
||||||
|
: base(interop, media, NullLogger<StreamingAudioPlayerService>.Instance, settings) { }
|
||||||
|
|
||||||
|
public Task<AudioFormat> ResolveFormatForTest(string entryKey) =>
|
||||||
|
ResolveStreamFormatAsync(entryKey, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly byte[] SidecarBytes = "setup-header+seek-index"u8.ToArray();
|
||||||
|
|
||||||
|
private static (TestablePreferencePlayer player, FakeJsRuntime js) Build(
|
||||||
|
StreamQuality quality,
|
||||||
|
bool canDecode = true,
|
||||||
|
bool sidecarParseSucceeds = true,
|
||||||
|
HttpStatusCode sidecarStatus = HttpStatusCode.OK,
|
||||||
|
byte[]? sidecarBody = null)
|
||||||
|
{
|
||||||
|
sidecarBody ??= SidecarBytes;
|
||||||
|
var js = new FakeJsRuntime(canDecode, sidecarParseSucceeds);
|
||||||
|
var interop = new AudioInteropService(js);
|
||||||
|
var media = new TrackMediaClient(new SingleClientFactory(new StubSidecarHandler(sidecarStatus, sidecarBody)));
|
||||||
|
var settings = new PublicSiteSettings { StreamQuality = quality };
|
||||||
|
return (new TestablePreferencePlayer(interop, media, settings), js);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lossless branch: must NOT probe capability or fetch the sidecar ──
|
||||||
|
|
||||||
|
// A Lossless preference short-circuits to AudioFormat.Lossless with zero JS calls.
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveStreamFormat_LosslessPreference_ReturnsLosslessWithoutProbe()
|
||||||
|
{
|
||||||
|
var (player, js) = Build(StreamQuality.Lossless, canDecode: true);
|
||||||
|
|
||||||
|
var format = await player.ResolveFormatForTest("track-1");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "Lossless preference must yield lossless");
|
||||||
|
Assert.That(js.CanDecodeCallCount, Is.Zero, "Lossless branch must not probe Opus capability");
|
||||||
|
Assert.That(js.SetSidecarCallCount, Is.Zero, "Lossless branch must not inject a sidecar");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Lossless branch is independent of browser Opus capability — even an incapable browser
|
||||||
|
// must get lossless without a probe (the probe is unnecessary and should not fire).
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveStreamFormat_LosslessPreference_IncapableBrowser_ReturnsLosslessWithoutProbe()
|
||||||
|
{
|
||||||
|
var (player, js) = Build(StreamQuality.Lossless, canDecode: false);
|
||||||
|
|
||||||
|
var format = await player.ResolveFormatForTest("track-2");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(format, Is.EqualTo(AudioFormat.Lossless));
|
||||||
|
Assert.That(js.CanDecodeCallCount, Is.Zero, "no probe on the Lossless path, even for incapable browsers");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LowData branch: must delegate to base (capability gate + C2 fallback are inherited) ──
|
||||||
|
|
||||||
|
// Happy path: capable browser + present sidecar → Opus (base logic reached and succeeded).
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveStreamFormat_LowDataPreference_CapableBrowser_SidecarPresent_ChoosesOpus()
|
||||||
|
{
|
||||||
|
var (player, js) = Build(StreamQuality.LowData, canDecode: true, sidecarParseSucceeds: true,
|
||||||
|
HttpStatusCode.OK, SidecarBytes);
|
||||||
|
|
||||||
|
var format = await player.ResolveFormatForTest("track-3");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(format, Is.EqualTo(AudioFormat.Opus), "capable browser + present sidecar → Opus");
|
||||||
|
Assert.That(js.CanDecodeCallCount, Is.EqualTo(1), "LowData path must invoke the capability probe");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// AC7 inherited: LowData + incapable browser → lossless (base capability gate fires).
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveStreamFormat_LowDataPreference_IncapableBrowser_FallsBackToLossless()
|
||||||
|
{
|
||||||
|
var (player, js) = Build(StreamQuality.LowData, canDecode: false);
|
||||||
|
|
||||||
|
var format = await player.ResolveFormatForTest("track-4");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "AC7: incapable browser must get lossless");
|
||||||
|
Assert.That(js.CanDecodeCallCount, Is.EqualTo(1), "the capability probe was called (and returned false)");
|
||||||
|
Assert.That(js.SetSidecarCallCount, Is.Zero, "no sidecar injected when Opus is gated out");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// C2 inherited: LowData + capable browser + no sidecar → lossless (base C2 fallback fires).
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveStreamFormat_LowDataPreference_CapableBrowser_NoSidecar_FallsBackToLossless()
|
||||||
|
{
|
||||||
|
var (player, _) = Build(StreamQuality.LowData, canDecode: true,
|
||||||
|
sidecarStatus: HttpStatusCode.NotFound, sidecarBody: null);
|
||||||
|
|
||||||
|
var format = await player.ResolveFormatForTest("track-5");
|
||||||
|
|
||||||
|
Assert.That(format, Is.EqualTo(AudioFormat.Lossless),
|
||||||
|
"C2: no sidecar → lossless even with a capable browser");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using DeepDrftPublic.Client.Common;
|
||||||
|
using DeepDrftPublic.Client.Services;
|
||||||
|
|
||||||
|
namespace DeepDrftTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the <see cref="SettingsServiceBase"/> parse/format contract (Phase 18 wave 18.6):
|
||||||
|
/// the <c>streamQuality</c> cookie round-trips through <c>FormatStreamQuality</c> → wire string →
|
||||||
|
/// <c>ParseStreamQuality</c>, and an absent, empty, or unrecognized cookie value defaults to
|
||||||
|
/// <see cref="StreamQuality.LowData"/> (the OQ2 default — a missing preference should never force
|
||||||
|
/// lossless unnecessarily on a low-bandwidth listener).
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public class SettingsServiceBaseTests
|
||||||
|
{
|
||||||
|
// Expose the protected static helpers via a concrete subclass — no other members needed.
|
||||||
|
private sealed class Exposed : SettingsServiceBase
|
||||||
|
{
|
||||||
|
public static StreamQuality Parse(string? v) => ParseStreamQuality(v);
|
||||||
|
public static string Format(StreamQuality q) => FormatStreamQuality(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── round-trip ──
|
||||||
|
|
||||||
|
// LowData serializes and deserializes intact.
|
||||||
|
[Test]
|
||||||
|
public void FormatThenParse_LowData_RoundTrips()
|
||||||
|
{
|
||||||
|
var wire = Exposed.Format(StreamQuality.LowData);
|
||||||
|
Assert.That(Exposed.Parse(wire), Is.EqualTo(StreamQuality.LowData));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lossless serializes and deserializes intact.
|
||||||
|
[Test]
|
||||||
|
public void FormatThenParse_Lossless_RoundTrips()
|
||||||
|
{
|
||||||
|
var wire = Exposed.Format(StreamQuality.Lossless);
|
||||||
|
Assert.That(Exposed.Parse(wire), Is.EqualTo(StreamQuality.Lossless));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── default for absent / garbled cookie ──
|
||||||
|
|
||||||
|
// A null cookie (no cookie present) defaults to LowData, not Lossless.
|
||||||
|
[Test]
|
||||||
|
public void Parse_Null_DefaultsToLowData()
|
||||||
|
=> Assert.That(Exposed.Parse(null), Is.EqualTo(StreamQuality.LowData));
|
||||||
|
|
||||||
|
// An empty string (cookie present but blank) defaults to LowData.
|
||||||
|
[Test]
|
||||||
|
public void Parse_Empty_DefaultsToLowData()
|
||||||
|
=> Assert.That(Exposed.Parse(string.Empty), Is.EqualTo(StreamQuality.LowData));
|
||||||
|
|
||||||
|
// A garbled/unknown name defaults to LowData rather than throwing.
|
||||||
|
// Note: Enum.TryParse accepts numeric strings as valid (e.g. "0" → LowData, "1" → Lossless),
|
||||||
|
// so only non-numeric unrecognized names are tested here.
|
||||||
|
[TestCase("garbage")]
|
||||||
|
[TestCase("lossless_extra")]
|
||||||
|
public void Parse_Unrecognized_DefaultsToLowData(string value)
|
||||||
|
=> Assert.That(Exposed.Parse(value), Is.EqualTo(StreamQuality.LowData));
|
||||||
|
|
||||||
|
// Case-insensitive parse: the enum parse is case-insensitive, so wire values survive case drift.
|
||||||
|
[TestCase("lossless")]
|
||||||
|
[TestCase("LOSSLESS")]
|
||||||
|
[TestCase("Lossless")]
|
||||||
|
public void Parse_LosslessCaseVariants_ParseCorrectly(string value)
|
||||||
|
=> Assert.That(Exposed.Parse(value), Is.EqualTo(StreamQuality.Lossless));
|
||||||
|
|
||||||
|
[TestCase("lowdata")]
|
||||||
|
[TestCase("LOWDATA")]
|
||||||
|
[TestCase("LowData")]
|
||||||
|
public void Parse_LowDataCaseVariants_ParseCorrectly(string value)
|
||||||
|
=> Assert.That(Exposed.Parse(value), Is.EqualTo(StreamQuality.LowData));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user