From c63c7ca0337308e1ff14bd3f570845d0c8d95fd5 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 14:06:19 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feature:=20Phase=2018.6=20Track=20A=20?= =?UTF-8?q?=E2=80=94=20public=20Settings=20menu=20+=20streaming-quality=20?= =?UTF-8?q?toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/PublicSiteSettings.cs | 29 +++++++++ DeepDrftPublic.Client/Common/SettingsItem.cs | 12 ++++ DeepDrftPublic.Client/Common/StreamQuality.cs | 18 +++++ .../Controls/AudioPlayerProvider.razor.cs | 9 ++- .../Controls/Settings/SettingsMenu.razor | 40 ++++++++++++ .../Settings/StreamQualitySetting.razor | 65 +++++++++++++++++++ .../Layout/DeepDrftMenu.razor | 5 +- DeepDrftPublic.Client/Layout/MainLayout.razor | 16 ++++- .../Services/AudioInteropService.cs | 2 +- .../PreferenceAwareStreamingPlayerService.cs | 49 ++++++++++++++ .../Services/SettingsCookieService.cs | 34 ++++++++++ .../Services/SettingsServiceBase.cs | 28 ++++++++ .../Services/StreamingAudioPlayerService.cs | 2 +- DeepDrftPublic.Client/Startup.cs | 7 ++ DeepDrftPublic/Components/App.razor | 2 + DeepDrftPublic/Services/SettingsService.cs | 25 +++++++ DeepDrftPublic/Startup.cs | 5 ++ .../wwwroot/styles/deepdrft-styles.css | 40 ++++++++++++ 18 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 DeepDrftPublic.Client/Common/PublicSiteSettings.cs create mode 100644 DeepDrftPublic.Client/Common/SettingsItem.cs create mode 100644 DeepDrftPublic.Client/Common/StreamQuality.cs create mode 100644 DeepDrftPublic.Client/Controls/Settings/SettingsMenu.razor create mode 100644 DeepDrftPublic.Client/Controls/Settings/StreamQualitySetting.razor create mode 100644 DeepDrftPublic.Client/Services/PreferenceAwareStreamingPlayerService.cs create mode 100644 DeepDrftPublic.Client/Services/SettingsCookieService.cs create mode 100644 DeepDrftPublic.Client/Services/SettingsServiceBase.cs create mode 100644 DeepDrftPublic/Services/SettingsService.cs diff --git a/DeepDrftPublic.Client/Common/PublicSiteSettings.cs b/DeepDrftPublic.Client/Common/PublicSiteSettings.cs new file mode 100644 index 0000000..1b83b16 --- /dev/null +++ b/DeepDrftPublic.Client/Common/PublicSiteSettings.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Components; + +namespace DeepDrftPublic.Client.Common; + +/// +/// The single public-site listener-settings object (Phase 18 wave 18.6, §4a). The generalized analogue of +/// : one scoped holder for every remembered listener preference, seeded at +/// server prerender, carried into WASM via , 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. +/// +/// Built design-for-adaptability per §4a: a new preference is a new [PersistentState] property here +/// plus a new in the menu — not a rewire. Dark mode is intentionally +/// not migrated in now (it keeps its own seam); this object is shaped +/// so that consolidation is later a merge of two identical seams, not a reconciliation of two different ones. +/// +/// +public class PublicSiteSettings +{ + /// + /// The listener's streaming-quality preference. Defaults to (Opus, + /// capability-gated — OQ2). Seeded from the streamQuality cookie at prerender; persisted on change + /// by the client cookie service. The player reads this to decide which ?format= to request, but + /// the capability gate and C2 fallback still apply on top, so a + /// preference never forces an unplayable stream. + /// + [PersistentState] + public StreamQuality StreamQuality { get; set; } = StreamQuality.LowData; +} diff --git a/DeepDrftPublic.Client/Common/SettingsItem.cs b/DeepDrftPublic.Client/Common/SettingsItem.cs new file mode 100644 index 0000000..9cdfa6b --- /dev/null +++ b/DeepDrftPublic.Client/Common/SettingsItem.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Components; + +namespace DeepDrftPublic.Client.Common; + +/// +/// 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 plus a +/// 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 +/// and its own persistence call, so each item is self-contained and the menu stays preference-agnostic. +/// +public sealed record SettingsItem(string Label, RenderFragment Control); diff --git a/DeepDrftPublic.Client/Common/StreamQuality.cs b/DeepDrftPublic.Client/Common/StreamQuality.cs new file mode 100644 index 0000000..5dd1508 --- /dev/null +++ b/DeepDrftPublic.Client/Common/StreamQuality.cs @@ -0,0 +1,18 @@ +namespace DeepDrftPublic.Client.Common; + +/// +/// The listener's streaming-quality preference (Phase 18 wave 18.6, §4). This is the user's intent, +/// not the wire format that ultimately gets served: 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 +/// DeepDrftModels.Enums.AudioFormat (the delivery rendering resolved per request): one is the +/// remembered preference, the other is what a given stream request actually asks for. +/// +public enum StreamQuality +{ + /// Bandwidth-friendly Opus (capability-gated; the default before any choice — OQ2). + LowData, + + /// The lossless WAV path, always playable everywhere. + Lossless +} diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs index 5202023..22e4a8a 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs @@ -1,3 +1,4 @@ +using DeepDrftPublic.Client.Common; using DeepDrftPublic.Client.Services; using DeepDrftPublic.Client.Clients; using Microsoft.AspNetCore.Components; @@ -13,6 +14,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable [Inject] public required BeaconInterop Beacon { get; set; } [Inject] public required IPlayEventSink PlayEventSink { get; set; } [Inject] public required IAnonIdProvider AnonId { get; set; } + [Inject] public required PublicSiteSettings Settings { get; set; } private IStreamingPlayerService? _audioPlayerService; private QueueService? _queueService; @@ -26,7 +28,12 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable // EnsureInitializedAsync — that path is correct because audio contexts // require a user gesture anyway. Initializing eagerly here causes 4+ // 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 // queue binds — the player is built with `new`, not DI, so threading telemetry through its diff --git a/DeepDrftPublic.Client/Controls/Settings/SettingsMenu.razor b/DeepDrftPublic.Client/Controls/Settings/SettingsMenu.razor new file mode 100644 index 0000000..9fd1a60 --- /dev/null +++ b/DeepDrftPublic.Client/Controls/Settings/SettingsMenu.razor @@ -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. +*@ +
+ +
+
Settings
+ @foreach (var item in _items) + { +
+
@item.Label
+ @item.Control +
+ } +
+
+
+ +@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 _items = + [ + new SettingsItem("Streaming quality", @) + ]; +} diff --git a/DeepDrftPublic.Client/Controls/Settings/StreamQualitySetting.razor b/DeepDrftPublic.Client/Controls/Settings/StreamQualitySetting.razor new file mode 100644 index 0000000..4fcff26 --- /dev/null +++ b/DeepDrftPublic.Client/Controls/Settings/StreamQualitySetting.razor @@ -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. +*@ +
+ + + Low-data (Opus) + + + Lossless (WAV) + + + + @if (_opusUnavailable && _quality == StreamQuality.LowData) + { +
+ This browser can't decode Opus — you'll stream lossless. +
+ } +
+ +@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); + } +} diff --git a/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor b/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor index f8808bf..27b7028 100644 --- a/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor +++ b/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor @@ -1,5 +1,6 @@ @using DeepDrftPublic.Client.Common @using DeepDrftPublic.Client.Controls +@using DeepDrftPublic.Client.Controls.Settings @using DeepDrftPublic.Client.Services @* Desktop Menu *@ @@ -42,6 +43,7 @@
+
@@ -74,7 +76,8 @@ @onclick="ToggleMobileMenu"> - + + diff --git a/DeepDrftPublic.Client/Layout/MainLayout.razor b/DeepDrftPublic.Client/Layout/MainLayout.razor index bdfc432..66ec0d5 100644 --- a/DeepDrftPublic.Client/Layout/MainLayout.razor +++ b/DeepDrftPublic.Client/Layout/MainLayout.razor @@ -42,6 +42,7 @@ @code { private string _audioPlayerClass = "minimized"; private const string DarkModeKey = "darkMode"; + private const string StreamQualityKey = "streamQuality"; private bool _isDarkMode = false; private bool? _lastAppliedDarkMode = null; private PersistingComponentStateSubscription _persistingSubscription; @@ -49,6 +50,7 @@ [Inject] public required PersistentComponentState PersistentState { get; set; } [Inject] public required DarkModeSettings DarkModeSettings { get; set; } + [Inject] public required PublicSiteSettings PublicSiteSettings { get; set; } [Inject] public required IJSRuntime JS { get; set; } protected override void OnInitialized() @@ -66,8 +68,17 @@ _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(StreamQualityKey, out var restoredQuality)) + { + PublicSiteSettings.StreamQuality = restoredQuality; + } + // Register to persist state when prerendering completes - _persistingSubscription = PersistentState.RegisterOnPersisting(PersistDarkMode); + _persistingSubscription = PersistentState.RegisterOnPersisting(PersistState); } // Sync dark mode class on so portaled MudBlazor elements (popovers, menus, selects) @@ -91,9 +102,10 @@ // Theme wrapper class for CSS targeting private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light"; - private Task PersistDarkMode() + private Task PersistState() { PersistentState.PersistAsJson(DarkModeKey, _isDarkMode); + PersistentState.PersistAsJson(StreamQualityKey, PublicSiteSettings.StreamQuality); return Task.CompletedTask; } diff --git a/DeepDrftPublic.Client/Services/AudioInteropService.cs b/DeepDrftPublic.Client/Services/AudioInteropService.cs index 4c109ee..6ea87e5 100644 --- a/DeepDrftPublic.Client/Services/AudioInteropService.cs +++ b/DeepDrftPublic.Client/Services/AudioInteropService.cs @@ -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 /// failures degrade to false (assume incapable) so an interop error can never silence playback. /// - public async Task CanDecodeOggOpus(string playerId) + public async Task CanDecodeOggOpus() { try { diff --git a/DeepDrftPublic.Client/Services/PreferenceAwareStreamingPlayerService.cs b/DeepDrftPublic.Client/Services/PreferenceAwareStreamingPlayerService.cs new file mode 100644 index 0000000..1375bed --- /dev/null +++ b/DeepDrftPublic.Client/Services/PreferenceAwareStreamingPlayerService.cs @@ -0,0 +1,49 @@ +using DeepDrftModels.Enums; +using DeepDrftPublic.Client.Clients; +using DeepDrftPublic.Client.Common; +using Microsoft.Extensions.Logging; + +namespace DeepDrftPublic.Client.Services; + +/// +/// The production player that honours the listener's streaming-quality preference (Phase 18 wave 18.6). +/// Extends through the single deliberately-overridable seam, +/// , so the rest of the streaming stack +/// (seek, telemetry, the seek-beyond-buffer format reuse) is inherited verbatim. +/// +/// The override is one branch: a preference returns +/// immediately; anything else falls through to base, 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. +/// +/// +public class PreferenceAwareStreamingPlayerService : StreamingAudioPlayerService +{ + private readonly PublicSiteSettings _settings; + + public PreferenceAwareStreamingPlayerService( + AudioInteropService audioInterop, + TrackMediaClient trackMediaClient, + ILogger logger, + PublicSiteSettings settings) + : base(audioInterop, trackMediaClient, logger) + { + _settings = settings; + } + + protected override async Task 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); + } +} diff --git a/DeepDrftPublic.Client/Services/SettingsCookieService.cs b/DeepDrftPublic.Client/Services/SettingsCookieService.cs new file mode 100644 index 0000000..e58f08b --- /dev/null +++ b/DeepDrftPublic.Client/Services/SettingsCookieService.cs @@ -0,0 +1,34 @@ +using DeepDrftPublic.Client.Common; +using Microsoft.JSInterop; + +namespace DeepDrftPublic.Client.Services; + +/// +/// Client-side runtime writer for public-site settings (Phase 18 wave 18.6), the analogue of +/// . Reads the current preference off the in-memory +/// (already seeded at prerender and bridged into WASM), and writes a +/// 365-day cookie via document.cookie 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). +/// +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) + { + var expires = DateTime.UtcNow.AddDays(ExpiryDays).ToString("R"); + await js.InvokeVoidAsync("eval", + $"document.cookie = '{name}={value}; expires={expires}; path=/; SameSite=Lax'"); + } +} diff --git a/DeepDrftPublic.Client/Services/SettingsServiceBase.cs b/DeepDrftPublic.Client/Services/SettingsServiceBase.cs new file mode 100644 index 0000000..73845c5 --- /dev/null +++ b/DeepDrftPublic.Client/Services/SettingsServiceBase.cs @@ -0,0 +1,28 @@ +using DeepDrftPublic.Client.Common; + +namespace DeepDrftPublic.Client.Services; + +/// +/// Shared cookie contract for the public-site settings seam (Phase 18 wave 18.6), the analogue of +/// . 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. +/// +public abstract class SettingsServiceBase +{ + protected const string StreamQualityCookieName = "streamQuality"; + + /// + /// Parses the streamQuality cookie value into , defaulting to + /// (the OQ2 default) for an absent, empty, or unrecognized value so + /// a missing/garbled cookie never produces a surprising preference. + /// + protected static StreamQuality ParseStreamQuality(string? cookieValue) => + Enum.TryParse(cookieValue, ignoreCase: true, out var parsed) + ? parsed + : StreamQuality.LowData; + + /// Formats a for cookie storage (round-trips with ). + protected static string FormatStreamQuality(StreamQuality quality) => quality.ToString(); +} diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs index bf49532..9b6f856 100644 --- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs @@ -280,7 +280,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS protected virtual async Task ResolveStreamFormatAsync(string entryKey, CancellationToken cancellationToken) { // 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; } diff --git a/DeepDrftPublic.Client/Startup.cs b/DeepDrftPublic.Client/Startup.cs index 55781aa..89bccf7 100644 --- a/DeepDrftPublic.Client/Startup.cs +++ b/DeepDrftPublic.Client/Startup.cs @@ -14,6 +14,13 @@ public static class Startup services.AddScoped(); services.AddScoped(); + // 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(); + services.AddScoped(); + // Track Client. The HTTP-backed ITrackDataService is used by both WASM and SSR // prerender — both call DeepDrftAPI over the "DeepDrft.API" client. services.AddScoped(); diff --git a/DeepDrftPublic/Components/App.razor b/DeepDrftPublic/Components/App.razor index e36ba5b..851143b 100644 --- a/DeepDrftPublic/Components/App.razor +++ b/DeepDrftPublic/Components/App.razor @@ -34,11 +34,13 @@ @code { [Inject] public required DarkModeService DarkModeService { get; set; } + [Inject] public required SettingsService SettingsService { get; set; } protected override void OnInitialized() { base.OnInitialized(); DarkModeService.CheckDarkMode(); + SettingsService.CheckSettings(); } } diff --git a/DeepDrftPublic/Services/SettingsService.cs b/DeepDrftPublic/Services/SettingsService.cs new file mode 100644 index 0000000..efc475e --- /dev/null +++ b/DeepDrftPublic/Services/SettingsService.cs @@ -0,0 +1,25 @@ +using DeepDrftPublic.Client.Common; +using DeepDrftPublic.Client.Services; + +namespace DeepDrftPublic.Services; + +/// +/// Server-side prerender reader for public-site listener settings (Phase 18 wave 18.6), the sibling of +/// . Reads each preference's cookie via +/// during prerender and seeds the scoped , which MainLayout then +/// rounds through PersistentComponentState 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 so the server read and +/// the client write agree on one wire format. +/// +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); + } +} diff --git a/DeepDrftPublic/Startup.cs b/DeepDrftPublic/Startup.cs index 4ab30e1..de3cd8e 100644 --- a/DeepDrftPublic/Startup.cs +++ b/DeepDrftPublic/Startup.cs @@ -11,5 +11,10 @@ public static class Startup builder.Services .AddHttpContextAccessor() .AddScoped(); + + // 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(); } } diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css index f2c3b5c..ea4a26a 100644 --- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css +++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css @@ -463,6 +463,46 @@ h2, h3, h4, h5, h6, 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 { font-family: var(--deepdrft-font-mono) !important; font-size: 0.75rem; From 77c6c42c9474511debd523477ec5a5bb81008fbb Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 14:17:34 -0400 Subject: [PATCH 2/2] remediate: replace eval cookie writes with safe JS helper + add tests (18.6 Track A) Both SettingsCookieService and DarkModeCookieService now call window.DeepDrftSettings.setCookie (new Interop/settings/settings.ts) instead of eval. New tests cover SettingsServiceBase parse/format round-trip and the PreferenceAwareStreamingPlayerService invariant (Lossless skips probe; LowData inherits base). --- .../Services/DarkModeCookieService.cs | 4 +- .../Services/SettingsCookieService.cs | 4 +- DeepDrftPublic/Components/App.razor | 1 + DeepDrftPublic/Interop/settings/settings.ts | 33 +++ ...ferenceAwareStreamingPlayerServiceTests.cs | 209 ++++++++++++++++++ DeepDrftTests/SettingsServiceBaseTests.cs | 73 ++++++ 6 files changed, 318 insertions(+), 6 deletions(-) create mode 100644 DeepDrftPublic/Interop/settings/settings.ts create mode 100644 DeepDrftTests/PreferenceAwareStreamingPlayerServiceTests.cs create mode 100644 DeepDrftTests/SettingsServiceBaseTests.cs diff --git a/DeepDrftPublic.Client/Services/DarkModeCookieService.cs b/DeepDrftPublic.Client/Services/DarkModeCookieService.cs index 177894e..30e6b2f 100644 --- a/DeepDrftPublic.Client/Services/DarkModeCookieService.cs +++ b/DeepDrftPublic.Client/Services/DarkModeCookieService.cs @@ -14,9 +14,7 @@ public class DarkModeCookieService(DarkModeSettings darkModeSetting, IJSRuntime public async ValueTask SetDarkModeAsync(bool isDarkMode) { - var expires = DateTime.UtcNow.AddDays(EXPIRY_DAYS).ToString("R"); - await js.InvokeVoidAsync("eval", - $"document.cookie = '{COOKIE_NAME}={isDarkMode.ToString().ToLower()}; expires={expires}; path=/; SameSite=Lax'"); + await js.InvokeVoidAsync("DeepDrftSettings.setCookie", COOKIE_NAME, isDarkMode.ToString().ToLower(), EXPIRY_DAYS); darkModeSetting.IsDarkMode = isDarkMode; } } \ No newline at end of file diff --git a/DeepDrftPublic.Client/Services/SettingsCookieService.cs b/DeepDrftPublic.Client/Services/SettingsCookieService.cs index e58f08b..d0bbce7 100644 --- a/DeepDrftPublic.Client/Services/SettingsCookieService.cs +++ b/DeepDrftPublic.Client/Services/SettingsCookieService.cs @@ -27,8 +27,6 @@ public class SettingsCookieService(PublicSiteSettings settings, IJSRuntime js) : private async ValueTask WriteCookieAsync(string name, string value) { - var expires = DateTime.UtcNow.AddDays(ExpiryDays).ToString("R"); - await js.InvokeVoidAsync("eval", - $"document.cookie = '{name}={value}; expires={expires}; path=/; SameSite=Lax'"); + await js.InvokeVoidAsync("DeepDrftSettings.setCookie", name, value, ExpiryDays); } } diff --git a/DeepDrftPublic/Components/App.razor b/DeepDrftPublic/Components/App.razor index 851143b..4576b1f 100644 --- a/DeepDrftPublic/Components/App.razor +++ b/DeepDrftPublic/Components/App.razor @@ -24,6 +24,7 @@