.CreatePassResult(new TrackMediaResponse(stream, contentLength));
}
catch (Exception e)
diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor b/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor
index f01c528..7f09ef6 100644
--- a/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor
+++ b/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor
@@ -42,7 +42,7 @@ else
Step="0.1"
Value="@CurrentTime"
ValueChanged="@OnSeek"
- Disabled="@(!IsLoaded || IsStreamingMode)"/>
+ Disabled="@(!CanSeek)"/>
@@ -77,7 +77,7 @@ else
Step="0.1"
Value="@CurrentTime"
ValueChanged="@OnSeek"
- Disabled="@(!IsLoaded || IsStreamingMode)"/>
+ Disabled="@(!CanSeek)"/>
@* Control Buttons - positioned absolutely like original *@
diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs b/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs
index 3ac2f66..9cebc41 100644
--- a/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs
+++ b/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs
@@ -22,6 +22,12 @@ public partial class AudioPlayerBar : ComponentBase
private double LoadProgress => PlayerService.LoadProgress;
private string? ErrorMessage => PlayerService.ErrorMessage;
+ ///
+ /// 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.
+ ///
+ private bool CanSeek => IsLoaded && Duration.HasValue && Duration.Value > 0;
+
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
diff --git a/DeepDrftWeb.Client/Services/AudioInteropService.cs b/DeepDrftWeb.Client/Services/AudioInteropService.cs
index d81e7fe..23a574c 100644
--- a/DeepDrftWeb.Client/Services/AudioInteropService.cs
+++ b/DeepDrftWeb.Client/Services/AudioInteropService.cs
@@ -81,9 +81,39 @@ public class AudioInteropService : IAsyncDisposable
return await InvokeJsAsync("DeepDrftAudio.unload", playerId);
}
- public async Task SeekAsync(string playerId, double position)
+ public async Task SeekAsync(string playerId, double position)
{
- return await InvokeJsAsync("DeepDrftAudio.seek", playerId, position);
+ return await InvokeJsAsync("DeepDrftAudio.seek", playerId, position);
+ }
+
+ // New methods for seek-beyond-buffer support
+ public async Task GetBufferedDuration(string playerId)
+ {
+ try
+ {
+ return await _jsRuntime.InvokeAsync("DeepDrftAudio.getBufferedDuration", playerId);
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ public async Task CalculateByteOffset(string playerId, double positionSeconds)
+ {
+ try
+ {
+ return (long)await _jsRuntime.InvokeAsync("DeepDrftAudio.calculateByteOffset", playerId, positionSeconds);
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ public async Task ReinitializeFromOffset(string playerId, long totalStreamLength, double seekPosition)
+ {
+ return await InvokeJsAsync("DeepDrftAudio.reinitializeFromOffset", playerId, totalStreamLength, seekPosition);
}
public async Task 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; }
diff --git a/DeepDrftWeb.Client/Services/AudioPlayerService.cs b/DeepDrftWeb.Client/Services/AudioPlayerService.cs
index 6a63d40..8915d0e 100644
--- a/DeepDrftWeb.Client/Services/AudioPlayerService.cs
+++ b/DeepDrftWeb.Client/Services/AudioPlayerService.cs
@@ -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)
diff --git a/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs
index 88ff132..8ee643f 100644
--- a/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs
+++ b/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs
@@ -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 _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();
}
+ ///
+ /// Override Seek to handle seek-beyond-buffer for streaming mode.
+ ///
+ 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();
+ }
+ }
+
+ ///
+ /// Handle seeking beyond the currently buffered content by requesting a new stream from offset.
+ ///
+ 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();
+ }
+ }
+
///
/// Single method to reset all state - called by both Stop and Unload.
///
@@ -291,6 +411,8 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
HeaderParsed = false;
BufferedChunks = 0;
_streamingPlaybackStarted = false;
+ IsSeekingBeyondBuffer = false;
+ _currentTrackId = null;
await NotifyStateChanged();
}
diff --git a/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts b/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts
index 37e769f..6afebe5 100644
--- a/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts
+++ b/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts
@@ -26,6 +26,11 @@ export class PlaybackScheduler {
private nextScheduleTime: number = 0; // AudioContext time for next buffer
private isActive_: boolean = false; // Prevents scheduling during pause/stop
+ // Offset for seek-beyond-buffer scenarios
+ // When seeking to position T beyond buffers, we clear buffers and set playbackOffset = T
+ // The new stream starts at T, so buffer positions are relative to T
+ private playbackOffset: number = 0;
+
// Callbacks
public onPlaybackEnded: (() => void) | null = null;
@@ -56,14 +61,30 @@ export class PlaybackScheduler {
}
/**
- * Get current playback position in seconds
+ * Get current playback position in seconds (includes playbackOffset for seek-beyond-buffer)
*/
getCurrentPosition(): number {
if (this.playbackAnchorTime === 0) {
- return this.playbackAnchorPosition;
+ return this.playbackAnchorPosition + this.playbackOffset;
}
const elapsed = this.contextManager.currentTime - this.playbackAnchorTime;
- return Math.min(this.playbackAnchorPosition + elapsed, this.getTotalDuration());
+ return Math.min(this.playbackAnchorPosition + this.playbackOffset + elapsed, this.getTotalDuration() + this.playbackOffset);
+ }
+
+ /**
+ * Set the playback offset for seek-beyond-buffer scenarios
+ * This represents the absolute time position where the current buffers start
+ */
+ setPlaybackOffset(offset: number): void {
+ this.playbackOffset = offset;
+ console.log(`📍 Playback offset set to ${offset.toFixed(3)}s`);
+ }
+
+ /**
+ * Get the current playback offset
+ */
+ getPlaybackOffset(): number {
+ return this.playbackOffset;
}
/**
@@ -244,7 +265,7 @@ export class PlaybackScheduler {
}
/**
- * Full reset - clears all buffers
+ * Full reset - clears all buffers and resets offset
*/
clear(): void {
this.isActive_ = false;
@@ -254,9 +275,25 @@ export class PlaybackScheduler {
this.playbackAnchorTime = 0;
this.nextBufferIndex = 0;
this.nextScheduleTime = 0;
+ this.playbackOffset = 0;
console.log('🗑️ Scheduler cleared');
}
+ /**
+ * Clear buffers but keep offset - for seek-beyond-buffer scenarios
+ */
+ clearForSeek(): void {
+ this.isActive_ = false;
+ this.stopAllSources();
+ this.buffers = [];
+ this.playbackAnchorPosition = 0;
+ this.playbackAnchorTime = 0;
+ this.nextBufferIndex = 0;
+ this.nextScheduleTime = 0;
+ // Note: playbackOffset is NOT reset - it will be set by the caller
+ console.log('🗑️ Scheduler cleared for seek (offset preserved)');
+ }
+
/**
* Check if we have buffers
*/
diff --git a/DeepDrftWeb/Interop/audio/StreamDecoder.ts b/DeepDrftWeb/Interop/audio/StreamDecoder.ts
index 7215aa0..3f4b68a 100644
--- a/DeepDrftWeb/Interop/audio/StreamDecoder.ts
+++ b/DeepDrftWeb/Interop/audio/StreamDecoder.ts
@@ -202,6 +202,25 @@ export class StreamDecoder {
return this.totalStreamLength > 0 && this.totalRawBytes >= (this.totalStreamLength - (this.wavHeader?.headerSize ?? 0));
}
+ /**
+ * Get the WAV header info for byte offset calculation
+ */
+ getWavHeader(): WavHeader | null {
+ return this.wavHeader;
+ }
+
+ /**
+ * Calculate byte offset from a time position (in seconds)
+ * Returns block-aligned byte offset for clean audio
+ */
+ calculateByteOffset(positionSeconds: number): number {
+ if (!this.wavHeader || this.wavHeader.byteRate <= 0) return 0;
+
+ const rawOffset = Math.floor(positionSeconds * this.wavHeader.byteRate);
+ // Align to block boundary for clean audio
+ return Math.floor(rawOffset / this.wavHeader.blockAlign) * this.wavHeader.blockAlign;
+ }
+
/**
* Reset decoder state
*/
@@ -213,4 +232,20 @@ export class StreamDecoder {
this.isFirstChunk = true;
this.totalStreamLength = 0;
}
+
+ /**
+ * Reinitialize for offset streaming - preserves header format knowledge
+ * Called when seeking beyond buffer to prepare for new stream from server
+ */
+ reinitializeForOffset(totalStreamLength: number): void {
+ // Reset data state but we'll get a fresh header from the offset stream
+ this.rawChunks = [];
+ this.totalRawBytes = 0;
+ this.processedBytes = 0;
+ this.isFirstChunk = true;
+ this.totalStreamLength = totalStreamLength;
+ // wavHeader will be reparsed from the new stream (server sends fresh header)
+ this.wavHeader = null;
+ console.log(`StreamDecoder reinitialized for offset: expecting ${totalStreamLength} bytes`);
+ }
}
diff --git a/DeepDrftWeb/Interop/audio/index.ts b/DeepDrftWeb/Interop/audio/index.ts
index 548aa2d..69d41b5 100644
--- a/DeepDrftWeb/Interop/audio/index.ts
+++ b/DeepDrftWeb/Interop/audio/index.ts
@@ -81,6 +81,23 @@ const DeepDrftAudio = {
return player.seek(position);
},
+ // New methods for seek-beyond-buffer support
+ getBufferedDuration: (playerId: string): number => {
+ const player = audioPlayers.get(playerId);
+ return player?.getBufferedDuration() ?? 0;
+ },
+
+ calculateByteOffset: (playerId: string, positionSeconds: number): number => {
+ const player = audioPlayers.get(playerId);
+ return player?.calculateByteOffset(positionSeconds) ?? 0;
+ },
+
+ reinitializeFromOffset: (playerId: string, totalStreamLength: number, seekPosition: number): AudioResult => {
+ const player = audioPlayers.get(playerId);
+ if (!player) return { success: false, error: 'Player not found' };
+ return player.reinitializeFromOffset(totalStreamLength, seekPosition);
+ },
+
setVolume: (playerId: string, volume: number): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };