Front End Audio Player Always Available

This commit is contained in:
daniel-c-harvey
2025-09-08 18:42:07 -04:00
parent a25d067dff
commit 73d4b0a9c5
15 changed files with 419 additions and 126 deletions
+12 -10
View File
@@ -1,12 +1,14 @@
<MudStack Row AlignItems="AlignItems.Center" Spacing="3" Class="mx-3">
@if (Icon != null)
{
<MudIcon Icon="@Icon" />
}
<NavLink href="@Href" Match="@(Match ?? NavLinkMatch.Prefix)">
@if (ChildContent != null)
<NavLink href="@Href" Match="@(Match ?? NavLinkMatch.Prefix)" class="nav-menu-item">
<div class="nav-item-content">
@if (Icon != null)
{
@ChildContent
<MudIcon Icon="@Icon" class="nav-item-icon" />
}
</NavLink>
</MudStack>
<span class="nav-item-text">
@if (ChildContent != null)
{
@ChildContent
}
</span>
</div>
</NavLink>
@@ -1 +1,71 @@

/* 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 state */
.nav-menu-item:hover {
background-color: rgba(138, 43, 226, 0.08);
color: #8A2BE2;
text-decoration: none;
}
.nav-menu-item:hover .nav-item-content {
color: var(--mud-palette-tertiary);
}
.nav-menu-item:hover .nav-item-text {
font-weight: 600;
}
/* Active state */
.nav-menu-item.active {
background-color: rgba(138, 43, 226, 0.12);
color: var(--mud-palette-primary);
border-left: 4px solid #8A2BE2;
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;
}
/* Focus state */
.nav-menu-item:focus {
outline: 2px solid #8A2BE2;
outline-offset: 2px;
}
@@ -1,55 +1,87 @@
<MudPaper MaxWidth="1600px" Square="true">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4" Class="px-4 py-2">
<MudStack Class="pb-2">
<MudStack Row AlignItems="AlignItems.Center">
<MudIconButton Icon="@GetPlayIcon()"
Color="Color.Primary"
Size="Size.Large"
OnClick="@TogglePlayPause"
Disabled="!IsLoaded"/>
@if (IsLoaded)
{
<MudIconButton Icon="Icons.Material.Filled.Stop"
Color="Color.Primary"
OnClick="@Stop"
Disabled="!IsLoaded"/>
}
</MudStack>
<MudStack Row AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2" Class="font-monospace deepdrft-audio-time">
@FormatTime(CurrentTime) / @(Duration.HasValue ? FormatTime(Duration.Value) : "--:--")
</MudText>
@if (!IsLoaded)
{
<MudProgressCircular Color="Color.Tertiary" Value="@LoadProgress" Size="Size.Small"/>
}
</MudStack>
</MudStack>
@if (_isMinimized)
{
<div class="deepdrft-minimized-player-dock">
<MudIconButton Icon="@GetPlayIcon()"
Color="Color.Primary"
Size="Size.Large"
Class="deepdrft-minimized-player-button"
OnClick="@ToggleMinimized" />
</div>
}
else
{
@* Full-width outer container *@
<div class="deepdrft-player-outer-container">
<MudContainer MaxWidth="MaxWidth.Large" Class="deepdrft-player-inner-container">
<div class="deepdrft-audio-player-bar">
<div class="deepdrft-audio-player-content">
@* Controls section *@
<div class="deepdrft-audio-controls-section">
<div class="deepdrft-audio-buttons-row">
<MudIconButton Icon="@GetPlayIcon()"
Color="Color.Primary"
Size="Size.Large"
OnClick="@TogglePlayPause"
Disabled="!IsLoaded"/>
@if (IsLoaded)
{
<MudIconButton Icon="@Icons.Material.Filled.Stop"
Color="Color.Primary"
OnClick="@Stop"/>
}
</div>
<div class="deepdrft-audio-info-row">
<MudText Typo="Typo.body2" Class="font-monospace deepdrft-audio-time">
@FormatTime(CurrentTime) / @(Duration.HasValue ? FormatTime(Duration.Value) : "--:--")
</MudText>
@if (!IsLoaded)
{
<MudProgressCircular Color="Color.Tertiary" Value="@LoadProgress" Size="Size.Small"/>
}
</div>
</div>
<MudSlider T="double"
Min="0"
Max="@(Duration ?? 0D)"
Step="0.1"
Value="@CurrentTime"
ValueChanged="@OnSeek"
Disabled="!IsLoaded"
Class="deepdrft-audio-slider-seek"/>
@* Seek slider *@
<MudSlider T="double"
Min="0"
Max="@(Duration ?? 0D)"
Step="0.1"
Value="@CurrentTime"
ValueChanged="@OnSeek"
Disabled="!IsLoaded"
Class="deepdrft-audio-seek-slider"/>
<div class="deepdrft-audio-controls">
<MudIcon Icon="@GetVolumeIcon()" Class="deepdrft-audio-volume-icon"/>
<MudSlider T="double"
Min="0"
Max="1"
Step="0.01"
Value="@Volume"
ValueChanged="@OnVolumeChange"
Class="deepdrft-audio-slider"/>
</div>
</MudStack>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<MudAlert Severity="Severity.Error" ShowCloseIcon="true" CloseIconClicked="ClearError">
@ErrorMessage
</MudAlert>
}
</MudPaper>
@* Volume section *@
<div class="deepdrft-audio-volume-section">
<MudIcon Icon="@GetVolumeIcon()" Class="deepdrft-audio-volume-icon"/>
<MudSlider T="double"
Min="0"
Max="1"
Step="0.01"
Value="@Volume"
ValueChanged="@OnVolumeChange"
Class="deepdrft-audio-volume-slider"/>
</div>
<div class="deepdrft-audio-minimize-section">
<MudIconButton Icon="@Icons.Material.Filled.Minimize"
Color="Color.Secondary"
Size="Size.Small"
OnClick="@ToggleMinimized"/>
</div>
</div>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<MudAlert Severity="Severity.Error" ShowCloseIcon="true" CloseIconClicked="ClearError" Class="ma-2">
@ErrorMessage
</MudAlert>
}
</div>
</MudContainer>
</div>
@* Spacer div to maintain layout spacing *@
<div class="deepdrft-player-spacer"></div>
}
@@ -10,6 +10,7 @@ public partial class AudioPlayerBar : ComponentBase
{
[CascadingParameter] public required IPlayerService PlayerService { get; set; }
[Parameter] public bool ShowLoadProgress { get; set; } = true;
private bool _isMinimized = true;
private bool IsLoaded => PlayerService.IsLoaded;
private bool IsPlaying => PlayerService.IsPlaying;
@@ -70,4 +71,10 @@ public partial class AudioPlayerBar : ComponentBase
PlayerService.ClearError();
}
private void ToggleMinimized()
{
_isMinimized = !_isMinimized;
StateHasChanged();
}
}
@@ -0,0 +1,196 @@
/* AudioPlayerBar Component - Scoped Styles */
/* Outer container - full width, fixed to bottom */
.deepdrft-player-outer-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
z-index: 1200;
padding: 0;
margin: 0;
}
/* Inner container wrapper */
.deepdrft-player-inner-container {
padding: 1rem;
padding-bottom: 1.5rem;
margin: 0 auto;
}
/* Player bar with rounded corners and semi-opaque background */
.deepdrft-audio-player-bar {
position: relative;
background: rgba(var(--mud-palette-surface-rgb), 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 1rem;
border: 1px solid rgba(var(--mud-palette-divider-rgb), 0.9);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
color: var(--mud-palette-text-primary);
overflow: hidden;
margin-bottom: 1rem;
}
/* Spacer div to maintain layout spacing */
.deepdrft-player-spacer {
height: 140px;
width: 100%;
flex-shrink: 0;
}
/* Minimized floating dock */
.deepdrft-minimized-player-dock {
position: fixed;
bottom: 60px;
right: 60px;
z-index: 1300;
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg,
rgba(var(--mud-palette-primary-rgb), 0.95) 0%,
rgba(var(--mud-palette-secondary-rgb), 0.9) 50%,
rgba(var(--mud-palette-tertiary-rgb), 0.95) 100%
);
backdrop-filter: blur(15px);
border: 2px solid rgba(var(--mud-palette-secondary-rgb), 0.7);
box-shadow: 0 4px 20px rgba(var(--mud-palette-primary-rgb), 0.6),
0 2px 10px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
cursor: pointer;
}
.deepdrft-minimized-player-dock:hover {
transform: scale(1.1);
box-shadow: 0 6px 25px rgba(var(--mud-palette-primary-rgb), 0.8),
0 3px 15px rgba(0, 0, 0, 0.4);
}
.deepdrft-minimized-player-button {
border-radius: 50%;
background: transparent !important;
color: white;
transition: all 0.3s ease;
box-shadow: none !important;
border: none !important;
width: 48px !important;
height: 48px !important;
}
/* Layout containers */
.deepdrft-audio-player-content {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
}
.deepdrft-audio-controls-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 200px;
}
.deepdrft-audio-buttons-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.deepdrft-audio-info-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.25rem;
}
.deepdrft-audio-volume-section {
display: flex;
align-items: center;
gap: 0.25rem;
width: 140px;
}
.deepdrft-audio-minimize-section {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
/* Control elements */
.deepdrft-audio-seek-slider {
flex: 1;
margin-right: 0.5rem;
}
.deepdrft-audio-volume-slider {
width: 100px;
}
.deepdrft-audio-time {
min-width: 120px;
}
.deepdrft-audio-volume-icon {
margin-right: 4px;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.deepdrft-minimized-player-dock {
bottom: 15px;
right: 15px;
width: 56px;
height: 56px;
}
.deepdrft-minimized-player-button {
width: 44px !important;
height: 44px !important;
}
.deepdrft-player-inner-container {
padding: 0.75rem;
padding-bottom: 1.25rem;
}
.deepdrft-audio-player-bar {
border-radius: 1rem;
margin-bottom: 1.25rem;
}
.deepdrft-audio-player-content {
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem;
}
.deepdrft-audio-controls-section {
align-items: center;
width: 100%;
}
.deepdrft-audio-seek-slider {
margin-right: 0;
width: 100%;
}
.deepdrft-audio-volume-section {
width: 100%;
justify-content: center;
}
.deepdrft-player-spacer {
height: 160px;
}
}
@@ -1,5 +1,3 @@
@using DeepDrftWeb.Client.Services
<CascadingValue Value="@(PlayerService)" IsFixed="true">
<CascadingValue Value="@(PlayerService)" IsFixed="true">
@ChildContent
</CascadingValue>
@@ -13,14 +13,6 @@ public partial class AudioPlayerService : ComponentBase
[Parameter] public RenderFragment? ChildContent { get; set; }
protected override void OnInitialized()
{
base.OnInitialized();
// PlayerService is already created as a field, so it's immediately available to cascading components
// It will be in uninitialized state until OnAfterRenderAsync when AudioPlaybackEngine is ready
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
+6 -4
View File
@@ -8,7 +8,7 @@
<MudLayout>
<AudioPlayerService>
@* <MudThemeManagerButton OnClick="@((e) => OpenThemeManager(true))" /> *@
<MudThemeManager Open="_themeManagerOpen" OpenChanged="OpenThemeManager" Theme="_themeManager" ThemeChanged="UpdateTheme" />
@* <MudThemeManager Open="_themeManagerOpen" OpenChanged="OpenThemeManager" Theme="_themeManager" ThemeChanged="UpdateTheme" /> *@
<MudAppBar Elevation="_themeManager.AppBarElevation">
<MudAvatar Class="mr-2">
<MudImage Src="img/deepdrft-logo.jpg"></MudImage>
@@ -16,10 +16,12 @@
<NavMenu />
<MudSpacer/>
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
<MudIconButton Icon="@Icons.Material.Filled.MoreVert" Color="Color.Inherit" Edge="Edge.End"/>
@* <MudIconButton Icon="@Icons.Material.Filled.MoreVert" Color="Color.Inherit" Edge="Edge.End"/> *@
</MudAppBar>
<MudMainContent Class="pt-16 pa-4" Style="min-height: 100vh">
@Body
<MudMainContent Class="pt-16 deepdrft-layout-with-overlay-player">
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
@Body
</MudContainer>
<AudioPlayerBar />
</MudMainContent>
</AudioPlayerService>
-6
View File
@@ -33,12 +33,6 @@
<Folder Include="wwwroot\js\" />
</ItemGroup>
<ItemGroup>
<TypeScriptCompile Include="Interop\webaudio.ts">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</TypeScriptCompile>
</ItemGroup>
<!-- Prevent TypeScript compilation issues during publish -->
<PropertyGroup>
<TypeScriptCompileOnSaveEnabled>false</TypeScriptCompileOnSaveEnabled>
+18 -18
View File
@@ -72,12 +72,12 @@ class AudioPlayer {
try {
this.bufferChunks.push(audioBlock);
this.currentSize += audioBlock.length;
if (this.expectedSize > 0 && this.onLoadProgressCallback) {
const progress = (this.currentSize / this.expectedSize) * 100;
this.onLoadProgressCallback(Math.min(progress, 100));
}
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
@@ -89,24 +89,24 @@ class AudioPlayer {
const arrayBuffer = new ArrayBuffer(this.currentSize);
const view = new Uint8Array(arrayBuffer);
let offset = 0;
for (const chunk of this.bufferChunks) {
view.set(chunk, offset);
offset += chunk.length;
}
this.audioBuffer = await this.audioContext!.decodeAudioData(arrayBuffer);
this.duration = this.audioBuffer.duration;
this.bufferChunks = [];
this.currentSize = 0;
if (this.onLoadProgressCallback) {
this.onLoadProgressCallback(100);
}
return {
success: true,
return {
success: true,
duration: this.duration,
sampleRate: this.audioBuffer.sampleRate,
numberOfChannels: this.audioBuffer.numberOfChannels,
@@ -202,18 +202,18 @@ class AudioPlayer {
try {
const wasPlaying = this.isPlaying;
if (this.isPlaying) {
this.source!.stop();
}
this.pauseOffset = position;
if (wasPlaying) {
this.source = this.audioContext!.createBufferSource();
this.source.buffer = this.audioBuffer;
this.source.connect(this.gainNode!);
this.source.onended = () => {
this.isPlaying = false;
this.isPaused = false;
@@ -424,11 +424,11 @@ const DeepDrftAudio = {
if (!player) {
return { success: false, error: "Player not found" };
}
player.setOnProgressCallback((currentTime: number) => {
dotNetObjectReference.invokeMethodAsync(methodName, currentTime);
});
return { success: true };
},
@@ -437,11 +437,11 @@ const DeepDrftAudio = {
if (!player) {
return { success: false, error: "Player not found" };
}
player.setOnEndCallback(() => {
dotNetObjectReference.invokeMethodAsync(methodName);
});
return { success: true };
},
@@ -450,11 +450,11 @@ const DeepDrftAudio = {
if (!player) {
return { success: false, error: "Player not found" };
}
player.setOnLoadProgressCallback((progress: number) => {
dotNetObjectReference.invokeMethodAsync(methodName, progress);
});
return { success: true };
},
+19 -20
View File
@@ -293,29 +293,28 @@ body, p, span, div,
justify-content: center;
}
/* Audio Player Layout */
.deepdrft-audio-controls {
display: flex;
align-items: center;
width: 140px;
}
.deepdrft-audio-time {
min-width: 120px;
}
/* Layout with overlay audio player - Global layout class */
/*.deepdrft-layout-with-overlay-player {*/
/* position: relative;*/
/* min-height: calc(100vh - 64px);*/
/* padding-bottom: 160px; !* Increased space for overlay player *!*/
/*}*/
.deepdrft-audio-volume-icon {
margin-right: 4px;
}
/*!* Audio player overlay positioning - Global positioning *!*/
/*.deepdrft-layout-with-overlay-player > .AudioPlayerBar,*/
/*.deepdrft-layout-with-overlay-player > *:last-child {*/
/* position: fixed;*/
/* bottom: 0;*/
/* left: 0;*/
/* right: 0;*/
/* z-index: 1000;*/
/* pointer-events: none;*/
/*}*/
.deepdrft-audio-slider {
flex: 1;
}
.deepdrft-audio-slider-seek {
flex: 1;
margin-right: 8px;
}
/*.deepdrft-layout-with-overlay-player > *:last-child > * {*/
/* pointer-events: auto;*/
/*}*/
/* Responsive Utilities */
@media (max-width: 768px) {
+4 -3
View File
@@ -4,6 +4,7 @@ ssh-add /c/.ssh/deepdrft_ed25519
CONTENT_PROJ="DeepDrftContent"
WEB_PROJ="DeepDrftWeb"
WEB_SERVICES_PROJ="DeepDrftWeb.Services"
CONTENT_APP="deepdrft-content.tar.gz"
WEB_APP="deepdrft-web.tar.gz"
@@ -16,15 +17,15 @@ WEB_MIG="deepdrft-migrations.sql"
REMOTE="deepdrft@dch5.snailbird.net"
WEB_APPROOT="/deepdrft/web"
LATEST_MIGRATION=$(dotnet ef migrations list --project $WEB_PROJ --context DeepDrftContext --no-build | tail -1)
LATEST_MIGRATION=$(dotnet ef migrations list --project $WEB_SERVICES_PROJ --context DeepDrftContext --no-build | tail -1)
REMOTE_MIGRATION=$(ssh $REMOTE "sqlite3 $WEB_APPROOT/Database/deepdrft.db 'SELECT MigrationId FROM __EFMigrationsHistory ORDER BY MigrationId DESC LIMIT 1;'" 2>/dev/null || echo "")
if [ "$LATEST_MIGRATION" != "$REMOTE_MIGRATION" ]; then
echo "Generating migration script from $REMOTE_MIGRATION to $LATEST_MIGRATION..."
if [ -z "$REMOTE_MIGRATION" ]; then
dotnet ef migrations script --project $WEB_PROJ --context DeepDrftContext --output $WEB_MIG --verbose --no-build
dotnet ef migrations script --project $WEB_SERVICES_PROJ --context DeepDrftContext --output $WEB_MIG --verbose --no-build
else
dotnet ef migrations script $REMOTE_MIGRATION --project $WEB_PROJ --context DeepDrftContext --output $WEB_MIG --verbose --no-build
dotnet ef migrations script $REMOTE_MIGRATION --project $WEB_SERVICES_PROJ --context DeepDrftContext --output $WEB_MIG --verbose --no-build
fi
APPLY_MIGRATIONS=true
else