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.

- Start Streaming + Browse Tracks
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; + } +}