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 0000000..94fee69 Binary files /dev/null and b/DeepDrftWeb.Client/wwwroot/img/deepdrft-logo.jpg differ 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();