refactor(split): rename DeepDrftWeb -> DeepDrftPublic and DeepDrftWeb.Client -> DeepDrftPublic.Client (Phase 4)

This commit is contained in:
Daniel Harvey
2026-05-19 23:06:16 -04:00
parent a981a99978
commit e5b4a79727
83 changed files with 116 additions and 116 deletions
@@ -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;
}
}
}