AUdio Player Service refactor

This commit is contained in:
daniel-c-harvey
2025-09-08 14:20:38 -04:00
parent bf054f3d1b
commit a25d067dff
14 changed files with 323 additions and 158 deletions
+14 -6
View File
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using NetBlocks.Models;
namespace DeepDrftWeb.Client.Clients;
@@ -23,14 +24,21 @@ public class TrackMediaClient
_http = httpClientFactory.CreateClient("DeepDrft.Content");
}
public async Task<TrackMediaResponse> GetTrackMedia(string trackId)
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(string trackId)
{
var response = await _http.GetAsync($"api/track/{trackId}");
response.EnsureSuccessStatusCode();
try
{
var response = await _http.GetAsync($"api/track/{trackId}");
response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength ?? 0;
var stream = await response.Content.ReadAsStreamAsync();
var contentLength = response.Content.Headers.ContentLength ?? 0;
var stream = await response.Content.ReadAsStreamAsync();
return new TrackMediaResponse(stream, contentLength);
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength));
}
catch (Exception e)
{
return ApiResult<TrackMediaResponse>.CreateFailResult(e.Message);
}
}
}
@@ -10,19 +10,25 @@
@if (IsLoaded)
{
<MudIconButton Icon="Icons.Material.Filled.Stop"
Color="Color.Secondary"
Color="Color.Primary"
OnClick="@Stop"
Disabled="!IsLoaded"/>
}
</MudStack>
<MudText Typo="Typo.body2" Class="font-monospace deepdrft-audio-time">
@FormatTime(CurrentTime) / @FormatTime(Duration)
</MudText>
<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>
<MudSlider T="double"
Min="0"
Max="@Duration"
Max="@(Duration ?? 0D)"
Step="0.1"
Value="@CurrentTime"
ValueChanged="@OnSeek"
@@ -6,27 +6,25 @@ using MudBlazor;
namespace DeepDrftWeb.Client.Controls;
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
public partial class AudioPlayerBar : ComponentBase
{
[CascadingParameter] public required IPlayerService PlayerService { get; set; }
[Parameter] public bool ShowLoadProgress { get; set; } = true;
[Parameter] public required AudioPlaybackEngine AudioPlaybackEngine { get; set; }
private bool IsLoaded => AudioPlaybackEngine.IsLoaded;
private bool IsPlaying => AudioPlaybackEngine.IsPlaying;
private bool IsPaused => AudioPlaybackEngine.IsPaused;
private double CurrentTime => AudioPlaybackEngine.CurrentTime;
private double Duration => AudioPlaybackEngine.Duration;
private double Volume => AudioPlaybackEngine.Volume;
private double LoadProgress => AudioPlaybackEngine.LoadProgress;
private string? ErrorMessage => AudioPlaybackEngine.ErrorMessage;
private bool IsLoaded => PlayerService.IsLoaded;
private bool IsPlaying => PlayerService.IsPlaying;
private bool IsPaused => PlayerService.IsPaused;
private double CurrentTime => PlayerService.CurrentTime;
private double? Duration => PlayerService.Duration;
private double Volume => PlayerService.Volume;
private double LoadProgress => PlayerService.LoadProgress;
private string? ErrorMessage => PlayerService.ErrorMessage;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
AudioPlaybackEngine.OnProgressChanged += async _ => StateHasChanged();
AudioPlaybackEngine.OnPlaybackEnded += async () => await Stop(); // TODO unload the engine track instead of stopping
PlayerService.OnStateChanged += StateHasChanged;
}
private string GetPlayIcon()
@@ -49,36 +47,27 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private async Task TogglePlayPause()
{
await AudioPlaybackEngine.TogglePlayPause();
StateHasChanged();
await PlayerService.TogglePlayPause();
}
private async Task Stop()
{
await AudioPlaybackEngine.Stop();
StateHasChanged();
await PlayerService.Stop();
}
private async Task OnSeek(double position)
{
await AudioPlaybackEngine.OnSeek(position);
StateHasChanged();
await PlayerService.Seek(position);
}
private async Task OnVolumeChange(double volume)
{
await AudioPlaybackEngine.OnVolumeChange(volume);
StateHasChanged();
await PlayerService.SetVolume(volume);
}
private void ClearError()
{
AudioPlaybackEngine.ClearError();
StateHasChanged();
PlayerService.ClearError();
}
public async ValueTask DisposeAsync()
{
await AudioPlaybackEngine.DisposeAsync();
}
}
@@ -0,0 +1,5 @@
@using DeepDrftWeb.Client.Services
<CascadingValue Value="@(PlayerService)" IsFixed="true">
@ChildContent
</CascadingValue>
@@ -0,0 +1,33 @@
using DeepDrftWeb.Client.Services;
using Microsoft.AspNetCore.Components;
using DeepDrftModels.Entities;
namespace DeepDrftWeb.Client.Controls;
public partial class AudioPlayerService : ComponentBase
{
[Inject] public required AudioPlaybackEngine AudioPlaybackEngine { get; set; }
private readonly PlayerService _playerService = new();
private IPlayerService PlayerService => _playerService;
[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)
{
// Initialize the PlayerService with the AudioPlaybackEngine now that it's available
await _playerService.InitializeAsync(AudioPlaybackEngine);
StateHasChanged();
}
}
}
+19 -15
View File
@@ -1,24 +1,28 @@
@inherits LayoutComponentBase
@using DeepDrftWeb.Client.Controls
@inherits LayoutComponentBase
<MudThemeProvider Theme="@_themeManager.Theme" IsDarkMode="_isDarkMode" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout>
@* <MudThemeManagerButton OnClick="@((e) => OpenThemeManager(true))" /> *@
<MudThemeManager Open="_themeManagerOpen" OpenChanged="OpenThemeManager" Theme="_themeManager" ThemeChanged="UpdateTheme" />
<MudAppBar Elevation="_themeManager.AppBarElevation">
<MudAvatar Class="mr-2">
<MudImage Src="img/deepdrft-logo.jpg"></MudImage>
</MudAvatar>
<NavMenu />
<MudSpacer/>
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
<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>
<AudioPlayerService>
@* <MudThemeManagerButton OnClick="@((e) => OpenThemeManager(true))" /> *@
<MudThemeManager Open="_themeManagerOpen" OpenChanged="OpenThemeManager" Theme="_themeManager" ThemeChanged="UpdateTheme" />
<MudAppBar Elevation="_themeManager.AppBarElevation">
<MudAvatar Class="mr-2">
<MudImage Src="img/deepdrft-logo.jpg"></MudImage>
</MudAvatar>
<NavMenu />
<MudSpacer/>
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
<MudIconButton Icon="@Icons.Material.Filled.MoreVert" Color="Color.Inherit" Edge="Edge.End"/>
</MudAppBar>
<MudMainContent Class="pt-16 pa-4" Style="min-height: 100vh">
@Body
<AudioPlayerBar />
</MudMainContent>
</AudioPlayerService>
</MudLayout>
-19
View File
@@ -1,19 +0,0 @@
@page "/counter"
<PageTitle>Counter</PageTitle>
<MudText Typo="Typo.h3" GutterBottom="true">Counter</MudText>
<MudText Class="mb-4">Current count: @currentCount</MudText>
<MudButton Color="Color.Primary" Variant="Variant.Filled" @onclick="IncrementCount">Click me</MudButton>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
@@ -29,8 +29,6 @@
Lifecycle Status: @_lifecycleStatus
</MudText>
</div>
<AudioPlayerBar AudioPlaybackEngine="AudioPlaybackEngine" />
</div>
}
else
@@ -40,7 +38,6 @@
</div>
<div class="tracks-footer">
<MudSkeleton Height="60px" Width="240px" Class="justify-center"/>
<MudSkeleton Height="120px" Width="460px"/>
</div>
}
</div>
+6 -6
View File
@@ -9,8 +9,8 @@ namespace DeepDrftWeb.Client.Pages;
public partial class TracksView : ComponentBase
{
[Inject] public required TracksViewModel ViewModel { get; set; }
[Inject] public required AudioPlaybackEngine AudioPlaybackEngine { get; set; }
[CascadingParameter] public required IPlayerService PlayerService { get; set; }
private TrackEntity? _selectedTrack = null;
private int _clickCount = 0;
private string _lifecycleStatus = "Not initialized";
@@ -26,7 +26,6 @@ public partial class TracksView : ComponentBase
if (firstRender)
{
_lifecycleStatus = "OnAfterRenderAsync called - WebAssembly is active!";
await AudioPlaybackEngine.InitializeAudioPlayer();
StateHasChanged();
}
}
@@ -54,12 +53,13 @@ public partial class TracksView : ComponentBase
if (track is null)
{
await AudioPlaybackEngine.Stop();
await PlayerService.Stop();
}
else
{
await AudioPlaybackEngine.LoadTrack(track);
await PlayerService.SelectTrack(track);
}
StateHasChanged();
_selectedTrack = track;
}
}
-60
View File
@@ -1,60 +0,0 @@
@page "/weather"
<PageTitle>Weather</PageTitle>
<MudText Typo="Typo.h3" GutterBottom="true">Weather forecast</MudText>
<MudText Typo="Typo.body1" Class="mb-8">This component demonstrates fetching data from the server.</MudText>
@if (forecasts == null)
{
<MudProgressCircular Color="Color.Default" Indeterminate="true" />
}
else
{
<MudTable Items="forecasts" Hover="true" SortLabel="Sort By" Elevation="0" AllowUnsorted="false">
<HeaderContent>
<MudTh><MudTableSortLabel InitialDirection="SortDirection.Ascending" SortBy="new Func<WeatherForecast, object>(x=>x.Date)">Date</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<WeatherForecast, object>(x=>x.TemperatureC)">Temp. (C)</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<WeatherForecast, object>(x=>x.TemperatureF)">Temp. (F)</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<WeatherForecast, object>(x=>x.Summary!)">Summary</MudTableSortLabel></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Date">@context.Date</MudTd>
<MudTd DataLabel="Temp. (C)">@context.TemperatureC</MudTd>
<MudTd DataLabel="Temp. (F)">@context.TemperatureF</MudTd>
<MudTd DataLabel="Summary">@context.Summary</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager PageSizeOptions="new int[]{50, 100}" />
</PagerContent>
</MudTable>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
// Simulate asynchronous loading to demonstrate a loading indicator
await Task.Delay(500);
var startDate = DateOnly.FromDateTime(DateTime.Now);
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
}).ToArray();
}
private class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}
@@ -18,7 +18,7 @@ public class AudioPlaybackEngine : IAsyncDisposable
public bool IsPlaying { get; private set; } = false;
public bool IsPaused { get; private set; } = false;
public double CurrentTime { get; private set; } = 0;
public double Duration { get; private set; } = 0;
public double? Duration { get; private set; } = null;
public double Volume { get; private set; } = 0.8;
public double LoadProgress { get; private set; } = 0;
public string? ErrorMessage { get; private set; }
@@ -55,38 +55,99 @@ public class AudioPlaybackEngine : IAsyncDisposable
try
{
ErrorMessage = null;
LoadProgress = 0;
AudioOperationResult? loadResult = await AudioInterop.InitializeBufferedPlayerAsync(PlayerId);
TrackMediaResponse? audio = await Client.GetTrackMedia(track.EntryKey);
if (loadResult?.Success != true)
{
ErrorMessage = $"Failed to initialize audio buffer: {loadResult?.Error ?? "Unknown error"}";
return;
}
if (loadResult?.Success == true)
var mediaResult = await Client.GetTrackMedia(track.EntryKey);
if (!mediaResult.Success)
{
IsLoaded = true;
ErrorMessage = null;
await StreamAndPlay(audio);
ErrorMessage = mediaResult.GetMessage();
return;
}
else
if (mediaResult.Value == null)
{
ErrorMessage = $"Failed to play audio: {loadResult?.Error ?? "No audio source provided"}";
ErrorMessage = "No audio returned from server";
return;
}
TrackMediaResponse audio = mediaResult.Value;
await StreamAndPlay(audio);
}
catch (Exception ex)
{
ErrorMessage = $"Error loading audio: {ex.Message}";
LoadProgress = 0;
IsLoaded = false;
}
}
private async Task StreamAndPlay(TrackMediaResponse audio)
{
int bytesRead = 0;
do
try
{
var buffer = new byte[8 * 1024];
int newBytes = await audio.Stream.ReadAsync(buffer, 0, buffer.Length);
bytesRead += newBytes;
if (bytesRead == 0) break;
await AudioInterop.AppendAudioBlockAsync(PlayerId, buffer);
} while (bytesRead < audio.ContentLength);
await AudioInterop.FinalizeAudioBufferAsync(PlayerId);
const int bufferSize = 32 * 1024; // Increased buffer size for better performance
long totalBytesRead = 0;
int currentBytes;
do
{
var buffer = new byte[bufferSize];
currentBytes = await audio.Stream.ReadAsync(buffer, 0, buffer.Length);
if (currentBytes > 0)
{
totalBytesRead += currentBytes;
// Resize buffer if we didn't read the full amount
if (currentBytes < bufferSize)
{
var trimmedBuffer = new byte[currentBytes];
Array.Copy(buffer, trimmedBuffer, currentBytes);
buffer = trimmedBuffer;
}
var appendResult = await AudioInterop.AppendAudioBlockAsync(PlayerId, buffer);
if (!appendResult.Success)
{
throw new Exception($"Failed to append audio block: {appendResult.Error}");
}
// Update progress during streaming
if (audio.ContentLength > 0)
{
LoadProgress = Math.Min(1.0, (double)totalBytesRead / audio.ContentLength);
}
}
} while (currentBytes > 0);
// Finalize the buffer and update metadata
var finalizeResult = await AudioInterop.FinalizeAudioBufferAsync(PlayerId);
if (!finalizeResult.Success)
{
throw new Exception($"Failed to finalize audio buffer: {finalizeResult.Error}");
}
// Update engine state with audio metadata
Duration = finalizeResult.Duration;
LoadProgress = 1.0;
IsLoaded = true;
ErrorMessage = null;
}
catch (Exception ex)
{
ErrorMessage = $"Error streaming audio: {ex.Message}";
LoadProgress = 0;
IsLoaded = false;
throw;
}
}
public async Task TogglePlayPause()
@@ -0,0 +1,28 @@
using DeepDrftModels.Entities;
namespace DeepDrftWeb.Client.Services;
public interface IPlayerService
{
// State properties
bool IsInitialized { get; }
bool IsLoaded { get; }
bool IsPlaying { get; }
bool IsPaused { get; }
double CurrentTime { get; }
double? Duration { get; }
double Volume { get; }
double LoadProgress { get; }
string? ErrorMessage { get; }
// Events for UI updates
event Action? OnStateChanged;
// Control methods
Task SelectTrack(TrackEntity track);
Task Stop();
Task TogglePlayPause();
Task Seek(double position);
Task SetVolume(double volume);
void ClearError();
}
@@ -0,0 +1,113 @@
using DeepDrftModels.Entities;
namespace DeepDrftWeb.Client.Services;
public class PlayerService : IPlayerService
{
private AudioPlaybackEngine? _audioEngine;
private bool _isInitialized = false;
public PlayerService()
{
// Parameterless constructor - AudioPlaybackEngine will be set during initialization
}
// IPlayerService state properties with defensive checks
public bool IsInitialized => _isInitialized;
public bool IsLoaded => _isInitialized && _audioEngine?.IsLoaded == true;
public bool IsPlaying => _isInitialized && _audioEngine?.IsPlaying == true;
public bool IsPaused => _isInitialized && _audioEngine?.IsPaused == true;
public double CurrentTime => _isInitialized ? _audioEngine?.CurrentTime ?? 0.0 : 0.0;
public double? Duration => _isInitialized ? _audioEngine?.Duration : null;
public double Volume => _isInitialized ? _audioEngine?.Volume ?? 0.8 : 0.8;
public double LoadProgress => _isInitialized ? _audioEngine?.LoadProgress ?? 0.0 : 0.0;
public string? ErrorMessage => _isInitialized ? _audioEngine?.ErrorMessage : null;
public event Action? OnStateChanged;
public async Task SelectTrack(TrackEntity track)
{
if (!_isInitialized)
{
await EnsureInitializedAsync();
}
if (_isInitialized && _audioEngine != null)
{
await _audioEngine.LoadTrack(track);
OnStateChanged?.Invoke();
}
}
public async Task Stop()
{
if (!_isInitialized || _audioEngine == null) return;
await _audioEngine.Stop();
OnStateChanged?.Invoke();
}
public async Task TogglePlayPause()
{
if (!_isInitialized || _audioEngine == null) return;
await _audioEngine.TogglePlayPause();
OnStateChanged?.Invoke();
}
public async Task Seek(double position)
{
if (!_isInitialized || _audioEngine == null) return;
await _audioEngine.OnSeek(position);
OnStateChanged?.Invoke();
}
public async Task SetVolume(double volume)
{
if (!_isInitialized || _audioEngine == null) return;
await _audioEngine.OnVolumeChange(volume);
OnStateChanged?.Invoke();
}
public void ClearError()
{
if (!_isInitialized || _audioEngine == null) return;
_audioEngine.ClearError();
OnStateChanged?.Invoke();
}
public async Task InitializeAsync(AudioPlaybackEngine audioEngine)
{
if (_isInitialized) return;
_audioEngine = audioEngine;
try
{
await _audioEngine.InitializeAudioPlayer();
// Wire up engine events to trigger state change notifications
_audioEngine.OnProgressChanged += async _ => OnStateChanged?.Invoke();
_audioEngine.OnPlaybackEnded += async () => OnStateChanged?.Invoke();
_isInitialized = true;
OnStateChanged?.Invoke();
}
catch (Exception ex)
{
// Log error but don't throw - allow UI to continue functioning
Console.WriteLine($"Failed to initialize audio engine: {ex.Message}");
}
}
private async Task EnsureInitializedAsync()
{
if (!_isInitialized && _audioEngine != null)
{
await InitializeAsync(_audioEngine);
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB