From 0d4ef369b9b616528a71a4e1b8c1c51bdec6cf72 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 7 Jun 2026 18:33:08 -0400 Subject: [PATCH] feat: Stream Now instant-play of a random track from the nav button --- DeepDrftAPI/Controllers/TrackController.cs | 24 +++ DeepDrftData/ITrackService.cs | 6 + DeepDrftData/Repositories/TrackRepository.cs | 19 +++ DeepDrftData/TrackManager.cs | 16 ++ DeepDrftPublic.Client/Clients/TrackClient.cs | 26 ++++ .../Layout/DeepDrftMenu.razor | 137 +++++++++++++++++- .../Services/IPlayerService.cs | 11 ++ .../Services/ITrackDataService.cs | 7 + .../Services/StreamingAudioPlayerService.cs | 7 + .../Services/TrackClientDataService.cs | 3 + .../Controllers/TrackProxyController.cs | 33 +++++ 11 files changed, 287 insertions(+), 2 deletions(-) diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index 82a7e01..4b69aef 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -72,6 +72,30 @@ public class TrackController : ControllerBase return Ok(result.Value); } + // GET api/track/random (unauthenticated) + // Picks one track at random from the full library and returns its metadata. Public, same auth + // posture as GET api/track/page. Selection math lives in the SQL service/repository, not here. + // 404 when the library is empty (a valid state the client renders as "no tracks yet"), 200 + + // TrackDto otherwise. Literal segment, declared before "{trackId}" so it never routes there. + [HttpGet("random")] + public async Task GetRandom(CancellationToken cancellationToken = default) + { + var result = await _sqlTrackService.GetRandom(cancellationToken); + if (!result.Success) + { + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("GetRandom failed: {Error}", error); + return StatusCode(500, "Failed to load track"); + } + + if (result.Value is null) + { + return NotFound(); + } + + return Ok(result.Value); + } + // GET api/track/waveform-status ([ApiKeyAuthorize]) // Admin backfill view: returns every track with a flag for whether a waveform profile is // stored in the WaveformProfiles vault. The catalogue is small enough that the CMS panel reads diff --git a/DeepDrftData/ITrackService.cs b/DeepDrftData/ITrackService.cs index 41331f1..2b30754 100644 --- a/DeepDrftData/ITrackService.cs +++ b/DeepDrftData/ITrackService.cs @@ -13,6 +13,12 @@ public interface ITrackService { Task> GetById(long id); Task> GetByEntryKey(string entryKey); + + /// + /// Returns a single track chosen uniformly at random, or null when the library is empty + /// (a valid state, not a failure). Backs the public "Stream Now" instant-play feature. + /// + Task> GetRandom(CancellationToken cancellationToken = default); Task>> GetAll(); Task>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, CancellationToken cancellationToken = default); Task> Create(TrackDto newTrack); diff --git a/DeepDrftData/Repositories/TrackRepository.cs b/DeepDrftData/Repositories/TrackRepository.cs index 1223930..c5d452f 100644 --- a/DeepDrftData/Repositories/TrackRepository.cs +++ b/DeepDrftData/Repositories/TrackRepository.cs @@ -25,6 +25,25 @@ public class TrackRepository : Repository public async Task GetByEntryKeyAsync(string entryKey) => await _context.Tracks.FirstOrDefaultAsync(t => t.EntryKey == entryKey); + // Picks one track uniformly at random. Two round-trips (count, then a single offset row) + // rather than ORDER BY random() so the database never sorts the whole table — the catalogue + // is small today but this keeps the cost flat as it grows. Returns null when empty so the + // service surfaces a valid empty-library state, not an error. Queries the DbSet directly, + // mirroring GetByEntryKeyAsync, since the base Repository<> exposes only id-based reads. + public async Task GetRandomAsync(CancellationToken cancellationToken = default) + { + var count = await _context.Tracks.CountAsync(cancellationToken); + if (count == 0) + return null; + + var index = Random.Shared.Next(count); + return await _context.Tracks + .OrderBy(t => t.Id) + .Skip(index) + .Take(1) + .FirstOrDefaultAsync(cancellationToken); + } + protected override void UpdateEntity(TrackEntity target, TrackEntity source) { base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted diff --git a/DeepDrftData/TrackManager.cs b/DeepDrftData/TrackManager.cs index d874b07..862f101 100644 --- a/DeepDrftData/TrackManager.cs +++ b/DeepDrftData/TrackManager.cs @@ -62,6 +62,22 @@ public class TrackManager } } + // No base-name conflict, so this is a plain public method. Mirrors the nullable-on-empty + // shape of GetById: pass with null when the library has no tracks. + public async Task> GetRandom(CancellationToken cancellationToken = default) + { + try + { + var entity = await Repository.GetRandomAsync(cancellationToken); + return ResultContainer.CreatePassResult( + entity is null ? null : TrackConverter.Convert(entity)); + } + catch (Exception e) + { + return ResultContainer.CreateFailResult(e.Message); + } + } + public async Task>> GetAll() { try diff --git a/DeepDrftPublic.Client/Clients/TrackClient.cs b/DeepDrftPublic.Client/Clients/TrackClient.cs index da69e01..f7317e3 100644 --- a/DeepDrftPublic.Client/Clients/TrackClient.cs +++ b/DeepDrftPublic.Client/Clients/TrackClient.cs @@ -51,6 +51,32 @@ public class TrackClient : ApiResult>.CreateFailResult("Failed to deserialize response"); } + /// + /// Fetches a random track from the public library. A 404 means the library is empty — a valid + /// state, not an error — so it returns a pass result with a null value. Any other non-success + /// status is a genuine failure. + /// + public async Task> GetRandom() + { + var response = await _http.GetAsync("api/track/random"); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + return ApiResult.CreatePassResult(null); + + if (!response.IsSuccessStatusCode) + return ApiResult.CreateFailResult($"HTTP {(int)response.StatusCode}"); + + var json = await response.Content.ReadAsStringAsync(); + var track = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return track is not null + ? ApiResult.CreatePassResult(track) + : ApiResult.CreateFailResult("Failed to deserialize response"); + } + public async Task> GetTrack(string entryKey) { var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}"); diff --git a/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor b/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor index fd34263..985c553 100644 --- a/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor +++ b/DeepDrftPublic.Client/Layout/DeepDrftMenu.razor @@ -16,7 +16,20 @@
- Stream Now ▶ + @*
+ + @if (_streamMessage is not null) + { +

@_streamMessage

+ } @@ -60,15 +78,37 @@ }
  • - Stream Now ▶ +
  • } + + @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. @@ -77,6 +117,99 @@ [Parameter] public required EventCallback IsDarkModeChanged { get; set; } private bool _mobileMenuOpen; + private bool _streamLoading; + 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; + _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; + + if (PlayerService is not null) + await PlayerService.SelectTrackStreaming(track); + } + catch (Exception) + { + ShowTransientMessage(FetchFailedMessage); + } + finally + { + _streamLoading = 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/Services/IPlayerService.cs b/DeepDrftPublic.Client/Services/IPlayerService.cs index e966fc6..ff74384 100644 --- a/DeepDrftPublic.Client/Services/IPlayerService.cs +++ b/DeepDrftPublic.Client/Services/IPlayerService.cs @@ -63,6 +63,17 @@ public interface IStreamingPlayerService : IPlayerService // Streaming control methods Task SelectTrackStreaming(TrackDto track); + /// + /// Initializes the player (if needed) and resumes the AudioContext. Call this synchronously at + /// the very start of a user-gesture handler — before any await on network I/O — so the + /// gesture is still "active" when the context resumes. Safari refuses to start a suspended + /// AudioContext once the originating gesture has been consumed by an intervening await + /// (e.g. fetching which track to play), so warming here and streaming after is load-bearing. + /// also resumes the context, but only after its own internal + /// awaits — too late for a handler that must first fetch the track to play. + /// + Task WarmAudioContext(); + /// /// Stages a track as the current track without touching the audio context or starting the /// stream. Used by the embed player, where there is no user gesture on initial load: the track diff --git a/DeepDrftPublic.Client/Services/ITrackDataService.cs b/DeepDrftPublic.Client/Services/ITrackDataService.cs index 2146697..a3817d7 100644 --- a/DeepDrftPublic.Client/Services/ITrackDataService.cs +++ b/DeepDrftPublic.Client/Services/ITrackDataService.cs @@ -20,4 +20,11 @@ public interface ITrackDataService bool sortDescending = false); Task> GetTrack(string trackId); + + /// + /// Fetches a random track from the public library for instant play. Success with a value on a + /// hit; success with a null value when the library is empty (a valid state, not a failure); + /// failure on any other transport error. + /// + Task> GetRandomTrack(); } diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs index bed8965..0e5daff 100644 --- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs @@ -46,6 +46,13 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS await SelectTrackStreaming(track); } + /// + public async Task WarmAudioContext() + { + await EnsureInitializedAsync(); + await _audioInterop.EnsureAudioContextReady(PlayerId); + } + public async Task SelectTrackStreaming(TrackDto track) { await EnsureInitializedAsync(); diff --git a/DeepDrftPublic.Client/Services/TrackClientDataService.cs b/DeepDrftPublic.Client/Services/TrackClientDataService.cs index f825bfc..8d0bb55 100644 --- a/DeepDrftPublic.Client/Services/TrackClientDataService.cs +++ b/DeepDrftPublic.Client/Services/TrackClientDataService.cs @@ -28,4 +28,7 @@ public class TrackClientDataService : ITrackDataService public Task> GetTrack(string trackId) => _trackClient.GetTrack(trackId); + + public Task> GetRandomTrack() + => _trackClient.GetRandom(); } diff --git a/DeepDrftPublic/Controllers/TrackProxyController.cs b/DeepDrftPublic/Controllers/TrackProxyController.cs index f834ea8..87438b6 100644 --- a/DeepDrftPublic/Controllers/TrackProxyController.cs +++ b/DeepDrftPublic/Controllers/TrackProxyController.cs @@ -59,6 +59,39 @@ public class TrackProxyController : ControllerBase } } + /// + /// Proxies the random-track metadata lookup from DeepDrftAPI. Unauthenticated, same posture as + /// the paged listing. Small JSON, buffered and relayed; a 404 from upstream (empty library) + /// passes through so the client renders it as a valid empty state. Declared before the + /// parameterized "{trackId}" route so the literal segment is never treated as a trackId. + /// + [HttpGet("random")] + public async Task GetRandom(CancellationToken ct = default) + { + HttpResponseMessage upstream; + try + { + upstream = await _upstream.GetAsync("api/track/random", HttpCompletionOption.ResponseHeadersRead, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Upstream call to DeepDrftAPI track/random failed"); + return StatusCode(502, "Upstream unavailable"); + } + + using (upstream) + { + if (!upstream.IsSuccessStatusCode) + { + _logger.LogWarning("DeepDrftAPI track/random returned {Status}", (int)upstream.StatusCode); + return StatusCode((int)upstream.StatusCode); + } + + var json = await upstream.Content.ReadAsStringAsync(ct); + return Content(json, "application/json"); + } + } + /// /// Proxies single-track metadata lookup by vault entry key from DeepDrftAPI. Unauthenticated, /// same posture as the paged listing. Small JSON, so it is buffered and relayed; a 404 from