refactor(split): rename DeepDrftWeb -> DeepDrftPublic and DeepDrftWeb.Client -> DeepDrftPublic.Client (Phase 4)
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
# CLAUDE.md - DeepDrftPublic.Client
|
||||
|
||||
Guidance for working in the DeepDrftPublic.Client project (the Blazor WebAssembly assembly).
|
||||
|
||||
See the root `CLAUDE.md` for full architecture overview. This file covers what is specific to this project.
|
||||
|
||||
## One-line purpose
|
||||
|
||||
All interactive UI for the site. Blazor WebAssembly. Pages, controls, the streaming audio player stack, theme/dark-mode plumbing, HTTP clients for both backends.
|
||||
|
||||
## Actual structure
|
||||
|
||||
- `Pages/`: Routable components. `Home.razor` (hero/about), `TracksView.razor` (track gallery with pagination/sorting). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
|
||||
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list).
|
||||
- `Controls/`: Reusable components.
|
||||
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date).
|
||||
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints).
|
||||
- `AppNavLink.razor`: Nav link with active-page highlight.
|
||||
- `AudioPlayerProvider.razor`: Cascading host for `IPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
|
||||
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume).
|
||||
- `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback.
|
||||
- `Services/`: Audio player + dark-mode services.
|
||||
- `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI.
|
||||
- `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
|
||||
- `StreamingAudioPlayerService`: Production implementation. Chunked stream from `TrackMediaClient`, adaptive 16–64 KB buffer, early-playback, **seek-beyond-buffer** via offset request to the content API.
|
||||
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
|
||||
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
|
||||
- `Clients/`: HTTP API clients.
|
||||
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Async methods like `GetPageAsync(pageNumber, pageSize, sortColumn, sortDescending)` → `ApiResult<PagedResult<TrackEntity>>`.
|
||||
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, offset)` → `Stream`.
|
||||
- `ViewModels/`: Component state.
|
||||
- `TracksViewModel`: Scoped. Holds current page, page size, sort column, descending flag. `SetPage(pageNumber)` calls `TrackClient.GetPageAsync` and updates. Registered in `Startup.ConfigureDomainServices`.
|
||||
- `Common/`: Shared utilities.
|
||||
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
|
||||
- `DDIcons.cs`: Hand-rolled SVG icons (gas-lamp lit/unlit for dark mode toggle).
|
||||
- `Program.cs`: WASM entry point. Calls `Startup.ConfigureApiHttpClient`, `ConfigureContentServices`, `ConfigureDomainServices`.
|
||||
- `_Imports.razor`: Global using statements and component imports.
|
||||
|
||||
## Two HTTP clients pattern
|
||||
|
||||
Both clients are configured in `Startup.cs` (static methods called from **both** server and WASM `Program.cs`):
|
||||
|
||||
- `TrackClient` uses `"DeepDrft.API"` (base address from `appsettings.json` `ApiUrls:SqlApi`). Fetches paginated metadata.
|
||||
- `TrackMediaClient` uses `"DeepDrft.Content"` (base address from `appsettings.json` `ApiUrls:ContentApi`). Streams audio bytes, optionally with offset.
|
||||
|
||||
Both are configured with JSON serializer settings (case-insensitive property matching). The dual-client pattern keeps concerns separated: one for structured data, one for binary streaming.
|
||||
|
||||
## Audio player stack (deepest part of the codebase)
|
||||
|
||||
### Contracts
|
||||
- `IPlayerService`: Initialize, SelectTrack, Play, Pause, Stop, Seek, SetVolume. Sync interface.
|
||||
- `IStreamingPlayerService`: Extends above. SelectTrackStreaming(track) starts the chunked stream flow.
|
||||
|
||||
### Implementation
|
||||
- `AudioPlayerService` (abstract base): Lifecycle. Stores current track, playback state, volume. Derived classes implement `SelectTrackStreaming` / `SelectTrackImmediate`.
|
||||
- `StreamingAudioPlayerService` (production): Constructor takes `TrackMediaClient`, `AudioInteropService`, logger. `SelectTrackStreaming`:
|
||||
1. Calls `TrackMediaClient.GetAudioStreamAsync(trackId, offset: 0)`.
|
||||
2. `StreamingAudioPlayerService.StreamAudioAsync` reads chunks (16–64 KB adaptive), pushes each via `AudioInteropService.ProcessStreamingChunkAsync` (JS interop call).
|
||||
3. TypeScript `StreamDecoder` parses WAV header (first chunk), decodes subsequent chunks to `AudioBuffer`s.
|
||||
4. `PlaybackScheduler` schedules buffers on Web Audio `AudioContext`.
|
||||
5. Playback starts as soon as a configurable min buffer count is queued.
|
||||
6. **Seek beyond buffer**: if seek target is past the decoded range, `Seek(position)` calls `TrackMediaClient.GetAudioStreamAsync(trackId, offset: byteOffset)`. Server's `WavOffsetService` synthesises a new 44-byte WAV header and streams from the offset. Player tears down and re-initialises decoder for the new stream.
|
||||
|
||||
### Interop bridge
|
||||
- `AudioInteropService.ProcessStreamingChunkAsync(chunk)` calls JS `window.DeepDrftAudio.processStreamingChunk(chunk)` and awaits the Promise.
|
||||
- `AudioInteropService` also manages callback registrations for progress (fired by `PlaybackScheduler`), end-of-playback (fired by `PlaybackScheduler`), and spectrum data (fired by `SpectrumAnalyzer`). Each callback is a `DotNetObjectReference` to a delegate.
|
||||
|
||||
### Component integration
|
||||
- `AudioPlayerProvider.razor` is the cascading host. It injects `IPlayerService` (resolved to `StreamingAudioPlayerService` in DI), stores it in a cascade, and keeps it alive across navigation.
|
||||
- `AudioPlayerBar.razor` is the dock UI. It cascades the player, binds buttons to `Play()` / `Pause()` / `Seek()` / `SetVolume()`, and displays current time / duration / progress bar.
|
||||
- `SpectrumVisualizer.razor` calls `AudioInteropService.GetSpectrumData()` on a timer, receives bar heights, renders via MudBlazor `MudChart` or custom canvas.
|
||||
- `TracksView.razor` injects `TracksViewModel` + cascaded `IPlayerService`. `PlayTrack(track)` calls `PlayerService.SelectTrack(track)` (which resolves to `StreamingAudioPlayerService.SelectTrackStreaming(track)`).
|
||||
|
||||
## Dark-mode plumbing
|
||||
|
||||
- `DarkModeSettings` (`Common/`): `[PersistentState]`-annotated class with `IsDarkMode` property. Registered scoped in `Startup.ConfigureDomainServices`. Single source of truth in the client.
|
||||
- `DarkModeServiceBase`: Holds the cookie name constant (`"darkMode"`).
|
||||
- `DarkModeCookieService`: Reads/writes the cookie via JS (`document.cookie` interop). Calls `DarkModeSettings.IsDarkMode = value` when the cookie changes or user toggles the button.
|
||||
- Server-side `DarkModeService` (in `DeepDrftPublic`, **not here**): Reads the cookie during prerender, seeds the `DarkModeSettings` instance, rounds it through `PersistentComponentState` to the client.
|
||||
- `MainLayout.razor`: Wraps entire layout in `CascadingValue` of `DarkModeSettings`, so all children see the current dark-mode state. The dark-mode toggle button (hand-rolled lit/unlit gas-lamp icon from `DDIcons.cs`) calls `DarkModeCookieService.ToggleDarkModeAsync()`.
|
||||
|
||||
The flow ensures the first paint uses the correct theme (no flash), and toggling the button persists the setting to a 365-day cookie.
|
||||
|
||||
## MVVM convention
|
||||
|
||||
Component state lives in ViewModels (registered scoped in DI). Components render and dispatch only.
|
||||
|
||||
- `TracksViewModel`: Holds page number, page size, sort column, descending flag. `SetPage(pageNumber)` is the command. `TracksView.razor` injects it and calls `SetPage`.
|
||||
- New VMs go in `ViewModels/` and register in `Startup.ConfigureDomainServices`.
|
||||
|
||||
## Theming convention
|
||||
|
||||
- Bespoke `PaletteLight` / `PaletteDark` defined inline in `MainLayout.razor` (MudBlazor theme objects).
|
||||
- CSS classes prefixed `deepdrft-` live in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` (shared across server and client).
|
||||
- Custom SVG icons: `Common/DDIcons.cs` (hand-rolled gas-lamp, etc.).
|
||||
|
||||
## Development commands
|
||||
|
||||
```bash
|
||||
# The client runs as part of the DeepDrftPublic host:
|
||||
dotnet run --project DeepDrftPublic
|
||||
|
||||
# Watch during development (rebuilds WASM as you change .cs/.razor/.ts files):
|
||||
dotnet watch run --project DeepDrftPublic
|
||||
|
||||
# Build just the client (for verification):
|
||||
dotnet build DeepDrftPublic.Client
|
||||
|
||||
# Run client-specific tests (if any; currently none exist):
|
||||
dotnet test DeepDrftTests/
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- `Program.cs`: Entry point. Calls `Startup.ConfigureApiHttpClient` (registers named clients), `ConfigureContentServices` (same), `ConfigureDomainServices` (registers services like `TracksViewModel`, `DarkModeSettings`, `AudioPlayerService`).
|
||||
- Both `Startup` methods are static and called from **both** the server `DeepDrftPublic/Program.cs` and the client `Program.cs`, ensuring prerender and runtime DI are identical.
|
||||
- No `appsettings.json` in the WASM assembly — config comes from the server `appsettings.json` via HTTP or is hardcoded.
|
||||
|
||||
## Important patterns
|
||||
|
||||
- **Cascading parameters**: `AudioPlayerProvider` cascades `IPlayerService`. All children (including `MainLayout` and pages) access it via `[CascadingParameter] IPlayerService Player { get; set; }`.
|
||||
- **Result types**: Clients return `ApiResult<T>` from NetBlocks. UI checks `Success` before using `Value`.
|
||||
- **Async/await**: All operations are async.
|
||||
- **Stream consumption**: `TrackMediaClient.GetAudioStreamAsync` returns a `Stream` (not fully buffered). `StreamingAudioPlayerService` reads it in chunks to avoid memory pressure on large files.
|
||||
|
||||
When working with this project, maintain the separation between presentation (Razor components) and logic (ViewModels/Clients), follow the established audio player architecture, and respect the dark-mode round-trip (cookie → DarkModeSettings → PersistentComponentState → client).
|
||||
@@ -0,0 +1,48 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DeepDrftPublic.Client.Clients;
|
||||
|
||||
public class TrackClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public TrackClient(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_http = httpClientFactory.CreateClient("DeepDrft.API");
|
||||
}
|
||||
|
||||
public async Task<ApiResult<PagedResult<TrackEntity>>> GetPage(
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
string? sortColumn = null,
|
||||
bool sortDescending = false)
|
||||
{
|
||||
var queryArgs = new Dictionary<string, string?>(){
|
||||
["pageNumber"] = pageNumber.ToString(),
|
||||
["pageSize"] = pageSize.ToString()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(sortColumn))
|
||||
queryArgs["sortColumn"] = sortColumn;
|
||||
|
||||
if (sortDescending)
|
||||
queryArgs["sortDescending"] = "true";
|
||||
|
||||
string query = QueryString.Create(queryArgs).ToString();
|
||||
|
||||
var response = await _http.GetAsync($"api/track/page{query}");
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var dto = JsonSerializer.Deserialize<ApiResultDto<PagedResult<TrackEntity>>>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
return dto?.From() ?? ApiResult<PagedResult<TrackEntity>>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftPublic.Client.Clients;
|
||||
|
||||
public class TrackMediaResponse : IDisposable
|
||||
{
|
||||
public Stream Stream { get; }
|
||||
public long ContentLength { get; }
|
||||
|
||||
public TrackMediaResponse(Stream stream, long contentLength)
|
||||
{
|
||||
Stream = stream;
|
||||
ContentLength = contentLength;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stream?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public class TrackMediaClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public TrackMediaClient(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_http = httpClientFactory.CreateClient("DeepDrft.Content");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the WAV stream for a track, optionally starting from a byte offset.
|
||||
/// The cancellation token is forwarded to <see cref="HttpClient.GetAsync"/> so a
|
||||
/// navigation or seek-replacement aborts the in-flight server connection rather
|
||||
/// than leaving the server draining bytes into a dead socket.
|
||||
/// </summary>
|
||||
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
|
||||
string trackId,
|
||||
long byteOffset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Build URL with optional offset parameter
|
||||
var url = byteOffset > 0
|
||||
? $"api/track/{trackId}?offset={byteOffset}"
|
||||
: $"api/track/{trackId}";
|
||||
|
||||
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
|
||||
var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var contentLength = response.Content.Headers.ContentLength ?? 0;
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
|
||||
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ApiResult<TrackMediaResponse>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Common;
|
||||
|
||||
public class DarkModeSettings()
|
||||
{
|
||||
[PersistentState]
|
||||
public bool IsDarkMode
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
if (value == field) return;
|
||||
field = value;
|
||||
}
|
||||
} = false;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<NavLink href="@Href" Match="@(Match ?? NavLinkMatch.Prefix)" class="nav-menu-item">
|
||||
<div class="nav-item-content">
|
||||
@if (Icon != null)
|
||||
{
|
||||
<MudIcon Icon="@Icon" class="nav-item-icon" />
|
||||
}
|
||||
<span class="nav-item-text">
|
||||
@if (ChildContent != null)
|
||||
{
|
||||
@ChildContent
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</NavLink>
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
public partial class AppNavLink : ComponentBase
|
||||
{
|
||||
[Parameter] public required string Href { get; set; }
|
||||
[Parameter] public NavLinkMatch? Match { get; set; }
|
||||
[Parameter] public string? Icon { get; set; }
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/* Navigation menu item styling */
|
||||
.nav-menu-item {
|
||||
display: block;
|
||||
padding: 8px 16px;
|
||||
margin: 4px 8px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.nav-item-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-item-text {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* === HOVER STATES === */
|
||||
|
||||
/* Base hover */
|
||||
.nav-menu-item:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-menu-item:hover .nav-item-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Charleston (Light Mode) - Gold hover tint */
|
||||
:global(.deepdrft-theme-light) .nav-menu-item:hover {
|
||||
background-color: color-mix(in srgb, var(--charleston-gold) 10%, transparent);
|
||||
color: var(--charleston-iron);
|
||||
}
|
||||
|
||||
:global(.deepdrft-theme-light) .nav-menu-item:hover .nav-item-content {
|
||||
color: var(--charleston-rose);
|
||||
}
|
||||
|
||||
/* Lowcountry (Dark Mode) - Coral glow hover */
|
||||
:global(.deepdrft-theme-dark) .nav-menu-item:hover {
|
||||
background-color: color-mix(in srgb, var(--lowcountry-coral) 12%, transparent);
|
||||
color: var(--lowcountry-coral);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--lowcountry-coral) 15%, transparent);
|
||||
}
|
||||
|
||||
:global(.deepdrft-theme-dark) .nav-menu-item:hover .nav-item-content {
|
||||
color: var(--lowcountry-coral);
|
||||
}
|
||||
|
||||
/* === ACTIVE STATES === */
|
||||
|
||||
/* Base active */
|
||||
.nav-menu-item.active {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.nav-menu-item.active .nav-item-content {
|
||||
color: var(--mud-palette-primary);
|
||||
}
|
||||
|
||||
.nav-menu-item.active .nav-item-icon {
|
||||
color: var(--mud-palette-primary);
|
||||
}
|
||||
|
||||
.nav-menu-item.active .nav-item-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Charleston (Light Mode) - Iron left border */
|
||||
:global(.deepdrft-theme-light) .nav-menu-item.active {
|
||||
background-color: color-mix(in srgb, var(--charleston-iron) 8%, transparent);
|
||||
color: var(--charleston-iron);
|
||||
border-left: 4px solid var(--charleston-iron);
|
||||
}
|
||||
|
||||
:global(.deepdrft-theme-light) .nav-menu-item.active .nav-item-content {
|
||||
color: var(--charleston-iron);
|
||||
}
|
||||
|
||||
/* Lowcountry (Dark Mode) - Gold active indicator */
|
||||
:global(.deepdrft-theme-dark) .nav-menu-item.active {
|
||||
background-color: color-mix(in srgb, var(--lowcountry-gold) 12%, transparent);
|
||||
color: var(--lowcountry-gold);
|
||||
border-left: 4px solid var(--lowcountry-gold);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--lowcountry-gold) 20%, transparent);
|
||||
}
|
||||
|
||||
:global(.deepdrft-theme-dark) .nav-menu-item.active .nav-item-content {
|
||||
color: var(--lowcountry-gold);
|
||||
}
|
||||
|
||||
/* === FOCUS STATES === */
|
||||
|
||||
/* Charleston (Light Mode) - Iron focus outline */
|
||||
:global(.deepdrft-theme-light) .nav-menu-item:focus {
|
||||
outline: 2px solid var(--charleston-iron);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Lowcountry (Dark Mode) - Coral focus outline */
|
||||
:global(.deepdrft-theme-dark) .nav-menu-item:focus {
|
||||
outline: 2px solid var(--lowcountry-coral);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
@if (_isMinimized)
|
||||
{
|
||||
<div class="minimized-dock d-flex align-center justify-center"
|
||||
@onclick="@ToggleMinimized">
|
||||
<MudIconButton Icon="@GetPlayIcon()"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
Class="minimized-button"
|
||||
OnClick="@ToggleMinimized"/>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="player-outer-container d-flex flex-column">
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
|
||||
<div class="player-backdrop pa-3">
|
||||
|
||||
@if (_isDesktop)
|
||||
{
|
||||
@* Desktop Layout *@
|
||||
<div class="d-flex align-center gap-3">
|
||||
<div class="controls-left d-flex flex-column align-center gap-2">
|
||||
<div class="d-flex align-center gap-1">
|
||||
<PlayerControls IsPlaying="IsPlaying"
|
||||
IsLoaded="IsLoaded"
|
||||
TogglePlayPause="@TogglePlayPause"
|
||||
Stop="@Stop"/>
|
||||
@if (IsLoading && !IsStreaming)
|
||||
{
|
||||
<MudProgressCircular Color="Color.Tertiary"
|
||||
Size="Size.Small"
|
||||
Max="1D"
|
||||
Value="@LoadProgress"
|
||||
Indeterminate="@(LoadProgress == 0)"/>
|
||||
}
|
||||
</div>
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-grow-1">
|
||||
<div class="seekbar-flex mx-3"
|
||||
@onpointerdown="OnSeekStart"
|
||||
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
|
||||
@onpointerleave="@(() => { if (_isSeeking) OnSeekEnd(_seekPosition); })">
|
||||
<MudSlider T="double"
|
||||
Min="0"
|
||||
Max="@(Duration ?? 0D)"
|
||||
Step="0.1"
|
||||
Value="@DisplayTime"
|
||||
ValueChanged="@OnSeekChange"
|
||||
Immediate="true"
|
||||
Disabled="@(!CanSeek)"/>
|
||||
</div>
|
||||
<SpectrumVisualizer />
|
||||
</div>
|
||||
|
||||
<div class="volume-right">
|
||||
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Mobile Layout *@
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-3">
|
||||
<div class="d-flex align-center gap-2">
|
||||
<PlayerControls IsPlaying="IsPlaying"
|
||||
IsLoaded="IsLoaded"
|
||||
TogglePlayPause="@TogglePlayPause"
|
||||
Stop="@Stop"/>
|
||||
@if (IsLoading && !IsStreaming)
|
||||
{
|
||||
<MudProgressCircular Color="Color.Tertiary"
|
||||
Size="Size.Small"
|
||||
Max="1D"
|
||||
Value="@LoadProgress"
|
||||
Indeterminate="@(LoadProgress == 0)"/>
|
||||
}
|
||||
</div>
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
|
||||
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-grow-1">
|
||||
<div @onpointerdown="OnSeekStart"
|
||||
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
|
||||
@onpointerleave="@(() => { if (_isSeeking) OnSeekEnd(_seekPosition); })">
|
||||
<MudSlider T="double"
|
||||
Min="0"
|
||||
Max="@(Duration ?? 0D)"
|
||||
Step="0.1"
|
||||
Value="@DisplayTime"
|
||||
ValueChanged="@OnSeekChange"
|
||||
Immediate="true"
|
||||
Disabled="@(!CanSeek)"/>
|
||||
</div>
|
||||
<SpectrumVisualizer />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Control Buttons - positioned absolutely like original *@
|
||||
<div class="player-controls d-flex align-center justify-center gap-1">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Minimize"
|
||||
Color="Color.Secondary"
|
||||
Size="Size.Small"
|
||||
OnClick="@ToggleMinimized"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close"
|
||||
Color="Color.Secondary"
|
||||
Size="Size.Small"
|
||||
OnClick="@Close"/>
|
||||
</div>
|
||||
</div>
|
||||
</MudContainer>
|
||||
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error"
|
||||
ShowCloseIcon="true"
|
||||
CloseIconClicked="ClearError"
|
||||
Class="ma-2">
|
||||
@ErrorMessage
|
||||
</MudAlert>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Spacer to prevent content overlap *@
|
||||
<div class="player-spacer"></div>
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using MudBlazor;
|
||||
using MudBlazor.Services;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
|
||||
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
|
||||
[Inject] public required IBrowserViewportService BrowserViewportService { get; set; }
|
||||
|
||||
private bool _isMinimized = true;
|
||||
private bool _isSeeking = false;
|
||||
private double _seekPosition = 0;
|
||||
private bool _isDesktop = true;
|
||||
private Guid _viewportSubscriptionId;
|
||||
|
||||
private bool IsLoaded => PlayerService.IsLoaded;
|
||||
private bool IsLoading => PlayerService.IsLoading;
|
||||
private bool IsStreaming => PlayerService.CanStartStreaming;
|
||||
private bool IsStreamingMode => PlayerService.IsStreamingMode;
|
||||
private bool IsPlaying => PlayerService.IsPlaying;
|
||||
private bool IsPaused => PlayerService.IsPaused;
|
||||
private double? Duration => PlayerService.Duration;
|
||||
private double Volume => PlayerService.Volume;
|
||||
private double LoadProgress => PlayerService.LoadProgress;
|
||||
private string? ErrorMessage => PlayerService.ErrorMessage;
|
||||
|
||||
/// <summary>
|
||||
/// Display time - shows seek position while dragging, otherwise current playback time.
|
||||
/// </summary>
|
||||
private double DisplayTime => _isSeeking ? _seekPosition : PlayerService.CurrentTime;
|
||||
|
||||
/// <summary>
|
||||
/// Seek is enabled once track is loaded AND duration is known (from WAV header).
|
||||
/// This allows seeking even during streaming, including seeking beyond buffered content.
|
||||
/// </summary>
|
||||
private bool CanSeek => IsLoaded && Duration.HasValue && Duration.Value > 0;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
// Set up EventCallback for track selection
|
||||
PlayerService.OnTrackSelected = new EventCallback(this, Expand);
|
||||
|
||||
// Store the original OnStateChanged callback set by the provider
|
||||
var originalOnStateChanged = PlayerService.OnStateChanged;
|
||||
|
||||
// Set up a wrapper that calls both the original callback and our StateHasChanged
|
||||
PlayerService.OnStateChanged = new EventCallback(this, async () =>
|
||||
{
|
||||
// Invoke the original callback (AudioPlayerProvider's StateHasChanged)
|
||||
if (originalOnStateChanged.HasValue)
|
||||
{
|
||||
await originalOnStateChanged.Value.InvokeAsync();
|
||||
}
|
||||
// Also trigger our own re-render
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task Expand()
|
||||
{
|
||||
if (_isMinimized)
|
||||
{
|
||||
_isMinimized = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
private static string FormatTime(double seconds)
|
||||
{
|
||||
var timeSpan = TimeSpan.FromSeconds(seconds);
|
||||
return timeSpan.ToString(timeSpan.TotalHours >= 1 ? @"h\:mm\:ss" : @"m\:ss");
|
||||
}
|
||||
|
||||
private async Task TogglePlayPause()
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
|
||||
private async Task Stop()
|
||||
{
|
||||
await PlayerService.Stop();
|
||||
}
|
||||
|
||||
private void OnSeekStart()
|
||||
{
|
||||
_isSeeking = true;
|
||||
_seekPosition = PlayerService.CurrentTime;
|
||||
}
|
||||
|
||||
private void OnSeekChange(double position)
|
||||
{
|
||||
_seekPosition = position;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task OnSeekEnd(double position)
|
||||
{
|
||||
_isSeeking = false;
|
||||
await PlayerService.Seek(position);
|
||||
}
|
||||
|
||||
private async Task OnVolumeChange(double volume)
|
||||
{
|
||||
await PlayerService.SetVolume(volume);
|
||||
}
|
||||
|
||||
private void ClearError()
|
||||
{
|
||||
PlayerService.ClearError();
|
||||
}
|
||||
|
||||
private void ToggleMinimized()
|
||||
{
|
||||
_isMinimized = !_isMinimized;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
if (PlayerService.IsLoaded)
|
||||
{
|
||||
await PlayerService.Unload();
|
||||
}
|
||||
|
||||
if (!_isMinimized)
|
||||
{
|
||||
_isMinimized = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private string GetPlayIcon()
|
||||
{
|
||||
return IsPlaying ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
var breakpoint = await BrowserViewportService.GetCurrentBreakpointAsync();
|
||||
_isDesktop = breakpoint >= Breakpoint.Sm;
|
||||
|
||||
_viewportSubscriptionId = Guid.NewGuid();
|
||||
await BrowserViewportService.SubscribeAsync(
|
||||
_viewportSubscriptionId,
|
||||
args =>
|
||||
{
|
||||
_isDesktop = args.Breakpoint >= Breakpoint.Sm;
|
||||
InvokeAsync(StateHasChanged);
|
||||
},
|
||||
new ResizeOptions { NotifyOnBreakpointOnly = true },
|
||||
fireImmediately: true);
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await BrowserViewportService.UnsubscribeAsync(_viewportSubscriptionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/* Preserve key visual styles while simplifying layout */
|
||||
|
||||
/* Player outer container - fixed positioning */
|
||||
.player-outer-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1200;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Player inner container */
|
||||
.player-inner-container {
|
||||
padding: 1rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Custom backdrop blur container */
|
||||
.player-backdrop {
|
||||
position: relative;
|
||||
background: var(--deepdrft-theme-background-gray);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
border-radius: 1rem;
|
||||
border: 2px solid var(--deepdrft-theme-primary);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
color: inherit;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Charleston (Light Mode) - Iron frame effect */
|
||||
:global(.deepdrft-theme-light) .player-backdrop {
|
||||
background: color-mix(in srgb, var(--charleston-cream) 92%, transparent);
|
||||
border: 2px solid var(--charleston-iron);
|
||||
box-shadow: 0 4px 20px color-mix(in srgb, var(--charleston-iron) 20%, transparent),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--charleston-iron) 5%, transparent);
|
||||
color: var(--charleston-iron);
|
||||
}
|
||||
|
||||
/* Lowcountry (Dark Mode) - Warm sunset glow effect */
|
||||
:global(.deepdrft-theme-dark) .player-backdrop {
|
||||
background: color-mix(in srgb, var(--lowcountry-night) 88%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--lowcountry-coral) 50%, transparent);
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--lowcountry-coral) 25%, transparent),
|
||||
0 0 40px color-mix(in srgb, var(--lowcountry-twilight) 15%, transparent),
|
||||
0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
color: var(--lowcountry-moonlight);
|
||||
}
|
||||
|
||||
/* Control buttons positioning */
|
||||
.player-controls {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Minimized floating dock with gradient */
|
||||
.minimized-dock {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
right: 60px;
|
||||
z-index: 1300;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg,
|
||||
var(--deepdrft-theme-primary) 0%,
|
||||
var(--deepdrft-theme-secondary) 50%,
|
||||
var(--deepdrft-theme-tertiary) 100%
|
||||
);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
border: 2px solid var(--deepdrft-theme-secondary);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Charleston (Light Mode) - Iron dock */
|
||||
:global(.deepdrft-theme-light) .minimized-dock {
|
||||
background: linear-gradient(135deg, var(--charleston-iron) 0%, var(--charleston-rose) 50%, var(--charleston-gold) 100%);
|
||||
border: 2px solid var(--charleston-iron);
|
||||
box-shadow: 0 4px 15px color-mix(in srgb, var(--charleston-iron) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Lowcountry (Dark Mode) - Warm sunset dock */
|
||||
:global(.deepdrft-theme-dark) .minimized-dock {
|
||||
background: linear-gradient(135deg, var(--lowcountry-coral) 0%, var(--lowcountry-twilight) 50%, var(--lowcountry-gold) 100%);
|
||||
border: 2px solid color-mix(in srgb, var(--lowcountry-coral) 60%, transparent);
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--lowcountry-coral) 40%, transparent),
|
||||
0 0 40px color-mix(in srgb, var(--lowcountry-twilight) 20%, transparent);
|
||||
}
|
||||
|
||||
.minimized-dock:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
:global(.deepdrft-theme-light) .minimized-dock:hover {
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--charleston-iron) 50%, transparent);
|
||||
}
|
||||
|
||||
:global(.deepdrft-theme-dark) .minimized-dock:hover {
|
||||
box-shadow: 0 0 30px color-mix(in srgb, var(--lowcountry-coral) 50%, transparent),
|
||||
0 0 50px color-mix(in srgb, var(--lowcountry-twilight) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Minimized button styles */
|
||||
.minimized-button {
|
||||
border-radius: 50% !important;
|
||||
background: transparent !important;
|
||||
color: white !important;
|
||||
transition: all 0.3s ease !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
width: 48px !important;
|
||||
height: 48px !important;
|
||||
}
|
||||
|
||||
/* Spacer to prevent content overlap */
|
||||
.player-spacer {
|
||||
height: 140px;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Essential layout adjustments only */
|
||||
.controls-left {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.seekbar-visualizer-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.seekbar-flex {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.volume-right {
|
||||
/*min-width: 140px;*/
|
||||
}
|
||||
|
||||
/* Mobile responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.minimized-dock {
|
||||
bottom: 15px;
|
||||
right: 15px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.minimized-button {
|
||||
width: 44px !important;
|
||||
height: 44px !important;
|
||||
}
|
||||
|
||||
.player-inner-container {
|
||||
padding: 0.75rem;
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.player-backdrop {
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.player-spacer {
|
||||
height: 160px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="player-buttons">
|
||||
<MudIconButton Icon="@GetPlayIcon()"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@TogglePlayPause"
|
||||
Disabled="!IsLoaded"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@Stop"
|
||||
Disabled="!IsLoaded"/>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using MudBlazor;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
|
||||
public partial class PlayerControls : ComponentBase
|
||||
{
|
||||
[Parameter] public required bool IsPlaying { get; set; }
|
||||
[Parameter] public required bool IsLoaded { get; set; }
|
||||
[Parameter] public required EventCallback TogglePlayPause { get; set; }
|
||||
[Parameter] public required EventCallback Stop { get; set; }
|
||||
private string GetPlayIcon()
|
||||
{
|
||||
return IsPlaying ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/* PlayerControls Component Styles */
|
||||
|
||||
/* Button spacing and alignment */
|
||||
.player-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||
|
||||
<div class="spectrum-container @(IsVisible ? "" : "hidden")">
|
||||
<div class="spectrum-bars">
|
||||
@for (int i = 0; i < BucketCount; i++)
|
||||
{
|
||||
var index = i;
|
||||
var height = GetBarHeight(index);
|
||||
<div class="spectrum-bar" style="--bar-height: @(height.ToString("F1"))%;"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,114 @@
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
|
||||
public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
[Inject] public required AudioInteropService AudioInterop { get; set; }
|
||||
|
||||
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
|
||||
|
||||
[Parameter] public int BucketCount { get; set; } = 32;
|
||||
|
||||
private readonly string _instanceId = Guid.NewGuid().ToString();
|
||||
private double[] _spectrumData = Array.Empty<double>();
|
||||
private bool _isAnimating = false;
|
||||
private string? _playerId;
|
||||
private EventCallback? _originalOnStateChanged;
|
||||
|
||||
private bool IsVisible => PlayerService.IsPlaying || PlayerService.IsPaused || _isAnimating;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_spectrumData = new double[BucketCount];
|
||||
|
||||
// Get the player ID from the service
|
||||
if (PlayerService is AudioPlayerService baseService)
|
||||
{
|
||||
_playerId = baseService.PlayerId;
|
||||
}
|
||||
|
||||
// Chain into the existing OnStateChanged callback to detect play/pause
|
||||
_originalOnStateChanged = PlayerService.OnStateChanged;
|
||||
PlayerService.OnStateChanged = new EventCallback(this, async () =>
|
||||
{
|
||||
// Call original callback first
|
||||
if (_originalOnStateChanged.HasValue)
|
||||
{
|
||||
await _originalOnStateChanged.Value.InvokeAsync();
|
||||
}
|
||||
// Then update our animation state
|
||||
await UpdateAnimationState();
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
// Initial check in case already playing
|
||||
await UpdateAnimationState();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateAnimationState()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_playerId)) return;
|
||||
|
||||
var shouldAnimate = PlayerService.IsPlaying;
|
||||
|
||||
if (shouldAnimate && !_isAnimating)
|
||||
{
|
||||
await StartAnimation();
|
||||
}
|
||||
else if (!shouldAnimate && _isAnimating)
|
||||
{
|
||||
await StopAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartAnimation()
|
||||
{
|
||||
if (_isAnimating || string.IsNullOrEmpty(_playerId)) return;
|
||||
|
||||
_isAnimating = true;
|
||||
await AudioInterop.StartSpectrumAnimationAsync(_playerId, _instanceId, OnSpectrumData);
|
||||
}
|
||||
|
||||
private async Task StopAnimation()
|
||||
{
|
||||
if (!_isAnimating || string.IsNullOrEmpty(_playerId)) return;
|
||||
|
||||
_isAnimating = false;
|
||||
await AudioInterop.StopSpectrumAnimationAsync(_playerId, _instanceId);
|
||||
|
||||
// Clear the display
|
||||
Array.Clear(_spectrumData);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private Task OnSpectrumData(double[] data)
|
||||
{
|
||||
if (data.Length > 0)
|
||||
{
|
||||
_spectrumData = data;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private double GetBarHeight(int index)
|
||||
{
|
||||
if (index >= _spectrumData.Length) return 0;
|
||||
|
||||
// Scale to 0-100 percentage, with minimum height for visual appeal
|
||||
var value = _spectrumData[index];
|
||||
return Math.Max(2, value * 100);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAnimation();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
.spectrum-container {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spectrum-container.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.spectrum-bars {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
height: 100%;
|
||||
gap: 2px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.spectrum-bar {
|
||||
flex: 1;
|
||||
margin: 0 auto;
|
||||
max-width: 8px;
|
||||
min-width: 4px;
|
||||
height: var(--bar-height, 2%);
|
||||
min-height: 2px;
|
||||
background: var(--deepdrft-theme-secondary);
|
||||
border-radius: 2px 2px 0 0;
|
||||
transition: height 0.05s ease-out;
|
||||
}
|
||||
|
||||
/* Charleston (Light Mode) - Iron to gold colored bars */
|
||||
:global(.deepdrft-theme-light) .spectrum-bar {
|
||||
background: linear-gradient(to top, var(--charleston-iron) 0%, var(--charleston-rose) 50%, var(--charleston-gold) 100%);
|
||||
}
|
||||
|
||||
/* Lowcountry (Dark Mode) - Coral to gold bars with warm glow */
|
||||
:global(.deepdrft-theme-dark) .spectrum-bar {
|
||||
background: linear-gradient(to top, var(--lowcountry-coral) 0%, var(--lowcountry-gold) 100%);
|
||||
box-shadow: 0 0 4px color-mix(in srgb, var(--lowcountry-gold) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.spectrum-container {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.spectrum-bars {
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.spectrum-bar {
|
||||
max-width: 8px;
|
||||
min-width: 3px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="timestamp-display">
|
||||
<MudText Typo="Typo.body2" Class="time-text">
|
||||
@FormatTime(CurrentTime) / @(Duration.HasValue ? FormatTime(Duration.Value) : "--:--")
|
||||
</MudText>
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
|
||||
public partial class TimestampLabel : ComponentBase
|
||||
{
|
||||
[Parameter] public required double CurrentTime { get; set; }
|
||||
[Parameter] public required double? Duration { get; set; }
|
||||
private static string FormatTime(double seconds)
|
||||
{
|
||||
var timeSpan = TimeSpan.FromSeconds(seconds);
|
||||
return timeSpan.ToString(timeSpan.TotalHours >= 1 ? @"h\:mm\:ss" : @"m\:ss");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/* TimestampLabel Component Styles */
|
||||
|
||||
/* Timestamp display */
|
||||
.timestamp-display {
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Time text styling */
|
||||
.time-text {
|
||||
font-family: monospace;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<div class="volume-controls">
|
||||
<MudIcon Icon="@GetVolumeIcon()" Class="volume-icon"/>
|
||||
<MudSlider T="double"
|
||||
Min="0"
|
||||
Max="1"
|
||||
Step="0.01"
|
||||
Value="@Volume"
|
||||
ValueChanged="@VolumeChanged"
|
||||
Class="volume-slider"/>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using MudBlazor;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
|
||||
public partial class VolumeControls : ComponentBase
|
||||
{
|
||||
[Parameter] public required double Volume { get; set; }
|
||||
[Parameter] public required EventCallback<double> VolumeChanged { get; set; }
|
||||
private string GetVolumeIcon()
|
||||
{
|
||||
if (Volume == 0) return Icons.Material.Filled.VolumeOff;
|
||||
if (Volume < 0.5) return Icons.Material.Filled.VolumeDown;
|
||||
return Icons.Material.Filled.VolumeUp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* VolumeControls Component Styles */
|
||||
|
||||
/* Volume control container */
|
||||
.volume-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
/* Volume icon styling */
|
||||
.volume-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Volume slider styling */
|
||||
.volume-slider {
|
||||
width: 100px;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<CascadingValue Value="@(_audioPlayerService)" IsFixed="true">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
@@ -0,0 +1,52 @@
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using DeepDrftPublic.Client.Clients;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
[Inject] public required AudioInteropService AudioInterop { get; set; }
|
||||
[Inject] public required TrackMediaClient TrackMediaClient { get; set; }
|
||||
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
|
||||
|
||||
private StreamingAudioPlayerService? _audioPlayerService;
|
||||
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Create the service immediately (but don't initialize yet)
|
||||
_audioPlayerService = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger);
|
||||
|
||||
// Set up EventCallback to properly marshal UI updates back to UI thread
|
||||
// Use InvokeAsync to ensure proper Blazor render cycle triggering
|
||||
_audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged));
|
||||
// OnTrackSelected will be set by individual child components that need it
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && _audioPlayerService != null)
|
||||
{
|
||||
// Initialize the service after render when JavaScript is available
|
||||
await _audioPlayerService.InitializeAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose the player on unmount so the JS setInterval driving progress
|
||||
/// callbacks no longer holds a DotNetObjectReference into a destroyed
|
||||
/// component (otherwise it throws every 100ms after navigation away).
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_audioPlayerService != null)
|
||||
{
|
||||
await _audioPlayerService.DisposeAsync();
|
||||
_audioPlayerService = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||
<PackageReference Include="MudBlazor" Version="8.15.0" />
|
||||
<PackageReference Include="MudBlazor.ThemeManager" Version="3.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DeepDrftModels\DeepDrftModels.csproj" />
|
||||
<ProjectReference Include="..\DeepDrftShared.Client\DeepDrftShared.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Update="Layout\NavMenu.razor">
|
||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,127 @@
|
||||
@using DeepDrftPublic.Client.Common
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<nav class="@NavClass">
|
||||
<a class="dd-nav-brand" href="/">Deep DRFT</a>
|
||||
|
||||
@if (_isDesktop)
|
||||
{
|
||||
<ul class="dd-nav-links">
|
||||
@foreach (var navPage in Pages.MenuPages)
|
||||
{
|
||||
<li>
|
||||
<a href="@navPage.Route" class="dd-nav-link">@navPage.Name</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="dd-nav-actions">
|
||||
<a href="/tracks" class="dd-nav-cta">Stream Now ▶</a>
|
||||
<button type="button"
|
||||
class="dd-nav-toggle"
|
||||
aria-label="Toggle dark mode"
|
||||
aria-pressed="@IsDarkMode.ToString().ToLowerInvariant()"
|
||||
@onclick="DarkModeToggle">
|
||||
@((MarkupString)DarkLightModeIconSvg)
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dd-nav-actions">
|
||||
<button type="button"
|
||||
class="dd-nav-toggle"
|
||||
aria-label="Toggle dark mode"
|
||||
aria-pressed="@IsDarkMode.ToString().ToLowerInvariant()"
|
||||
@onclick="DarkModeToggle">
|
||||
@((MarkupString)DarkLightModeIconSvg)
|
||||
</button>
|
||||
<button type="button"
|
||||
class="dd-nav-hamburger"
|
||||
aria-label="Toggle navigation"
|
||||
aria-expanded="@_mobileMenuOpen.ToString().ToLowerInvariant()"
|
||||
@onclick="ToggleMobileMenu">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_mobileMenuOpen)
|
||||
{
|
||||
<ul class="dd-nav-links dd-nav-links-mobile">
|
||||
@foreach (var navPage in Pages.MenuPages)
|
||||
{
|
||||
<li>
|
||||
<a href="@navPage.Route" class="dd-nav-link" @onclick="CloseMobileMenu">@navPage.Name</a>
|
||||
</li>
|
||||
}
|
||||
<li>
|
||||
<a href="/tracks" class="dd-nav-cta" @onclick="CloseMobileMenu">Stream Now ▶</a>
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
[Inject] public required IBrowserViewportService BrowserViewportService { get; set; }
|
||||
[Inject] public required DarkModeCookieService DarkModeCookieService { get; set; }
|
||||
|
||||
// Elevation is vestigial under the frosted-glass design but kept on the parameter
|
||||
// surface so MainLayout's <DeepDrftMenu Elevation="..."> call site stays intact.
|
||||
[Parameter] public int Elevation { get; set; }
|
||||
[Parameter] public required bool IsDarkMode { get; set; }
|
||||
[Parameter] public required EventCallback<bool> IsDarkModeChanged { get; set; }
|
||||
|
||||
private bool _isDesktop = true;
|
||||
private bool _mobileMenuOpen;
|
||||
private Guid _viewportSubscriptionId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
IsDarkMode = DarkModeCookieService.GetDarkModeAsync();
|
||||
await IsDarkModeChanged.InvokeAsync(IsDarkMode);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
_viewportSubscriptionId = Guid.NewGuid();
|
||||
await BrowserViewportService.SubscribeAsync(
|
||||
_viewportSubscriptionId,
|
||||
args =>
|
||||
{
|
||||
_isDesktop = args.Breakpoint >= Breakpoint.Sm;
|
||||
if (_isDesktop)
|
||||
{
|
||||
_mobileMenuOpen = false;
|
||||
}
|
||||
InvokeAsync(StateHasChanged);
|
||||
},
|
||||
new ResizeOptions { NotifyOnBreakpointOnly = true },
|
||||
fireImmediately: true);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_viewportSubscriptionId != Guid.Empty)
|
||||
await BrowserViewportService.UnsubscribeAsync(_viewportSubscriptionId);
|
||||
}
|
||||
|
||||
private string NavClass => IsDarkMode ? "dd-nav dd-nav-dark" : "dd-nav dd-nav-light";
|
||||
|
||||
private string DarkLightModeIconSvg => IsDarkMode ? DDIcons.GasLampLit : DDIcons.GasLamp;
|
||||
|
||||
private async Task DarkModeToggle()
|
||||
{
|
||||
IsDarkMode = !IsDarkMode;
|
||||
await DarkModeCookieService.SetDarkModeAsync(IsDarkMode);
|
||||
await IsDarkModeChanged.InvokeAsync(IsDarkMode);
|
||||
}
|
||||
|
||||
private void ToggleMobileMenu() => _mobileMenuOpen = !_mobileMenuOpen;
|
||||
|
||||
private void CloseMobileMenu() => _mobileMenuOpen = false;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/* Frosted-glass top navigation.
|
||||
Scoped to DeepDrftMenu.razor only — global styles live in deepdrft-styles.css.
|
||||
The CSS variables consumed here (--deepdrft-navy etc.) are theme-aware and
|
||||
defined globally; the dd-nav-dark modifier overrides the backdrop RGBA since
|
||||
it must be hard-coded for the blur fallback. */
|
||||
|
||||
.dd-nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
|
||||
padding: 1.5rem 3rem;
|
||||
|
||||
border-bottom: 1px solid var(--deepdrft-border);
|
||||
box-shadow: none;
|
||||
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.dd-nav-light {
|
||||
background: rgba(250, 250, 248, 0.88);
|
||||
}
|
||||
|
||||
.dd-nav-dark {
|
||||
background: rgba(13, 13, 18, 0.85);
|
||||
}
|
||||
|
||||
/* Brand */
|
||||
.dd-nav-brand {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-navy);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dd-nav-dark .dd-nav-brand {
|
||||
color: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
/* Centred link list */
|
||||
.dd-nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dd-nav-link {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.25s ease;
|
||||
}
|
||||
|
||||
.dd-nav-link:hover,
|
||||
.dd-nav-link:focus-visible {
|
||||
color: var(--deepdrft-navy);
|
||||
}
|
||||
|
||||
.dd-nav-dark .dd-nav-link:hover,
|
||||
.dd-nav-dark .dd-nav-link:focus-visible {
|
||||
color: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
/* Right-side cluster */
|
||||
.dd-nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Stream Now CTA — square pill, navy on warm white */
|
||||
.dd-nav-cta {
|
||||
display: inline-block;
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-white);
|
||||
background: var(--deepdrft-navy);
|
||||
padding: 0.6rem 1.4rem;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.25s ease;
|
||||
}
|
||||
|
||||
.dd-nav-cta:hover,
|
||||
.dd-nav-cta:focus-visible {
|
||||
background: var(--deepdrft-green);
|
||||
}
|
||||
|
||||
.dd-nav-dark .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 {
|
||||
background: var(--deepdrft-senary);
|
||||
}
|
||||
|
||||
/* Dark-mode toggle (gas lamp) */
|
||||
.dd-nav-toggle {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--deepdrft-navy);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.25s ease;
|
||||
}
|
||||
|
||||
.dd-nav-toggle:hover,
|
||||
.dd-nav-toggle:focus-visible {
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
.dd-nav-toggle::deep svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.dd-nav-dark .dd-nav-toggle {
|
||||
color: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
.dd-nav-dark .dd-nav-toggle:hover,
|
||||
.dd-nav-dark .dd-nav-toggle:focus-visible {
|
||||
color: var(--deepdrft-tertiary);
|
||||
}
|
||||
|
||||
/* Mobile hamburger */
|
||||
.dd-nav-hamburger {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dd-nav-hamburger span {
|
||||
display: block;
|
||||
width: 1.25rem;
|
||||
height: 1.5px;
|
||||
background: var(--deepdrft-navy);
|
||||
}
|
||||
|
||||
.dd-nav-dark .dd-nav-hamburger span {
|
||||
background: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
/* Mobile dropdown panel — absolute-positioned beneath the bar */
|
||||
.dd-nav-links-mobile {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0.75rem 1.5rem 1.25rem;
|
||||
|
||||
background: rgba(250, 250, 248, 0.96);
|
||||
border-bottom: 1px solid var(--deepdrft-border);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.dd-nav-dark .dd-nav-links-mobile {
|
||||
background: rgba(13, 13, 18, 0.95);
|
||||
}
|
||||
|
||||
.dd-nav-links-mobile li {
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
|
||||
.dd-nav-links-mobile .dd-nav-cta {
|
||||
margin-top: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Mobile padding — give the nav room to breathe without crowding */
|
||||
@media (max-width: 599px) {
|
||||
.dd-nav {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
@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="@_themeManager.Theme" IsDarkMode="_isDarkMode" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
<div class="@ThemeWrapperClass">
|
||||
<MudLayout Style="display: flex; flex-direction: column; min-height: 100vh">
|
||||
<AudioPlayerProvider>
|
||||
<DeepDrftMenu Elevation="_themeManager.AppBarElevation" @bind-IsDarkMode="_isDarkMode" />
|
||||
<MudMainContent Class="flex-grow-1 pt-16 pb-8">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
|
||||
@Body
|
||||
</MudContainer>
|
||||
<AudioPlayerBar />
|
||||
</MudMainContent>
|
||||
<footer class="deepdrft-footer">
|
||||
<div class="deepdrft-footer-logo">Deep <span>Drft</span></div>
|
||||
<ul class="deepdrft-footer-links">
|
||||
<li><a href="/tracks">Listen</a></li>
|
||||
<li><a href="#">Sessions</a></li>
|
||||
<li><a href="/tracks">Archive</a></li>
|
||||
<li><a href="#">About</a></li>
|
||||
<li><a href="#">Contact</a></li>
|
||||
</ul>
|
||||
<div class="deepdrft-footer-copy">© 2026 Deep DRFT — Charleston, SC</div>
|
||||
</footer>
|
||||
</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 {
|
||||
private const string DarkModeKey = "darkMode";
|
||||
private bool _isDarkMode = true;
|
||||
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);
|
||||
|
||||
// Palettes + typography live in DeepDrftShared.Client.Common.DeepDrftPalettes
|
||||
// so the CMS host can reuse them. We wrap into ThemeManagerTheme to keep
|
||||
// the MudBlazor.ThemeManager integration intact.
|
||||
_themeManager = new ThemeManagerTheme
|
||||
{
|
||||
Theme = DeepDrftPalettes.Default,
|
||||
};
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private ThemeManagerTheme _themeManager;
|
||||
public bool _themeManagerOpen = false;
|
||||
|
||||
void OpenThemeManager(bool value)
|
||||
{
|
||||
_themeManagerOpen = value;
|
||||
}
|
||||
|
||||
void UpdateTheme(ThemeManagerTheme value)
|
||||
{
|
||||
_themeManager = value;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── SITE FOOTER ── */
|
||||
.deepdrft-footer {
|
||||
background: var(--deepdrft-white);
|
||||
border-top: 1px solid var(--deepdrft-border);
|
||||
padding: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.deepdrft-footer-logo {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--deepdrft-navy);
|
||||
}
|
||||
|
||||
.deepdrft-footer-logo span {
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-green);
|
||||
}
|
||||
|
||||
.deepdrft-footer-links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.deepdrft-footer-links a {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.deepdrft-footer-links a:hover { color: var(--deepdrft-navy); }
|
||||
|
||||
.deepdrft-footer-copy {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--deepdrft-muted);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using MudBlazor;
|
||||
|
||||
namespace DeepDrftPublic.Client.Layout;
|
||||
|
||||
public class PageRoute
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Route { get; set; } = string.Empty;
|
||||
public string? Icon { get; set; } = null;
|
||||
}
|
||||
|
||||
public static class Pages
|
||||
{
|
||||
public static readonly List<PageRoute> MenuPages =
|
||||
[
|
||||
new() { Name = "Listen", Route = "/tracks", Icon = Icons.Material.Filled.LibraryMusic },
|
||||
new() { Name = "Sessions", Route = "#", Icon = Icons.Material.Filled.Album }, // TODO: placeholder until Sessions ships
|
||||
new() { Name = "Archive", Route = "#", Icon = Icons.Material.Filled.FolderOpen }, // TODO: placeholder until Archive ships
|
||||
new() { Name = "About", Route = "#", Icon = Icons.Material.Filled.Info }, // TODO: placeholder until About ships
|
||||
];
|
||||
|
||||
public static readonly List<PageRoute> AllPages =
|
||||
new List<PageRoute>
|
||||
{
|
||||
new() { Name = "Home", Route = "/", Icon = Icons.Material.Filled.Home }
|
||||
}.Concat(MenuPages).ToList();
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
@page "/"
|
||||
@rendermode InteractiveAuto
|
||||
@using DeepDrftPublic.Client.Services
|
||||
|
||||
<PageTitle>Deep DRFT - Electronic Music Collective</PageTitle>
|
||||
|
||||
@* Hero - split 50/50 *@
|
||||
<section class="hero">
|
||||
<div class="hero-left">
|
||||
<div class="hero-eyebrow fade-up">Charleston, South Carolina</div>
|
||||
<h1 class="hero-title fade-up">Deep<br /><em>Drft</em></h1>
|
||||
<p class="hero-subtitle fade-up">Electronic Music Collective</p>
|
||||
<p class="hero-desc fade-up">
|
||||
We craft immersive electronic soundscapes — live, unscripted, and built from synthesizers, drum machines, and raw intention. No sets. No loops. Pure drift.
|
||||
</p>
|
||||
<div class="hero-actions fade-up">
|
||||
<a class="btn-primary" href="/tracks">Start Streaming</a>
|
||||
<a class="btn-ghost" href="/tracks">Browse Tracks</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-right">
|
||||
@* Pulsing rings *@
|
||||
<div class="circle-deco"></div>
|
||||
<div class="circle-deco"></div>
|
||||
<div class="circle-deco"></div>
|
||||
|
||||
<div class="hero-right-content">
|
||||
@* Now-Playing card *@
|
||||
<div class="now-playing">
|
||||
<div class="np-label"><span class="np-dot"></span>Now Playing</div>
|
||||
<div class="np-title">@(Player?.CurrentTrack?.TrackName ?? "Nothing playing")</div>
|
||||
<div class="np-sub">
|
||||
@(Player?.CurrentTrack != null
|
||||
? $"{Player.CurrentTrack.Artist} · {Player.CurrentTrack.Album ?? "Single"}"
|
||||
: "Select a track to begin")
|
||||
</div>
|
||||
|
||||
@if (Player?.IsLoaded == true)
|
||||
{
|
||||
<div class="waveform-bars">
|
||||
@* 20 bars - approximate the wireframe's varied animation timings *@
|
||||
<div class="waveform-bar" style="--h-lo:6px;--h-hi:28px;--dur:0.7s;height:14px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:10px;--h-hi:36px;--dur:0.9s;height:28px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:4px;--h-hi:20px;--dur:0.65s;height:10px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:12px;--h-hi:40px;--dur:1.1s;height:36px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:6px;--h-hi:26px;--dur:0.8s;height:18px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:8px;--h-hi:32px;--dur:0.75s;height:24px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:4px;--h-hi:18px;--dur:0.95s;height:8px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:14px;--h-hi:42px;--dur:1.2s;height:32px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:6px;--h-hi:22px;--dur:0.68s;height:16px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:10px;--h-hi:38px;--dur:0.88s;height:30px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:4px;--h-hi:16px;--dur:0.72s;height:6px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:8px;--h-hi:30px;--dur:1.0s;height:20px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:12px;--h-hi:36px;--dur:0.85s;height:26px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:6px;--h-hi:24px;--dur:0.9s;height:14px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:10px;--h-hi:34px;--dur:0.78s;height:22px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:4px;--h-hi:20px;--dur:1.05s;height:12px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:14px;--h-hi:44px;--dur:0.92s;height:38px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:6px;--h-hi:26px;--dur:0.7s;height:18px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:8px;--h-hi:32px;--dur:0.82s;height:22px"></div>
|
||||
<div class="waveform-bar" style="--h-lo:4px;--h-hi:18px;--dur:1.15s;height:10px"></div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="waveform-placeholder"></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Stat row - hard-coded for now. TODO Phase 2: wire to real track count / identity model. *@
|
||||
<div class="hero-stat-row">
|
||||
<div class="hero-stat">
|
||||
<div class="hero-stat-num">47+</div>
|
||||
<div class="hero-stat-label">Live Sessions</div>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<div class="hero-stat-num">2</div>
|
||||
<div class="hero-stat-label">Members</div>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<div class="hero-stat-num">∞</div>
|
||||
<div class="hero-stat-label">Drift Points</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Divider *@
|
||||
<div class="section-divider">
|
||||
<div class="divider-line"></div>
|
||||
<div class="divider-tag">The Sound</div>
|
||||
<div class="divider-line"></div>
|
||||
</div>
|
||||
|
||||
@* Sound section *@
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<div class="section-label">Genres & Moods</div>
|
||||
<h2 class="section-title">Every<br /><em>Frequency</em><br />Explored</h2>
|
||||
</div>
|
||||
<p class="section-body">
|
||||
From the hypnotic pulse of deep house to the fractal complexity of IDM, Deep DRFT refuses to be categorized. We treat genre as a palette — not a cage. Each session begins as something and ends as something else entirely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="genre-grid">
|
||||
@* TODO Phase 2.2: wire each genre to /genres/{slug} *@
|
||||
<div class="genre-card">
|
||||
<div class="genre-name">House</div>
|
||||
<div class="genre-count">Foundation</div>
|
||||
</div>
|
||||
<div class="genre-card">
|
||||
<div class="genre-name">Techno</div>
|
||||
<div class="genre-count">Architecture</div>
|
||||
</div>
|
||||
<div class="genre-card">
|
||||
<div class="genre-name">Trance</div>
|
||||
<div class="genre-count">Ascension</div>
|
||||
</div>
|
||||
<div class="genre-card">
|
||||
<div class="genre-name">IDM</div>
|
||||
<div class="genre-count">Intelligence</div>
|
||||
</div>
|
||||
<div class="genre-card">
|
||||
<div class="genre-name">Progressive</div>
|
||||
<div class="genre-count">Journey</div>
|
||||
</div>
|
||||
<div class="genre-card">
|
||||
<div class="genre-name">Ambient</div>
|
||||
<div class="genre-count">Atmosphere</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Dark features section *@
|
||||
<section class="section-dark">
|
||||
<div class="section-label section-label-dark">What We Offer</div>
|
||||
<h2 class="section-title section-title-dark">
|
||||
Built for the<br /><em>Committed Listener</em>
|
||||
</h2>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg>
|
||||
</div>
|
||||
<div class="feature-title">Lossless Audio Streaming</div>
|
||||
<div class="feature-desc">Crystal-clear sound delivered at full resolution. We don't compress the music — or the experience.</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><polygon points="10 8 16 12 10 16 10 8" /></svg>
|
||||
</div>
|
||||
<div class="feature-title">Live Sessions Broadcast</div>
|
||||
<div class="feature-desc">Join us in real time. Every session is unrepeatable — a singular moment sculpted between performer and listener.</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /></svg>
|
||||
</div>
|
||||
<div class="feature-title">Studio Video Content</div>
|
||||
<div class="feature-desc">Behind-the-machine footage. Watch synthesis happen in real time, gear and all — no performance, just process.</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 3h18v18H3z" /><path d="M3 9h18M9 21V9" /></svg>
|
||||
</div>
|
||||
<div class="feature-title">Growing Archive</div>
|
||||
<div class="feature-desc">A living catalogue of sessions, mixes, and experiments — indexed, searchable, and always expanding.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Split: origin + connect *@
|
||||
<div class="section-split">
|
||||
<div class="split-left">
|
||||
<div class="split-eyebrow">Our Origin</div>
|
||||
<h2 class="split-title">Where the Holy City<br /><em>Meets the Future</em></h2>
|
||||
<p class="split-body">
|
||||
Charleston, South Carolina holds centuries of culture in its streets. We carry that weight into the studio — tradition as tension against the forward pull of electronic sound. The result is music that feels both ancient and unimagined.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="split-right">
|
||||
<div class="connect-label">Stay Connected</div>
|
||||
<h2 class="connect-title">Never Miss<br />a <em>Session</em></h2>
|
||||
<div class="connect-options">
|
||||
@* TODO: wire to subscription system when newsletter/alerts backend exists *@
|
||||
<div class="connect-option">
|
||||
<div class="option-icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" /><polyline points="22,6 12,13 2,6" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="option-text-label">Newsletter</div>
|
||||
<div class="option-text-sub">New releases & studio dispatches</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="connect-option">
|
||||
<div class="option-icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.73 21a2 2 0 0 1-3.46 0" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="option-text-label">Live Alerts</div>
|
||||
<div class="option-text-sub">Know the moment we go live</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@* TODO: subscription form lives behind this CTA *@
|
||||
<button class="btn-primary connect-cta" type="button">Subscribe Free</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* CTA banner *@
|
||||
<section class="cta-banner">
|
||||
<div class="cta-text">
|
||||
<h2 class="cta-headline">Ready to<br /><em>Drift</em> Deeper?</h2>
|
||||
<p class="cta-sub">Immerse yourself. The current is always running.</p>
|
||||
</div>
|
||||
<div class="cta-actions">
|
||||
<a class="btn-white" href="/tracks">Explore the Archive</a>
|
||||
@* TODO: route to /schedule when live-session schedule page exists *@
|
||||
<button class="btn-outline-white" type="button">View Live Schedule</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] public IPlayerService? Player { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
/* Home.razor scoped styles - wireframe-faithful marketing page. */
|
||||
|
||||
/* ── Animations ── */
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(24px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0%, 100% { opacity: 0.15; transform: translate(-50%, -50%) scale(1); }
|
||||
50% { opacity: 0.4; transform: translate(-50%, -50%) scale(1.03); }
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
@keyframes wave-dance {
|
||||
from { height: var(--h-lo, 4px); }
|
||||
to { height: var(--h-hi, 20px); }
|
||||
}
|
||||
|
||||
.fade-up {
|
||||
opacity: 0;
|
||||
animation: fade-up 0.8s ease forwards;
|
||||
}
|
||||
|
||||
/* ── HERO ── */
|
||||
.hero {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 6rem 3rem;
|
||||
position: relative;
|
||||
background: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
.hero-eyebrow {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.hero-eyebrow::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 2.5rem;
|
||||
height: 1px;
|
||||
background: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: clamp(4.5rem, 8vw, 8.5rem);
|
||||
font-weight: 300;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--deepdrft-navy);
|
||||
margin-bottom: 0.5rem;
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
|
||||
.hero-title em {
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-green);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: clamp(1rem, 2vw, 1.35rem);
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-muted);
|
||||
margin-bottom: 3rem;
|
||||
letter-spacing: 0.04em;
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.75;
|
||||
color: var(--deepdrft-navy);
|
||||
opacity: 0.7;
|
||||
max-width: 36ch;
|
||||
margin-bottom: 3rem;
|
||||
animation-delay: 0.44s;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
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); }
|
||||
|
||||
.hero-right {
|
||||
background: var(--deepdrft-navy);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.circle-deco {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(61, 122, 104, 0.3);
|
||||
animation: pulse-ring 4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.circle-deco:nth-child(1) { width: 320px; height: 320px; animation-delay: 0s; }
|
||||
.circle-deco:nth-child(2) { width: 520px; height: 520px; animation-delay: 0.8s; }
|
||||
.circle-deco:nth-child(3) { width: 720px; height: 720px; animation-delay: 1.6s; }
|
||||
|
||||
.hero-right-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.now-playing {
|
||||
background: rgba(250, 250, 248, 0.06);
|
||||
border: 1px solid rgba(250, 250, 248, 0.12);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.np-label {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.25em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.np-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--deepdrft-green-accent);
|
||||
animation: blink 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.np-title {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--deepdrft-white);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.np-sub {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.75rem;
|
||||
color: rgba(250, 250, 248, 0.45);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.waveform-bars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.waveform-bar {
|
||||
width: 3px;
|
||||
background: var(--deepdrft-green-accent);
|
||||
border-radius: 2px;
|
||||
animation: wave-dance var(--dur, 1s) ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.waveform-placeholder {
|
||||
margin-top: 1.2rem;
|
||||
height: 1px;
|
||||
background: rgba(250, 250, 248, 0.12);
|
||||
}
|
||||
|
||||
.hero-stat-row {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
flex: 1;
|
||||
background: rgba(250, 250, 248, 0.04);
|
||||
border: 1px solid rgba(250, 250, 248, 0.08);
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.hero-stat-num {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
color: var(--deepdrft-white);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hero-stat-label {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: rgba(250, 250, 248, 0.4);
|
||||
text-transform: uppercase;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
/* ── DIVIDER ── */
|
||||
.section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 2rem 3rem;
|
||||
background: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--deepdrft-border);
|
||||
}
|
||||
|
||||
.divider-tag {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.25em;
|
||||
color: var(--deepdrft-muted);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── SECTION (sound) ── */
|
||||
.section {
|
||||
padding: 7rem 3rem;
|
||||
background: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 4rem;
|
||||
margin-bottom: 5rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: clamp(2.8rem, 5vw, 4.5rem);
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
color: var(--deepdrft-navy);
|
||||
}
|
||||
|
||||
.section-title em {
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-green);
|
||||
}
|
||||
|
||||
.section-body {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.8;
|
||||
color: var(--deepdrft-navy);
|
||||
opacity: 0.65;
|
||||
max-width: 52ch;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
/* Genre grid */
|
||||
.genre-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--deepdrft-border);
|
||||
border: 1px solid var(--deepdrft-border);
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.genre-card {
|
||||
background: var(--deepdrft-white);
|
||||
padding: 2rem 1.5rem;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.genre-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--deepdrft-green-accent);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.genre-card:hover::after { transform: scaleX(1); }
|
||||
.genre-card:hover { background: #f3f6f4; }
|
||||
|
||||
.genre-name {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--deepdrft-navy);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.genre-count {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--deepdrft-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── DARK FEATURE SECTION ── */
|
||||
.section-dark {
|
||||
background: var(--deepdrft-navy);
|
||||
padding: 7rem 3rem;
|
||||
color: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
.section-label-dark {
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
.section-title-dark {
|
||||
color: var(--deepdrft-white);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.section-title-dark em {
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0;
|
||||
border: 1px solid rgba(250, 250, 248, 0.08);
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: 2.5rem;
|
||||
border-right: 1px solid rgba(250, 250, 248, 0.08);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.feature-card:last-child { border-right: none; }
|
||||
|
||||
.feature-card:hover { background: rgba(250, 250, 248, 0.04); }
|
||||
|
||||
.feature-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 1px solid rgba(250, 250, 248, 0.15);
|
||||
margin-bottom: 1.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.feature-icon svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
stroke: var(--deepdrft-green-accent);
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 400;
|
||||
color: var(--deepdrft-white);
|
||||
margin-bottom: 0.8rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(250, 250, 248, 0.45);
|
||||
}
|
||||
|
||||
/* ── SPLIT: ORIGIN + CONNECT ── */
|
||||
.section-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.split-left {
|
||||
background: var(--deepdrft-green);
|
||||
padding: 6rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-left::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
border-radius: 50%;
|
||||
background: rgba(61, 122, 104, 0.3);
|
||||
}
|
||||
|
||||
.split-right {
|
||||
padding: 6rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
.split-eyebrow {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: rgba(250, 250, 248, 0.6);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.5rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.split-title {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: clamp(2.5rem, 4vw, 3.8rem);
|
||||
font-weight: 300;
|
||||
color: var(--deepdrft-white);
|
||||
line-height: 1.05;
|
||||
margin-bottom: 1.5rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.split-title em {
|
||||
font-style: italic;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.split-body {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.8;
|
||||
color: rgba(250, 250, 248, 0.6);
|
||||
max-width: 42ch;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.connect-label {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.connect-title {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: clamp(2rem, 3.5vw, 3rem);
|
||||
font-weight: 300;
|
||||
color: var(--deepdrft-navy);
|
||||
line-height: 1.05;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.connect-title em {
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-green);
|
||||
}
|
||||
|
||||
.connect-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.connect-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid var(--deepdrft-border);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s, background 0.25s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.connect-option:hover {
|
||||
border-color: var(--deepdrft-green-accent);
|
||||
background: #f3f6f4;
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--deepdrft-navy);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-icon svg {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
stroke: var(--deepdrft-white);
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.option-text-label {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--deepdrft-navy);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.option-text-sub {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.75rem;
|
||||
color: var(--deepdrft-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.connect-cta {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ── CTA BANNER ── */
|
||||
.cta-banner {
|
||||
background: var(--deepdrft-navy);
|
||||
padding: 6rem 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 3rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cta-banner::before {
|
||||
content: 'DRFT';
|
||||
position: absolute;
|
||||
right: -2rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 22rem;
|
||||
font-weight: 300;
|
||||
color: rgba(250, 250, 248, 0.03);
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.cta-text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cta-headline {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: clamp(2.5rem, 5vw, 5rem);
|
||||
font-weight: 300;
|
||||
color: var(--deepdrft-white);
|
||||
line-height: 1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cta-headline em {
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
.cta-sub {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.88rem;
|
||||
color: rgba(250, 250, 248, 0.4);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.cta-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-white {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-navy);
|
||||
background: var(--deepdrft-white);
|
||||
border: none;
|
||||
padding: 1rem 2.2rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.25s, color 0.25s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-white:hover {
|
||||
background: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
.btn-outline-white {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-white);
|
||||
background: transparent;
|
||||
border: 1px solid rgba(250, 250, 248, 0.3);
|
||||
padding: 1rem 2.2rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.25s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-outline-white:hover { border-color: var(--deepdrft-white); }
|
||||
@@ -0,0 +1,42 @@
|
||||
@page "/tracks"
|
||||
@rendermode InteractiveAuto
|
||||
|
||||
<PageTitle>DeepDrft Track Gallery</PageTitle>
|
||||
|
||||
<div class="tracks-page-wrapper">
|
||||
<div class="tracks-view-container">
|
||||
@if (ViewModel.Page != null)
|
||||
{
|
||||
<div class="tracks-content">
|
||||
<TracksGallery Tracks="@ViewModel.Page.Items"
|
||||
SelectedTrack="_selectedTrack"
|
||||
SelectedTrackChanged="@PlayTrack"/>
|
||||
</div>
|
||||
<div class="tracks-footer py-4">
|
||||
<MudPagination Count="@ViewModel.Page.TotalPages"
|
||||
Selected="@ViewModel.Page.Page"
|
||||
SelectedChanged="@SetPage"
|
||||
BoundaryCount="2"
|
||||
MiddleCount="3"/>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="tracks-content">
|
||||
<MudGrid Spacing="3">
|
||||
@foreach (var i in Enumerable.Range(0, 12))
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="deepdrft-track-gallery-item-center">
|
||||
<MudSkeleton Width="240px" Height="240px" SkeletonType="SkeletonType.Rectangle"/>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</div>
|
||||
<div class="tracks-footer">
|
||||
<MudSkeleton Height="60px" Width="240px" Class="justify-center"/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Models.Common;
|
||||
|
||||
namespace DeepDrftPublic.Client.Pages;
|
||||
|
||||
public partial class TracksView : ComponentBase
|
||||
{
|
||||
[Inject] public required TracksViewModel ViewModel { get; set; }
|
||||
[CascadingParameter] public required IPlayerService PlayerService { get; set; }
|
||||
|
||||
private TrackEntity? _selectedTrack = null;
|
||||
private int _clickCount = 0;
|
||||
private string _lifecycleStatus = "Not initialized";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_lifecycleStatus = "OnInitializedAsync called";
|
||||
await SetPage(1);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
_lifecycleStatus = "OnAfterRenderAsync called - WebAssembly is active!";
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void TestInteractivity()
|
||||
{
|
||||
_clickCount++;
|
||||
_lifecycleStatus = $"Button clicked {_clickCount} times - Interactivity working!";
|
||||
}
|
||||
|
||||
private async Task SetPage(int newPage)
|
||||
{
|
||||
var result = await ViewModel.TrackClient.GetPage(newPage, ViewModel.PageSize, ViewModel.SortBy, ViewModel.IsDescending);
|
||||
|
||||
if (result is { Success: true, Value: PagedResult<TrackEntity> pageResult })
|
||||
{
|
||||
ViewModel.Page = pageResult;
|
||||
ViewModel.PageSize = pageResult.PageSize;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PlayTrack(TrackEntity? track)
|
||||
{
|
||||
if (track == null && _selectedTrack == null || track?.Id == _selectedTrack?.Id) return;
|
||||
|
||||
if (track is null)
|
||||
{
|
||||
await PlayerService.Unload();
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrack(track);
|
||||
}
|
||||
|
||||
_selectedTrack = track;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.tracks-page-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tracks-view-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 0 16px; /* Horizontal padding only */
|
||||
}
|
||||
|
||||
.tracks-content {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.tracks-footer {
|
||||
flex: 0 0;
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using DeepDrftPublic.Client;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using MudBlazor.Services;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
Console.WriteLine(builder.HostEnvironment.BaseAddress);
|
||||
|
||||
var contentApiUrl = builder.Configuration["ApiUrls:ContentApi"] ?? "https://localhost:7001";
|
||||
|
||||
builder.Services.AddMudServices();
|
||||
|
||||
Startup.ConfigureApiHttpClient(builder.Services, builder.HostEnvironment.BaseAddress);
|
||||
Startup.ConfigureContentServices(builder.Services, contentApiUrl);
|
||||
Startup.ConfigureDomainServices(builder.Services);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -0,0 +1,358 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public class AudioInteropService : IAsyncDisposable
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private readonly Dictionary<string, IDisposable> _callbacks = new();
|
||||
|
||||
public AudioInteropService(IJSRuntime jsRuntime)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> CreatePlayerAsync(string playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jsRuntime.InvokeAsync<AudioOperationResult>("DeepDrftAudio.createPlayer", playerId);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AudioOperationResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> InitializeBufferedPlayerAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.initializeBufferedPlayer", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> AppendAudioBlockAsync(string playerId, byte[] audioBlock)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.appendAudioBlock", playerId, audioBlock);
|
||||
}
|
||||
|
||||
public async Task<AudioLoadResult> FinalizeAudioBufferAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioLoadResult>("DeepDrftAudio.finalizeAudioBuffer", playerId);
|
||||
}
|
||||
|
||||
// Streaming methods
|
||||
public async Task<AudioOperationResult> InitializeStreaming(string playerId, long totalStreamLength)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength);
|
||||
}
|
||||
|
||||
public async Task<StreamingResult> ProcessStreamingChunk(string playerId, byte[] audioChunk)
|
||||
{
|
||||
return await InvokeJsAsync<StreamingResult>("DeepDrftAudio.processStreamingChunk", playerId, audioChunk);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> StartStreamingPlayback(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.startStreamingPlayback", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> MarkStreamCompleteAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.markStreamComplete", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> EnsureAudioContextReady(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.ensureAudioContextReady", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> PlayAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.play", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> PauseAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.pause", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> StopAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stop", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> UnloadAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.unload", playerId);
|
||||
}
|
||||
|
||||
public async Task<SeekResult> SeekAsync(string playerId, double position)
|
||||
{
|
||||
return await InvokeJsAsync<SeekResult>("DeepDrftAudio.seek", playerId, position);
|
||||
}
|
||||
|
||||
// New methods for seek-beyond-buffer support
|
||||
public async Task<double> GetBufferedDuration(string playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<double>("DeepDrftAudio.getBufferedDuration", playerId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<long> CalculateByteOffset(string playerId, double positionSeconds)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (long)await _jsRuntime.InvokeAsync<double>("DeepDrftAudio.calculateByteOffset", playerId, positionSeconds);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> ReinitializeFromOffset(string playerId, long totalStreamLength, double seekPosition)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.reinitializeFromOffset", playerId, totalStreamLength, seekPosition);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetVolumeAsync(string playerId, double volume)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setVolume", playerId, volume);
|
||||
}
|
||||
|
||||
public async Task<double> GetCurrentTimeAsync(string playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<double>("DeepDrftAudio.getCurrentTime", playerId);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioPlayerState?> GetStateAsync(string playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<AudioPlayerState>("DeepDrftAudio.getState", playerId);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetOnProgressCallbackAsync(string playerId, Func<double, Task> callback)
|
||||
{
|
||||
return await SetCallbackAsync(playerId, "_progress", "setOnProgressCallback", "OnProgressCallback",
|
||||
wrapper => wrapper.OnProgress = callback);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetOnEndCallbackAsync(string playerId, Func<Task> callback)
|
||||
{
|
||||
return await SetCallbackAsync(playerId, "_end", "setOnEndCallback", "OnEndCallback",
|
||||
wrapper => wrapper.OnEnd = callback);
|
||||
}
|
||||
|
||||
// Spectrum analyzer methods
|
||||
public async Task<double[]?> GetSpectrumDataAsync(string playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<double[]>("DeepDrftAudio.getSpectrumData", playerId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetSpectrumHighPassAsync(string playerId, double freq)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumHighPass", playerId, freq);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetSpectrumLowPassAsync(string playerId, double freq)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumLowPass", playerId, freq);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetSpectrumSlopeAsync(string playerId, double dbPerDecade)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumSlope", playerId, dbPerDecade);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> StartSpectrumAnimationAsync(string playerId, string callbackId, Func<double[], Task> callback)
|
||||
{
|
||||
try
|
||||
{
|
||||
var callbackWrapper = new SpectrumCallback { OnData = callback };
|
||||
var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper);
|
||||
_callbacks[playerId + "_spectrum_" + callbackId] = dotNetObjectRef;
|
||||
|
||||
return await _jsRuntime.InvokeAsync<AudioOperationResult>(
|
||||
"DeepDrftAudio.startSpectrumAnimation",
|
||||
playerId, callbackId, dotNetObjectRef, "OnSpectrumDataCallback");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AudioOperationResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> StopSpectrumAnimationAsync(string playerId, string callbackId)
|
||||
{
|
||||
var key = playerId + "_spectrum_" + callbackId;
|
||||
if (_callbacks.TryGetValue(key, out var callback))
|
||||
{
|
||||
callback?.Dispose();
|
||||
_callbacks.Remove(key);
|
||||
}
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stopSpectrumAnimation", playerId, callbackId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> DisposePlayerAsync(string playerId)
|
||||
{
|
||||
CleanupPlayerCallbacks(playerId);
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.disposePlayer", playerId);
|
||||
}
|
||||
|
||||
// TODO: The typeof(T) switch below requires updating whenever a new result type is added.
|
||||
// Consider introducing a shared marker interface (e.g. IAudioResult with a static factory
|
||||
// method) so InvokeJsAsync can construct the failure result generically without a type switch.
|
||||
private async Task<T> InvokeJsAsync<T>(string identifier, params object[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<T>(identifier, args);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (typeof(T) == typeof(AudioOperationResult))
|
||||
return (T)(object)new AudioOperationResult { Success = false, Error = ex.Message };
|
||||
if (typeof(T) == typeof(AudioLoadResult))
|
||||
return (T)(object)new AudioLoadResult { Success = false, Error = ex.Message };
|
||||
if (typeof(T) == typeof(StreamingResult))
|
||||
return (T)(object)new StreamingResult { Success = false, Error = ex.Message };
|
||||
if (typeof(T) == typeof(SeekResult))
|
||||
return (T)(object)new SeekResult { Success = false, Error = ex.Message };
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AudioOperationResult> SetCallbackAsync(string playerId, string suffix, string jsMethod, string callbackMethod, Action<AudioPlayerCallback> configureCallback)
|
||||
{
|
||||
try
|
||||
{
|
||||
var callbackWrapper = new AudioPlayerCallback();
|
||||
configureCallback(callbackWrapper);
|
||||
|
||||
var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper);
|
||||
_callbacks[playerId + suffix] = dotNetObjectRef;
|
||||
|
||||
return await _jsRuntime.InvokeAsync<AudioOperationResult>($"DeepDrftAudio.{jsMethod}",
|
||||
playerId, dotNetObjectRef, callbackMethod);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AudioOperationResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupPlayerCallbacks(string playerId)
|
||||
{
|
||||
var keysToRemove = _callbacks.Keys.Where(k => k.StartsWith(playerId + "_")).ToList();
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_callbacks[key]?.Dispose();
|
||||
_callbacks.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var callback in _callbacks.Values)
|
||||
{
|
||||
callback?.Dispose();
|
||||
}
|
||||
_callbacks.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public class AudioPlayerCallback
|
||||
{
|
||||
public Func<double, Task>? OnProgress { get; set; }
|
||||
public Func<Task>? OnEnd { get; set; }
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnProgressCallback(double currentTime)
|
||||
{
|
||||
if (OnProgress != null)
|
||||
await OnProgress(currentTime);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnEndCallback()
|
||||
{
|
||||
if (OnEnd != null)
|
||||
await OnEnd();
|
||||
}
|
||||
}
|
||||
|
||||
public class SpectrumCallback
|
||||
{
|
||||
public Func<double[], Task>? OnData { get; set; }
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnSpectrumDataCallback(double[] data)
|
||||
{
|
||||
if (OnData != null)
|
||||
await OnData(data);
|
||||
}
|
||||
}
|
||||
|
||||
public class AudioOperationResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
public class SeekResult : AudioOperationResult
|
||||
{
|
||||
public bool SeekBeyondBuffer { get; set; }
|
||||
public long ByteOffset { get; set; }
|
||||
}
|
||||
|
||||
public class AudioLoadResult : AudioOperationResult
|
||||
{
|
||||
public double Duration { get; set; }
|
||||
public int SampleRate { get; set; }
|
||||
public int NumberOfChannels { get; set; }
|
||||
public double LoadProgress { get; set; }
|
||||
}
|
||||
|
||||
public class StreamingResult : AudioOperationResult
|
||||
{
|
||||
public bool CanStartStreaming { get; set; }
|
||||
public bool HeaderParsed { get; set; }
|
||||
public int BufferCount { get; set; }
|
||||
public double? Duration { get; set; } // Duration in seconds calculated from WAV header
|
||||
}
|
||||
|
||||
public class AudioPlayerState
|
||||
{
|
||||
public bool IsPlaying { get; set; }
|
||||
public bool IsPaused { get; set; }
|
||||
public double CurrentTime { get; set; }
|
||||
public double Duration { get; set; }
|
||||
public double Volume { get; set; }
|
||||
public double LoadProgress { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftPublic.Client.Clients;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using NetBlocks.Models;
|
||||
using System.Buffers;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
{
|
||||
protected readonly AudioInteropService _audioInterop;
|
||||
protected readonly TrackMediaClient _trackMediaClient;
|
||||
|
||||
public string PlayerId { get; private set; } = Guid.NewGuid().ToString();
|
||||
|
||||
// State properties
|
||||
public bool IsInitialized { get; protected set; } = false;
|
||||
public bool IsLoaded { get; protected set; } = false;
|
||||
public bool IsLoading { get; protected set; } = false;
|
||||
public bool IsPlaying { get; protected set; } = false;
|
||||
public bool IsPaused { get; protected set; } = false;
|
||||
public double CurrentTime { get; protected set; } = 0;
|
||||
public double? Duration { get; protected set; } = null;
|
||||
public double Volume { get; protected set; } = 0.8;
|
||||
public double LoadProgress { get; protected set; } = 0;
|
||||
public string? ErrorMessage { get; protected set; }
|
||||
/// <summary>
|
||||
/// The currently selected track. In the streaming subclass this property is managed
|
||||
/// exclusively by <see cref="StreamingAudioPlayerService"/>: set in
|
||||
/// <c>LoadTrackStreaming</c> after <c>ResetToIdle</c> clears it, and cleared again
|
||||
/// by <c>ResetToIdle</c> on stop/unload/dispose. Base-class subclasses that take the
|
||||
/// <see cref="SelectTrack"/>/<see cref="Unload"/> path are responsible for managing
|
||||
/// it themselves.
|
||||
/// </summary>
|
||||
public TrackEntity? CurrentTrack { get; protected set; }
|
||||
|
||||
// Events
|
||||
public EventCallback? OnStateChanged { get; set; }
|
||||
public EventCallback? OnTrackSelected { get; set; }
|
||||
|
||||
protected AudioPlayerService(AudioInteropService audioInterop, TrackMediaClient trackMediaClient)
|
||||
{
|
||||
_audioInterop = audioInterop;
|
||||
_trackMediaClient = trackMediaClient;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (IsInitialized) return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _audioInterop.CreatePlayerAsync(PlayerId);
|
||||
if (!result.Success)
|
||||
{
|
||||
ErrorMessage = $"Failed to initialize audio player: {result.Error}";
|
||||
await NotifyStateChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
await _audioInterop.SetOnProgressCallbackAsync(PlayerId, OnProgressCallback);
|
||||
await _audioInterop.SetOnEndCallbackAsync(PlayerId, OnPlaybackEndCallback);
|
||||
|
||||
await _audioInterop.SetVolumeAsync(PlayerId, Volume);
|
||||
|
||||
IsInitialized = true;
|
||||
ErrorMessage = null;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Failed to initialize audio player: {ex.Message}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task SelectTrack(TrackEntity track)
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
|
||||
await NotifyStateChanged();
|
||||
|
||||
if (OnTrackSelected.HasValue)
|
||||
await OnTrackSelected.Value.InvokeAsync();
|
||||
|
||||
await LoadTrack(track);
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
private async Task LoadTrack(TrackEntity track)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsLoading) return;
|
||||
|
||||
if (IsPlaying || IsPaused)
|
||||
{
|
||||
await Unload();
|
||||
}
|
||||
|
||||
// Reset state to indicate loading has started
|
||||
ErrorMessage = null;
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
IsLoading = true;
|
||||
Duration = null;
|
||||
CurrentTime = 0;
|
||||
await NotifyStateChanged();
|
||||
|
||||
var loadResult = await _audioInterop.InitializeBufferedPlayerAsync(PlayerId);
|
||||
if (loadResult?.Success != true)
|
||||
{
|
||||
ErrorMessage = $"Failed to initialize audio buffer: {loadResult?.Error ?? "Unknown error"}";
|
||||
return;
|
||||
}
|
||||
|
||||
var mediaResult = await _trackMediaClient.GetTrackMedia(track.EntryKey);
|
||||
if (!mediaResult.Success)
|
||||
{
|
||||
ErrorMessage = mediaResult.GetMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaResult.Value == null)
|
||||
{
|
||||
ErrorMessage = "No audio returned from server";
|
||||
return;
|
||||
}
|
||||
|
||||
TrackMediaResponse audio = mediaResult.Value;
|
||||
await StreamAudio(audio);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error loading audio: {ex.Message}";
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StreamAudio(TrackMediaResponse audio)
|
||||
{
|
||||
const int bufferSize = 32 * 1024;
|
||||
var rentedBuffer = ArrayPool<byte>.Shared.Rent(bufferSize);
|
||||
try
|
||||
{
|
||||
long totalBytesRead = 0;
|
||||
int currentBytes;
|
||||
|
||||
do
|
||||
{
|
||||
currentBytes = await audio.Stream.ReadAsync(rentedBuffer, 0, bufferSize);
|
||||
|
||||
if (currentBytes > 0)
|
||||
{
|
||||
totalBytesRead += currentBytes;
|
||||
|
||||
// Slice to actual bytes read before sending to interop
|
||||
var chunk = rentedBuffer[..currentBytes];
|
||||
|
||||
var appendResult = await _audioInterop.AppendAudioBlockAsync(PlayerId, chunk);
|
||||
if (!appendResult.Success)
|
||||
{
|
||||
throw new Exception($"Failed to append audio block: {appendResult.Error}");
|
||||
}
|
||||
|
||||
if (audio.ContentLength > 0)
|
||||
{
|
||||
LoadProgress = Math.Min(1.0, (double)totalBytesRead / audio.ContentLength);
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
} while (currentBytes > 0);
|
||||
|
||||
var finalizeResult = await _audioInterop.FinalizeAudioBufferAsync(PlayerId);
|
||||
if (!finalizeResult.Success)
|
||||
{
|
||||
throw new Exception($"Failed to finalize audio buffer: {finalizeResult.Error}");
|
||||
}
|
||||
|
||||
Duration = finalizeResult.Duration;
|
||||
LoadProgress = 1.0;
|
||||
IsLoaded = true;
|
||||
ErrorMessage = null;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error streaming audio: {ex.Message}";
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
await NotifyStateChanged();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(rentedBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task TogglePlayPause()
|
||||
{
|
||||
if (!IsLoaded) return;
|
||||
|
||||
try
|
||||
{
|
||||
AudioOperationResult result;
|
||||
|
||||
if (IsPlaying)
|
||||
{
|
||||
result = await _audioInterop.PauseAsync(PlayerId);
|
||||
if (result.Success)
|
||||
{
|
||||
IsPlaying = false;
|
||||
IsPaused = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await _audioInterop.PlayAsync(PlayerId);
|
||||
if (result.Success)
|
||||
{
|
||||
IsPlaying = true;
|
||||
IsPaused = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
ErrorMessage = $"Playback error: {result.Error}";
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = null;
|
||||
}
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error controlling playback: {ex.Message}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task Stop()
|
||||
{
|
||||
if (!IsLoaded) return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _audioInterop.StopAsync(PlayerId);
|
||||
if (result.Success)
|
||||
{
|
||||
IsPlaying = false;
|
||||
IsPaused = false;
|
||||
CurrentTime = 0;
|
||||
ErrorMessage = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = $"Stop error: {result.Error}";
|
||||
}
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error stopping playback: {ex.Message}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task Unload()
|
||||
{
|
||||
if (!IsLoaded) return;
|
||||
|
||||
try
|
||||
{
|
||||
await Stop();
|
||||
var result = await _audioInterop.UnloadAsync(PlayerId);
|
||||
if (result.Success)
|
||||
{
|
||||
IsPlaying = false;
|
||||
IsPaused = false;
|
||||
CurrentTime = 0;
|
||||
Duration = null;
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
ErrorMessage = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = $"Unload error: {result.Error}";
|
||||
}
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error unloading track: {ex.Message}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task Seek(double position)
|
||||
{
|
||||
if (!IsLoaded) return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _audioInterop.SeekAsync(PlayerId, position);
|
||||
if (result.Success)
|
||||
{
|
||||
CurrentTime = position;
|
||||
ErrorMessage = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = $"Seek error: {result.Error}";
|
||||
}
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error seeking: {ex.Message}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetVolume(double volume)
|
||||
{
|
||||
Volume = volume;
|
||||
|
||||
if (IsLoaded)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _audioInterop.SetVolumeAsync(PlayerId, volume);
|
||||
if (!result.Success)
|
||||
{
|
||||
ErrorMessage = $"Volume error: {result.Error}";
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error setting volume: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
public async Task ClearError()
|
||||
{
|
||||
ErrorMessage = null;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
private async Task OnProgressCallback(double currentTime)
|
||||
{
|
||||
CurrentTime = currentTime;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
private async Task OnPlaybackEndCallback()
|
||||
{
|
||||
IsPlaying = false;
|
||||
IsPaused = false;
|
||||
CurrentTime = 0;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
|
||||
protected async Task EnsureInitializedAsync()
|
||||
{
|
||||
if (!IsInitialized)
|
||||
{
|
||||
await InitializeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task NotifyStateChanged()
|
||||
{
|
||||
if (OnStateChanged.HasValue)
|
||||
await OnStateChanged.Value.InvokeAsync();
|
||||
}
|
||||
|
||||
protected async Task NotifyTrackSelected()
|
||||
{
|
||||
if (OnTrackSelected.HasValue)
|
||||
await OnTrackSelected.Value.InvokeAsync();
|
||||
}
|
||||
|
||||
public virtual async ValueTask DisposeAsync()
|
||||
{
|
||||
if (IsInitialized)
|
||||
{
|
||||
await _audioInterop.DisposePlayerAsync(PlayerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using DeepDrftPublic.Client.Common;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public class DarkModeCookieService(DarkModeSettings darkModeSetting, IJSRuntime js) : DarkModeServiceBase
|
||||
{
|
||||
private const int EXPIRY_DAYS = 365;
|
||||
|
||||
public bool GetDarkModeAsync()
|
||||
{
|
||||
return darkModeSetting.IsDarkMode;
|
||||
// var value = await js.InvokeAsync<string?>("eval",
|
||||
// $"document.cookie.split('; ').find(c => c.startsWith('{COOKIE_NAME}='))?.split('=')[1]");
|
||||
// return value == "true";
|
||||
}
|
||||
|
||||
public async ValueTask SetDarkModeAsync(bool isDarkMode)
|
||||
{
|
||||
var expires = DateTime.UtcNow.AddDays(EXPIRY_DAYS).ToString("R");
|
||||
await js.InvokeVoidAsync("eval",
|
||||
$"document.cookie = '{COOKIE_NAME}={isDarkMode.ToString().ToLower()}; expires={expires}; path=/; SameSite=Lax'");
|
||||
darkModeSetting.IsDarkMode = isDarkMode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public abstract class DarkModeServiceBase
|
||||
{
|
||||
protected const string COOKIE_NAME = "darkMode";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public interface IPlayerService
|
||||
{
|
||||
// State properties
|
||||
bool IsInitialized { get; }
|
||||
bool IsLoaded { get; }
|
||||
bool IsLoading { get; }
|
||||
bool IsPlaying { get; }
|
||||
bool IsPaused { get; }
|
||||
double CurrentTime { get; }
|
||||
double? Duration { get; }
|
||||
double Volume { get; }
|
||||
double LoadProgress { get; }
|
||||
string? ErrorMessage { get; }
|
||||
TrackEntity? CurrentTrack { get; }
|
||||
|
||||
// Events for UI updates
|
||||
EventCallback? OnStateChanged { get; set; }
|
||||
EventCallback? OnTrackSelected { get; set; }
|
||||
|
||||
// Control methods
|
||||
Task InitializeAsync();
|
||||
Task SelectTrack(TrackEntity track);
|
||||
Task Stop();
|
||||
Task Unload();
|
||||
Task TogglePlayPause();
|
||||
Task Seek(double position);
|
||||
Task SetVolume(double volume);
|
||||
Task ClearError();
|
||||
}
|
||||
|
||||
public interface IStreamingPlayerService : IPlayerService
|
||||
{
|
||||
// Streaming state properties
|
||||
bool IsStreamingMode { get; }
|
||||
bool CanStartStreaming { get; }
|
||||
bool HeaderParsed { get; }
|
||||
int BufferedChunks { get; }
|
||||
|
||||
// Streaming control methods
|
||||
Task SelectTrackStreaming(TrackEntity track);
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftPublic.Client.Clients;
|
||||
using System.Buffers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerService
|
||||
{
|
||||
// Configuration constants
|
||||
private const int DefaultBufferSize = 32 * 1024; // 32KB chunks
|
||||
private const int NotificationThrottleMs = 100; // Throttle UI updates to max 10 per second
|
||||
|
||||
// Adaptive chunk sizing
|
||||
private const int MinBufferSize = 16 * 1024; // 16KB minimum
|
||||
private const int MaxBufferSize = 64 * 1024; // 64KB maximum
|
||||
private int _currentBufferSize = DefaultBufferSize;
|
||||
private int _consecutiveSlowReads = 0;
|
||||
|
||||
|
||||
// Streaming state properties
|
||||
public bool IsStreamingMode { get; private set; } = false;
|
||||
public bool CanStartStreaming { get; private set; } = false;
|
||||
public bool HeaderParsed { get; private set; } = false;
|
||||
public int BufferedChunks { get; private set; } = 0;
|
||||
public bool IsSeekingBeyondBuffer { get; private set; } = false;
|
||||
|
||||
private bool _streamingPlaybackStarted = false;
|
||||
private CancellationTokenSource? _streamingCancellation;
|
||||
private Task? _activeStreamingTask;
|
||||
private DateTime _lastNotification = DateTime.MinValue;
|
||||
private readonly ILogger<StreamingAudioPlayerService> _logger;
|
||||
private string? _currentTrackId;
|
||||
|
||||
public StreamingAudioPlayerService(
|
||||
AudioInteropService audioInterop,
|
||||
TrackMediaClient trackMediaClient,
|
||||
ILogger<StreamingAudioPlayerService> logger)
|
||||
: base(audioInterop, trackMediaClient)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override async Task SelectTrack(TrackEntity track)
|
||||
{
|
||||
await SelectTrackStreaming(track);
|
||||
}
|
||||
|
||||
public async Task SelectTrackStreaming(TrackEntity track)
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
|
||||
// Resume AudioContext immediately on track selection (user interaction) to avoid clicks later
|
||||
await _audioInterop.EnsureAudioContextReady(PlayerId);
|
||||
|
||||
await NotifyTrackSelected();
|
||||
|
||||
await LoadTrackStreaming(track);
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
private async Task LoadTrackStreaming(TrackEntity track)
|
||||
{
|
||||
// Always reset to clean state before loading new track. ResetToIdle
|
||||
// both cancels and awaits any in-flight streaming loop, so by the time
|
||||
// we return from it the previous loop is guaranteed to have exited and
|
||||
// there is no risk of interleaved ProcessStreamingChunk calls against
|
||||
// the single-instance JS StreamDecoder.
|
||||
await ResetToIdle();
|
||||
|
||||
// Save track ID for seek operations
|
||||
_currentTrackId = track.EntryKey;
|
||||
// Expose to UI immediately — Now-Playing surfaces should reflect the selected
|
||||
// track while it's still loading, not only after playback starts.
|
||||
CurrentTrack = track;
|
||||
|
||||
// Create new cancellation token for this streaming operation
|
||||
_streamingCancellation = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
// Set state to indicate loading has started
|
||||
ErrorMessage = null;
|
||||
LoadProgress = 0;
|
||||
IsLoading = true;
|
||||
IsStreamingMode = true;
|
||||
|
||||
// Reset adaptive buffer sizing
|
||||
_currentBufferSize = DefaultBufferSize;
|
||||
_consecutiveSlowReads = 0;
|
||||
|
||||
await NotifyStateChanged();
|
||||
|
||||
// Pass the streaming token to the HTTP layer so a navigation/track switch
|
||||
// aborts the server connection instead of leaving it draining bytes.
|
||||
var mediaResult = await _trackMediaClient.GetTrackMedia(
|
||||
track.EntryKey,
|
||||
byteOffset: 0,
|
||||
cancellationToken: _streamingCancellation.Token);
|
||||
if (!mediaResult.Success)
|
||||
{
|
||||
var technicalError = mediaResult.GetMessage();
|
||||
_logger.LogError("Failed to get track media for {TrackId}: {Error}",
|
||||
track.EntryKey, technicalError);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaResult.Value == null)
|
||||
{
|
||||
const string technicalError = "No audio returned from server";
|
||||
_logger.LogError("No audio data returned for track {TrackId}", track.EntryKey);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
return;
|
||||
}
|
||||
|
||||
using var audio = mediaResult.Value;
|
||||
|
||||
// Initialize streaming mode with content length
|
||||
var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, audio.ContentLength);
|
||||
if (!streamingResult.Success)
|
||||
{
|
||||
var technicalError = $"Failed to initialize streaming: {streamingResult.Error}";
|
||||
_logger.LogError("Streaming initialization failed for track {TrackId}: {Error}",
|
||||
track.EntryKey, technicalError);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
return;
|
||||
}
|
||||
|
||||
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token);
|
||||
await _activeStreamingTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cancellation is expected, reset state
|
||||
_logger.LogDebug("Audio streaming cancelled for track {TrackId}", track.EntryKey);
|
||||
IsLoaded = false;
|
||||
IsStreamingMode = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StreamingErrorHandler.LogError(_logger, ex, "LoadTrackStreaming", track.EntryKey);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
IsStreamingMode = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StreamAudioWithEarlyPlayback(TrackMediaResponse audio, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[]? buffer = null;
|
||||
try
|
||||
{
|
||||
long totalBytesRead = 0;
|
||||
buffer = ArrayPool<byte>.Shared.Rent(MaxBufferSize); // Rent larger buffer to accommodate adaptive sizing
|
||||
int currentBytes;
|
||||
var readTimer = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
do
|
||||
{
|
||||
readTimer.Restart();
|
||||
currentBytes = await audio.Stream.ReadAsync(buffer, 0, _currentBufferSize, cancellationToken);
|
||||
readTimer.Stop();
|
||||
|
||||
// Adapt buffer size based on read performance
|
||||
AdaptBufferSize(currentBytes, readTimer.ElapsedMilliseconds);
|
||||
|
||||
if (currentBytes > 0)
|
||||
{
|
||||
totalBytesRead += currentBytes;
|
||||
|
||||
// Always slice to the exact number of bytes read. The pooled buffer
|
||||
// is rented at MaxBufferSize and may carry stale bytes past
|
||||
// currentBytes from a prior rental — handing the full array to JS
|
||||
// interop would serialise that garbage into the audio stream.
|
||||
var actualBuffer = buffer.AsSpan(0, currentBytes).ToArray();
|
||||
|
||||
// Process chunk for streaming
|
||||
var chunkResult = await _audioInterop.ProcessStreamingChunk(PlayerId, actualBuffer);
|
||||
if (!chunkResult.Success)
|
||||
{
|
||||
var error = $"Failed to process streaming chunk: {chunkResult.Error}";
|
||||
_logger.LogWarning("Chunk processing failed: {Error}", error);
|
||||
throw new Exception(error);
|
||||
}
|
||||
|
||||
// Update streaming state
|
||||
CanStartStreaming = chunkResult.CanStartStreaming;
|
||||
HeaderParsed = chunkResult.HeaderParsed;
|
||||
BufferedChunks = chunkResult.BufferCount;
|
||||
|
||||
// Set duration from WAV header when available (only set once)
|
||||
if (chunkResult.Duration.HasValue && Duration == null)
|
||||
{
|
||||
Duration = chunkResult.Duration.Value;
|
||||
_logger.LogInformation("Duration set from WAV header: {Duration:F2} seconds", Duration);
|
||||
}
|
||||
|
||||
// Start playback as soon as we can
|
||||
if (!_streamingPlaybackStarted && CanStartStreaming)
|
||||
{
|
||||
var playbackResult = await _audioInterop.StartStreamingPlayback(PlayerId);
|
||||
if (playbackResult.Success)
|
||||
{
|
||||
_streamingPlaybackStarted = true;
|
||||
IsPlaying = true;
|
||||
IsPaused = false;
|
||||
IsLoaded = true; // Track is loaded and ready to play (even if still downloading)
|
||||
ErrorMessage = null;
|
||||
await NotifyStateChanged(); // Immediate notification for critical state change
|
||||
}
|
||||
else
|
||||
{
|
||||
var technicalError = $"Failed to start streaming playback: {playbackResult.Error}";
|
||||
_logger.LogError("Failed to start playback: {Error}", technicalError);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
}
|
||||
}
|
||||
|
||||
// Update progress
|
||||
if (audio.ContentLength > 0)
|
||||
{
|
||||
LoadProgress = Math.Min(1.0, (double)totalBytesRead / audio.ContentLength);
|
||||
}
|
||||
|
||||
await ThrottledNotifyStateChanged();
|
||||
}
|
||||
} while (currentBytes > 0);
|
||||
|
||||
// Notify the JS decoder that the stream is finished. When the server omits
|
||||
// Content-Length the StreamDecoder cannot determine completion via byte counting
|
||||
// alone; this explicit signal ensures the tail-decoding path (streamComplete=true)
|
||||
// fires regardless of whether Content-Length was present.
|
||||
await _audioInterop.MarkStreamCompleteAsync(PlayerId);
|
||||
|
||||
// Mark as fully loaded
|
||||
LoadProgress = 1.0;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StreamingErrorHandler.LogError(_logger, ex, "StreamAudioWithEarlyPlayback");
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
IsStreamingMode = false;
|
||||
await NotifyStateChanged();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (buffer != null)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In streaming mode, Stop fully resets to Idle state since audio data is consumed.
|
||||
/// This is equivalent to Unload for streaming playback.
|
||||
/// </summary>
|
||||
public override async Task Stop()
|
||||
{
|
||||
// In streaming mode, Stop = Unload (data is consumed, can't replay)
|
||||
await ResetToIdle();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fully resets the player to Idle state, ready for a new track.
|
||||
/// </summary>
|
||||
public override async Task Unload()
|
||||
{
|
||||
await ResetToIdle();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override Seek to handle seek-beyond-buffer for streaming mode.
|
||||
/// </summary>
|
||||
public override async Task Seek(double position)
|
||||
{
|
||||
if (!IsLoaded || !IsStreamingMode) return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _audioInterop.SeekAsync(PlayerId, position);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
if (result.SeekBeyondBuffer && result.ByteOffset > 0)
|
||||
{
|
||||
// Need to load new stream from offset
|
||||
_logger.LogInformation("Seeking beyond buffer to {Position:F2}s, byte offset: {ByteOffset}",
|
||||
position, result.ByteOffset);
|
||||
await SeekBeyondBuffer(position, result.ByteOffset);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Seek within buffer succeeded
|
||||
CurrentTime = position;
|
||||
ErrorMessage = null;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = $"Seek error: {result.Error}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error seeking to position {Position}", position);
|
||||
ErrorMessage = $"Error seeking: {ex.Message}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle seeking beyond the currently buffered content by requesting a new stream from offset.
|
||||
/// </summary>
|
||||
private async Task SeekBeyondBuffer(double seekPosition, long byteOffset)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_currentTrackId))
|
||||
{
|
||||
ErrorMessage = "Cannot seek - no track loaded";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSeekingBeyondBuffer = true;
|
||||
|
||||
// Cancel the current streaming loop AND wait for it to fully exit before
|
||||
// starting a new one. The previous loop's pending ReadAsync will throw
|
||||
// OperationCanceledException asynchronously; if we kick off a new loop
|
||||
// immediately, both can race against the single-instance JS StreamDecoder
|
||||
// and corrupt decode state. Draining here is the load-bearing guarantee.
|
||||
_streamingCancellation?.Cancel();
|
||||
await DrainActiveStreamingTaskAsync();
|
||||
_streamingCancellation?.Dispose();
|
||||
_streamingCancellation = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
// Update UI immediately
|
||||
CurrentTime = seekPosition;
|
||||
await NotifyStateChanged();
|
||||
|
||||
// Request new stream from offset
|
||||
var mediaResult = await _trackMediaClient.GetTrackMedia(
|
||||
_currentTrackId,
|
||||
byteOffset,
|
||||
cancellationToken: _streamingCancellation.Token);
|
||||
if (!mediaResult.Success || mediaResult.Value == null)
|
||||
{
|
||||
var technicalError = mediaResult.GetMessage() ?? "Failed to load audio from position";
|
||||
_logger.LogError("Failed to get track media from offset {Offset}: {Error}", byteOffset, technicalError);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
IsSeekingBeyondBuffer = false;
|
||||
return;
|
||||
}
|
||||
|
||||
using var audio = mediaResult.Value;
|
||||
|
||||
// Reinitialize JS player for offset streaming
|
||||
var reinitResult = await _audioInterop.ReinitializeFromOffset(PlayerId, audio.ContentLength, seekPosition);
|
||||
if (!reinitResult.Success)
|
||||
{
|
||||
_logger.LogError("Failed to reinitialize for offset streaming: {Error}", reinitResult.Error);
|
||||
ErrorMessage = "Failed to seek to position";
|
||||
IsSeekingBeyondBuffer = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset streaming state for new stream
|
||||
_streamingPlaybackStarted = false;
|
||||
CanStartStreaming = false;
|
||||
HeaderParsed = false;
|
||||
BufferedChunks = 0;
|
||||
|
||||
// Stream audio from offset
|
||||
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token);
|
||||
await _activeStreamingTask;
|
||||
|
||||
IsSeekingBeyondBuffer = false;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Another seek or stop interrupted this one
|
||||
_logger.LogDebug("Seek beyond buffer cancelled");
|
||||
IsSeekingBeyondBuffer = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during seek beyond buffer to position {Position}", seekPosition);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
|
||||
IsSeekingBeyondBuffer = false;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single method to reset all state - called by both Stop and Unload.
|
||||
/// </summary>
|
||||
private async Task ResetToIdle()
|
||||
{
|
||||
// 1. Cancel any ongoing streaming operation and wait for it to exit
|
||||
// before tearing down JS state. Otherwise the loop's pending
|
||||
// ProcessStreamingChunk call can land after StopAsync/UnloadAsync.
|
||||
_streamingCancellation?.Cancel();
|
||||
await DrainActiveStreamingTaskAsync();
|
||||
_streamingCancellation?.Dispose();
|
||||
_streamingCancellation = null;
|
||||
|
||||
// 2. Tell JS to stop and unload
|
||||
try
|
||||
{
|
||||
await _audioInterop.StopAsync(PlayerId);
|
||||
await _audioInterop.UnloadAsync(PlayerId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore JS errors during cleanup
|
||||
}
|
||||
|
||||
// 3. Reset ALL state to Idle
|
||||
IsPlaying = false;
|
||||
IsPaused = false;
|
||||
IsLoaded = false;
|
||||
IsLoading = false;
|
||||
CurrentTime = 0;
|
||||
Duration = null;
|
||||
LoadProgress = 0;
|
||||
ErrorMessage = null;
|
||||
CurrentTrack = null;
|
||||
|
||||
// 4. Reset streaming-specific state
|
||||
IsStreamingMode = false;
|
||||
CanStartStreaming = false;
|
||||
HeaderParsed = false;
|
||||
BufferedChunks = 0;
|
||||
_streamingPlaybackStarted = false;
|
||||
IsSeekingBeyondBuffer = false;
|
||||
_currentTrackId = null;
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for the previously-started streaming loop to fully exit. The caller
|
||||
/// must have already cancelled <see cref="_streamingCancellation"/>. Swallows
|
||||
/// the expected OperationCanceledException; any other exception was already
|
||||
/// surfaced through the loop's own catch block, so we ignore it here too.
|
||||
/// </summary>
|
||||
private async Task DrainActiveStreamingTaskAsync()
|
||||
{
|
||||
var task = _activeStreamingTask;
|
||||
if (task == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected when we cancelled the loop ourselves.
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Any other failure was already logged inside the loop.
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Only clear if we are still the active task — a concurrent caller
|
||||
// may have started a new stream while we were draining the old one.
|
||||
if (ReferenceEquals(_activeStreamingTask, task))
|
||||
{
|
||||
_activeStreamingTask = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ThrottledNotifyStateChanged()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if ((now - _lastNotification).TotalMilliseconds >= NotificationThrottleMs)
|
||||
{
|
||||
_lastNotification = now;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On component unmount we must cancel the in-flight streaming loop and tear
|
||||
/// down JS callbacks before the JS side's setInterval fires again with a
|
||||
/// stale DotNetObjectReference. ResetToIdle covers cancellation + JS stop
|
||||
/// + state reset; the base then disposes the JS player and its callbacks.
|
||||
/// </summary>
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ResetToIdle();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disposal must not throw; any failure here is best-effort cleanup.
|
||||
}
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
private void AdaptBufferSize(int bytesRead, long readTimeMs)
|
||||
{
|
||||
// Adaptive buffer sizing based on network performance
|
||||
if (readTimeMs > 100) // Slow read (>100ms)
|
||||
{
|
||||
_consecutiveSlowReads++;
|
||||
if (_consecutiveSlowReads >= 3 && _currentBufferSize > MinBufferSize)
|
||||
{
|
||||
// Reduce buffer size for slow connections
|
||||
_currentBufferSize = Math.Max(MinBufferSize, _currentBufferSize / 2);
|
||||
_consecutiveSlowReads = 0;
|
||||
}
|
||||
}
|
||||
else if (readTimeMs < 20 && bytesRead == _currentBufferSize) // Fast read, buffer fully utilized
|
||||
{
|
||||
_consecutiveSlowReads = 0;
|
||||
if (_currentBufferSize < MaxBufferSize)
|
||||
{
|
||||
// Increase buffer size for fast connections
|
||||
_currentBufferSize = Math.Min(MaxBufferSize, _currentBufferSize * 2);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_consecutiveSlowReads = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public static class StreamingErrorHandler
|
||||
{
|
||||
public static string GetUserFriendlyMessage(string technicalError)
|
||||
{
|
||||
var lowerError = technicalError.ToLowerInvariant();
|
||||
|
||||
return lowerError switch
|
||||
{
|
||||
_ when lowerError.Contains("network") || lowerError.Contains("connection") || lowerError.Contains("timeout") =>
|
||||
"Unable to load audio. Please check your connection and try again.",
|
||||
|
||||
_ when lowerError.Contains("header") || lowerError.Contains("wav") || lowerError.Contains("invalid wav") =>
|
||||
"This file format is not supported. Only WAV files can be played.",
|
||||
|
||||
_ when lowerError.Contains("audio") || lowerError.Contains("decode") || lowerError.Contains("format") =>
|
||||
"This audio file may be corrupted or in an unsupported format.",
|
||||
|
||||
_ when lowerError.Contains("cancel") || lowerError.Contains("abort") =>
|
||||
"Audio loading was cancelled.",
|
||||
|
||||
_ => "Unable to play audio. Please try again."
|
||||
};
|
||||
}
|
||||
|
||||
public static void LogError(ILogger logger, Exception ex, string operation, string trackId = "")
|
||||
{
|
||||
logger.LogError(ex, "Streaming error in {Operation} for track {TrackId}", operation, trackId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using DeepDrftPublic.Client.Clients;
|
||||
using DeepDrftPublic.Client.Common;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DeepDrftPublic.Client;
|
||||
|
||||
public static class Startup
|
||||
{
|
||||
public static void ConfigureDomainServices(IServiceCollection services)
|
||||
{
|
||||
// Theme Support
|
||||
services.AddScoped<DarkModeSettings>();
|
||||
services.AddScoped<DarkModeCookieService>();
|
||||
|
||||
// Track Client
|
||||
services.AddScoped<TrackClient>();
|
||||
services.AddScoped<TracksViewModel>();
|
||||
}
|
||||
|
||||
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
|
||||
{
|
||||
services.AddHttpClient("DeepDrft.API", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(baseAddress);
|
||||
});
|
||||
}
|
||||
|
||||
public static void ConfigureContentServices(IServiceCollection services, string contentApiUrl)
|
||||
{
|
||||
services.AddHttpClient("DeepDrft.Content", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(contentApiUrl);
|
||||
});
|
||||
services.AddScoped<TrackMediaClient>();
|
||||
services.AddScoped<AudioInteropService>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftPublic.Client.Clients;
|
||||
using Models.Common;
|
||||
|
||||
namespace DeepDrftPublic.Client.ViewModels;
|
||||
|
||||
public class TracksViewModel
|
||||
{
|
||||
public TrackClient TrackClient { get; }
|
||||
|
||||
// private int _pageNumber = 1;
|
||||
public int PageNumber { get; set; } = 1;
|
||||
|
||||
public int PageSize
|
||||
{
|
||||
get => Page?.PageSize ?? 15;
|
||||
set
|
||||
{
|
||||
if (Page == null) throw new Exception();
|
||||
if (value != Page.PageSize)
|
||||
{
|
||||
Page.PageSize = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
public string SortBy { get; set; } = string.Empty;
|
||||
public bool IsDescending { get; set; } = false;
|
||||
public PagedResult<TrackEntity>? Page { get; set; } = null;
|
||||
|
||||
public TracksViewModel(TrackClient trackClient)
|
||||
{
|
||||
TrackClient = trackClient;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using MudBlazor
|
||||
@using MudBlazor.Services
|
||||
@using MudBlazor.ThemeManager
|
||||
@using DeepDrftPublic.Client.Common
|
||||
@using DeepDrftShared.Client.Common
|
||||
@using DeepDrftShared.Client.Components
|
||||
@layout DeepDrftPublic.Client.Layout.MainLayout
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ApiUrls": {
|
||||
"ContentApi": "http://localhost:54494/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ApiUrls": {
|
||||
"ContentApi": "https://media.deepdrft.com/"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Reference in New Issue
Block a user