From 66d800bd26fe8830ce8c731874649177584b187f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 6 Sep 2025 13:39:26 -0400 Subject: [PATCH] Front End Work - Redesign component wiring for audio playback - Removed playback logic from the player control and moved it to injectable audio player engine service - Chunked/buffered stream loading from Content API passed to Web Audio API playback in 8K blocks --- .../Controllers/TrackController.cs | 3 +- .../Clients/TrackMediaClient.cs | 22 +- DeepDrftWeb.Client/Controls/AudioPlayer.razor | 312 ------------------ .../Controls/AudioPlayerBar.razor | 92 +++--- .../Controls/AudioPlayerBar.razor.cs | 259 +++------------ .../Controls/AudioPlayerBar.razor.css | 10 - .../Controls/AudioPlayerExample.razor | 98 ------ .../Controls/TrackCard.razor.cs | 9 +- .../Controls/TracksGallery.razor | 2 +- .../Controls/TracksGallery.razor.cs | 22 +- DeepDrftWeb.Client/Layout/MainLayout.razor | 2 +- DeepDrftWeb.Client/Pages/TracksView.razor | 23 +- DeepDrftWeb.Client/Pages/TracksView.razor.cs | 19 +- DeepDrftWeb.Client/Pages/TracksView.razor.css | 8 +- DeepDrftWeb.Client/Program.cs | 3 +- .../Services/AudioInteropService.cs | 169 ++++------ .../Services/AudioPlaybackEngine.cs | 233 +++++++++++++ DeepDrftWeb.Client/Startup.cs | 5 +- .../wwwroot/img/deepdrft-logo.jpg | Bin 0 -> 58047 bytes DeepDrftWeb/Interop/webaudio.ts | 133 ++++---- DeepDrftWeb/Program.cs | 2 +- 21 files changed, 519 insertions(+), 907 deletions(-) delete mode 100644 DeepDrftWeb.Client/Controls/AudioPlayer.razor delete mode 100644 DeepDrftWeb.Client/Controls/AudioPlayerBar.razor.css delete mode 100644 DeepDrftWeb.Client/Controls/AudioPlayerExample.razor create mode 100644 DeepDrftWeb.Client/Services/AudioPlaybackEngine.cs create mode 100644 DeepDrftWeb.Client/wwwroot/img/deepdrft-logo.jpg diff --git a/DeepDrftContent/Controllers/TrackController.cs b/DeepDrftContent/Controllers/TrackController.cs index 1dc583f..33e1ed3 100644 --- a/DeepDrftContent/Controllers/TrackController.cs +++ b/DeepDrftContent/Controllers/TrackController.cs @@ -21,7 +21,8 @@ public class TrackController : ControllerBase { var file = await _fileDatabase.LoadResourceAsync(VaultConstants.Tracks, trackId); if (file == null) { return NotFound(); } - return File(file.Buffer, MimeTypeExtensions.GetMimeType(file.Extension)); + + return File(file.Buffer, MimeTypeExtensions.GetMimeType(file.Extension), enableRangeProcessing: true); } [ApiKeyAuthorize] diff --git a/DeepDrftWeb.Client/Clients/TrackMediaClient.cs b/DeepDrftWeb.Client/Clients/TrackMediaClient.cs index ed23f6b..6204fbb 100644 --- a/DeepDrftWeb.Client/Clients/TrackMediaClient.cs +++ b/DeepDrftWeb.Client/Clients/TrackMediaClient.cs @@ -2,6 +2,18 @@ using Microsoft.Extensions.DependencyInjection; namespace DeepDrftWeb.Client.Clients; +public class TrackMediaResponse +{ + public Stream Stream { get; } + public long ContentLength { get; } + + public TrackMediaResponse(Stream stream, long contentLength) + { + Stream = stream; + ContentLength = contentLength; + } +} + public class TrackMediaClient { private readonly HttpClient _http; @@ -11,8 +23,14 @@ public class TrackMediaClient _http = httpClientFactory.CreateClient("DeepDrft.Content"); } - public async Task GetTrackMedia(string trackId) + public async Task GetTrackMedia(string trackId) { - return await _http.GetStreamAsync($"api/track/{trackId}"); + var response = await _http.GetAsync($"api/track/{trackId}"); + response.EnsureSuccessStatusCode(); + + var contentLength = response.Content.Headers.ContentLength ?? 0; + var stream = await response.Content.ReadAsStreamAsync(); + + return new TrackMediaResponse(stream, contentLength); } } \ No newline at end of file diff --git a/DeepDrftWeb.Client/Controls/AudioPlayer.razor b/DeepDrftWeb.Client/Controls/AudioPlayer.razor deleted file mode 100644 index 20e5827..0000000 --- a/DeepDrftWeb.Client/Controls/AudioPlayer.razor +++ /dev/null @@ -1,312 +0,0 @@ -@using DeepDrftWeb.Client.Services - -@implements IAsyncDisposable - -@inject AudioInteropService AudioInterop - - - - - - - - - - @FormatTime(CurrentTime) / @FormatTime(Duration) - - - - - - - @if (ShowLoadProgress && LoadProgress < 100) - { - - - Loading: @LoadProgress.ToString("F1")% - - } - - - - - - - - @if (!string.IsNullOrEmpty(ErrorMessage)) - { - - @ErrorMessage - - } - - - - -@code { - [Parameter] public string? AudioUrl { get; set; } - [Parameter] public bool ShowLoadProgress { get; set; } = true; - [Parameter] public EventCallback OnProgressChanged { get; set; } - [Parameter] public EventCallback OnPlaybackEnded { get; set; } - - private string PlayerId = Guid.NewGuid().ToString(); - private bool IsLoaded = false; - private bool IsPlaying = false; - private bool IsPaused = false; - private double CurrentTime = 0; - private double Duration = 0; - private double Volume = 0.8; - private double LoadProgress = 0; - private string? ErrorMessage; - private Timer? progressTimer; - - protected override async Task OnInitializedAsync() - { - var result = await AudioInterop.CreatePlayerAsync(PlayerId); - if (!result.Success) - { - ErrorMessage = $"Failed to initialize audio player: {result.Error}"; - return; - } - - await AudioInterop.SetOnProgressCallbackAsync(PlayerId, OnProgress); - await AudioInterop.SetOnEndCallbackAsync(PlayerId, OnPlaybackEnd); - await AudioInterop.SetOnLoadProgressCallbackAsync(PlayerId, OnLoadProgress); - - await AudioInterop.SetVolumeAsync(PlayerId, Volume); - } - - protected override async Task OnParametersSetAsync() - { - if (IsLoaded) return; - - try - { - AudioLoadResult? loadResult = null; - - if (!string.IsNullOrEmpty(AudioUrl)) - { - loadResult = await AudioInterop.LoadAudioFromUrlAsync(PlayerId, AudioUrl); - } - - if (loadResult?.Success == true) - { - IsLoaded = true; - Duration = loadResult.Duration; - LoadProgress = loadResult.LoadProgress; - ErrorMessage = null; - StateHasChanged(); - } - else - { - ErrorMessage = $"Failed to load audio: {loadResult?.Error ?? "No audio source provided"}"; - } - } - catch (Exception ex) - { - ErrorMessage = $"Error loading audio: {ex.Message}"; - } - } - - private 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; - } - } - catch (Exception ex) - { - ErrorMessage = $"Error controlling playback: {ex.Message}"; - } - - StateHasChanged(); - } - - private 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}"; - } - } - catch (Exception ex) - { - ErrorMessage = $"Error stopping playback: {ex.Message}"; - } - - StateHasChanged(); - } - - private async Task OnSeek(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}"; - } - } - catch (Exception ex) - { - ErrorMessage = $"Error seeking: {ex.Message}"; - } - - StateHasChanged(); - } - - private async Task OnVolumeChange(double volume) - { - Volume = volume; - - if (IsLoaded) - { - try - { - var result = await AudioInterop.SetVolumeAsync(PlayerId, volume); - if (!result.Success) - { - ErrorMessage = $"Volume error: {result.Error}"; - StateHasChanged(); - } - } - catch (Exception ex) - { - ErrorMessage = $"Error setting volume: {ex.Message}"; - StateHasChanged(); - } - } - } - - private async Task OnProgress(double currentTime) - { - CurrentTime = currentTime; - if (OnProgressChanged.HasDelegate) - { - await OnProgressChanged.InvokeAsync(currentTime); - } - await InvokeAsync(StateHasChanged); - } - - private async Task OnPlaybackEnd() - { - IsPlaying = false; - IsPaused = false; - CurrentTime = 0; - - if (OnPlaybackEnded.HasDelegate) - { - await OnPlaybackEnded.InvokeAsync(); - } - - await InvokeAsync(StateHasChanged); - } - - private async Task OnLoadProgress(double progress) - { - LoadProgress = progress; - await InvokeAsync(StateHasChanged); - } - - private string GetPlayIcon() - { - return IsPlaying ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow; - } - - 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; - } - - private static string FormatTime(double seconds) - { - var timeSpan = TimeSpan.FromSeconds(seconds); - return timeSpan.ToString(timeSpan.TotalHours >= 1 ? @"h\:mm\:ss" : @"m\:ss"); - } - - private void ClearError() - { - ErrorMessage = null; - StateHasChanged(); - } - - public async ValueTask DisposeAsync() - { - progressTimer?.Dispose(); - - if (IsLoaded) - { - await AudioInterop.DisposePlayerAsync(PlayerId); - } - } -} \ No newline at end of file diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor b/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor index 6733bd4..325416c 100644 --- a/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor +++ b/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor @@ -1,51 +1,49 @@ - - - - - - - @if (IsLoaded) - { - - } - - - @FormatTime(CurrentTime) / @FormatTime(Duration) - + + + + + + @if (IsLoaded) + { + + } + + @FormatTime(CurrentTime) / @FormatTime(Duration) + + + + +
+ - -
- - -
- - @if (!string.IsNullOrEmpty(ErrorMessage)) - { - - @ErrorMessage - - } - - \ No newline at end of file + Max="1" + Step="0.01" + Value="@Volume" + ValueChanged="@OnVolumeChange" + Style="flex: 1;"/> +
+
+ @if (!string.IsNullOrEmpty(ErrorMessage)) + { + + @ErrorMessage + + } +
diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor.cs b/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor.cs index 6939e42..15a7686 100644 --- a/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor.cs +++ b/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor.cs @@ -1,4 +1,6 @@ -using Microsoft.AspNetCore.Components; +using DeepDrftModels.Entities; +using DeepDrftWeb.Client.Clients; +using Microsoft.AspNetCore.Components; using DeepDrftWeb.Client.Services; using MudBlazor; @@ -6,219 +8,25 @@ namespace DeepDrftWeb.Client.Controls; public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable { - [Parameter] public string? AudioUrl { get; set; } [Parameter] public bool ShowLoadProgress { get; set; } = true; - [Parameter] public EventCallback OnProgressChanged { get; set; } - [Parameter] public EventCallback OnPlaybackEnded { get; set; } - - [Inject] public required AudioInteropService AudioInterop { get; set; } - private string PlayerId = Guid.NewGuid().ToString(); - private bool IsLoaded = false; - private bool IsPlaying = false; - private bool IsPaused = false; - private double CurrentTime = 0; - private double Duration = 0; - private double Volume = 0.8; - private double LoadProgress = 0; - private string? ErrorMessage; - private Timer? progressTimer; + [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; protected override async Task OnInitializedAsync() { - var result = await AudioInterop.CreatePlayerAsync(PlayerId); - if (!result.Success) - { - ErrorMessage = $"Failed to initialize audio player: {result.Error}"; - return; - } + await base.OnInitializedAsync(); - await AudioInterop.SetOnProgressCallbackAsync(PlayerId, OnProgress); - await AudioInterop.SetOnEndCallbackAsync(PlayerId, OnPlaybackEnd); - await AudioInterop.SetOnLoadProgressCallbackAsync(PlayerId, OnLoadProgress); - - await AudioInterop.SetVolumeAsync(PlayerId, Volume); - } - - protected override async Task OnParametersSetAsync() - { - if (IsLoaded) return; - - try - { - AudioLoadResult? loadResult = null; - - if (!string.IsNullOrEmpty(AudioUrl)) - { - loadResult = await AudioInterop.LoadAudioFromUrlAsync(PlayerId, AudioUrl); - } - - if (loadResult?.Success == true) - { - IsLoaded = true; - Duration = loadResult.Duration; - LoadProgress = loadResult.LoadProgress; - ErrorMessage = null; - StateHasChanged(); - } - else - { - ErrorMessage = $"Failed to load audio: {loadResult?.Error ?? "No audio source provided"}"; - } - } - catch (Exception ex) - { - ErrorMessage = $"Error loading audio: {ex.Message}"; - } - } - - private 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; - } - } - catch (Exception ex) - { - ErrorMessage = $"Error controlling playback: {ex.Message}"; - } - - StateHasChanged(); - } - - private 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}"; - } - } - catch (Exception ex) - { - ErrorMessage = $"Error stopping playback: {ex.Message}"; - } - - StateHasChanged(); - } - - private async Task OnSeek(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}"; - } - } - catch (Exception ex) - { - ErrorMessage = $"Error seeking: {ex.Message}"; - } - - StateHasChanged(); - } - - private async Task OnVolumeChange(double volume) - { - Volume = volume; - - if (IsLoaded) - { - try - { - var result = await AudioInterop.SetVolumeAsync(PlayerId, volume); - if (!result.Success) - { - ErrorMessage = $"Volume error: {result.Error}"; - StateHasChanged(); - } - } - catch (Exception ex) - { - ErrorMessage = $"Error setting volume: {ex.Message}"; - StateHasChanged(); - } - } - } - - private async Task OnProgress(double currentTime) - { - CurrentTime = currentTime; - if (OnProgressChanged.HasDelegate) - { - await OnProgressChanged.InvokeAsync(currentTime); - } - await InvokeAsync(StateHasChanged); - } - - private async Task OnPlaybackEnd() - { - IsPlaying = false; - IsPaused = false; - CurrentTime = 0; - - if (OnPlaybackEnded.HasDelegate) - { - await OnPlaybackEnded.InvokeAsync(); - } - - await InvokeAsync(StateHasChanged); - } - - private async Task OnLoadProgress(double progress) - { - LoadProgress = progress; - await InvokeAsync(StateHasChanged); + AudioPlaybackEngine.OnProgressChanged += async _ => StateHasChanged(); + AudioPlaybackEngine.OnPlaybackEnded += async () => await Stop(); // TODO unload the engine track instead of stopping } private string GetPlayIcon() @@ -239,19 +47,38 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable return timeSpan.ToString(timeSpan.TotalHours >= 1 ? @"h\:mm\:ss" : @"m\:ss"); } - private void ClearError() + private async Task TogglePlayPause() { - ErrorMessage = null; + await AudioPlaybackEngine.TogglePlayPause(); StateHasChanged(); } + private async Task Stop() + { + await AudioPlaybackEngine.Stop(); + StateHasChanged(); + } + + private async Task OnSeek(double position) + { + await AudioPlaybackEngine.OnSeek(position); + StateHasChanged(); + } + + private async Task OnVolumeChange(double volume) + { + await AudioPlaybackEngine.OnVolumeChange(volume); + StateHasChanged(); + } + + private void ClearError() + { + AudioPlaybackEngine.ClearError(); + StateHasChanged(); + } + public async ValueTask DisposeAsync() { - progressTimer?.Dispose(); - - if (IsLoaded) - { - await AudioInterop.DisposePlayerAsync(PlayerId); - } + await AudioPlaybackEngine.DisposeAsync(); } } \ No newline at end of file diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor.css b/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor.css deleted file mode 100644 index feff813..0000000 --- a/DeepDrftWeb.Client/Controls/AudioPlayerBar.razor.css +++ /dev/null @@ -1,10 +0,0 @@ -.bottom-bar { - justify-self: center; - max-width: 1800px; - position: fixed; - bottom: 0; - left: var(--mud-drawer-width-left); - right: 0; - margin: 0 1.5rem 1.5rem 1.5rem; - z-index: 1000; -} \ No newline at end of file diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerExample.razor b/DeepDrftWeb.Client/Controls/AudioPlayerExample.razor deleted file mode 100644 index bbe83ae..0000000 --- a/DeepDrftWeb.Client/Controls/AudioPlayerExample.razor +++ /dev/null @@ -1,98 +0,0 @@ -@page "/audio-example" - -Audio Player Example - - - Audio Player Example - -
- Load Audio from URL - - - - - Load Audio - - - @if (showUrlPlayer) - { -
- -
- } -
- - @if (!string.IsNullOrEmpty(statusMessage)) - { - - @statusMessage - - } - -
- Usage Instructions - - - - - - -
-
- -@code { - private string audioUrl = ""; - private bool showUrlPlayer = false; - private string statusMessage = ""; - - private void LoadFromUrl() - { - if (string.IsNullOrWhiteSpace(audioUrl)) - { - statusMessage = "Please enter a valid audio URL"; - return; - } - - showUrlPlayer = true; - statusMessage = $"Loading audio from: {audioUrl}"; - StateHasChanged(); - } - - private Task OnProgressChanged(double currentTime) - { - // Update status with current playback time - statusMessage = $"Playing: {FormatTime(currentTime)}"; - StateHasChanged(); - return Task.CompletedTask; - } - - private Task OnPlaybackEnded() - { - statusMessage = "Playback completed"; - StateHasChanged(); - return Task.CompletedTask; - } - - private static string FormatTime(double seconds) - { - var timeSpan = TimeSpan.FromSeconds(seconds); - return timeSpan.ToString(timeSpan.TotalHours >= 1 ? @"h\:mm\:ss" : @"m\:ss"); - } -} \ No newline at end of file diff --git a/DeepDrftWeb.Client/Controls/TrackCard.razor.cs b/DeepDrftWeb.Client/Controls/TrackCard.razor.cs index 1db47fb..592bd45 100644 --- a/DeepDrftWeb.Client/Controls/TrackCard.razor.cs +++ b/DeepDrftWeb.Client/Controls/TrackCard.razor.cs @@ -9,16 +9,15 @@ public partial class TrackCard : ComponentBase { [Parameter] public required TrackEntity TrackModel { get; set; } [Parameter] public EventCallback OnPlay { get; set; } + [Parameter] public bool IsPlaying { get; set; } = false; - private bool _isPlaying = false; - private string PlayPauseIcon => _isPlaying ? Icons.Material.Filled.MusicNote : Icons.Material.Filled.PlayArrow; + private string PlayPauseIcon => IsPlaying ? Icons.Material.Filled.MusicNote : Icons.Material.Filled.PlayArrow; private async Task PlayClick() { - if (!_isPlaying) + if (!IsPlaying && OnPlay.HasDelegate) { - _isPlaying = true; - await OnPlay.InvokeAsync(); + await OnPlay.InvokeAsync(TrackModel); } } } \ No newline at end of file diff --git a/DeepDrftWeb.Client/Controls/TracksGallery.razor b/DeepDrftWeb.Client/Controls/TracksGallery.razor index 564e158..3127d82 100644 --- a/DeepDrftWeb.Client/Controls/TracksGallery.razor +++ b/DeepDrftWeb.Client/Controls/TracksGallery.razor @@ -4,7 +4,7 @@ {
- +
} diff --git a/DeepDrftWeb.Client/Controls/TracksGallery.razor.cs b/DeepDrftWeb.Client/Controls/TracksGallery.razor.cs index 73056bd..e4dfad8 100644 --- a/DeepDrftWeb.Client/Controls/TracksGallery.razor.cs +++ b/DeepDrftWeb.Client/Controls/TracksGallery.razor.cs @@ -6,23 +6,19 @@ namespace DeepDrftWeb.Client.Controls; public partial class TracksGallery : ComponentBase { - private Stream? _audioStream = null; - [Parameter] public IEnumerable Tracks { get; set; } = Enumerable.Empty(); - - [Inject] public required TrackMediaClient Client { get; set; } + [Parameter] public IEnumerable Tracks { get; set; } = []; + [Parameter] public TrackEntity? SelectedTrack { get; set; } + [Parameter] public EventCallback SelectedTrackChanged { get; set; } private async Task HandlePlayClick(TrackEntity track) { - if (_audioStream == null) + if (SelectedTrack == track) return; + SelectedTrack = track; + StateHasChanged(); + + if (SelectedTrackChanged.HasDelegate) { - _audioStream = await Client.GetTrackMedia(track.EntryKey); - PlayAudio(); + await SelectedTrackChanged.InvokeAsync(track); } } - - private void PlayAudio() - { - throw new NotImplementedException(); - } - } \ No newline at end of file diff --git a/DeepDrftWeb.Client/Layout/MainLayout.razor b/DeepDrftWeb.Client/Layout/MainLayout.razor index b908487..ebc060c 100644 --- a/DeepDrftWeb.Client/Layout/MainLayout.razor +++ b/DeepDrftWeb.Client/Layout/MainLayout.razor @@ -5,7 +5,7 @@ - + @* *@ diff --git a/DeepDrftWeb.Client/Pages/TracksView.razor b/DeepDrftWeb.Client/Pages/TracksView.razor index ec6cfd4..a69a31a 100644 --- a/DeepDrftWeb.Client/Pages/TracksView.razor +++ b/DeepDrftWeb.Client/Pages/TracksView.razor @@ -9,29 +9,30 @@ @if (ViewModel.Page != null) {
- +
- -
- + } else {
- +
-
- + }
- -
\ No newline at end of file diff --git a/DeepDrftWeb.Client/Pages/TracksView.razor.cs b/DeepDrftWeb.Client/Pages/TracksView.razor.cs index d31eee0..1fecd6f 100644 --- a/DeepDrftWeb.Client/Pages/TracksView.razor.cs +++ b/DeepDrftWeb.Client/Pages/TracksView.razor.cs @@ -1,5 +1,6 @@ using DeepDrftModels.Entities; using DeepDrftModels.Models; +using DeepDrftWeb.Client.Services; using DeepDrftWeb.Client.ViewModels; using Microsoft.AspNetCore.Components; @@ -7,13 +8,17 @@ namespace DeepDrftWeb.Client.Pages; public partial class TracksView : ComponentBase { - [Inject] - public required TracksViewModel ViewModel { get; set; } - + [Inject] public required TracksViewModel ViewModel { get; set; } + [Inject] public required AudioPlaybackEngine AudioPlaybackEngine { get; set; } + private TrackEntity? _selectedTrack = null; + protected override async Task OnInitializedAsync() { await SetPage(1); + + if (!RendererInfo.IsInteractive) return; + await AudioPlaybackEngine.InitializeAudioPlayer(); } private async Task SetPage(int newPage) @@ -26,4 +31,12 @@ public partial class TracksView : ComponentBase ViewModel.PageSize = pageResult.PageSize; } } + + private async Task PlayTrack(TrackEntity? track) + { + if (track == null) return; + + await AudioPlaybackEngine.LoadTrack(track); + StateHasChanged(); + } } \ No newline at end of file diff --git a/DeepDrftWeb.Client/Pages/TracksView.razor.css b/DeepDrftWeb.Client/Pages/TracksView.razor.css index f816847..f800446 100644 --- a/DeepDrftWeb.Client/Pages/TracksView.razor.css +++ b/DeepDrftWeb.Client/Pages/TracksView.razor.css @@ -1,8 +1,8 @@ .tracks-page-wrapper { display: flex; flex-direction: column; - height: calc(100dvh - 64px); /* Subtract app bar height (pt-16 = 4rem = 64px) */ - margin: -16px; /* Counteract MudMainContent padding */ + height: calc(100dvh - 80px); /* Subtract app bar height (pt-16 = 4rem = 64px) */ + /*margin: -16px; !* Counteract MudMainContent padding *!*/ padding-top: 16px; /* Restore top padding for spacing */ } @@ -17,12 +17,12 @@ .tracks-content { flex: 1 1 auto; - overflow-y: scroll; min-height: 0; padding-top: 16px; } -.tracks-pagination { +.tracks-footer { flex: 0 0 auto; padding: 8px 0; + justify-items: center; } \ No newline at end of file diff --git a/DeepDrftWeb.Client/Program.cs b/DeepDrftWeb.Client/Program.cs index 4cece6c..4343915 100644 --- a/DeepDrftWeb.Client/Program.cs +++ b/DeepDrftWeb.Client/Program.cs @@ -10,10 +10,9 @@ Console.WriteLine(builder.HostEnvironment.BaseAddress); var contentApiUrl = builder.Configuration["ApiUrls:ContentApi"] ?? "https://localhost:7001"; builder.Services.AddMudServices(); -builder.Services.AddScoped(); Startup.ConfigureApiHttpClient(builder.Services, builder.HostEnvironment.BaseAddress); -Startup.ConfigureCommonServices(builder.Services, contentApiUrl); +Startup.ConfigureContentServices(builder.Services, contentApiUrl); Startup.ConfigureDomainServices(builder.Services); var app = builder.Build(); diff --git a/DeepDrftWeb.Client/Services/AudioInteropService.cs b/DeepDrftWeb.Client/Services/AudioInteropService.cs index 5b28413..085cb9a 100644 --- a/DeepDrftWeb.Client/Services/AudioInteropService.cs +++ b/DeepDrftWeb.Client/Services/AudioInteropService.cs @@ -25,83 +25,45 @@ public class AudioInteropService : IAsyncDisposable } } - public async Task LoadAudioFromUrlAsync(string playerId, string url) + public async Task InitializeBufferedPlayerAsync(string playerId) { - try - { - var result = await _jsRuntime.InvokeAsync("DeepDrftAudio.loadAudioFromUrl", playerId, url); - return result; - } - catch (Exception ex) - { - return new AudioLoadResult { Success = false, Error = ex.Message }; - } + return await InvokeJsAsync("DeepDrftAudio.initializeBufferedPlayer", playerId); + } + + public async Task AppendAudioBlockAsync(string playerId, byte[] audioBlock) + { + return await InvokeJsAsync("DeepDrftAudio.appendAudioBlock", playerId, audioBlock); + } + + public async Task FinalizeAudioBufferAsync(string playerId) + { + return await InvokeJsAsync("DeepDrftAudio.finalizeAudioBuffer", playerId); } public async Task PlayAsync(string playerId) { - try - { - var result = await _jsRuntime.InvokeAsync("DeepDrftAudio.play", playerId); - return result; - } - catch (Exception ex) - { - return new AudioOperationResult { Success = false, Error = ex.Message }; - } + return await InvokeJsAsync("DeepDrftAudio.play", playerId); } public async Task PauseAsync(string playerId) { - try - { - var result = await _jsRuntime.InvokeAsync("DeepDrftAudio.pause", playerId); - return result; - } - catch (Exception ex) - { - return new AudioOperationResult { Success = false, Error = ex.Message }; - } + return await InvokeJsAsync("DeepDrftAudio.pause", playerId); } public async Task StopAsync(string playerId) { - try - { - var result = await _jsRuntime.InvokeAsync("DeepDrftAudio.stop", playerId); - return result; - } - catch (Exception ex) - { - return new AudioOperationResult { Success = false, Error = ex.Message }; - } + return await InvokeJsAsync("DeepDrftAudio.stop", playerId); } public async Task SeekAsync(string playerId, double position) { - try - { - var result = await _jsRuntime.InvokeAsync("DeepDrftAudio.seek", playerId, position); - return result; - } - catch (Exception ex) - { - return new AudioOperationResult { Success = false, Error = ex.Message }; - } + return await InvokeJsAsync("DeepDrftAudio.seek", playerId, position); } public async Task SetVolumeAsync(string playerId, double volume) { - try - { - var result = await _jsRuntime.InvokeAsync("DeepDrftAudio.setVolume", playerId, volume); - return result; - } - catch (Exception ex) - { - return new AudioOperationResult { Success = false, Error = ex.Message }; - } + return await InvokeJsAsync("DeepDrftAudio.setVolume", playerId, volume); } public async Task GetCurrentTimeAsync(string playerId) @@ -130,60 +92,56 @@ public class AudioInteropService : IAsyncDisposable public async Task SetOnProgressCallbackAsync(string playerId, Func callback) { - try - { - var callbackWrapper = new AudioPlayerCallback(); - callbackWrapper.OnProgress = callback; - - var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper); - _callbacks[playerId + "_progress"] = dotNetObjectRef; - - var result = await _jsRuntime.InvokeAsync("DeepDrftAudio.setOnProgressCallback", - playerId, dotNetObjectRef, "OnProgressCallback"); - - return result; - } - catch (Exception ex) - { - return new AudioOperationResult { Success = false, Error = ex.Message }; - } + return await SetCallbackAsync(playerId, "_progress", "setOnProgressCallback", "OnProgressCallback", + wrapper => wrapper.OnProgress = callback); } public async Task SetOnEndCallbackAsync(string playerId, Func callback) { - try - { - var callbackWrapper = new AudioPlayerCallback(); - callbackWrapper.OnEnd = callback; - - var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper); - _callbacks[playerId + "_end"] = dotNetObjectRef; - - var result = await _jsRuntime.InvokeAsync("DeepDrftAudio.setOnEndCallback", - playerId, dotNetObjectRef, "OnEndCallback"); - - return result; - } - catch (Exception ex) - { - return new AudioOperationResult { Success = false, Error = ex.Message }; - } + return await SetCallbackAsync(playerId, "_end", "setOnEndCallback", "OnEndCallback", + wrapper => wrapper.OnEnd = callback); } public async Task SetOnLoadProgressCallbackAsync(string playerId, Func callback) + { + return await SetCallbackAsync(playerId, "_loadprogress", "setOnLoadProgressCallback", "OnLoadProgressCallback", + wrapper => wrapper.OnLoadProgress = callback); + } + + public async Task DisposePlayerAsync(string playerId) + { + CleanupPlayerCallbacks(playerId); + return await InvokeJsAsync("DeepDrftAudio.disposePlayer", playerId); + } + + private async Task InvokeJsAsync(string identifier, params object[] args) + { + try + { + return await _jsRuntime.InvokeAsync(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 }; + throw; + } + } + + private async Task SetCallbackAsync(string playerId, string suffix, string jsMethod, string callbackMethod, Action configureCallback) { try { var callbackWrapper = new AudioPlayerCallback(); - callbackWrapper.OnLoadProgress = callback; + configureCallback(callbackWrapper); var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper); - _callbacks[playerId + "_loadprogress"] = dotNetObjectRef; + _callbacks[playerId + suffix] = dotNetObjectRef; - var result = await _jsRuntime.InvokeAsync("DeepDrftAudio.setOnLoadProgressCallback", - playerId, dotNetObjectRef, "OnLoadProgressCallback"); - - return result; + return await _jsRuntime.InvokeAsync($"DeepDrftAudio.{jsMethod}", + playerId, dotNetObjectRef, callbackMethod); } catch (Exception ex) { @@ -191,24 +149,13 @@ public class AudioInteropService : IAsyncDisposable } } - public async Task DisposePlayerAsync(string playerId) + private void CleanupPlayerCallbacks(string playerId) { - try + var keysToRemove = _callbacks.Keys.Where(k => k.StartsWith(playerId + "_")).ToList(); + foreach (var key in keysToRemove) { - // Clean up callbacks - var keysToRemove = _callbacks.Keys.Where(k => k.StartsWith(playerId + "_")).ToList(); - foreach (var key in keysToRemove) - { - _callbacks[key]?.Dispose(); - _callbacks.Remove(key); - } - - var result = await _jsRuntime.InvokeAsync("DeepDrftAudio.disposePlayer", playerId); - return result; - } - catch (Exception ex) - { - return new AudioOperationResult { Success = false, Error = ex.Message }; + _callbacks[key]?.Dispose(); + _callbacks.Remove(key); } } diff --git a/DeepDrftWeb.Client/Services/AudioPlaybackEngine.cs b/DeepDrftWeb.Client/Services/AudioPlaybackEngine.cs new file mode 100644 index 0000000..f5f8c7e --- /dev/null +++ b/DeepDrftWeb.Client/Services/AudioPlaybackEngine.cs @@ -0,0 +1,233 @@ +using DeepDrftModels.Entities; +using DeepDrftWeb.Client.Clients; +using NetBlocks.Models; + +namespace DeepDrftWeb.Client.Services; + +public class AudioPlaybackEngine : IAsyncDisposable +{ + public event Events.EventAsync? OnProgressChanged; + public event Events.EventAsync? OnPlaybackEnded; + + public required TrackMediaClient Client { get; set; } + public required AudioInteropService AudioInterop { get; set; } + + public string PlayerId { get; private set; } = Guid.NewGuid().ToString(); + public bool IsLoaded { get; private set; } = false; + 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 Volume { get; private set; } = 0.8; + public double LoadProgress { get; private set; } = 0; + public string? ErrorMessage { get; private set; } + + public AudioPlaybackEngine(AudioInteropService audioInterop, TrackMediaClient client) + { + AudioInterop = audioInterop; + Client = client; + } + + public async Task InitializeAudioPlayer() + { + var result = await AudioInterop.CreatePlayerAsync(PlayerId); + if (!result.Success) + { + ErrorMessage = $"Failed to initialize audio player: {result.Error}"; + return; + } + + await AudioInterop.SetOnProgressCallbackAsync(PlayerId, OnProgress); + await AudioInterop.SetOnEndCallbackAsync(PlayerId, OnPlaybackEnd); + await AudioInterop.SetOnLoadProgressCallbackAsync(PlayerId, OnLoadProgress); + + await AudioInterop.SetVolumeAsync(PlayerId, Volume); + } + + public async Task LoadTrack(TrackEntity track) + { + if (IsLoaded) return; + + try + { + AudioOperationResult? loadResult = await AudioInterop.InitializeBufferedPlayerAsync(PlayerId); + TrackMediaResponse? audio = await Client.GetTrackMedia(track.EntryKey); + + if (loadResult?.Success == true) + { + IsLoaded = true; + ErrorMessage = null; + await StreamAndPlay(audio); + } + else + { + ErrorMessage = $"Failed to play audio: {loadResult?.Error ?? "No audio source provided"}"; + } + } + catch (Exception ex) + { + ErrorMessage = $"Error loading audio: {ex.Message}"; + } + } + + private async Task StreamAndPlay(TrackMediaResponse audio) + { + int bytesRead = 0; + do + { + 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); + } + + 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; + } + } + catch (Exception ex) + { + ErrorMessage = $"Error controlling playback: {ex.Message}"; + } + } + + public 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}"; + } + } + catch (Exception ex) + { + ErrorMessage = $"Error stopping playback: {ex.Message}"; + } + } + + public async Task OnSeek(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}"; + } + } + catch (Exception ex) + { + ErrorMessage = $"Error seeking: {ex.Message}"; + } + } + + public async Task OnVolumeChange(double volume) + { + Volume = volume; + + if (IsLoaded) + { + try + { + var result = await AudioInterop.SetVolumeAsync(PlayerId, volume); + if (!result.Success) + { + ErrorMessage = $"Volume error: {result.Error}"; + } + } + catch (Exception ex) + { + ErrorMessage = $"Error setting volume: {ex.Message}"; + } + } + } + + private async Task OnProgress(double currentTime) + { + CurrentTime = currentTime; + if (OnProgressChanged != null) + { + await OnProgressChanged(currentTime); + } + } + + private async Task OnPlaybackEnd() + { + IsPlaying = false; + IsPaused = false; + CurrentTime = 0; + + if (OnPlaybackEnded != null) + { + await OnPlaybackEnded(); + } + } + + private async Task OnLoadProgress(double progress) + { + LoadProgress = progress; + } + + public void ClearError() + { + ErrorMessage = null; + } + + public async ValueTask DisposeAsync() + { + await AudioInterop.DisposePlayerAsync(PlayerId); + } +} \ No newline at end of file diff --git a/DeepDrftWeb.Client/Startup.cs b/DeepDrftWeb.Client/Startup.cs index 6695385..e8eddea 100644 --- a/DeepDrftWeb.Client/Startup.cs +++ b/DeepDrftWeb.Client/Startup.cs @@ -1,4 +1,5 @@ using DeepDrftWeb.Client.Clients; +using DeepDrftWeb.Client.Services; using DeepDrftWeb.Client.ViewModels; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using NetBlocks.Models; @@ -22,12 +23,14 @@ public static class Startup }); } - public static void ConfigureCommonServices(IServiceCollection services, string contentApiUrl) + public static void ConfigureContentServices(IServiceCollection services, string contentApiUrl) { services.AddHttpClient("DeepDrft.Content", client => { client.BaseAddress = new Uri(contentApiUrl); }); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } \ No newline at end of file diff --git a/DeepDrftWeb.Client/wwwroot/img/deepdrft-logo.jpg b/DeepDrftWeb.Client/wwwroot/img/deepdrft-logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..94fee69d69ec321ea1c452ca65a6159731ea173f GIT binary patch literal 58047 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 ACjbBd literal 0 HcmV?d00001 diff --git a/DeepDrftWeb/Interop/webaudio.ts b/DeepDrftWeb/Interop/webaudio.ts index 6315b5f..738e2fa 100644 --- a/DeepDrftWeb/Interop/webaudio.ts +++ b/DeepDrftWeb/Interop/webaudio.ts @@ -42,6 +42,9 @@ class AudioPlayer { private onEndCallback: EndCallback | null = null; private onLoadProgressCallback: LoadProgressCallback | null = null; private progressInterval: number | null = null; + private bufferChunks: Uint8Array[] = []; + private expectedSize: number = 0; + private currentSize: number = 0; async initialize(): Promise { try { @@ -54,76 +57,52 @@ class AudioPlayer { } } - async loadAudioFromUrl(url: string): Promise { + initializeBuffered(): AudioResult { try { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + this.bufferChunks = []; + this.currentSize = 0; + this.expectedSize = 0; + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + appendAudioBlock(audioBlock: Uint8Array): AudioResult { + 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)); } - const contentLength = response.headers.get('Content-Length'); - const reader = response.body?.getReader(); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + async finalizeAudioBuffer(): Promise { + try { + const arrayBuffer = new ArrayBuffer(this.currentSize); + const view = new Uint8Array(arrayBuffer); + let offset = 0; - if (reader && contentLength) { - // Stream with progress tracking - const total = parseInt(contentLength, 10); - let loaded = 0; - const chunks: Uint8Array[] = []; - - // Initial progress - if (this.onLoadProgressCallback) { - this.onLoadProgressCallback(0); - } - - let readAttempts = 0; - const maxReadAttempts = 10000; // Prevent infinite loop - - while (readAttempts < maxReadAttempts) { - try { - const { done, value } = await reader.read(); - if (done) break; - - chunks.push(value); - loaded += value.length; - - const progress = (loaded / total) * 100; - if (this.onLoadProgressCallback) { - this.onLoadProgressCallback(progress); - } - - readAttempts++; - } catch (readerError) { - break; - } - } - - - // Combine chunks into single ArrayBuffer - const arrayBuffer = new ArrayBuffer(loaded); - const view = new Uint8Array(arrayBuffer); - let offset = 0; - for (const chunk of chunks) { - view.set(chunk, offset); - offset += chunk.length; - } - - this.audioBuffer = await this.audioContext!.decodeAudioData(arrayBuffer); - this.duration = this.audioBuffer.duration; - - // Final progress - if (this.onLoadProgressCallback) { - this.onLoadProgressCallback(100); - } - } else { - // Fallback to original method if streaming not possible - const arrayBuffer = await response.arrayBuffer(); - this.audioBuffer = await this.audioContext!.decodeAudioData(arrayBuffer); - this.duration = this.audioBuffer.duration; - - // Report 100% immediately for non-streaming responses - if (this.onLoadProgressCallback) { - this.onLoadProgressCallback(100); - } + 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 { @@ -331,6 +310,8 @@ class AudioPlayer { this.audioContext = null; this.audioBuffer = null; this.gainNode = null; + this.bufferChunks = []; + this.currentSize = 0; } } @@ -357,12 +338,28 @@ const DeepDrftAudio = { } }, - loadAudioFromUrl: async (playerId: string, url: string): Promise => { + initializeBufferedPlayer: (playerId: string): AudioResult => { const player = audioPlayers.get(playerId); if (!player) { return { success: false, error: "Player not found" }; } - return await player.loadAudioFromUrl(url); + return player.initializeBuffered(); + }, + + appendAudioBlock: (playerId: string, audioBlock: Uint8Array): AudioResult => { + const player = audioPlayers.get(playerId); + if (!player) { + return { success: false, error: "Player not found" }; + } + return player.appendAudioBlock(audioBlock); + }, + + finalizeAudioBuffer: async (playerId: string): Promise => { + const player = audioPlayers.get(playerId); + if (!player) { + return { success: false, error: "Player not found" }; + } + return await player.finalizeAudioBuffer(); }, diff --git a/DeepDrftWeb/Program.cs b/DeepDrftWeb/Program.cs index 3237266..be3fc82 100644 --- a/DeepDrftWeb/Program.cs +++ b/DeepDrftWeb/Program.cs @@ -17,8 +17,8 @@ var contentApiUrl = builder.Configuration["ApiUrls:ContentApi"] ?? "https://loca Startup.ConfigureDomainServices(builder); DeepDrftWeb.Client.Startup.ConfigureApiHttpClient(builder.Services, baseUrl); -DeepDrftWeb.Client.Startup.ConfigureCommonServices(builder.Services, contentApiUrl); DeepDrftWeb.Client.Startup.ConfigureDomainServices(builder.Services); +DeepDrftWeb.Client.Startup.ConfigureContentServices(builder.Services, contentApiUrl); builder.Services.AddControllers();