feature: Embed Frame Player
This commit is contained in:
@@ -236,6 +236,28 @@ public class TrackController : ControllerBase
|
|||||||
return Ok(result.Value);
|
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<ActionResult> 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.
|
// PUT api/track/meta/{id}: metadata-only update. EntryKey is immutable and not part of the body.
|
||||||
[ApiKeyAuthorize]
|
[ApiKeyAuthorize]
|
||||||
[HttpPut("meta/{id:long}")]
|
[HttpPut("meta/{id:long}")]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ namespace DeepDrftData;
|
|||||||
public interface ITrackService
|
public interface ITrackService
|
||||||
{
|
{
|
||||||
Task<ResultContainer<TrackDto?>> GetById(long id);
|
Task<ResultContainer<TrackDto?>> GetById(long id);
|
||||||
|
Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey);
|
||||||
Task<ResultContainer<List<TrackDto>>> GetAll();
|
Task<ResultContainer<List<TrackDto>>> GetAll();
|
||||||
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, CancellationToken cancellationToken = default);
|
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, CancellationToken cancellationToken = default);
|
||||||
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
|
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
|
||||||
|
|||||||
@@ -2,20 +2,29 @@ using Data.Data.Repositories;
|
|||||||
using Data.Errors;
|
using Data.Errors;
|
||||||
using DeepDrftData.Data;
|
using DeepDrftData.Data;
|
||||||
using DeepDrftModels.Entities;
|
using DeepDrftModels.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace DeepDrftData.Repositories;
|
namespace DeepDrftData.Repositories;
|
||||||
|
|
||||||
public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||||
{
|
{
|
||||||
|
private readonly DeepDrftContext _context;
|
||||||
|
|
||||||
public TrackRepository(
|
public TrackRepository(
|
||||||
DeepDrftContext context,
|
DeepDrftContext context,
|
||||||
ILogger<Repository<DeepDrftContext, TrackEntity>> logger,
|
ILogger<Repository<DeepDrftContext, TrackEntity>> logger,
|
||||||
IDbExceptionClassifier? classifier = null)
|
IDbExceptionClassifier? classifier = null)
|
||||||
: base(context, logger, classifier: classifier)
|
: 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<TrackEntity?> GetByEntryKeyAsync(string entryKey)
|
||||||
|
=> await _context.Tracks.FirstOrDefaultAsync(t => t.EntryKey == entryKey);
|
||||||
|
|
||||||
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
|
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
|
||||||
{
|
{
|
||||||
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
|
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
|
||||||
|
|||||||
@@ -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<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entity = await Repository.GetByEntryKeyAsync(entryKey);
|
||||||
|
return ResultContainer<TrackDto?>.CreatePassResult(
|
||||||
|
entity is null ? null : TrackConverter.Convert(entity));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return ResultContainer<TrackDto?>.CreateFailResult(e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ResultContainer<List<TrackDto>>> GetAll()
|
public async Task<ResultContainer<List<TrackDto>>> GetAll()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -50,4 +50,22 @@ public class TrackClient
|
|||||||
? ApiResult<PagedResult<TrackDto>>.CreatePassResult(paged)
|
? ApiResult<PagedResult<TrackDto>>.CreatePassResult(paged)
|
||||||
: ApiResult<PagedResult<TrackDto>>.CreateFailResult("Failed to deserialize response");
|
: ApiResult<PagedResult<TrackDto>>.CreateFailResult("Failed to deserialize response");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResult<TrackDto>> GetTrack(string entryKey)
|
||||||
|
{
|
||||||
|
var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}");
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return ApiResult<TrackDto>.CreateFailResult($"HTTP {(int)response.StatusCode}");
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var track = JsonSerializer.Deserialize<TrackDto>(json, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
});
|
||||||
|
|
||||||
|
return track is not null
|
||||||
|
? ApiResult<TrackDto>.CreatePassResult(track)
|
||||||
|
: ApiResult<TrackDto>.CreateFailResult("Failed to deserialize response");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,12 +9,13 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="player-dock d-flex flex-column">
|
<div class="@PlayerModeClass d-flex flex-column">
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
|
||||||
<MudPaper Elevation="8" Class="player-surface pa-3">
|
<MudPaper Elevation="8" Class="player-surface pa-3">
|
||||||
|
|
||||||
<div class="player-layout">
|
<div class="player-layout">
|
||||||
<PlayerTransportZone IsLoaded="IsLoaded"
|
<PlayerTransportZone IsLoaded="IsLoaded"
|
||||||
|
CanPlay="CanPlay"
|
||||||
IsLoading="IsLoading"
|
IsLoading="IsLoading"
|
||||||
IsStreaming="IsStreaming"
|
IsStreaming="IsStreaming"
|
||||||
LoadProgress="LoadProgress"
|
LoadProgress="LoadProgress"
|
||||||
@@ -33,7 +34,10 @@ else
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Minimize / close — positioned absolutely top-right *@
|
@* Minimize / close — positioned absolutely top-right *@
|
||||||
<PlayerWindowControls OnMinimize="@ToggleMinimized" OnClose="@Close"/>
|
@if (!Fixed)
|
||||||
|
{
|
||||||
|
<PlayerWindowControls OnMinimize="@ToggleMinimized" OnClose="@Close"/>
|
||||||
|
}
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
|
||||||
@@ -48,6 +52,3 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* Spacer to prevent content overlap *@
|
|
||||||
<div class="player-spacer"></div>
|
|
||||||
@@ -7,7 +7,8 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
|||||||
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||||
|
[Parameter] public bool Fixed { get; set; } = false;
|
||||||
|
|
||||||
private bool _isMinimized = true;
|
private bool _isMinimized = true;
|
||||||
private bool _isSeeking = false;
|
private bool _isSeeking = false;
|
||||||
private double _seekPosition = 0;
|
private double _seekPosition = 0;
|
||||||
@@ -15,6 +16,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
|
|
||||||
private bool IsLoaded => PlayerService?.IsLoaded ?? false;
|
private bool IsLoaded => PlayerService?.IsLoaded ?? false;
|
||||||
private bool IsLoading => PlayerService?.IsLoading ?? false;
|
private bool IsLoading => PlayerService?.IsLoading ?? false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private bool IsStaged => PlayerService is { IsLoaded: false, IsLoading: false, CurrentTrack: not null };
|
||||||
|
|
||||||
|
/// <summary>Play is available once a track is loaded, or staged and waiting for the first gesture.</summary>
|
||||||
|
private bool CanPlay => IsLoaded || IsStaged;
|
||||||
private bool IsStreaming => PlayerService?.CanStartStreaming ?? false;
|
private bool IsStreaming => PlayerService?.CanStartStreaming ?? false;
|
||||||
private bool IsStreamingMode => PlayerService?.IsStreamingMode ?? false;
|
private bool IsStreamingMode => PlayerService?.IsStreamingMode ?? false;
|
||||||
private double? Duration => PlayerService?.Duration;
|
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.
|
/// Display time - shows seek position while dragging, otherwise current playback time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private double DisplayTime => _isSeeking ? _seekPosition : (PlayerService?.CurrentTime ?? 0);
|
private double DisplayTime => _isSeeking ? _seekPosition : (PlayerService?.CurrentTime ?? 0);
|
||||||
|
private string PlayerModeClass => Fixed ? "player-fixed" : "player-docked";
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
|
if (Fixed)
|
||||||
|
{
|
||||||
|
_isMinimized = false;
|
||||||
|
}
|
||||||
|
|
||||||
// PlayerService is cascaded by AudioPlayerProvider; once it arrives,
|
// PlayerService is cascaded by AudioPlayerProvider; once it arrives,
|
||||||
// wire our track-selection handler. The provider owns OnStateChanged —
|
// wire our track-selection handler. The provider owns OnStateChanged —
|
||||||
// we intentionally do NOT wrap or replace it. Because the cascade is
|
// 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()
|
private async Task TogglePlayPause()
|
||||||
{
|
{
|
||||||
if (PlayerService == null) return;
|
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();
|
await PlayerService.TogglePlayPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,16 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-fixed {
|
||||||
|
position: relative;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
::deep .player-inner-container {
|
::deep .player-inner-container {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
padding-bottom: 1.5rem;
|
padding-bottom: 1.5rem;
|
||||||
@@ -45,13 +55,6 @@
|
|||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Spacer to prevent content overlap */
|
|
||||||
.player-spacer {
|
|
||||||
height: 100px;
|
|
||||||
width: 100%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
::deep .minimized-dock {
|
::deep .minimized-dock {
|
||||||
bottom: 15px;
|
bottom: 15px;
|
||||||
@@ -66,10 +69,6 @@
|
|||||||
::deep .player-surface {
|
::deep .player-surface {
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-spacer {
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Unified responsive player layout.
|
/* Unified responsive player layout.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
<PlayStateIcon Size="Size.Large"
|
<PlayStateIcon Size="Size.Large"
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
Disabled="!IsLoaded"
|
Disabled="!CanPlay"
|
||||||
OnToggle="@TogglePlayPause"/>
|
OnToggle="@TogglePlayPause"/>
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
|||||||
public partial class PlayerControls : ComponentBase
|
public partial class PlayerControls : ComponentBase
|
||||||
{
|
{
|
||||||
[Parameter] public required bool IsLoaded { get; set; }
|
[Parameter] public required bool IsLoaded { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the play button is enabled. Distinct from <see cref="IsLoaded"/> so a staged-but-
|
||||||
|
/// unloaded track (embed pre-gesture state) can still be played: the click loads it. Stop stays
|
||||||
|
/// gated on <see cref="IsLoaded"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public bool CanPlay { get; set; }
|
||||||
[Parameter] public required EventCallback TogglePlayPause { get; set; }
|
[Parameter] public required EventCallback TogglePlayPause { get; set; }
|
||||||
[Parameter] public required EventCallback Stop { get; set; }
|
[Parameter] public required EventCallback Stop { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="@Class">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="@Class">
|
||||||
<PlayerControls IsLoaded="IsLoaded"
|
<PlayerControls IsLoaded="IsLoaded"
|
||||||
|
CanPlay="CanPlay"
|
||||||
TogglePlayPause="TogglePlayPause"
|
TogglePlayPause="TogglePlayPause"
|
||||||
Stop="Stop"/>
|
Stop="Stop"/>
|
||||||
@if (IsLoading && !IsStreaming)
|
@if (IsLoading && !IsStreaming)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
|||||||
public partial class PlayerTransportZone : ComponentBase
|
public partial class PlayerTransportZone : ComponentBase
|
||||||
{
|
{
|
||||||
[Parameter] public bool IsLoaded { get; set; }
|
[Parameter] public bool IsLoaded { get; set; }
|
||||||
|
[Parameter] public bool CanPlay { get; set; }
|
||||||
[Parameter] public bool IsLoading { get; set; }
|
[Parameter] public bool IsLoading { get; set; }
|
||||||
[Parameter] public bool IsStreaming { get; set; }
|
[Parameter] public bool IsStreaming { get; set; }
|
||||||
[Parameter] public double LoadProgress { get; set; }
|
[Parameter] public double LoadProgress { get; set; }
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
@namespace DeepDrftPublic.Client.Controls
|
@namespace DeepDrftPublic.Client.Controls
|
||||||
|
|
||||||
<MudIconButton Icon="@Icon"
|
@if (!RendererInfo.IsInteractive)
|
||||||
Color="Color"
|
{
|
||||||
Size="Size"
|
@* Interactive runtime (WASM, or Server on first visit) not attached yet — the prerendered
|
||||||
Disabled="@Disabled"
|
button has no wired click handler, so clicks would vanish. Show a spinner in its place
|
||||||
OnClick="@OnToggle"/>
|
until the component hydrates, at which point it re-renders into the live button. *@
|
||||||
|
<MudProgressCircular Color="Color"
|
||||||
|
Size="Size"
|
||||||
|
Indeterminate="true"
|
||||||
|
Class="mud-icon-button" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudIconButton Icon="@Icon"
|
||||||
|
Color="Color"
|
||||||
|
Size="Size"
|
||||||
|
Disabled="@Disabled"
|
||||||
|
OnClick="@OnToggle" />
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,70 @@
|
|||||||
<h3>EmbedLayout</h3>
|
@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
|
||||||
|
|
||||||
|
<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />
|
||||||
|
<div class="@ThemeWrapperClass">
|
||||||
|
<MudLayout Style="display: flex; flex-direction: column; min-height: 100vh">
|
||||||
|
<AudioPlayerProvider>
|
||||||
|
<MudMainContent Class="pt-0">
|
||||||
|
@Body
|
||||||
|
</MudMainContent>
|
||||||
|
</AudioPlayerProvider>
|
||||||
|
</MudLayout>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div id="blazor-error-ui" data-nosnippet>
|
||||||
|
An unhandled error has occurred.
|
||||||
|
<a href="." class="reload">Reload</a>
|
||||||
|
<span class="dismiss">🗙</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
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<bool>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
</MudMainContent>
|
</MudMainContent>
|
||||||
<DeepDrftFooter />
|
<DeepDrftFooter />
|
||||||
<AudioPlayerBar />
|
<AudioPlayerBar />
|
||||||
|
|
||||||
|
@* Spacer to prevent content overlap *@
|
||||||
|
<div class="player-spacer"></div>
|
||||||
</AudioPlayerProvider>
|
</AudioPlayerProvider>
|
||||||
</MudLayout>
|
</MudLayout>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/* Spacer to prevent content overlap */
|
||||||
|
.player-spacer {
|
||||||
|
height: 100px;
|
||||||
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#blazor-error-ui {
|
#blazor-error-ui {
|
||||||
color-scheme: light only;
|
color-scheme: light only;
|
||||||
background: lightyellow;
|
background: lightyellow;
|
||||||
|
|||||||
@@ -1,7 +1,38 @@
|
|||||||
|
@using DeepDrftModels.DTOs
|
||||||
|
@using DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||||
|
@using DeepDrftPublic.Client.Layout
|
||||||
|
@using DeepDrftPublic.Client.Services
|
||||||
|
|
||||||
@page "/FramePlayer"
|
@page "/FramePlayer"
|
||||||
<h3>FramePlayer</h3>
|
@layout EmbedLayout
|
||||||
|
|
||||||
|
<AudioPlayerBar Fixed />
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
/* TODO make an iframe compatible player using the AudioPlayerControl,
|
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||||
and a new embeddable layout that doesn't include any of the nav deco */
|
[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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -62,4 +62,13 @@ public interface IStreamingPlayerService : IPlayerService
|
|||||||
|
|
||||||
// Streaming control methods
|
// Streaming control methods
|
||||||
Task SelectTrackStreaming(TrackDto track);
|
Task SelectTrackStreaming(TrackDto track);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="SelectTrackStreaming"/> so the browser allows the AudioContext to start. Sets
|
||||||
|
/// <see cref="IPlayerService.CurrentTrack"/> and notifies; performs no JS interop.
|
||||||
|
/// </summary>
|
||||||
|
Task StageTrack(TrackDto track);
|
||||||
}
|
}
|
||||||
@@ -18,4 +18,6 @@ public interface ITrackDataService
|
|||||||
int pageSize,
|
int pageSize,
|
||||||
string? sortColumn = null,
|
string? sortColumn = null,
|
||||||
bool sortDescending = false);
|
bool sortDescending = false);
|
||||||
|
|
||||||
|
Task<ApiResult<TrackDto>> GetTrack(string trackId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
await NotifyStateChanged();
|
await NotifyStateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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)
|
private async Task LoadTrackStreaming(TrackDto track)
|
||||||
{
|
{
|
||||||
// Always reset to clean state before loading new track. ResetToIdle
|
// Always reset to clean state before loading new track. ResetToIdle
|
||||||
|
|||||||
@@ -25,4 +25,7 @@ public class TrackClientDataService : ITrackDataService
|
|||||||
string? sortColumn = null,
|
string? sortColumn = null,
|
||||||
bool sortDescending = false)
|
bool sortDescending = false)
|
||||||
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending);
|
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending);
|
||||||
|
|
||||||
|
public Task<ApiResult<TrackDto>> GetTrack(string trackId)
|
||||||
|
=> _trackClient.GetTrack(trackId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,41 @@ public class TrackProxyController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("meta/by-key/{entryKey}")]
|
||||||
|
public async Task<ActionResult> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Proxies audio streaming from DeepDrftAPI. Passes the optional byte offset
|
/// Proxies audio streaming from DeepDrftAPI. Passes the optional byte offset
|
||||||
/// so seek-beyond-buffer works through the proxy without buffering.
|
/// so seek-beyond-buffer works through the proxy without buffering.
|
||||||
|
|||||||
Reference in New Issue
Block a user