Fix streaming majors: PCM-only validation, stream-from-disk, ConcatStream offset, AsyncDisposable, HTTP cancellation, await ensureReady, seekBeyondBuffer offset-0 guard, negative WAV chunk guard

This commit is contained in:
Daniel Harvey
2026-05-17 16:57:20 -04:00
parent fc5b8de81a
commit 02d146ce02
12 changed files with 481 additions and 68 deletions
+15 -6
View File
@@ -7,7 +7,7 @@ public class TrackMediaResponse : IDisposable
{
public Stream Stream { get; }
public long ContentLength { get; }
public TrackMediaResponse(Stream stream, long contentLength)
{
Stream = stream;
@@ -23,13 +23,22 @@ public class TrackMediaResponse : IDisposable
public class TrackMediaClient
{
private readonly HttpClient _http;
public TrackMediaClient(IHttpClientFactory httpClientFactory)
{
_http = httpClientFactory.CreateClient("DeepDrft.Content");
}
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(string trackId, long byteOffset = 0)
/// <summary>
/// Fetches the WAV stream for a track, optionally starting from a byte offset.
/// The cancellation token is forwarded to <see cref="HttpClient.GetAsync"/> so a
/// navigation or seek-replacement aborts the in-flight server connection rather
/// than leaving the server draining bytes into a dead socket.
/// </summary>
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
string trackId,
long byteOffset = 0,
CancellationToken cancellationToken = default)
{
try
{
@@ -39,11 +48,11 @@ public class TrackMediaClient
: $"api/track/{trackId}";
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength ?? 0;
var stream = await response.Content.ReadAsStreamAsync();
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength));
}
@@ -52,4 +61,4 @@ public class TrackMediaClient
return ApiResult<TrackMediaResponse>.CreateFailResult(e.Message);
}
}
}
}
@@ -1,20 +1,20 @@
using DeepDrftWeb.Client.Services;
using DeepDrftWeb.Client.Services;
using DeepDrftWeb.Client.Clients;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
namespace DeepDrftWeb.Client.Controls;
public partial class AudioPlayerProvider : ComponentBase
public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
{
[Inject] public required AudioInteropService AudioInterop { get; set; }
[Inject] public required TrackMediaClient TrackMediaClient { get; set; }
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
private StreamingAudioPlayerService? _audioPlayerService;
[Parameter] public RenderFragment? ChildContent { get; set; }
protected override void OnInitialized()
{
// Create the service immediately (but don't initialize yet)
@@ -25,7 +25,7 @@ public partial class AudioPlayerProvider : ComponentBase
_audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged));
// OnTrackSelected will be set by individual child components that need it
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && _audioPlayerService != null)
@@ -35,4 +35,18 @@ public partial class AudioPlayerProvider : ComponentBase
StateHasChanged();
}
}
}
/// <summary>
/// Dispose the player on unmount so the JS setInterval driving progress
/// callbacks no longer holds a DotNetObjectReference into a destroyed
/// component (otherwise it throws every 100ms after navigation away).
/// </summary>
public async ValueTask DisposeAsync()
{
if (_audioPlayerService != null)
{
await _audioPlayerService.DisposeAsync();
_audioPlayerService = null;
}
}
}
@@ -392,7 +392,7 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
await OnTrackSelected.Value.InvokeAsync();
}
public async ValueTask DisposeAsync()
public virtual async ValueTask DisposeAsync()
{
if (IsInitialized)
{
@@ -90,7 +90,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
await NotifyStateChanged();
var mediaResult = await _trackMediaClient.GetTrackMedia(track.EntryKey);
// 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();
@@ -346,7 +351,10 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
await NotifyStateChanged();
// Request new stream from offset
var mediaResult = await _trackMediaClient.GetTrackMedia(_currentTrackId, byteOffset);
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";
@@ -485,6 +493,25 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
}
}
/// <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