From c83b132522494af55f4575e75eb9ee6035a56c49 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 6 Jun 2026 15:43:09 -0400 Subject: [PATCH] feature: Embed Frame Player --- DeepDrftAPI/Controllers/TrackController.cs | 22 ++++++ DeepDrftData/ITrackService.cs | 1 + DeepDrftData/Repositories/TrackRepository.cs | 9 +++ DeepDrftData/TrackManager.cs | 16 +++++ DeepDrftPublic.Client/Clients/TrackClient.cs | 18 +++++ .../AudioPlayerBar/AudioPlayerBar.razor | 11 +-- .../AudioPlayerBar/AudioPlayerBar.razor.cs | 27 ++++++- .../AudioPlayerBar/AudioPlayerBar.razor.css | 21 +++--- .../AudioPlayerBar/PlayerControls.razor | 2 +- .../AudioPlayerBar/PlayerControls.razor.cs | 7 ++ .../AudioPlayerBar/PlayerTransportZone.razor | 1 + .../PlayerTransportZone.razor.cs | 1 + .../Controls/PlayStateIcon.razor | 23 ++++-- .../Layout/EmbedLayout.razor | 71 ++++++++++++++++++- DeepDrftPublic.Client/Layout/MainLayout.razor | 3 + .../Layout/MainLayout.razor.css | 7 ++ DeepDrftPublic.Client/Pages/FramePlayer.razor | 37 +++++++++- .../Services/IPlayerService.cs | 9 +++ .../Services/ITrackDataService.cs | 2 + .../Services/StreamingAudioPlayerService.cs | 11 +++ .../Services/TrackClientDataService.cs | 3 + .../Controllers/TrackProxyController.cs | 35 +++++++++ 22 files changed, 308 insertions(+), 29 deletions(-) diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index 6368247..6591dc9 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -236,6 +236,28 @@ public class TrackController : ControllerBase return Ok(result.Value); } + // GET api/track/meta/by-key/{entryKey}: single track metadata by vault entry key. + // Unauthenticated, like GET api/track/page and GET api/track/{id} — reachable through the + // public proxy. 3-segment route, so no collision with meta/{id:long} or {trackId}. + [HttpGet("meta/by-key/{entryKey}")] + public async Task GetMetaByKey(string entryKey) + { + var result = await _sqlTrackService.GetByEntryKey(entryKey); + if (!result.Success) + { + var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; + _logger.LogError("GetMetaByKey failed for {EntryKey}: {Error}", entryKey, error); + return StatusCode(500, "Failed to load track"); + } + + if (result.Value is null) + { + return NotFound(); + } + + return Ok(result.Value); + } + // PUT api/track/meta/{id}: metadata-only update. EntryKey is immutable and not part of the body. [ApiKeyAuthorize] [HttpPut("meta/{id:long}")] diff --git a/DeepDrftData/ITrackService.cs b/DeepDrftData/ITrackService.cs index 1a13f50..41331f1 100644 --- a/DeepDrftData/ITrackService.cs +++ b/DeepDrftData/ITrackService.cs @@ -12,6 +12,7 @@ namespace DeepDrftData; public interface ITrackService { Task> GetById(long id); + Task> GetByEntryKey(string entryKey); 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 878163a..1223930 100644 --- a/DeepDrftData/Repositories/TrackRepository.cs +++ b/DeepDrftData/Repositories/TrackRepository.cs @@ -2,20 +2,29 @@ using Data.Data.Repositories; using Data.Errors; using DeepDrftData.Data; using DeepDrftModels.Entities; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace DeepDrftData.Repositories; public class TrackRepository : Repository { + private readonly DeepDrftContext _context; + public TrackRepository( DeepDrftContext context, ILogger> logger, IDbExceptionClassifier? classifier = null) : base(context, logger, classifier: classifier) { + _context = context; } + // Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this + // queries the DbSet directly. Returns null on miss (service wraps in ResultContainer). + public async Task GetByEntryKeyAsync(string entryKey) + => await _context.Tracks.FirstOrDefaultAsync(t => t.EntryKey == entryKey); + 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 9e5ec14..d874b07 100644 --- a/DeepDrftData/TrackManager.cs +++ b/DeepDrftData/TrackManager.cs @@ -46,6 +46,22 @@ public class TrackManager } } + // Lookup by vault entry key. No base-name conflict (unlike GetById), so this is a plain + // public method. Mirrors the nullable-on-miss shape of ITrackService.GetById. + public async Task> GetByEntryKey(string entryKey) + { + try + { + var entity = await Repository.GetByEntryKeyAsync(entryKey); + 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 f4681e4..da69e01 100644 --- a/DeepDrftPublic.Client/Clients/TrackClient.cs +++ b/DeepDrftPublic.Client/Clients/TrackClient.cs @@ -50,4 +50,22 @@ public class TrackClient ? ApiResult>.CreatePassResult(paged) : 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)}"); + + 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"); + } } \ No newline at end of file diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor index 11c1658..92a13d3 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor @@ -9,12 +9,13 @@ } else { -
+
@* Minimize / close — positioned absolutely top-right *@ - + @if (!Fixed) + { + + } @@ -48,6 +52,3 @@ else }
} - -@* Spacer to prevent content overlap *@ -
\ No newline at end of file diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs index 0108071..159d0af 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs @@ -7,7 +7,8 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar; public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable { [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } - + [Parameter] public bool Fixed { get; set; } = false; + private bool _isMinimized = true; private bool _isSeeking = false; private double _seekPosition = 0; @@ -15,6 +16,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable private bool IsLoaded => PlayerService?.IsLoaded ?? false; private bool IsLoading => PlayerService?.IsLoading ?? false; + + /// + /// A track is staged when it has been selected as the current track but not yet loaded into + /// the audio context (the embed's pre-gesture state). The first play click loads + plays it. + /// + private bool IsStaged => PlayerService is { IsLoaded: false, IsLoading: false, CurrentTrack: not null }; + + /// Play is available once a track is loaded, or staged and waiting for the first gesture. + private bool CanPlay => IsLoaded || IsStaged; private bool IsStreaming => PlayerService?.CanStartStreaming ?? false; private bool IsStreamingMode => PlayerService?.IsStreamingMode ?? false; private double? Duration => PlayerService?.Duration; @@ -26,9 +36,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable /// Display time - shows seek position while dragging, otherwise current playback time. /// private double DisplayTime => _isSeeking ? _seekPosition : (PlayerService?.CurrentTime ?? 0); + private string PlayerModeClass => Fixed ? "player-fixed" : "player-docked"; protected override void OnParametersSet() { + if (Fixed) + { + _isMinimized = false; + } + // PlayerService is cascaded by AudioPlayerProvider; once it arrives, // wire our track-selection handler. The provider owns OnStateChanged — // we intentionally do NOT wrap or replace it. Because the cascade is @@ -60,6 +76,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable private async Task TogglePlayPause() { if (PlayerService == null) return; + + // Gesture-gated start: a staged-but-unloaded track (the embed autoplay path) is loaded on + // the first play click — the user gesture the browser requires before audio can start. + if (IsStaged) + { + await PlayerService.SelectTrackStreaming(PlayerService.CurrentTrack!); + return; + } + await PlayerService.TogglePlayPause(); } diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.css b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.css index 523709e..49a9d6d 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.css +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.css @@ -12,6 +12,16 @@ margin: 0; } +.player-fixed { + position: relative; + top: 0; + left: 0; + right: 0; + z-index: 1200; + padding: 0; + margin: 0; +} + ::deep .player-inner-container { padding: 1rem; padding-bottom: 1.5rem; @@ -45,13 +55,6 @@ transform: scale(1.1); } -/* Spacer to prevent content overlap */ -.player-spacer { - height: 100px; - width: 100%; - flex-shrink: 0; -} - @media (max-width: 768px) { ::deep .minimized-dock { bottom: 15px; @@ -66,10 +69,6 @@ ::deep .player-surface { margin-bottom: 1.25rem; } - - .player-spacer { - height: 120px; - } } /* Unified responsive player layout. diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerControls.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerControls.razor index 8a0134e..8accea0 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerControls.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerControls.razor @@ -4,7 +4,7 @@ + /// Whether the play button is enabled. Distinct from so a staged-but- + /// unloaded track (embed pre-gesture state) can still be played: the click loads it. Stop stays + /// gated on . + /// + [Parameter] public bool CanPlay { get; set; } [Parameter] public required EventCallback TogglePlayPause { get; set; } [Parameter] public required EventCallback Stop { get; set; } } diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor index 099ccad..63c38c3 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor @@ -2,6 +2,7 @@ @if (IsLoading && !IsStreaming) diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor.cs index c99ce3c..68d81bd 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor.cs @@ -5,6 +5,7 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar; public partial class PlayerTransportZone : ComponentBase { [Parameter] public bool IsLoaded { get; set; } + [Parameter] public bool CanPlay { get; set; } [Parameter] public bool IsLoading { get; set; } [Parameter] public bool IsStreaming { get; set; } [Parameter] public double LoadProgress { get; set; } diff --git a/DeepDrftPublic.Client/Controls/PlayStateIcon.razor b/DeepDrftPublic.Client/Controls/PlayStateIcon.razor index e64917d..dd720ac 100644 --- a/DeepDrftPublic.Client/Controls/PlayStateIcon.razor +++ b/DeepDrftPublic.Client/Controls/PlayStateIcon.razor @@ -1,7 +1,20 @@ @namespace DeepDrftPublic.Client.Controls - +@if (!RendererInfo.IsInteractive) +{ + @* Interactive runtime (WASM, or Server on first visit) not attached yet — the prerendered + button has no wired click handler, so clicks would vanish. Show a spinner in its place + until the component hydrates, at which point it re-renders into the live button. *@ + +} +else +{ + +} diff --git a/DeepDrftPublic.Client/Layout/EmbedLayout.razor b/DeepDrftPublic.Client/Layout/EmbedLayout.razor index 0bdd361..87a2efa 100644 --- a/DeepDrftPublic.Client/Layout/EmbedLayout.razor +++ b/DeepDrftPublic.Client/Layout/EmbedLayout.razor @@ -1,5 +1,70 @@ -

EmbedLayout

+@using DeepDrftPublic.Client.Controls +@using DeepDrftPublic.Client.Controls.AudioPlayerBar +@using DeepDrftPublic.Client.Services +@using DeepDrftPublic.Client.Common +@using DeepDrftShared.Client.Common +@using Microsoft.AspNetCore.Components +@inherits LayoutComponentBase +@implements IDisposable + + +
+ + + + @Body + + + +
+ + +
+ An unhandled error has occurred. + Reload + 🗙 +
@code { - -} \ No newline at end of file + private const string DarkModeKey = "darkMode"; + private bool _isDarkMode = false; + private PersistingComponentStateSubscription _persistingSubscription; + + [Inject] public required PersistentComponentState PersistentState { get; set; } + [Inject] public required DarkModeSettings DarkModeSettings { get; set; } + + protected override void OnInitialized() + { + base.OnInitialized(); + + // Restore persisted dark mode state (from server prerender) + if (PersistentState.TryTakeFromJson(DarkModeKey, out var restored)) + { + _isDarkMode = restored; + DarkModeSettings.IsDarkMode = restored; + } + else + { + _isDarkMode = DarkModeSettings.IsDarkMode; + } + + // Register to persist state when prerendering completes + _persistingSubscription = PersistentState.RegisterOnPersisting(PersistDarkMode); + } + + // Theme wrapper class for CSS targeting + private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light"; + + private Task PersistDarkMode() + { + PersistentState.PersistAsJson(DarkModeKey, _isDarkMode); + return Task.CompletedTask; + } + + public void Dispose() + { + _persistingSubscription.Dispose(); + } +} + + diff --git a/DeepDrftPublic.Client/Layout/MainLayout.razor b/DeepDrftPublic.Client/Layout/MainLayout.razor index 1ba23fa..c24c59b 100644 --- a/DeepDrftPublic.Client/Layout/MainLayout.razor +++ b/DeepDrftPublic.Client/Layout/MainLayout.razor @@ -22,6 +22,9 @@ + + @* Spacer to prevent content overlap *@ +
diff --git a/DeepDrftPublic.Client/Layout/MainLayout.razor.css b/DeepDrftPublic.Client/Layout/MainLayout.razor.css index 60cec92..574c16e 100644 --- a/DeepDrftPublic.Client/Layout/MainLayout.razor.css +++ b/DeepDrftPublic.Client/Layout/MainLayout.razor.css @@ -1,3 +1,10 @@ +/* Spacer to prevent content overlap */ +.player-spacer { + height: 100px; + width: 100%; + flex-shrink: 0; +} + #blazor-error-ui { color-scheme: light only; background: lightyellow; diff --git a/DeepDrftPublic.Client/Pages/FramePlayer.razor b/DeepDrftPublic.Client/Pages/FramePlayer.razor index bd10fec..177c406 100644 --- a/DeepDrftPublic.Client/Pages/FramePlayer.razor +++ b/DeepDrftPublic.Client/Pages/FramePlayer.razor @@ -1,7 +1,38 @@ +@using DeepDrftModels.DTOs +@using DeepDrftPublic.Client.Controls.AudioPlayerBar +@using DeepDrftPublic.Client.Layout +@using DeepDrftPublic.Client.Services + @page "/FramePlayer" -

FramePlayer

+@layout EmbedLayout + + @code { - /* TODO make an iframe compatible player using the AudioPlayerControl, - and a new embeddable layout that doesn't include any of the nav deco */ + [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } + [SupplyParameterFromQuery] public string? TrackEntryKey { get; set; } + [Inject] public required ITrackDataService TrackDataService { get; set; } + + private string? _stagedKey; + + protected override async Task OnParametersSetAsync() + { + if (PlayerService is null || string.IsNullOrWhiteSpace(TrackEntryKey)) return; + + // OnParametersSetAsync can fire repeatedly (and once per render pass); only act when the + // key actually changes so we don't re-fetch on every parameter set. + if (TrackEntryKey == _stagedKey) return; + _stagedKey = TrackEntryKey; + + var result = await TrackDataService.GetTrack(TrackEntryKey); + if (result.Success && result.Value is not null) + { + // Stage only — no audio context, no streaming. The browser blocks audio until a user + // gesture, so the embed shows the track ready and the first play click (handled in + // AudioPlayerBar) calls SelectTrackStreaming. This also keeps this pass free of JS + // interop, so it works whether it runs during prerender or after WASM is interactive. + await PlayerService.StageTrack(result.Value); + } + // On failure, leave the bar idle; a stream-level error surfaces via PlayerService.ErrorMessage. + } } \ No newline at end of file diff --git a/DeepDrftPublic.Client/Services/IPlayerService.cs b/DeepDrftPublic.Client/Services/IPlayerService.cs index 1844429..e966fc6 100644 --- a/DeepDrftPublic.Client/Services/IPlayerService.cs +++ b/DeepDrftPublic.Client/Services/IPlayerService.cs @@ -62,4 +62,13 @@ public interface IStreamingPlayerService : IPlayerService // Streaming control methods Task SelectTrackStreaming(TrackDto track); + + /// + /// 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 + /// is shown as ready, and the first play click (a genuine gesture) calls + /// so the browser allows the AudioContext to start. Sets + /// and notifies; performs no JS interop. + /// + Task StageTrack(TrackDto track); } \ No newline at end of file diff --git a/DeepDrftPublic.Client/Services/ITrackDataService.cs b/DeepDrftPublic.Client/Services/ITrackDataService.cs index 8bf97eb..2146697 100644 --- a/DeepDrftPublic.Client/Services/ITrackDataService.cs +++ b/DeepDrftPublic.Client/Services/ITrackDataService.cs @@ -18,4 +18,6 @@ public interface ITrackDataService int pageSize, string? sortColumn = null, bool sortDescending = false); + + Task> GetTrack(string trackId); } diff --git a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs index 499e732..bed8965 100644 --- a/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs @@ -59,6 +59,17 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS await NotifyStateChanged(); } + /// + public async Task StageTrack(TrackDto track) + { + // Pure state: expose the track as current so the bar shows it ready, but do NOT + // initialize the player, resume the AudioContext, or start streaming. Those steps + // require a user gesture and run on the first play click via SelectTrackStreaming. + CurrentTrack = track; + ErrorMessage = null; + await NotifyStateChanged(); + } + private async Task LoadTrackStreaming(TrackDto track) { // Always reset to clean state before loading new track. ResetToIdle diff --git a/DeepDrftPublic.Client/Services/TrackClientDataService.cs b/DeepDrftPublic.Client/Services/TrackClientDataService.cs index 5f360c4..f825bfc 100644 --- a/DeepDrftPublic.Client/Services/TrackClientDataService.cs +++ b/DeepDrftPublic.Client/Services/TrackClientDataService.cs @@ -25,4 +25,7 @@ public class TrackClientDataService : ITrackDataService string? sortColumn = null, bool sortDescending = false) => _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending); + + public Task> GetTrack(string trackId) + => _trackClient.GetTrack(trackId); } diff --git a/DeepDrftPublic/Controllers/TrackProxyController.cs b/DeepDrftPublic/Controllers/TrackProxyController.cs index 694921d..f834ea8 100644 --- a/DeepDrftPublic/Controllers/TrackProxyController.cs +++ b/DeepDrftPublic/Controllers/TrackProxyController.cs @@ -59,6 +59,41 @@ public class TrackProxyController : ControllerBase } } + /// + /// 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 + /// upstream (no track with that entry key) passes through. Declared before the parameterized + /// "{trackId}" route, though the 3-segment template makes a collision impossible regardless. + /// + [HttpGet("meta/by-key/{entryKey}")] + public async Task GetMetaByKey(string entryKey, CancellationToken ct = default) + { + var path = $"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}"; + + HttpResponseMessage upstream; + try + { + upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Upstream call to DeepDrftAPI track/meta/by-key/{EntryKey} failed", entryKey); + return StatusCode(502, "Upstream unavailable"); + } + + using (upstream) + { + if (!upstream.IsSuccessStatusCode) + { + _logger.LogWarning("DeepDrftAPI track/meta/by-key/{EntryKey} returned {Status}", entryKey, (int)upstream.StatusCode); + return StatusCode((int)upstream.StatusCode); + } + + var json = await upstream.Content.ReadAsStringAsync(ct); + return Content(json, "application/json"); + } + } + /// /// Proxies audio streaming from DeepDrftAPI. Passes the optional byte offset /// so seek-beyond-buffer works through the proxy without buffering.