Streaming Seek Support
This commit is contained in:
@@ -29,17 +29,22 @@ public class TrackMediaClient
|
||||
_http = httpClientFactory.CreateClient("DeepDrft.Content");
|
||||
}
|
||||
|
||||
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(string trackId)
|
||||
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(string trackId, long byteOffset = 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Build URL with optional offset parameter
|
||||
var url = byteOffset > 0
|
||||
? $"api/track/{trackId}?offset={byteOffset}"
|
||||
: $"api/track/{trackId}";
|
||||
|
||||
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
|
||||
var response = await _http.GetAsync($"api/track/{trackId}", HttpCompletionOption.ResponseHeadersRead);
|
||||
var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
|
||||
var contentLength = response.Content.Headers.ContentLength ?? 0;
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
|
||||
|
||||
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength));
|
||||
}
|
||||
catch (Exception e)
|
||||
|
||||
@@ -42,7 +42,7 @@ else
|
||||
Step="0.1"
|
||||
Value="@CurrentTime"
|
||||
ValueChanged="@OnSeek"
|
||||
Disabled="@(!IsLoaded || IsStreamingMode)"/>
|
||||
Disabled="@(!CanSeek)"/>
|
||||
</div>
|
||||
|
||||
<div class="volume-right">
|
||||
@@ -77,7 +77,7 @@ else
|
||||
Step="0.1"
|
||||
Value="@CurrentTime"
|
||||
ValueChanged="@OnSeek"
|
||||
Disabled="@(!IsLoaded || IsStreamingMode)"/>
|
||||
Disabled="@(!CanSeek)"/>
|
||||
</div>
|
||||
|
||||
@* Control Buttons - positioned absolutely like original *@
|
||||
|
||||
@@ -22,6 +22,12 @@ public partial class AudioPlayerBar : ComponentBase
|
||||
private double LoadProgress => PlayerService.LoadProgress;
|
||||
private string? ErrorMessage => PlayerService.ErrorMessage;
|
||||
|
||||
/// <summary>
|
||||
/// Seek is enabled once track is loaded AND duration is known (from WAV header).
|
||||
/// This allows seeking even during streaming, including seeking beyond buffered content.
|
||||
/// </summary>
|
||||
private bool CanSeek => IsLoaded && Duration.HasValue && Duration.Value > 0;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
@@ -81,9 +81,39 @@ public class AudioInteropService : IAsyncDisposable
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.unload", playerId);
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> SeekAsync(string playerId, double position)
|
||||
public async Task<SeekResult> SeekAsync(string playerId, double position)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.seek", playerId, 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)
|
||||
@@ -148,6 +178,8 @@ public class AudioInteropService : IAsyncDisposable
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -217,6 +249,12 @@ public class AudioOperationResult
|
||||
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; }
|
||||
|
||||
@@ -298,7 +298,7 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Seek(double position)
|
||||
public virtual async Task Seek(double position)
|
||||
{
|
||||
if (!IsLoaded) return;
|
||||
|
||||
@@ -314,7 +314,7 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
{
|
||||
ErrorMessage = $"Seek error: {result.Error}";
|
||||
}
|
||||
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -23,11 +23,13 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
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 DateTime _lastNotification = DateTime.MinValue;
|
||||
private readonly ILogger<StreamingAudioPlayerService> _logger;
|
||||
private string? _currentTrackId;
|
||||
|
||||
public StreamingAudioPlayerService(
|
||||
AudioInteropService audioInterop,
|
||||
@@ -63,6 +65,9 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
// Always reset to clean state before loading new track
|
||||
await ResetToIdle();
|
||||
|
||||
// Save track ID for seek operations
|
||||
_currentTrackId = track.EntryKey;
|
||||
|
||||
// Create new cancellation token for this streaming operation
|
||||
_streamingCancellation = new CancellationTokenSource();
|
||||
|
||||
@@ -254,6 +259,121 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
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 current streaming
|
||||
_streamingCancellation?.Cancel();
|
||||
_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);
|
||||
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
|
||||
await StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token);
|
||||
|
||||
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>
|
||||
@@ -291,6 +411,8 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
HeaderParsed = false;
|
||||
BufferedChunks = 0;
|
||||
_streamingPlaybackStarted = false;
|
||||
IsSeekingBeyondBuffer = false;
|
||||
_currentTrackId = null;
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user