refactor(split): rename DeepDrftWeb -> DeepDrftPublic and DeepDrftWeb.Client -> DeepDrftPublic.Client (Phase 4)
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public class AudioInteropService : IAsyncDisposable
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private readonly Dictionary<string, IDisposable> _callbacks = new();
|
||||
|
||||
public AudioInteropService(IJSRuntime jsRuntime)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> CreatePlayerAsync(string playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jsRuntime.InvokeAsync<AudioOperationResult>("DeepDrftAudio.createPlayer", playerId);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AudioOperationResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> InitializeBufferedPlayerAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.initializeBufferedPlayer", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> AppendAudioBlockAsync(string playerId, byte[] audioBlock)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.appendAudioBlock", playerId, audioBlock);
|
||||
}
|
||||
|
||||
public async Task<AudioLoadResult> FinalizeAudioBufferAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioLoadResult>("DeepDrftAudio.finalizeAudioBuffer", playerId);
|
||||
}
|
||||
|
||||
// Streaming methods
|
||||
public async Task<AudioOperationResult> InitializeStreaming(string playerId, long totalStreamLength)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength);
|
||||
}
|
||||
|
||||
public async Task<StreamingResult> ProcessStreamingChunk(string playerId, byte[] audioChunk)
|
||||
{
|
||||
return await InvokeJsAsync<StreamingResult>("DeepDrftAudio.processStreamingChunk", playerId, audioChunk);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> StartStreamingPlayback(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.startStreamingPlayback", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> MarkStreamCompleteAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.markStreamComplete", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> EnsureAudioContextReady(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.ensureAudioContextReady", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> PlayAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.play", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> PauseAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.pause", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> StopAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stop", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> UnloadAsync(string playerId)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.unload", playerId);
|
||||
}
|
||||
|
||||
public async Task<SeekResult> SeekAsync(string playerId, double position)
|
||||
{
|
||||
return await InvokeJsAsync<SeekResult>("DeepDrftAudio.seek", playerId, position);
|
||||
}
|
||||
|
||||
// New methods for seek-beyond-buffer support
|
||||
public async Task<double> GetBufferedDuration(string playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<double>("DeepDrftAudio.getBufferedDuration", playerId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<long> CalculateByteOffset(string playerId, double positionSeconds)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (long)await _jsRuntime.InvokeAsync<double>("DeepDrftAudio.calculateByteOffset", playerId, positionSeconds);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> ReinitializeFromOffset(string playerId, long totalStreamLength, double seekPosition)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.reinitializeFromOffset", playerId, totalStreamLength, seekPosition);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetVolumeAsync(string playerId, double volume)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setVolume", playerId, volume);
|
||||
}
|
||||
|
||||
public async Task<double> GetCurrentTimeAsync(string playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<double>("DeepDrftAudio.getCurrentTime", playerId);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioPlayerState?> GetStateAsync(string playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<AudioPlayerState>("DeepDrftAudio.getState", playerId);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetOnProgressCallbackAsync(string playerId, Func<double, Task> callback)
|
||||
{
|
||||
return await SetCallbackAsync(playerId, "_progress", "setOnProgressCallback", "OnProgressCallback",
|
||||
wrapper => wrapper.OnProgress = callback);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetOnEndCallbackAsync(string playerId, Func<Task> callback)
|
||||
{
|
||||
return await SetCallbackAsync(playerId, "_end", "setOnEndCallback", "OnEndCallback",
|
||||
wrapper => wrapper.OnEnd = callback);
|
||||
}
|
||||
|
||||
// Spectrum analyzer methods
|
||||
public async Task<double[]?> GetSpectrumDataAsync(string playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<double[]>("DeepDrftAudio.getSpectrumData", playerId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetSpectrumHighPassAsync(string playerId, double freq)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumHighPass", playerId, freq);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetSpectrumLowPassAsync(string playerId, double freq)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumLowPass", playerId, freq);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SetSpectrumSlopeAsync(string playerId, double dbPerDecade)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setSpectrumSlope", playerId, dbPerDecade);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> StartSpectrumAnimationAsync(string playerId, string callbackId, Func<double[], Task> callback)
|
||||
{
|
||||
try
|
||||
{
|
||||
var callbackWrapper = new SpectrumCallback { OnData = callback };
|
||||
var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper);
|
||||
_callbacks[playerId + "_spectrum_" + callbackId] = dotNetObjectRef;
|
||||
|
||||
return await _jsRuntime.InvokeAsync<AudioOperationResult>(
|
||||
"DeepDrftAudio.startSpectrumAnimation",
|
||||
playerId, callbackId, dotNetObjectRef, "OnSpectrumDataCallback");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AudioOperationResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> StopSpectrumAnimationAsync(string playerId, string callbackId)
|
||||
{
|
||||
var key = playerId + "_spectrum_" + callbackId;
|
||||
if (_callbacks.TryGetValue(key, out var callback))
|
||||
{
|
||||
callback?.Dispose();
|
||||
_callbacks.Remove(key);
|
||||
}
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stopSpectrumAnimation", playerId, callbackId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> DisposePlayerAsync(string playerId)
|
||||
{
|
||||
CleanupPlayerCallbacks(playerId);
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.disposePlayer", playerId);
|
||||
}
|
||||
|
||||
// TODO: The typeof(T) switch below requires updating whenever a new result type is added.
|
||||
// Consider introducing a shared marker interface (e.g. IAudioResult with a static factory
|
||||
// method) so InvokeJsAsync can construct the failure result generically without a type switch.
|
||||
private async Task<T> InvokeJsAsync<T>(string identifier, params object[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<T>(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 };
|
||||
if (typeof(T) == typeof(StreamingResult))
|
||||
return (T)(object)new StreamingResult { Success = false, Error = ex.Message };
|
||||
if (typeof(T) == typeof(SeekResult))
|
||||
return (T)(object)new SeekResult { Success = false, Error = ex.Message };
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AudioOperationResult> SetCallbackAsync(string playerId, string suffix, string jsMethod, string callbackMethod, Action<AudioPlayerCallback> configureCallback)
|
||||
{
|
||||
try
|
||||
{
|
||||
var callbackWrapper = new AudioPlayerCallback();
|
||||
configureCallback(callbackWrapper);
|
||||
|
||||
var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper);
|
||||
_callbacks[playerId + suffix] = dotNetObjectRef;
|
||||
|
||||
return await _jsRuntime.InvokeAsync<AudioOperationResult>($"DeepDrftAudio.{jsMethod}",
|
||||
playerId, dotNetObjectRef, callbackMethod);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AudioOperationResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupPlayerCallbacks(string playerId)
|
||||
{
|
||||
var keysToRemove = _callbacks.Keys.Where(k => k.StartsWith(playerId + "_")).ToList();
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_callbacks[key]?.Dispose();
|
||||
_callbacks.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var callback in _callbacks.Values)
|
||||
{
|
||||
callback?.Dispose();
|
||||
}
|
||||
_callbacks.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public class AudioPlayerCallback
|
||||
{
|
||||
public Func<double, Task>? OnProgress { get; set; }
|
||||
public Func<Task>? OnEnd { 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();
|
||||
}
|
||||
}
|
||||
|
||||
public class SpectrumCallback
|
||||
{
|
||||
public Func<double[], Task>? OnData { get; set; }
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnSpectrumDataCallback(double[] data)
|
||||
{
|
||||
if (OnData != null)
|
||||
await OnData(data);
|
||||
}
|
||||
}
|
||||
|
||||
public class AudioOperationResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
public class SeekResult : AudioOperationResult
|
||||
{
|
||||
public bool SeekBeyondBuffer { get; set; }
|
||||
public long ByteOffset { 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 StreamingResult : AudioOperationResult
|
||||
{
|
||||
public bool CanStartStreaming { get; set; }
|
||||
public bool HeaderParsed { get; set; }
|
||||
public int BufferCount { get; set; }
|
||||
public double? Duration { get; set; } // Duration in seconds calculated from WAV header
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftPublic.Client.Clients;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using NetBlocks.Models;
|
||||
using System.Buffers;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
{
|
||||
protected readonly AudioInteropService _audioInterop;
|
||||
protected readonly TrackMediaClient _trackMediaClient;
|
||||
|
||||
public string PlayerId { get; private set; } = Guid.NewGuid().ToString();
|
||||
|
||||
// State properties
|
||||
public bool IsInitialized { get; protected set; } = false;
|
||||
public bool IsLoaded { get; protected set; } = false;
|
||||
public bool IsLoading { get; protected set; } = false;
|
||||
public bool IsPlaying { get; protected set; } = false;
|
||||
public bool IsPaused { get; protected set; } = false;
|
||||
public double CurrentTime { get; protected set; } = 0;
|
||||
public double? Duration { get; protected set; } = null;
|
||||
public double Volume { get; protected set; } = 0.8;
|
||||
public double LoadProgress { get; protected set; } = 0;
|
||||
public string? ErrorMessage { get; protected set; }
|
||||
/// <summary>
|
||||
/// The currently selected track. In the streaming subclass this property is managed
|
||||
/// exclusively by <see cref="StreamingAudioPlayerService"/>: set in
|
||||
/// <c>LoadTrackStreaming</c> after <c>ResetToIdle</c> clears it, and cleared again
|
||||
/// by <c>ResetToIdle</c> on stop/unload/dispose. Base-class subclasses that take the
|
||||
/// <see cref="SelectTrack"/>/<see cref="Unload"/> path are responsible for managing
|
||||
/// it themselves.
|
||||
/// </summary>
|
||||
public TrackEntity? CurrentTrack { get; protected set; }
|
||||
|
||||
// Events
|
||||
public EventCallback? OnStateChanged { get; set; }
|
||||
public EventCallback? OnTrackSelected { get; set; }
|
||||
|
||||
protected AudioPlayerService(AudioInteropService audioInterop, TrackMediaClient trackMediaClient)
|
||||
{
|
||||
_audioInterop = audioInterop;
|
||||
_trackMediaClient = trackMediaClient;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (IsInitialized) return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _audioInterop.CreatePlayerAsync(PlayerId);
|
||||
if (!result.Success)
|
||||
{
|
||||
ErrorMessage = $"Failed to initialize audio player: {result.Error}";
|
||||
await NotifyStateChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
await _audioInterop.SetOnProgressCallbackAsync(PlayerId, OnProgressCallback);
|
||||
await _audioInterop.SetOnEndCallbackAsync(PlayerId, OnPlaybackEndCallback);
|
||||
|
||||
await _audioInterop.SetVolumeAsync(PlayerId, Volume);
|
||||
|
||||
IsInitialized = true;
|
||||
ErrorMessage = null;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Failed to initialize audio player: {ex.Message}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task SelectTrack(TrackEntity track)
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
|
||||
await NotifyStateChanged();
|
||||
|
||||
if (OnTrackSelected.HasValue)
|
||||
await OnTrackSelected.Value.InvokeAsync();
|
||||
|
||||
await LoadTrack(track);
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
private async Task LoadTrack(TrackEntity track)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsLoading) return;
|
||||
|
||||
if (IsPlaying || IsPaused)
|
||||
{
|
||||
await Unload();
|
||||
}
|
||||
|
||||
// Reset state to indicate loading has started
|
||||
ErrorMessage = null;
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
IsLoading = true;
|
||||
Duration = null;
|
||||
CurrentTime = 0;
|
||||
await NotifyStateChanged();
|
||||
|
||||
var loadResult = await _audioInterop.InitializeBufferedPlayerAsync(PlayerId);
|
||||
if (loadResult?.Success != true)
|
||||
{
|
||||
ErrorMessage = $"Failed to initialize audio buffer: {loadResult?.Error ?? "Unknown error"}";
|
||||
return;
|
||||
}
|
||||
|
||||
var mediaResult = await _trackMediaClient.GetTrackMedia(track.EntryKey);
|
||||
if (!mediaResult.Success)
|
||||
{
|
||||
ErrorMessage = mediaResult.GetMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaResult.Value == null)
|
||||
{
|
||||
ErrorMessage = "No audio returned from server";
|
||||
return;
|
||||
}
|
||||
|
||||
TrackMediaResponse audio = mediaResult.Value;
|
||||
await StreamAudio(audio);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error loading audio: {ex.Message}";
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StreamAudio(TrackMediaResponse audio)
|
||||
{
|
||||
const int bufferSize = 32 * 1024;
|
||||
var rentedBuffer = ArrayPool<byte>.Shared.Rent(bufferSize);
|
||||
try
|
||||
{
|
||||
long totalBytesRead = 0;
|
||||
int currentBytes;
|
||||
|
||||
do
|
||||
{
|
||||
currentBytes = await audio.Stream.ReadAsync(rentedBuffer, 0, bufferSize);
|
||||
|
||||
if (currentBytes > 0)
|
||||
{
|
||||
totalBytesRead += currentBytes;
|
||||
|
||||
// Slice to actual bytes read before sending to interop
|
||||
var chunk = rentedBuffer[..currentBytes];
|
||||
|
||||
var appendResult = await _audioInterop.AppendAudioBlockAsync(PlayerId, chunk);
|
||||
if (!appendResult.Success)
|
||||
{
|
||||
throw new Exception($"Failed to append audio block: {appendResult.Error}");
|
||||
}
|
||||
|
||||
if (audio.ContentLength > 0)
|
||||
{
|
||||
LoadProgress = Math.Min(1.0, (double)totalBytesRead / audio.ContentLength);
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
} while (currentBytes > 0);
|
||||
|
||||
var finalizeResult = await _audioInterop.FinalizeAudioBufferAsync(PlayerId);
|
||||
if (!finalizeResult.Success)
|
||||
{
|
||||
throw new Exception($"Failed to finalize audio buffer: {finalizeResult.Error}");
|
||||
}
|
||||
|
||||
Duration = finalizeResult.Duration;
|
||||
LoadProgress = 1.0;
|
||||
IsLoaded = true;
|
||||
ErrorMessage = null;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error streaming audio: {ex.Message}";
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
await NotifyStateChanged();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(rentedBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error controlling playback: {ex.Message}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public virtual 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}";
|
||||
}
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error stopping playback: {ex.Message}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task Unload()
|
||||
{
|
||||
if (!IsLoaded) return;
|
||||
|
||||
try
|
||||
{
|
||||
await Stop();
|
||||
var result = await _audioInterop.UnloadAsync(PlayerId);
|
||||
if (result.Success)
|
||||
{
|
||||
IsPlaying = false;
|
||||
IsPaused = false;
|
||||
CurrentTime = 0;
|
||||
Duration = null;
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
ErrorMessage = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = $"Unload error: {result.Error}";
|
||||
}
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error unloading track: {ex.Message}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task Seek(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}";
|
||||
}
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error seeking: {ex.Message}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetVolume(double volume)
|
||||
{
|
||||
Volume = volume;
|
||||
|
||||
if (IsLoaded)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _audioInterop.SetVolumeAsync(PlayerId, volume);
|
||||
if (!result.Success)
|
||||
{
|
||||
ErrorMessage = $"Volume error: {result.Error}";
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error setting volume: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
public async Task ClearError()
|
||||
{
|
||||
ErrorMessage = null;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
private async Task OnProgressCallback(double currentTime)
|
||||
{
|
||||
CurrentTime = currentTime;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
private async Task OnPlaybackEndCallback()
|
||||
{
|
||||
IsPlaying = false;
|
||||
IsPaused = false;
|
||||
CurrentTime = 0;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
|
||||
protected async Task EnsureInitializedAsync()
|
||||
{
|
||||
if (!IsInitialized)
|
||||
{
|
||||
await InitializeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task NotifyStateChanged()
|
||||
{
|
||||
if (OnStateChanged.HasValue)
|
||||
await OnStateChanged.Value.InvokeAsync();
|
||||
}
|
||||
|
||||
protected async Task NotifyTrackSelected()
|
||||
{
|
||||
if (OnTrackSelected.HasValue)
|
||||
await OnTrackSelected.Value.InvokeAsync();
|
||||
}
|
||||
|
||||
public virtual async ValueTask DisposeAsync()
|
||||
{
|
||||
if (IsInitialized)
|
||||
{
|
||||
await _audioInterop.DisposePlayerAsync(PlayerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using DeepDrftPublic.Client.Common;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public class DarkModeCookieService(DarkModeSettings darkModeSetting, IJSRuntime js) : DarkModeServiceBase
|
||||
{
|
||||
private const int EXPIRY_DAYS = 365;
|
||||
|
||||
public bool GetDarkModeAsync()
|
||||
{
|
||||
return darkModeSetting.IsDarkMode;
|
||||
// var value = await js.InvokeAsync<string?>("eval",
|
||||
// $"document.cookie.split('; ').find(c => c.startsWith('{COOKIE_NAME}='))?.split('=')[1]");
|
||||
// return value == "true";
|
||||
}
|
||||
|
||||
public async ValueTask SetDarkModeAsync(bool isDarkMode)
|
||||
{
|
||||
var expires = DateTime.UtcNow.AddDays(EXPIRY_DAYS).ToString("R");
|
||||
await js.InvokeVoidAsync("eval",
|
||||
$"document.cookie = '{COOKIE_NAME}={isDarkMode.ToString().ToLower()}; expires={expires}; path=/; SameSite=Lax'");
|
||||
darkModeSetting.IsDarkMode = isDarkMode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public abstract class DarkModeServiceBase
|
||||
{
|
||||
protected const string COOKIE_NAME = "darkMode";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public interface IPlayerService
|
||||
{
|
||||
// State properties
|
||||
bool IsInitialized { get; }
|
||||
bool IsLoaded { get; }
|
||||
bool IsLoading { get; }
|
||||
bool IsPlaying { get; }
|
||||
bool IsPaused { get; }
|
||||
double CurrentTime { get; }
|
||||
double? Duration { get; }
|
||||
double Volume { get; }
|
||||
double LoadProgress { get; }
|
||||
string? ErrorMessage { get; }
|
||||
TrackEntity? CurrentTrack { get; }
|
||||
|
||||
// Events for UI updates
|
||||
EventCallback? OnStateChanged { get; set; }
|
||||
EventCallback? OnTrackSelected { get; set; }
|
||||
|
||||
// Control methods
|
||||
Task InitializeAsync();
|
||||
Task SelectTrack(TrackEntity track);
|
||||
Task Stop();
|
||||
Task Unload();
|
||||
Task TogglePlayPause();
|
||||
Task Seek(double position);
|
||||
Task SetVolume(double volume);
|
||||
Task ClearError();
|
||||
}
|
||||
|
||||
public interface IStreamingPlayerService : IPlayerService
|
||||
{
|
||||
// Streaming state properties
|
||||
bool IsStreamingMode { get; }
|
||||
bool CanStartStreaming { get; }
|
||||
bool HeaderParsed { get; }
|
||||
int BufferedChunks { get; }
|
||||
|
||||
// Streaming control methods
|
||||
Task SelectTrackStreaming(TrackEntity track);
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftPublic.Client.Clients;
|
||||
using System.Buffers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerService
|
||||
{
|
||||
// Configuration constants
|
||||
private const int DefaultBufferSize = 32 * 1024; // 32KB chunks
|
||||
private const int NotificationThrottleMs = 100; // Throttle UI updates to max 10 per second
|
||||
|
||||
// Adaptive chunk sizing
|
||||
private const int MinBufferSize = 16 * 1024; // 16KB minimum
|
||||
private const int MaxBufferSize = 64 * 1024; // 64KB maximum
|
||||
private int _currentBufferSize = DefaultBufferSize;
|
||||
private int _consecutiveSlowReads = 0;
|
||||
|
||||
|
||||
// Streaming state properties
|
||||
public bool IsStreamingMode { get; private set; } = false;
|
||||
public bool CanStartStreaming { get; private set; } = false;
|
||||
public bool HeaderParsed { get; private set; } = false;
|
||||
public int BufferedChunks { get; private set; } = 0;
|
||||
public bool IsSeekingBeyondBuffer { get; private set; } = false;
|
||||
|
||||
private bool _streamingPlaybackStarted = false;
|
||||
private CancellationTokenSource? _streamingCancellation;
|
||||
private Task? _activeStreamingTask;
|
||||
private DateTime _lastNotification = DateTime.MinValue;
|
||||
private readonly ILogger<StreamingAudioPlayerService> _logger;
|
||||
private string? _currentTrackId;
|
||||
|
||||
public StreamingAudioPlayerService(
|
||||
AudioInteropService audioInterop,
|
||||
TrackMediaClient trackMediaClient,
|
||||
ILogger<StreamingAudioPlayerService> logger)
|
||||
: base(audioInterop, trackMediaClient)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override async Task SelectTrack(TrackEntity track)
|
||||
{
|
||||
await SelectTrackStreaming(track);
|
||||
}
|
||||
|
||||
public async Task SelectTrackStreaming(TrackEntity track)
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
|
||||
// Resume AudioContext immediately on track selection (user interaction) to avoid clicks later
|
||||
await _audioInterop.EnsureAudioContextReady(PlayerId);
|
||||
|
||||
await NotifyTrackSelected();
|
||||
|
||||
await LoadTrackStreaming(track);
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
private async Task LoadTrackStreaming(TrackEntity track)
|
||||
{
|
||||
// Always reset to clean state before loading new track. ResetToIdle
|
||||
// both cancels and awaits any in-flight streaming loop, so by the time
|
||||
// we return from it the previous loop is guaranteed to have exited and
|
||||
// there is no risk of interleaved ProcessStreamingChunk calls against
|
||||
// the single-instance JS StreamDecoder.
|
||||
await ResetToIdle();
|
||||
|
||||
// Save track ID for seek operations
|
||||
_currentTrackId = track.EntryKey;
|
||||
// Expose to UI immediately — Now-Playing surfaces should reflect the selected
|
||||
// track while it's still loading, not only after playback starts.
|
||||
CurrentTrack = track;
|
||||
|
||||
// Create new cancellation token for this streaming operation
|
||||
_streamingCancellation = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
// Set state to indicate loading has started
|
||||
ErrorMessage = null;
|
||||
LoadProgress = 0;
|
||||
IsLoading = true;
|
||||
IsStreamingMode = true;
|
||||
|
||||
// Reset adaptive buffer sizing
|
||||
_currentBufferSize = DefaultBufferSize;
|
||||
_consecutiveSlowReads = 0;
|
||||
|
||||
await NotifyStateChanged();
|
||||
|
||||
// Pass the streaming token to the HTTP layer so a navigation/track switch
|
||||
// aborts the server connection instead of leaving it draining bytes.
|
||||
var mediaResult = await _trackMediaClient.GetTrackMedia(
|
||||
track.EntryKey,
|
||||
byteOffset: 0,
|
||||
cancellationToken: _streamingCancellation.Token);
|
||||
if (!mediaResult.Success)
|
||||
{
|
||||
var technicalError = mediaResult.GetMessage();
|
||||
_logger.LogError("Failed to get track media for {TrackId}: {Error}",
|
||||
track.EntryKey, technicalError);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaResult.Value == null)
|
||||
{
|
||||
const string technicalError = "No audio returned from server";
|
||||
_logger.LogError("No audio data returned for track {TrackId}", track.EntryKey);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
return;
|
||||
}
|
||||
|
||||
using var audio = mediaResult.Value;
|
||||
|
||||
// Initialize streaming mode with content length
|
||||
var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, audio.ContentLength);
|
||||
if (!streamingResult.Success)
|
||||
{
|
||||
var technicalError = $"Failed to initialize streaming: {streamingResult.Error}";
|
||||
_logger.LogError("Streaming initialization failed for track {TrackId}: {Error}",
|
||||
track.EntryKey, technicalError);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
return;
|
||||
}
|
||||
|
||||
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token);
|
||||
await _activeStreamingTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cancellation is expected, reset state
|
||||
_logger.LogDebug("Audio streaming cancelled for track {TrackId}", track.EntryKey);
|
||||
IsLoaded = false;
|
||||
IsStreamingMode = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StreamingErrorHandler.LogError(_logger, ex, "LoadTrackStreaming", track.EntryKey);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
IsStreamingMode = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StreamAudioWithEarlyPlayback(TrackMediaResponse audio, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[]? buffer = null;
|
||||
try
|
||||
{
|
||||
long totalBytesRead = 0;
|
||||
buffer = ArrayPool<byte>.Shared.Rent(MaxBufferSize); // Rent larger buffer to accommodate adaptive sizing
|
||||
int currentBytes;
|
||||
var readTimer = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
do
|
||||
{
|
||||
readTimer.Restart();
|
||||
currentBytes = await audio.Stream.ReadAsync(buffer, 0, _currentBufferSize, cancellationToken);
|
||||
readTimer.Stop();
|
||||
|
||||
// Adapt buffer size based on read performance
|
||||
AdaptBufferSize(currentBytes, readTimer.ElapsedMilliseconds);
|
||||
|
||||
if (currentBytes > 0)
|
||||
{
|
||||
totalBytesRead += currentBytes;
|
||||
|
||||
// Always slice to the exact number of bytes read. The pooled buffer
|
||||
// is rented at MaxBufferSize and may carry stale bytes past
|
||||
// currentBytes from a prior rental — handing the full array to JS
|
||||
// interop would serialise that garbage into the audio stream.
|
||||
var actualBuffer = buffer.AsSpan(0, currentBytes).ToArray();
|
||||
|
||||
// Process chunk for streaming
|
||||
var chunkResult = await _audioInterop.ProcessStreamingChunk(PlayerId, actualBuffer);
|
||||
if (!chunkResult.Success)
|
||||
{
|
||||
var error = $"Failed to process streaming chunk: {chunkResult.Error}";
|
||||
_logger.LogWarning("Chunk processing failed: {Error}", error);
|
||||
throw new Exception(error);
|
||||
}
|
||||
|
||||
// Update streaming state
|
||||
CanStartStreaming = chunkResult.CanStartStreaming;
|
||||
HeaderParsed = chunkResult.HeaderParsed;
|
||||
BufferedChunks = chunkResult.BufferCount;
|
||||
|
||||
// Set duration from WAV header when available (only set once)
|
||||
if (chunkResult.Duration.HasValue && Duration == null)
|
||||
{
|
||||
Duration = chunkResult.Duration.Value;
|
||||
_logger.LogInformation("Duration set from WAV header: {Duration:F2} seconds", Duration);
|
||||
}
|
||||
|
||||
// Start playback as soon as we can
|
||||
if (!_streamingPlaybackStarted && CanStartStreaming)
|
||||
{
|
||||
var playbackResult = await _audioInterop.StartStreamingPlayback(PlayerId);
|
||||
if (playbackResult.Success)
|
||||
{
|
||||
_streamingPlaybackStarted = true;
|
||||
IsPlaying = true;
|
||||
IsPaused = false;
|
||||
IsLoaded = true; // Track is loaded and ready to play (even if still downloading)
|
||||
ErrorMessage = null;
|
||||
await NotifyStateChanged(); // Immediate notification for critical state change
|
||||
}
|
||||
else
|
||||
{
|
||||
var technicalError = $"Failed to start streaming playback: {playbackResult.Error}";
|
||||
_logger.LogError("Failed to start playback: {Error}", technicalError);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
}
|
||||
}
|
||||
|
||||
// Update progress
|
||||
if (audio.ContentLength > 0)
|
||||
{
|
||||
LoadProgress = Math.Min(1.0, (double)totalBytesRead / audio.ContentLength);
|
||||
}
|
||||
|
||||
await ThrottledNotifyStateChanged();
|
||||
}
|
||||
} while (currentBytes > 0);
|
||||
|
||||
// Notify the JS decoder that the stream is finished. When the server omits
|
||||
// Content-Length the StreamDecoder cannot determine completion via byte counting
|
||||
// alone; this explicit signal ensures the tail-decoding path (streamComplete=true)
|
||||
// fires regardless of whether Content-Length was present.
|
||||
await _audioInterop.MarkStreamCompleteAsync(PlayerId);
|
||||
|
||||
// Mark as fully loaded
|
||||
LoadProgress = 1.0;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StreamingErrorHandler.LogError(_logger, ex, "StreamAudioWithEarlyPlayback");
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
|
||||
LoadProgress = 0;
|
||||
IsLoaded = false;
|
||||
IsStreamingMode = false;
|
||||
await NotifyStateChanged();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (buffer != null)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In streaming mode, Stop fully resets to Idle state since audio data is consumed.
|
||||
/// This is equivalent to Unload for streaming playback.
|
||||
/// </summary>
|
||||
public override async Task Stop()
|
||||
{
|
||||
// In streaming mode, Stop = Unload (data is consumed, can't replay)
|
||||
await ResetToIdle();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fully resets the player to Idle state, ready for a new track.
|
||||
/// </summary>
|
||||
public override async Task Unload()
|
||||
{
|
||||
await ResetToIdle();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override Seek to handle seek-beyond-buffer for streaming mode.
|
||||
/// </summary>
|
||||
public override async Task Seek(double position)
|
||||
{
|
||||
if (!IsLoaded || !IsStreamingMode) return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _audioInterop.SeekAsync(PlayerId, position);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
if (result.SeekBeyondBuffer && result.ByteOffset > 0)
|
||||
{
|
||||
// Need to load new stream from offset
|
||||
_logger.LogInformation("Seeking beyond buffer to {Position:F2}s, byte offset: {ByteOffset}",
|
||||
position, result.ByteOffset);
|
||||
await SeekBeyondBuffer(position, result.ByteOffset);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Seek within buffer succeeded
|
||||
CurrentTime = position;
|
||||
ErrorMessage = null;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = $"Seek error: {result.Error}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error seeking to position {Position}", position);
|
||||
ErrorMessage = $"Error seeking: {ex.Message}";
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle seeking beyond the currently buffered content by requesting a new stream from offset.
|
||||
/// </summary>
|
||||
private async Task SeekBeyondBuffer(double seekPosition, long byteOffset)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_currentTrackId))
|
||||
{
|
||||
ErrorMessage = "Cannot seek - no track loaded";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSeekingBeyondBuffer = true;
|
||||
|
||||
// Cancel the current streaming loop AND wait for it to fully exit before
|
||||
// starting a new one. The previous loop's pending ReadAsync will throw
|
||||
// OperationCanceledException asynchronously; if we kick off a new loop
|
||||
// immediately, both can race against the single-instance JS StreamDecoder
|
||||
// and corrupt decode state. Draining here is the load-bearing guarantee.
|
||||
_streamingCancellation?.Cancel();
|
||||
await DrainActiveStreamingTaskAsync();
|
||||
_streamingCancellation?.Dispose();
|
||||
_streamingCancellation = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
// Update UI immediately
|
||||
CurrentTime = seekPosition;
|
||||
await NotifyStateChanged();
|
||||
|
||||
// Request new stream from offset
|
||||
var mediaResult = await _trackMediaClient.GetTrackMedia(
|
||||
_currentTrackId,
|
||||
byteOffset,
|
||||
cancellationToken: _streamingCancellation.Token);
|
||||
if (!mediaResult.Success || mediaResult.Value == null)
|
||||
{
|
||||
var technicalError = mediaResult.GetMessage() ?? "Failed to load audio from position";
|
||||
_logger.LogError("Failed to get track media from offset {Offset}: {Error}", byteOffset, technicalError);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
|
||||
IsSeekingBeyondBuffer = false;
|
||||
return;
|
||||
}
|
||||
|
||||
using var audio = mediaResult.Value;
|
||||
|
||||
// Reinitialize JS player for offset streaming
|
||||
var reinitResult = await _audioInterop.ReinitializeFromOffset(PlayerId, audio.ContentLength, seekPosition);
|
||||
if (!reinitResult.Success)
|
||||
{
|
||||
_logger.LogError("Failed to reinitialize for offset streaming: {Error}", reinitResult.Error);
|
||||
ErrorMessage = "Failed to seek to position";
|
||||
IsSeekingBeyondBuffer = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset streaming state for new stream
|
||||
_streamingPlaybackStarted = false;
|
||||
CanStartStreaming = false;
|
||||
HeaderParsed = false;
|
||||
BufferedChunks = 0;
|
||||
|
||||
// Stream audio from offset
|
||||
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token);
|
||||
await _activeStreamingTask;
|
||||
|
||||
IsSeekingBeyondBuffer = false;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Another seek or stop interrupted this one
|
||||
_logger.LogDebug("Seek beyond buffer cancelled");
|
||||
IsSeekingBeyondBuffer = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during seek beyond buffer to position {Position}", seekPosition);
|
||||
ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
|
||||
IsSeekingBeyondBuffer = false;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single method to reset all state - called by both Stop and Unload.
|
||||
/// </summary>
|
||||
private async Task ResetToIdle()
|
||||
{
|
||||
// 1. Cancel any ongoing streaming operation and wait for it to exit
|
||||
// before tearing down JS state. Otherwise the loop's pending
|
||||
// ProcessStreamingChunk call can land after StopAsync/UnloadAsync.
|
||||
_streamingCancellation?.Cancel();
|
||||
await DrainActiveStreamingTaskAsync();
|
||||
_streamingCancellation?.Dispose();
|
||||
_streamingCancellation = null;
|
||||
|
||||
// 2. Tell JS to stop and unload
|
||||
try
|
||||
{
|
||||
await _audioInterop.StopAsync(PlayerId);
|
||||
await _audioInterop.UnloadAsync(PlayerId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore JS errors during cleanup
|
||||
}
|
||||
|
||||
// 3. Reset ALL state to Idle
|
||||
IsPlaying = false;
|
||||
IsPaused = false;
|
||||
IsLoaded = false;
|
||||
IsLoading = false;
|
||||
CurrentTime = 0;
|
||||
Duration = null;
|
||||
LoadProgress = 0;
|
||||
ErrorMessage = null;
|
||||
CurrentTrack = null;
|
||||
|
||||
// 4. Reset streaming-specific state
|
||||
IsStreamingMode = false;
|
||||
CanStartStreaming = false;
|
||||
HeaderParsed = false;
|
||||
BufferedChunks = 0;
|
||||
_streamingPlaybackStarted = false;
|
||||
IsSeekingBeyondBuffer = false;
|
||||
_currentTrackId = null;
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for the previously-started streaming loop to fully exit. The caller
|
||||
/// must have already cancelled <see cref="_streamingCancellation"/>. Swallows
|
||||
/// the expected OperationCanceledException; any other exception was already
|
||||
/// surfaced through the loop's own catch block, so we ignore it here too.
|
||||
/// </summary>
|
||||
private async Task DrainActiveStreamingTaskAsync()
|
||||
{
|
||||
var task = _activeStreamingTask;
|
||||
if (task == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected when we cancelled the loop ourselves.
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Any other failure was already logged inside the loop.
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Only clear if we are still the active task — a concurrent caller
|
||||
// may have started a new stream while we were draining the old one.
|
||||
if (ReferenceEquals(_activeStreamingTask, task))
|
||||
{
|
||||
_activeStreamingTask = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ThrottledNotifyStateChanged()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if ((now - _lastNotification).TotalMilliseconds >= NotificationThrottleMs)
|
||||
{
|
||||
_lastNotification = now;
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On component unmount we must cancel the in-flight streaming loop and tear
|
||||
/// down JS callbacks before the JS side's setInterval fires again with a
|
||||
/// stale DotNetObjectReference. ResetToIdle covers cancellation + JS stop
|
||||
/// + state reset; the base then disposes the JS player and its callbacks.
|
||||
/// </summary>
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ResetToIdle();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disposal must not throw; any failure here is best-effort cleanup.
|
||||
}
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
private void AdaptBufferSize(int bytesRead, long readTimeMs)
|
||||
{
|
||||
// Adaptive buffer sizing based on network performance
|
||||
if (readTimeMs > 100) // Slow read (>100ms)
|
||||
{
|
||||
_consecutiveSlowReads++;
|
||||
if (_consecutiveSlowReads >= 3 && _currentBufferSize > MinBufferSize)
|
||||
{
|
||||
// Reduce buffer size for slow connections
|
||||
_currentBufferSize = Math.Max(MinBufferSize, _currentBufferSize / 2);
|
||||
_consecutiveSlowReads = 0;
|
||||
}
|
||||
}
|
||||
else if (readTimeMs < 20 && bytesRead == _currentBufferSize) // Fast read, buffer fully utilized
|
||||
{
|
||||
_consecutiveSlowReads = 0;
|
||||
if (_currentBufferSize < MaxBufferSize)
|
||||
{
|
||||
// Increase buffer size for fast connections
|
||||
_currentBufferSize = Math.Min(MaxBufferSize, _currentBufferSize * 2);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_consecutiveSlowReads = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
public static class StreamingErrorHandler
|
||||
{
|
||||
public static string GetUserFriendlyMessage(string technicalError)
|
||||
{
|
||||
var lowerError = technicalError.ToLowerInvariant();
|
||||
|
||||
return lowerError switch
|
||||
{
|
||||
_ when lowerError.Contains("network") || lowerError.Contains("connection") || lowerError.Contains("timeout") =>
|
||||
"Unable to load audio. Please check your connection and try again.",
|
||||
|
||||
_ when lowerError.Contains("header") || lowerError.Contains("wav") || lowerError.Contains("invalid wav") =>
|
||||
"This file format is not supported. Only WAV files can be played.",
|
||||
|
||||
_ when lowerError.Contains("audio") || lowerError.Contains("decode") || lowerError.Contains("format") =>
|
||||
"This audio file may be corrupted or in an unsupported format.",
|
||||
|
||||
_ when lowerError.Contains("cancel") || lowerError.Contains("abort") =>
|
||||
"Audio loading was cancelled.",
|
||||
|
||||
_ => "Unable to play audio. Please try again."
|
||||
};
|
||||
}
|
||||
|
||||
public static void LogError(ILogger logger, Exception ex, string operation, string trackId = "")
|
||||
{
|
||||
logger.LogError(ex, "Streaming error in {Operation} for track {TrackId}", operation, trackId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user