From a25d067dff09f9f6793d93151234f761e69db6a1 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 8 Sep 2025 14:20:38 -0400 Subject: [PATCH] AUdio Player Service refactor --- .../Clients/TrackMediaClient.cs | 20 +++- .../Controls/AudioPlayerBar.razor | 16 ++- .../Controls/AudioPlayerBar.razor.cs | 43 +++---- .../Controls/AudioPlayerService.razor | 5 + .../Controls/AudioPlayerService.razor.cs | 33 +++++ DeepDrftWeb.Client/Layout/MainLayout.razor | 34 +++--- DeepDrftWeb.Client/Pages/Counter.razor | 19 --- DeepDrftWeb.Client/Pages/TracksView.razor | 3 - DeepDrftWeb.Client/Pages/TracksView.razor.cs | 12 +- DeepDrftWeb.Client/Pages/Weather.razor | 60 ---------- .../Services/AudioPlaybackEngine.cs | 95 ++++++++++++--- DeepDrftWeb.Client/Services/IPlayerService.cs | 28 +++++ DeepDrftWeb.Client/Services/PlayerService.cs | 113 ++++++++++++++++++ DeepDrftWeb/wwwroot/favicon.ico | Bin 15086 -> 0 bytes 14 files changed, 323 insertions(+), 158 deletions(-) create mode 100644 DeepDrftWeb.Client/Controls/AudioPlayerService.razor create mode 100644 DeepDrftWeb.Client/Controls/AudioPlayerService.razor.cs delete mode 100644 DeepDrftWeb.Client/Pages/Counter.razor delete mode 100644 DeepDrftWeb.Client/Pages/Weather.razor create mode 100644 DeepDrftWeb.Client/Services/IPlayerService.cs create mode 100644 DeepDrftWeb.Client/Services/PlayerService.cs delete mode 100644 DeepDrftWeb/wwwroot/favicon.ico diff --git a/DeepDrftWeb.Client/Clients/TrackMediaClient.cs b/DeepDrftWeb.Client/Clients/TrackMediaClient.cs index 6204fbb..17da949 100644 --- a/DeepDrftWeb.Client/Clients/TrackMediaClient.cs +++ b/DeepDrftWeb.Client/Clients/TrackMediaClient.cs @@ -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 GetTrackMedia(string trackId) + public async Task> 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.CreatePassResult(new TrackMediaResponse(stream, contentLength)); + } + catch (Exception e) + { + return ApiResult.CreateFailResult(e.Message); + } } } \ No newline at end of file diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor b/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor index 60ec456..782bf8f 100644 --- a/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor +++ b/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor @@ -10,19 +10,25 @@ @if (IsLoaded) { } - - @FormatTime(CurrentTime) / @FormatTime(Duration) - + + + @FormatTime(CurrentTime) / @(Duration.HasValue ? FormatTime(Duration.Value) : "--:--") + + @if (!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(); - } } \ No newline at end of file diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerService.razor b/DeepDrftWeb.Client/Controls/AudioPlayerService.razor new file mode 100644 index 0000000..86ceffa --- /dev/null +++ b/DeepDrftWeb.Client/Controls/AudioPlayerService.razor @@ -0,0 +1,5 @@ +@using DeepDrftWeb.Client.Services + + + @ChildContent + \ No newline at end of file diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerService.razor.cs b/DeepDrftWeb.Client/Controls/AudioPlayerService.razor.cs new file mode 100644 index 0000000..69d4b2c --- /dev/null +++ b/DeepDrftWeb.Client/Controls/AudioPlayerService.razor.cs @@ -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(); + } + } +} \ No newline at end of file diff --git a/DeepDrftWeb.Client/Layout/MainLayout.razor b/DeepDrftWeb.Client/Layout/MainLayout.razor index 1875127..dbe5167 100644 --- a/DeepDrftWeb.Client/Layout/MainLayout.razor +++ b/DeepDrftWeb.Client/Layout/MainLayout.razor @@ -1,24 +1,28 @@ -@inherits LayoutComponentBase +@using DeepDrftWeb.Client.Controls +@inherits LayoutComponentBase - @* *@ - - - - - - - - - - - - @Body - + + @* *@ + + + + + + + + + + + + @Body + + + diff --git a/DeepDrftWeb.Client/Pages/Counter.razor b/DeepDrftWeb.Client/Pages/Counter.razor deleted file mode 100644 index 83d09a7..0000000 --- a/DeepDrftWeb.Client/Pages/Counter.razor +++ /dev/null @@ -1,19 +0,0 @@ -@page "/counter" - - -Counter - -Counter - -Current count: @currentCount - -Click me - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/DeepDrftWeb.Client/Pages/TracksView.razor b/DeepDrftWeb.Client/Pages/TracksView.razor index 60d9fdd..57278e1 100644 --- a/DeepDrftWeb.Client/Pages/TracksView.razor +++ b/DeepDrftWeb.Client/Pages/TracksView.razor @@ -29,8 +29,6 @@ Lifecycle Status: @_lifecycleStatus - - } else @@ -40,7 +38,6 @@ } diff --git a/DeepDrftWeb.Client/Pages/TracksView.razor.cs b/DeepDrftWeb.Client/Pages/TracksView.razor.cs index 43b8dc1..72621b4 100644 --- a/DeepDrftWeb.Client/Pages/TracksView.razor.cs +++ b/DeepDrftWeb.Client/Pages/TracksView.razor.cs @@ -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; } } \ No newline at end of file diff --git a/DeepDrftWeb.Client/Pages/Weather.razor b/DeepDrftWeb.Client/Pages/Weather.razor deleted file mode 100644 index 3ffd7d8..0000000 --- a/DeepDrftWeb.Client/Pages/Weather.razor +++ /dev/null @@ -1,60 +0,0 @@ -@page "/weather" - - - -Weather - -Weather forecast -This component demonstrates fetching data from the server. - -@if (forecasts == null) -{ - -} -else -{ - - - Date - Temp. (C) - Temp. (F) - Summary - - - @context.Date - @context.TemperatureC - @context.TemperatureF - @context.Summary - - - - - -} - -@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); - } -} diff --git a/DeepDrftWeb.Client/Services/AudioPlaybackEngine.cs b/DeepDrftWeb.Client/Services/AudioPlaybackEngine.cs index 040ac47..348f94c 100644 --- a/DeepDrftWeb.Client/Services/AudioPlaybackEngine.cs +++ b/DeepDrftWeb.Client/Services/AudioPlaybackEngine.cs @@ -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() diff --git a/DeepDrftWeb.Client/Services/IPlayerService.cs b/DeepDrftWeb.Client/Services/IPlayerService.cs new file mode 100644 index 0000000..db59f58 --- /dev/null +++ b/DeepDrftWeb.Client/Services/IPlayerService.cs @@ -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(); +} \ No newline at end of file diff --git a/DeepDrftWeb.Client/Services/PlayerService.cs b/DeepDrftWeb.Client/Services/PlayerService.cs new file mode 100644 index 0000000..2630916 --- /dev/null +++ b/DeepDrftWeb.Client/Services/PlayerService.cs @@ -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); + } + } +} \ No newline at end of file diff --git a/DeepDrftWeb/wwwroot/favicon.ico b/DeepDrftWeb/wwwroot/favicon.ico deleted file mode 100644 index 12392236657563eb8cc764f235e66d568b3a0fbb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15086 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