From 9be35e5a58a30493fecaa2bac383b6077f23586b Mon Sep 17 00:00:00 2001
From: daniel-c-harvey
Date: Tue, 9 Jun 2026 07:00:37 -0400
Subject: [PATCH] refactor: extract StreamNowButton component shared by hero
and nav menu
---
.../Controls/DeepDrftHero.razor | 2 +-
.../Controls/DeepDrftHero.razor.css | 42 -----
.../Controls/StreamNowButton.razor | 129 +++++++++++++++
.../Controls/StreamNowButton.razor.css | 7 +
.../Layout/DeepDrftMenu.razor | 149 +-----------------
.../Layout/DeepDrftMenu.razor.css | 14 +-
.../wwwroot/styles/deepdrft-styles.css | 48 ++++++
7 files changed, 196 insertions(+), 195 deletions(-)
create mode 100644 DeepDrftPublic.Client/Controls/StreamNowButton.razor
create mode 100644 DeepDrftPublic.Client/Controls/StreamNowButton.razor.css
diff --git a/DeepDrftPublic.Client/Controls/DeepDrftHero.razor b/DeepDrftPublic.Client/Controls/DeepDrftHero.razor
index d1ace13..b4a75d3 100644
--- a/DeepDrftPublic.Client/Controls/DeepDrftHero.razor
+++ b/DeepDrftPublic.Client/Controls/DeepDrftHero.razor
@@ -5,7 +5,7 @@
We craft immersive electronic soundscapes — live; built from synthesizers, drum machines, and raw intention.
diff --git a/DeepDrftPublic.Client/Controls/DeepDrftHero.razor.css b/DeepDrftPublic.Client/Controls/DeepDrftHero.razor.css
index 62e267c..9fc9b97 100644
--- a/DeepDrftPublic.Client/Controls/DeepDrftHero.razor.css
+++ b/DeepDrftPublic.Client/Controls/DeepDrftHero.razor.css
@@ -75,51 +75,9 @@
animation-delay: 0.54s;
}
-.btn-primary {
- font-family: var(--deepdrft-font-mono);
- font-size: 0.68rem;
- letter-spacing: 0.2em;
- text-transform: uppercase;
- color: var(--deepdrft-white);
- background: var(--deepdrft-navy);
- border: none;
- padding: 1rem 2.2rem;
- cursor: pointer;
- text-decoration: none;
- transition: background 0.25s, transform 0.2s;
- display: inline-block;
-}
-
-.btn-primary:hover {
- background: var(--deepdrft-green);
- transform: translateY(-1px);
-}
-
-.btn-ghost {
- font-family: var(--deepdrft-font-mono);
- font-size: 0.68rem;
- letter-spacing: 0.2em;
- text-transform: uppercase;
- color: var(--deepdrft-navy);
- background: transparent;
- border: 1px solid var(--deepdrft-border);
- padding: 1rem 2.2rem;
- cursor: pointer;
- text-decoration: none;
- transition: border-color 0.25s, color 0.25s;
- display: inline-block;
-}
-
-.btn-ghost:hover { border-color: var(--deepdrft-navy); }
-
@media (max-width: 599px) {
.hero-actions {
flex-direction: column;
align-items: stretch;
}
-
- .btn-primary,
- .btn-ghost {
- text-align: center;
- }
}
diff --git a/DeepDrftPublic.Client/Controls/StreamNowButton.razor b/DeepDrftPublic.Client/Controls/StreamNowButton.razor
new file mode 100644
index 0000000..de58068
--- /dev/null
+++ b/DeepDrftPublic.Client/Controls/StreamNowButton.razor
@@ -0,0 +1,129 @@
+@using DeepDrftPublic.Client.Services
+
+
+@if (_streamMessage is not null)
+{
+ @_streamMessage
+}
+
+@implements IDisposable
+
+@code {
+ [Parameter, EditorRequired] public required string ButtonClass { get; set; }
+ [Parameter, EditorRequired] public required string ButtonLabel { get; set; }
+ [Parameter] public string LoadingLabel { get; set; } = "Finding a track…";
+ [Parameter] public EventCallback OnStreamStarted { get; set; }
+ [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
+ [Inject] public required ITrackDataService TrackData { get; set; }
+
+ private bool _streamLoading;
+ private bool _findingTrack;
+ private string? _streamMessage;
+ private CancellationTokenSource? _messageCts;
+
+ private const string EmptyLibraryMessage = "No tracks yet — check back soon.";
+ private const string FetchFailedMessage = "Couldn't reach the library — try again.";
+
+ private async Task StreamNow()
+ {
+ // Re-entrancy guard: the button is disabled while loading, but guard in code too so a
+ // double-dispatch can never start two concurrent streams.
+ if (_streamLoading) return;
+
+ _streamLoading = true;
+ _findingTrack = true;
+ _streamMessage = null;
+
+ // Warm the AudioContext FIRST, inside the gesture's call stack and before the network
+ // await below. Safari only lets a suspended AudioContext resume while the originating
+ // user gesture is still active; awaiting GetRandomTrack() first would consume the gesture
+ // and leave playback silently refused. PlayerService is null only outside the
+ // AudioPlayerProvider cascade (it should always be present in the public layout).
+ var warmTask = PlayerService?.WarmAudioContext() ?? Task.CompletedTask;
+
+ try
+ {
+ await warmTask;
+ var result = await TrackData.GetRandomTrack();
+
+ if (!result.Success)
+ {
+ ShowTransientMessage(FetchFailedMessage);
+ return;
+ }
+
+ if (result.Value is not { } track)
+ {
+ ShowTransientMessage(EmptyLibraryMessage);
+ return;
+ }
+
+ await OnStreamStarted.InvokeAsync();
+
+ // Track is found — flip only the label flag so the button reverts to
+ // its resting label before the stream begins, while _streamLoading stays true
+ // to keep the button disabled and the re-entrancy guard intact.
+ _findingTrack = false;
+ StateHasChanged();
+
+ if (PlayerService is not null)
+ await PlayerService.SelectTrackStreaming(track);
+ }
+ catch (Exception)
+ {
+ ShowTransientMessage(FetchFailedMessage);
+ }
+ finally
+ {
+ _streamLoading = false;
+ _findingTrack = false;
+ }
+ }
+
+ private void ShowTransientMessage(string message)
+ {
+ _streamMessage = message;
+
+ // Cancel any in-flight clear timer so the newest message gets its full display window.
+ _messageCts?.Cancel();
+ _messageCts?.Dispose();
+ _messageCts = new CancellationTokenSource();
+ var token = _messageCts.Token;
+
+ _ = ClearMessageAfterDelayAsync(token);
+ }
+
+ private async Task ClearMessageAfterDelayAsync(CancellationToken token)
+ {
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(4), token);
+ }
+ catch (TaskCanceledException)
+ {
+ return;
+ }
+
+ _streamMessage = null;
+ await InvokeAsync(StateHasChanged);
+ }
+
+ public void Dispose()
+ {
+ _messageCts?.Cancel();
+ _messageCts?.Dispose();
+ }
+}
diff --git a/DeepDrftPublic.Client/Controls/StreamNowButton.razor.css b/DeepDrftPublic.Client/Controls/StreamNowButton.razor.css
new file mode 100644
index 0000000..ce3d5f4
--- /dev/null
+++ b/DeepDrftPublic.Client/Controls/StreamNowButton.razor.css
@@ -0,0 +1,7 @@
+.stream-now-message {
+ font-family: var(--deepdrft-font-mono);
+ font-size: 0.62rem;
+ letter-spacing: 0.12em;
+ color: var(--deepdrft-muted);
+ margin: 0.5rem 0 0;
+}
diff --git a/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor b/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor
index ddaaee7..5f2f39e 100644
--- a/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor
+++ b/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor
@@ -1,4 +1,5 @@
@using DeepDrftPublic.Client.Common
+@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@* Desktop Menu *@
@@ -16,20 +17,7 @@
-
+
@*
-
- @if (_streamMessage is not null)
- {
- @_streamMessage
- }
@@ -78,147 +61,23 @@
}
-
+
}
-
- @if (_streamMessage is not null)
- {
- @_streamMessage
- }
-@implements IDisposable
-
@code {
[Inject] public required DarkModeCookieService DarkModeCookieService { get; set; }
- [Inject] public required ITrackDataService TrackData { get; set; }
- [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
// Elevation is vestigial under the frosted-glass design but kept on the parameter
// surface so MainLayout's call site stays intact.
[Parameter] public int Elevation { get; set; }
[Parameter] public required bool IsDarkMode { get; set; }
[Parameter] public required EventCallback IsDarkModeChanged { get; set; }
-
+
private bool _mobileMenuOpen;
- private bool _streamLoading;
- private bool _findingTrack;
- private string? _streamMessage;
- private CancellationTokenSource? _messageCts;
-
- private const string EmptyLibraryMessage = "No tracks yet — check back soon.";
- private const string FetchFailedMessage = "Couldn't reach the library — try again.";
-
- private Task StreamNow() => StreamNowCore(closeMobileMenu: false);
-
- private Task StreamNowMobile() => StreamNowCore(closeMobileMenu: true);
-
- private async Task StreamNowCore(bool closeMobileMenu)
- {
- // Re-entrancy guard: the button is disabled while loading, but guard in code too so a
- // double-dispatch can never start two concurrent streams.
- if (_streamLoading) return;
-
- _streamLoading = true;
- _findingTrack = true;
- _streamMessage = null;
-
- // Warm the AudioContext FIRST, inside the gesture's call stack and before the network
- // await below. Safari only lets a suspended AudioContext resume while the originating
- // user gesture is still active; awaiting GetRandomTrack() first would consume the gesture
- // and leave playback silently refused. PlayerService is null only outside the
- // AudioPlayerProvider cascade (it should always be present in the public layout).
- var warmTask = PlayerService?.WarmAudioContext() ?? Task.CompletedTask;
-
- try
- {
- await warmTask;
- var result = await TrackData.GetRandomTrack();
-
- if (!result.Success)
- {
- ShowTransientMessage(FetchFailedMessage);
- return;
- }
-
- if (result.Value is not { } track)
- {
- ShowTransientMessage(EmptyLibraryMessage);
- return;
- }
-
- if (closeMobileMenu)
- _mobileMenuOpen = false;
-
- // Track is found — flip only the label flag so the button reverts to
- // "Stream Now ▶" before the stream begins, while _streamLoading stays true
- // to keep the button disabled and the re-entrancy guard intact.
- _findingTrack = false;
- StateHasChanged();
-
- if (PlayerService is not null)
- await PlayerService.SelectTrackStreaming(track);
- }
- catch (Exception)
- {
- ShowTransientMessage(FetchFailedMessage);
- }
- finally
- {
- _streamLoading = false;
- _findingTrack = false;
- }
- }
-
- private void ShowTransientMessage(string message)
- {
- _streamMessage = message;
-
- // Cancel any in-flight clear timer so the newest message gets its full display window.
- _messageCts?.Cancel();
- _messageCts?.Dispose();
- _messageCts = new CancellationTokenSource();
- var token = _messageCts.Token;
-
- _ = ClearMessageAfterDelayAsync(token);
- }
-
- private async Task ClearMessageAfterDelayAsync(CancellationToken token)
- {
- try
- {
- await Task.Delay(TimeSpan.FromSeconds(4), token);
- }
- catch (TaskCanceledException)
- {
- return;
- }
-
- _streamMessage = null;
- await InvokeAsync(StateHasChanged);
- }
-
- public void Dispose()
- {
- _messageCts?.Cancel();
- _messageCts?.Dispose();
- }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
diff --git a/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor.css b/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor.css
index 74b80fd..2b47015 100644
--- a/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor.css
+++ b/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor.css
@@ -90,7 +90,7 @@
}
/* Stream Now CTA — square pill, navy on warm white */
-.dd-nav-cta {
+::deep .dd-nav-cta {
display: inline-block;
font-family: var(--deepdrft-font-mono);
font-size: 0.68rem;
@@ -106,18 +106,18 @@
transition: background 0.25s ease;
}
-.dd-nav-cta:hover,
-.dd-nav-cta:focus-visible {
+::deep .dd-nav-cta:hover,
+::deep .dd-nav-cta:focus-visible {
background: var(--deepdrft-green);
}
-.dd-nav-dark .dd-nav-cta {
+.dd-nav-dark ::deep .dd-nav-cta {
color: var(--deepdrft-white);
background: var(--deepdrft-primary);
}
-.dd-nav-dark .dd-nav-cta:hover,
-.dd-nav-dark .dd-nav-cta:focus-visible {
+.dd-nav-dark ::deep .dd-nav-cta:hover,
+.dd-nav-dark ::deep .dd-nav-cta:focus-visible {
background: var(--deepdrft-senary);
}
@@ -207,7 +207,7 @@
padding: 0.6rem 0;
}
-.dd-nav-links-mobile .dd-nav-cta {
+.dd-nav-links-mobile ::deep .dd-nav-cta {
margin-top: 0.5rem;
text-align: center;
}
diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
index 9d182be..1032225 100644
--- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
+++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
@@ -378,3 +378,51 @@ h2, h3, h4, h5, h6,
flex-direction: column;
}
}
+
+/* =============================================================================
+ BUTTON UTILITIES (btn-primary, btn-ghost)
+ ============================================================================= */
+
+.btn-primary {
+ font-family: var(--deepdrft-font-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: var(--deepdrft-white);
+ background: var(--deepdrft-navy);
+ border: none;
+ padding: 1rem 2.2rem;
+ cursor: pointer;
+ text-decoration: none;
+ transition: background 0.25s, transform 0.2s;
+ display: inline-block;
+}
+
+.btn-primary:hover {
+ background: var(--deepdrft-green);
+ transform: translateY(-1px);
+}
+
+.btn-ghost {
+ font-family: var(--deepdrft-font-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: var(--deepdrft-navy);
+ background: transparent;
+ border: 1px solid var(--deepdrft-border);
+ padding: 1rem 2.2rem;
+ cursor: pointer;
+ text-decoration: none;
+ transition: border-color 0.25s, color 0.25s;
+ display: inline-block;
+}
+
+.btn-ghost:hover { border-color: var(--deepdrft-navy); }
+
+@media (max-width: 599px) {
+ .btn-primary,
+ .btn-ghost {
+ text-align: center;
+ }
+}