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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user