diff --git a/.gitignore b/.gitignore index d707a59..7b99f11 100644 --- a/.gitignore +++ b/.gitignore @@ -302,4 +302,7 @@ __pycache__/ **/NetBlocks # File Database files -Database/Vaults/* \ No newline at end of file +Database/Vaults/* + +# TypeScript output +**/wwwroot/js/* \ No newline at end of file diff --git a/DeepDrftContent/Controllers/TrackController.cs b/DeepDrftContent/Controllers/TrackController.cs index 6421722..1dc583f 100644 --- a/DeepDrftContent/Controllers/TrackController.cs +++ b/DeepDrftContent/Controllers/TrackController.cs @@ -1,6 +1,5 @@ using DeepDrftContent.Constants; using DeepDrftContent.FileDatabase.Models; -using DeepDrftContent.FileDatabase.Services; using DeepDrftContent.Middleware; using Microsoft.AspNetCore.Mvc; diff --git a/DeepDrftContent/Models/CorsSettings.cs b/DeepDrftContent/Models/CorsSettings.cs new file mode 100644 index 0000000..d0055d4 --- /dev/null +++ b/DeepDrftContent/Models/CorsSettings.cs @@ -0,0 +1,6 @@ +namespace DeepDrftContent.Models; + +public class CorsSettings +{ + public string[] AllowedOrigins { get; set; } = []; +} \ No newline at end of file diff --git a/DeepDrftContent/Program.cs b/DeepDrftContent/Program.cs index 679eaed..4ac767f 100644 --- a/DeepDrftContent/Program.cs +++ b/DeepDrftContent/Program.cs @@ -12,6 +12,24 @@ builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); +// Add CORS policy using configured origins +var corsSettings = builder.Configuration.GetSection(nameof(CorsSettings)).Get(); +if (corsSettings?.AllowedOrigins == null || corsSettings.AllowedOrigins.Length == 0) +{ + throw new Exception("CorsSettings.AllowedOrigins configuration is required for CORS policy"); +} + +builder.Services.AddCors(options => +{ + options.AddPolicy("ContentApiPolicy", policy => + { + policy.WithOrigins(corsSettings.AllowedOrigins) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + }); +}); + // Load API key configuration builder.Configuration.AddJsonFile("environment/apikey.json", optional: false, reloadOnChange: true); var apiKeySettings = builder.Configuration.GetSection(nameof(ApiKeySettings)).Get(); @@ -25,6 +43,7 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); } +app.UseCors("ContentApiPolicy"); app.UseApiKeyAuthentication(apiKeySettings.ApiKey); app.UseAuthorization(); diff --git a/DeepDrftContent/appsettings.Development.json b/DeepDrftContent/appsettings.Development.json index 0c208ae..52bffe9 100644 --- a/DeepDrftContent/appsettings.Development.json +++ b/DeepDrftContent/appsettings.Development.json @@ -4,5 +4,13 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "CorsSettings": { + "AllowedOrigins": [ + "http://localhost:5070", + "https://localhost:5071", + "http://localhost:3000", + "http://127.0.0.1:5070" + ] } } diff --git a/DeepDrftContent/appsettings.json b/DeepDrftContent/appsettings.json index 10f68b8..3c8444f 100644 --- a/DeepDrftContent/appsettings.json +++ b/DeepDrftContent/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "CorsSettings": { + "AllowedOrigins": [] + } } diff --git a/DeepDrftWeb.Client/Clients/TrackClient.cs b/DeepDrftWeb.Client/Clients/TrackClient.cs index 11c14e8..98cfd27 100644 --- a/DeepDrftWeb.Client/Clients/TrackClient.cs +++ b/DeepDrftWeb.Client/Clients/TrackClient.cs @@ -4,6 +4,7 @@ using NetBlocks.Models; using System.Text.Json; using System.Web; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace DeepDrftWeb.Client.Clients; @@ -11,9 +12,9 @@ public class TrackClient { private readonly HttpClient _http; - public TrackClient(HttpClient http) + public TrackClient(IHttpClientFactory httpClientFactory) { - _http = http; + _http = httpClientFactory.CreateClient("DeepDrft.API"); } public async Task>> GetPage( diff --git a/DeepDrftWeb.Client/Clients/TrackMediaClient.cs b/DeepDrftWeb.Client/Clients/TrackMediaClient.cs new file mode 100644 index 0000000..ed23f6b --- /dev/null +++ b/DeepDrftWeb.Client/Clients/TrackMediaClient.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace DeepDrftWeb.Client.Clients; + +public class TrackMediaClient +{ + private readonly HttpClient _http; + + public TrackMediaClient(IHttpClientFactory httpClientFactory) + { + _http = httpClientFactory.CreateClient("DeepDrft.Content"); + } + + public async Task GetTrackMedia(string trackId) + { + return await _http.GetStreamAsync($"api/track/{trackId}"); + } +} \ No newline at end of file diff --git a/DeepDrftWeb.Client/Controls/AudioPlayer.razor b/DeepDrftWeb.Client/Controls/AudioPlayer.razor new file mode 100644 index 0000000..20e5827 --- /dev/null +++ b/DeepDrftWeb.Client/Controls/AudioPlayer.razor @@ -0,0 +1,312 @@ +@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/AudioPlayerExample.razor b/DeepDrftWeb.Client/Controls/AudioPlayerExample.razor new file mode 100644 index 0000000..bbe83ae --- /dev/null +++ b/DeepDrftWeb.Client/Controls/AudioPlayerExample.razor @@ -0,0 +1,98 @@ +@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/TrackPlayer.razor b/DeepDrftWeb.Client/Controls/TrackPlayer.razor index 407d1d7..f7b265a 100644 --- a/DeepDrftWeb.Client/Controls/TrackPlayer.razor +++ b/DeepDrftWeb.Client/Controls/TrackPlayer.razor @@ -73,9 +73,9 @@ + StartIcon="@_playPauseIcon" + OnClick="@HandlePlayClick"/> + diff --git a/DeepDrftWeb.Client/Controls/TrackPlayer.razor.cs b/DeepDrftWeb.Client/Controls/TrackPlayer.razor.cs index edb28db..566034d 100644 --- a/DeepDrftWeb.Client/Controls/TrackPlayer.razor.cs +++ b/DeepDrftWeb.Client/Controls/TrackPlayer.razor.cs @@ -1,14 +1,29 @@ using Microsoft.AspNetCore.Components; using DeepDrftModels.Entities; +using DeepDrftWeb.Client.Clients; +using MudBlazor; namespace DeepDrftWeb.Client.Controls; public partial class TrackPlayer : ComponentBase { - [Parameter] public TrackEntity? Track { get; set; } - - private void HandlePlayClick() + [Parameter] public required TrackEntity Track { get; set; } + [Inject] public required TrackMediaClient Client { get; set; } + + private Stream? _audioStream = null; + private bool _isPlaying = false; + private string _playPauseIcon => _isPlaying ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow; + private async Task HandlePlayClick() { - // TODO: Implement play functionality with injected service + if (_audioStream == null) + { + _audioStream = await Client.GetTrackMedia(Track.EntryKey); + PlayAudio(); + } + } + + private void PlayAudio() + { + throw new NotImplementedException(); } } \ No newline at end of file diff --git a/DeepDrftWeb.Client/DeepDrftWeb.Client.csproj b/DeepDrftWeb.Client/DeepDrftWeb.Client.csproj index 252cb11..af5ab3c 100644 --- a/DeepDrftWeb.Client/DeepDrftWeb.Client.csproj +++ b/DeepDrftWeb.Client/DeepDrftWeb.Client.csproj @@ -11,6 +11,7 @@ + diff --git a/DeepDrftWeb.Client/Layout/NavMenu.razor b/DeepDrftWeb.Client/Layout/NavMenu.razor index 2a2f905..30a1ecb 100644 --- a/DeepDrftWeb.Client/Layout/NavMenu.razor +++ b/DeepDrftWeb.Client/Layout/NavMenu.razor @@ -2,6 +2,7 @@ Home Track Gallery + Audio Test diff --git a/DeepDrftWeb.Client/Program.cs b/DeepDrftWeb.Client/Program.cs index eb7e4e0..4cece6c 100644 --- a/DeepDrftWeb.Client/Program.cs +++ b/DeepDrftWeb.Client/Program.cs @@ -1,14 +1,19 @@ using DeepDrftWeb.Client; +using DeepDrftWeb.Client.Services; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using MudBlazor.Services; var builder = WebAssemblyHostBuilder.CreateDefault(args); Console.WriteLine(builder.HostEnvironment.BaseAddress); -builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(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.ConfigureDomainServices(builder.Services); -await builder.Build().RunAsync(); +var app = builder.Build(); diff --git a/DeepDrftWeb.Client/Services/AudioInteropService.cs b/DeepDrftWeb.Client/Services/AudioInteropService.cs new file mode 100644 index 0000000..5b28413 --- /dev/null +++ b/DeepDrftWeb.Client/Services/AudioInteropService.cs @@ -0,0 +1,275 @@ +using Microsoft.JSInterop; + +namespace DeepDrftWeb.Client.Services; + +public class AudioInteropService : IAsyncDisposable +{ + private readonly IJSRuntime _jsRuntime; + private readonly Dictionary> _callbacks = new(); + + public AudioInteropService(IJSRuntime jsRuntime) + { + _jsRuntime = jsRuntime; + } + + public async Task CreatePlayerAsync(string playerId) + { + try + { + var result = await _jsRuntime.InvokeAsync("DeepDrftAudio.createPlayer", playerId); + return result; + } + catch (Exception ex) + { + return new AudioOperationResult { Success = false, Error = ex.Message }; + } + } + + public async Task LoadAudioFromUrlAsync(string playerId, string url) + { + try + { + var result = await _jsRuntime.InvokeAsync("DeepDrftAudio.loadAudioFromUrl", playerId, url); + return result; + } + catch (Exception ex) + { + return new AudioLoadResult { Success = false, Error = ex.Message }; + } + } + + + 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 }; + } + } + + 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 }; + } + } + + 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 }; + } + } + + 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 }; + } + } + + 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 }; + } + } + + public async Task GetCurrentTimeAsync(string playerId) + { + try + { + return await _jsRuntime.InvokeAsync("DeepDrftAudio.getCurrentTime", playerId); + } + catch (Exception) + { + return 0; + } + } + + public async Task GetStateAsync(string playerId) + { + try + { + return await _jsRuntime.InvokeAsync("DeepDrftAudio.getState", playerId); + } + catch (Exception) + { + return null; + } + } + + 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 }; + } + } + + 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 }; + } + } + + public async Task SetOnLoadProgressCallbackAsync(string playerId, Func callback) + { + try + { + var callbackWrapper = new AudioPlayerCallback(); + callbackWrapper.OnLoadProgress = callback; + + var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper); + _callbacks[playerId + "_loadprogress"] = dotNetObjectRef; + + var result = await _jsRuntime.InvokeAsync("DeepDrftAudio.setOnLoadProgressCallback", + playerId, dotNetObjectRef, "OnLoadProgressCallback"); + + return result; + } + catch (Exception ex) + { + return new AudioOperationResult { Success = false, Error = ex.Message }; + } + } + + public async Task DisposePlayerAsync(string playerId) + { + try + { + // 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 }; + } + } + + public async ValueTask DisposeAsync() + { + foreach (var callback in _callbacks.Values) + { + callback?.Dispose(); + } + _callbacks.Clear(); + } +} + +public class AudioPlayerCallback +{ + public Func? OnProgress { get; set; } + public Func? OnEnd { get; set; } + public Func? OnLoadProgress { get; set; } + + [JSInvokable] + public async Task OnProgressCallback(double currentTime) + { + if (OnProgress != null) + await OnProgress(currentTime); + } + + [JSInvokable] + public async Task OnEndCallback() + { + if (OnEnd != null) + await OnEnd(); + } + + [JSInvokable] + public async Task OnLoadProgressCallback(double progress) + { + if (OnLoadProgress != null) + await OnLoadProgress(progress); + } +} + +public class AudioOperationResult +{ + public bool Success { get; set; } + public string? Error { get; set; } +} + +public class AudioLoadResult : AudioOperationResult +{ + public double Duration { get; set; } + public int SampleRate { get; set; } + public int NumberOfChannels { get; set; } + public double LoadProgress { get; set; } +} + +public class AudioPlayerState +{ + public bool IsPlaying { get; set; } + public bool IsPaused { get; set; } + public double CurrentTime { get; set; } + public double Duration { get; set; } + public double Volume { get; set; } + public double LoadProgress { get; set; } +} \ No newline at end of file diff --git a/DeepDrftWeb.Client/Startup.cs b/DeepDrftWeb.Client/Startup.cs index d1980a0..6695385 100644 --- a/DeepDrftWeb.Client/Startup.cs +++ b/DeepDrftWeb.Client/Startup.cs @@ -13,4 +13,21 @@ public static class Startup services.AddScoped(); services.AddScoped(); } + + public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress) + { + services.AddHttpClient("DeepDrft.API", client => + { + client.BaseAddress = new Uri(baseAddress); + }); + } + + public static void ConfigureCommonServices(IServiceCollection services, string contentApiUrl) + { + services.AddHttpClient("DeepDrft.Content", client => + { + client.BaseAddress = new Uri(contentApiUrl); + }); + services.AddScoped(); + } } \ No newline at end of file diff --git a/DeepDrftWeb.Client/wwwroot/appsettings.json b/DeepDrftWeb.Client/wwwroot/appsettings.json index 0c208ae..3e8925c 100644 --- a/DeepDrftWeb.Client/wwwroot/appsettings.json +++ b/DeepDrftWeb.Client/wwwroot/appsettings.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "ApiUrls": { + "ContentApi": "https://localhost:54493" } } diff --git a/DeepDrftWeb/Components/App.razor b/DeepDrftWeb/Components/App.razor index e5611cb..3affce3 100644 --- a/DeepDrftWeb/Components/App.razor +++ b/DeepDrftWeb/Components/App.razor @@ -16,6 +16,7 @@ + diff --git a/DeepDrftWeb/DeepDrftWeb.csproj b/DeepDrftWeb/DeepDrftWeb.csproj index 58c8c02..bc2e751 100644 --- a/DeepDrftWeb/DeepDrftWeb.csproj +++ b/DeepDrftWeb/DeepDrftWeb.csproj @@ -24,4 +24,16 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + true + PreserveNewest + + + \ No newline at end of file diff --git a/DeepDrftWeb/Interop/webaudio.ts b/DeepDrftWeb/Interop/webaudio.ts new file mode 100644 index 0000000..6315b5f --- /dev/null +++ b/DeepDrftWeb/Interop/webaudio.ts @@ -0,0 +1,476 @@ +interface AudioResult { + success: boolean; + error?: string; +} + +interface LoadAudioResult extends AudioResult { + duration?: number; + sampleRate?: number; + numberOfChannels?: number; + loadProgress?: number; +} + +interface AudioState { + isPlaying: boolean; + isPaused: boolean; + currentTime: number; + duration: number; + volume: number; + loadProgress: number; +} + +type ProgressCallback = (currentTime: number) => void; +type EndCallback = () => void; +type LoadProgressCallback = (progress: number) => void; + +interface Window { + webkitAudioContext?: typeof AudioContext; + DeepDrftAudio: typeof DeepDrftAudio; +} + +class AudioPlayer { + private audioContext: AudioContext | null = null; + private audioBuffer: AudioBuffer | null = null; + private source: AudioBufferSourceNode | null = null; + private gainNode: GainNode | null = null; + private isPlaying: boolean = false; + private isPaused: boolean = false; + private startTime: number = 0; + private pauseOffset: number = 0; + private duration: number = 0; + private onProgressCallback: ProgressCallback | null = null; + private onEndCallback: EndCallback | null = null; + private onLoadProgressCallback: LoadProgressCallback | null = null; + private progressInterval: number | null = null; + + async initialize(): Promise { + try { + this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); + this.gainNode = this.audioContext.createGain(); + this.gainNode.connect(this.audioContext.destination); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + async loadAudioFromUrl(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const contentLength = response.headers.get('Content-Length'); + const reader = response.body?.getReader(); + + 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); + } + } + + return { + success: true, + duration: this.duration, + sampleRate: this.audioBuffer.sampleRate, + numberOfChannels: this.audioBuffer.numberOfChannels, + loadProgress: 100 + }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + + play(): AudioResult { + if (!this.audioBuffer) { + return { success: false, error: "No audio loaded" }; + } + + try { + if (this.audioContext!.state === 'suspended') { + this.audioContext!.resume(); + } + + this.source = this.audioContext!.createBufferSource(); + this.source.buffer = this.audioBuffer; + this.source.connect(this.gainNode!); + + this.source.onended = () => { + this.isPlaying = false; + this.isPaused = false; + this.startTime = 0; + this.pauseOffset = 0; + if (this.onEndCallback) { + this.onEndCallback(); + } + }; + + if (this.isPaused) { + this.source.start(0, this.pauseOffset); + this.startTime = this.audioContext!.currentTime - this.pauseOffset; + } else { + this.source.start(0); + this.startTime = this.audioContext!.currentTime; + } + + this.isPlaying = true; + this.isPaused = false; + this.startProgressTracking(); + + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + pause(): AudioResult { + if (!this.isPlaying) { + return { success: false, error: "Audio is not playing" }; + } + + try { + this.source!.stop(); + this.pauseOffset += this.audioContext!.currentTime - this.startTime; + this.isPlaying = false; + this.isPaused = true; + this.stopProgressTracking(); + + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + stop(): AudioResult { + try { + if (this.source) { + this.source.stop(); + } + this.isPlaying = false; + this.isPaused = false; + this.startTime = 0; + this.pauseOffset = 0; + this.stopProgressTracking(); + + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + seek(position: number): AudioResult { + if (!this.audioBuffer || position < 0 || position > this.duration) { + return { success: false, error: "Invalid seek position" }; + } + + try { + const wasPlaying = this.isPlaying; + + if (this.isPlaying) { + this.source!.stop(); + } + + this.pauseOffset = position; + + if (wasPlaying) { + this.source = this.audioContext!.createBufferSource(); + this.source.buffer = this.audioBuffer; + this.source.connect(this.gainNode!); + + this.source.onended = () => { + this.isPlaying = false; + this.isPaused = false; + this.startTime = 0; + this.pauseOffset = 0; + if (this.onEndCallback) { + this.onEndCallback(); + } + }; + + this.source.start(0, position); + this.startTime = this.audioContext!.currentTime - position; + } else { + this.isPaused = true; + } + + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + setVolume(volume: number): AudioResult { + if (!this.gainNode) { + return { success: false, error: "Audio not initialized" }; + } + + try { + const clampedVolume = Math.max(0, Math.min(1, volume)); + this.gainNode.gain.setValueAtTime(clampedVolume, this.audioContext!.currentTime); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + } + + getCurrentTime(): number { + if (!this.isPlaying && !this.isPaused) { + return 0; + } + + if (this.isPlaying) { + return Math.min(this.pauseOffset + (this.audioContext!.currentTime - this.startTime), this.duration); + } else { + return this.pauseOffset; + } + } + + getState(): AudioState { + return { + isPlaying: this.isPlaying, + isPaused: this.isPaused, + currentTime: this.getCurrentTime(), + duration: this.duration, + volume: this.gainNode ? this.gainNode.gain.value : 0, + loadProgress: 100 + }; + } + + private startProgressTracking(): void { + this.stopProgressTracking(); + this.progressInterval = setInterval(() => { + if (this.onProgressCallback) { + this.onProgressCallback(this.getCurrentTime()); + } + }, 100); + } + + private stopProgressTracking(): void { + if (this.progressInterval) { + clearInterval(this.progressInterval); + this.progressInterval = null; + } + } + + setOnProgressCallback(callback: ProgressCallback): void { + this.onProgressCallback = callback; + } + + setOnEndCallback(callback: EndCallback): void { + this.onEndCallback = callback; + } + + setOnLoadProgressCallback(callback: LoadProgressCallback): void { + this.onLoadProgressCallback = callback; + } + + dispose(): void { + this.stop(); + this.stopProgressTracking(); + if (this.audioContext && this.audioContext.state !== 'closed') { + this.audioContext.close(); + } + this.audioContext = null; + this.audioBuffer = null; + this.gainNode = null; + } +} + +// Global player instances +const audioPlayers = new Map(); + +// Define .NET interop types +interface DotNetObjectReference { + invokeMethodAsync(methodName: string, ...args: any[]): Promise; +} + +// JavaScript interop functions for Blazor +const DeepDrftAudio = { + createPlayer: async (playerId: string): Promise => { + try { + const player = new AudioPlayer(); + const result = await player.initialize(); + if (result.success) { + audioPlayers.set(playerId, player); + } + return result; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }, + + loadAudioFromUrl: async (playerId: string, url: string): Promise => { + const player = audioPlayers.get(playerId); + if (!player) { + return { success: false, error: "Player not found" }; + } + return await player.loadAudioFromUrl(url); + }, + + + play: (playerId: string): AudioResult => { + const player = audioPlayers.get(playerId); + if (!player) { + return { success: false, error: "Player not found" }; + } + return player.play(); + }, + + pause: (playerId: string): AudioResult => { + const player = audioPlayers.get(playerId); + if (!player) { + return { success: false, error: "Player not found" }; + } + return player.pause(); + }, + + stop: (playerId: string): AudioResult => { + const player = audioPlayers.get(playerId); + if (!player) { + return { success: false, error: "Player not found" }; + } + return player.stop(); + }, + + seek: (playerId: string, position: number): AudioResult => { + const player = audioPlayers.get(playerId); + if (!player) { + return { success: false, error: "Player not found" }; + } + return player.seek(position); + }, + + setVolume: (playerId: string, volume: number): AudioResult => { + const player = audioPlayers.get(playerId); + if (!player) { + return { success: false, error: "Player not found" }; + } + return player.setVolume(volume); + }, + + getCurrentTime: (playerId: string): number => { + const player = audioPlayers.get(playerId); + if (!player) { + return 0; + } + return player.getCurrentTime(); + }, + + getState: (playerId: string): AudioState | null => { + const player = audioPlayers.get(playerId); + if (!player) { + return null; + } + return player.getState(); + }, + + setOnProgressCallback: (playerId: string, dotNetObjectReference: DotNetObjectReference, methodName: string): AudioResult => { + const player = audioPlayers.get(playerId); + if (!player) { + return { success: false, error: "Player not found" }; + } + + player.setOnProgressCallback((currentTime: number) => { + dotNetObjectReference.invokeMethodAsync(methodName, currentTime); + }); + + return { success: true }; + }, + + setOnEndCallback: (playerId: string, dotNetObjectReference: DotNetObjectReference, methodName: string): AudioResult => { + const player = audioPlayers.get(playerId); + if (!player) { + return { success: false, error: "Player not found" }; + } + + player.setOnEndCallback(() => { + dotNetObjectReference.invokeMethodAsync(methodName); + }); + + return { success: true }; + }, + + setOnLoadProgressCallback: (playerId: string, dotNetObjectReference: DotNetObjectReference, methodName: string): AudioResult => { + const player = audioPlayers.get(playerId); + if (!player) { + return { success: false, error: "Player not found" }; + } + + player.setOnLoadProgressCallback((progress: number) => { + dotNetObjectReference.invokeMethodAsync(methodName, progress); + }); + + return { success: true }; + }, + + disposePlayer: (playerId: string): AudioResult => { + const player = audioPlayers.get(playerId); + if (player) { + player.dispose(); + audioPlayers.delete(playerId); + return { success: true }; + } + return { success: false, error: "Player not found" }; + } +}; + +// Assign to window for global access +window.DeepDrftAudio = DeepDrftAudio; \ No newline at end of file diff --git a/DeepDrftWeb/Program.cs b/DeepDrftWeb/Program.cs index 4c0db10..3237266 100644 --- a/DeepDrftWeb/Program.cs +++ b/DeepDrftWeb/Program.cs @@ -1,4 +1,5 @@ using DeepDrftWeb; +using DeepDrftWeb.Client.Services; using MudBlazor.Services; using DeepDrftWeb.Components; @@ -7,13 +8,16 @@ var builder = WebApplication.CreateBuilder(args); // Add MudBlazor services builder.Services.AddMudServices(); -// Add HttpClient services for prerendering -builder.Services.AddHttpClient("DeepDrft.API", client => client.BaseAddress = new Uri(Startup.GetKestrelUrl(builder))); -builder.Services.AddScoped(sp => - sp.GetRequiredService().CreateClient("DeepDrft.API")); +// Add AudioInteropService for both server and client rendering +builder.Services.AddScoped(); + +var baseUrl = Startup.GetKestrelUrl(builder); +var contentApiUrl = builder.Configuration["ApiUrls:ContentApi"] ?? "https://localhost:7001"; Startup.ConfigureDomainServices(builder); +DeepDrftWeb.Client.Startup.ConfigureApiHttpClient(builder.Services, baseUrl); +DeepDrftWeb.Client.Startup.ConfigureCommonServices(builder.Services, contentApiUrl); DeepDrftWeb.Client.Startup.ConfigureDomainServices(builder.Services); builder.Services.AddControllers(); diff --git a/DeepDrftWeb/appsettings.json b/DeepDrftWeb/appsettings.json index 1f05f15..b2537c0 100644 --- a/DeepDrftWeb/appsettings.json +++ b/DeepDrftWeb/appsettings.json @@ -8,5 +8,8 @@ "AllowedHosts": "*", "ConnectionStrings": { "DefaultConnection": "Data Source=../Database/deepdrft.db" + }, + "ApiUrls": { + "ContentApi": "https://localhost:54493" } } diff --git a/DeepDrftWeb/tsconfig.json b/DeepDrftWeb/tsconfig.json new file mode 100644 index 0000000..bdf2aa5 --- /dev/null +++ b/DeepDrftWeb/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "ES6", // or "ESNext" + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "sourceMap": true, + "outDir": "wwwroot/js" + }, + "include": [ + "Interop/**/*" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file