fix(audiobook-reader): use preload=none with explicit load() for faster streaming (#2520)

* fix(audiobook-reader): use preload=none with API duration for instant load

* fix(audiobook-reader): debounce seek and limit streaming chunk size to 2MB

* no message

---------

Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
ACX
2026-01-29 17:45:10 -07:00
committed by GitHub
parent 17aa633b41
commit 67269ffe4c
2 changed files with 57 additions and 124 deletions

View File

@@ -12,25 +12,13 @@ import java.net.SocketTimeoutException;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Generic file streaming service with HTTP Range support.
* Can be used for streaming audio, video, or any binary files.
*/
@Slf4j
@Service
public class FileStreamingService {
private static final int BUFFER_SIZE = 8192;
private static final long MAX_CHUNK_SIZE = 2 * 1024 * 1024;
/**
* Stream a file with HTTP Range support for seeking/scrubbing.
* Implements HTTP 206 Partial Content for range requests.
*
* @param filePath Path to the file to stream
* @param contentType MIME type of the file
* @param request HTTP request (to read Range header)
* @param response HTTP response (to write headers and body)
*/
public void streamWithRangeSupport(Path filePath, String contentType, HttpServletRequest request, HttpServletResponse response) throws IOException {
if (!Files.exists(filePath)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "File not found");
@@ -40,28 +28,23 @@ public class FileStreamingService {
long fileSize = Files.size(filePath);
String rangeHeader = request.getHeader("Range");
// Always indicate we accept range requests
response.setHeader("Accept-Ranges", "bytes");
response.setContentType(contentType);
response.setHeader("Cache-Control", "public, max-age=3600");
try {
if (rangeHeader == null) {
// No Range header - return full file
response.setStatus(HttpServletResponse.SC_OK);
response.setContentLengthLong(fileSize);
streamBytes(filePath, 0, fileSize - 1, response.getOutputStream());
} else {
// Parse Range header
RangeInfo range = parseRange(rangeHeader, fileSize);
if (range == null) {
// Invalid range
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
response.setHeader("Content-Range", "bytes */" + fileSize);
return;
}
// Return partial content
long contentLength = range.end - range.start + 1;
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
response.setContentLengthLong(contentLength);
@@ -70,7 +53,6 @@ public class FileStreamingService {
streamBytes(filePath, range.start, range.end, response.getOutputStream());
}
} catch (IOException e) {
// Client disconnected (e.g., during seeking) - this is normal, just log at debug level
if (isClientDisconnect(e)) {
log.debug("Client disconnected during streaming: {}", e.getMessage());
} else {
@@ -79,18 +61,13 @@ public class FileStreamingService {
}
}
/**
* Check if the exception is due to client disconnect (common during seeking).
*/
boolean isClientDisconnect(IOException e) {
// SocketTimeoutException is common when client disconnects
if (e instanceof SocketTimeoutException) {
return true;
}
String message = e.getMessage();
if (message == null) {
// Check the exception class name for common disconnect types
String className = e.getClass().getSimpleName();
return className.contains("Timeout") || className.contains("Closed");
}
@@ -103,10 +80,6 @@ public class FileStreamingService {
message.contains("timed out");
}
/**
* Parse HTTP Range header and return start/end byte positions.
* Supports formats: "bytes=start-end", "bytes=start-", "bytes=-suffix"
*/
RangeInfo parseRange(String rangeHeader, long fileSize) {
if (!rangeHeader.startsWith("bytes=")) {
return null;
@@ -118,7 +91,6 @@ public class FileStreamingService {
return null;
}
// Only handle first range (most common case)
String range = ranges[0].trim();
String[] parts = range.split("-", -1);
if (parts.length != 2) {
@@ -129,26 +101,21 @@ public class FileStreamingService {
long start, end;
if (parts[0].isEmpty()) {
// Suffix range: "-500" means last 500 bytes
long suffix = Long.parseLong(parts[1]);
start = Math.max(0, fileSize - suffix);
end = fileSize - 1;
} else if (parts[1].isEmpty()) {
// Open-ended range: "500-" means from byte 500 to end
start = Long.parseLong(parts[0]);
end = fileSize - 1;
end = Math.min(start + MAX_CHUNK_SIZE - 1, fileSize - 1);
} else {
// Full range: "500-999"
start = Long.parseLong(parts[0]);
end = Long.parseLong(parts[1]);
}
// Validate range
if (start < 0 || start >= fileSize || end < start) {
return null;
}
// Clamp end to file size
end = Math.min(end, fileSize - 1);
return new RangeInfo(start, end);
@@ -157,9 +124,6 @@ public class FileStreamingService {
}
}
/**
* Stream bytes from start to end (inclusive) using RandomAccessFile for efficient seeking.
*/
private void streamBytes(Path filePath, long start, long end, OutputStream outputStream) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(filePath.toFile(), "r")) {
raf.seek(start);
@@ -182,8 +146,5 @@ public class FileStreamingService {
}
}
/**
* Range information holder.
*/
record RangeInfo(long start, long end) {}
}

View File

@@ -54,17 +54,14 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
private audiobookSessionService = inject(AudiobookSessionService);
private pageTitle = inject(PageTitleService);
// Loading state
isLoading = true;
audioLoading = true;
// Book data
bookId!: number;
audiobookInfo!: AudiobookInfo;
coverUrl?: string;
bookCoverUrl?: string;
// Audio state
isPlaying = false;
currentTime = 0;
duration = 0;
@@ -74,20 +71,16 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
playbackRate = 1;
buffered = 0;
// Saved position to restore after audio loads
private savedPosition = 0;
// Track state (for folder-based audiobooks)
currentTrackIndex = 0;
audioSrc = '';
// UI state
showTrackList = false;
showBookmarkList = false;
// Sleep timer
sleepTimerActive = false;
sleepTimerRemaining = 0; // seconds remaining
sleepTimerRemaining = 0;
sleepTimerEndOfChapter = false;
private sleepTimerInterval?: ReturnType<typeof setInterval>;
private originalVolume = 1;
@@ -102,10 +95,8 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
{ label: 'Cancel timer', command: () => this.cancelSleepTimer(), visible: false }
];
// Bookmarks
bookmarks: BookMark[] = [];
// Playback speed options
playbackRates = [
{ label: '0.5x', value: 0.5 },
{ label: '0.75x', value: 0.75 },
@@ -115,9 +106,11 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
{ label: '2x', value: 2 }
];
// Progress save interval
private progressSaveInterval?: ReturnType<typeof setInterval>;
private seekDebounceTimeout?: ReturnType<typeof setTimeout>;
private isSeeking = false;
ngOnInit(): void {
this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe(params => {
this.bookId = +params.get('bookId')!;
@@ -137,6 +130,10 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
clearInterval(this.sleepTimerInterval);
}
if (this.seekDebounceTimeout) {
clearTimeout(this.seekDebounceTimeout);
}
this.saveProgress();
if (this.audiobookSessionService.isSessionActive()) {
@@ -145,7 +142,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
}
private loadAudiobook(): void {
// Reset all state when loading a new audiobook
this.resetState();
this.isLoading = true;
@@ -157,32 +153,38 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
this.audiobookInfo = info;
this.pageTitle.setBookPageTitle(book);
// Set cover URL with auth token
const token = this.authService.getInternalAccessToken() || this.authService.getOidcAccessToken();
this.bookCoverUrl = `${API_CONFIG.BASE_URL}/api/v1/media/cover/${this.bookId}?token=${encodeURIComponent(token || '')}`;
this.coverUrl = this.audiobookService.getEmbeddedCoverUrl(this.bookId);
// Restore progress and load audio
if (book.audiobookProgress) {
// Store saved position - will be applied when audio loads
this.savedPosition = book.audiobookProgress.positionMs
? book.audiobookProgress.positionMs / 1000
: 0;
}
if (info.folderBased && info.tracks && info.tracks.length > 0) {
// Folder-based audiobook
const trackIndex = book.audiobookProgress?.trackIndex ?? 0;
this.currentTrackIndex = trackIndex;
this.loadTrack(trackIndex);
this.loadTrack(trackIndex, false);
const track = info.tracks[trackIndex];
if (track?.durationMs) {
this.duration = track.durationMs / 1000;
}
} else {
// Single-file audiobook
this.audioSrc = this.audiobookService.getStreamUrl(this.bookId);
if (info.durationMs) {
this.duration = info.durationMs / 1000;
}
}
this.isLoading = false;
this.audioLoading = false;
if (this.savedPosition > 0) {
this.currentTime = this.savedPosition;
}
// Load bookmarks
this.loadBookmarks();
},
error: () => {
@@ -196,22 +198,23 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
});
}
private loadTrack(index: number): void {
private loadTrack(index: number, showLoading = true): void {
if (!this.audiobookInfo.tracks || index < 0 || index >= this.audiobookInfo.tracks.length) {
return;
}
this.currentTrackIndex = index;
this.audioSrc = this.audiobookService.getTrackStreamUrl(this.bookId, index);
this.audioLoading = true;
// Reset buffered since it's a new track
this.audioLoading = showLoading;
this.buffered = 0;
const track = this.audiobookInfo.tracks[index];
if (track?.durationMs) {
this.duration = track.durationMs / 1000;
}
}
private resetState(): void {
// Stop any existing intervals
this.stopProgressSaveInterval();
// Reset audio state
this.isPlaying = false;
this.currentTime = 0;
this.duration = 0;
@@ -221,31 +224,28 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
this.audioSrc = '';
this.audioLoading = true;
// Reset UI state
this.showTrackList = false;
this.coverUrl = undefined;
this.bookCoverUrl = undefined;
}
// Audio event handlers
onAudioLoaded(): void {
this.audioLoading = false;
const audio = this.audioElement?.nativeElement;
if (audio) {
this.duration = audio.duration;
if (audio.duration && isFinite(audio.duration)) {
this.duration = audio.duration;
}
audio.volume = this.volume;
audio.playbackRate = this.playbackRate;
// Apply saved position now that we know the duration
if (this.savedPosition > 0 && this.savedPosition < this.duration) {
audio.currentTime = this.savedPosition;
this.currentTime = this.savedPosition;
this.savedPosition = 0;
}
// Setup Media Session for background playback
this.setupMediaSession();
// Note: Session will be started when user presses play, not on load
}
}
@@ -253,25 +253,23 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
const audio = this.audioElement?.nativeElement;
if (audio) {
const previousChapterIndex = this.getCurrentChapterIndex();
this.currentTime = audio.currentTime;
if (!this.isSeeking) {
this.currentTime = audio.currentTime;
}
// Update audiobook session position
this.audiobookSessionService.updatePosition(
Math.round(this.currentTime * 1000),
this.audiobookInfo?.folderBased ? this.currentTrackIndex : undefined
);
// Update Media Session position state (throttled to every 5 seconds)
if (Math.floor(this.currentTime) % 5 === 0) {
this.updateMediaSessionPositionState();
}
// Update metadata if chapter changed (for single-file audiobooks)
if (!this.audiobookInfo.folderBased && this.getCurrentChapterIndex() !== previousChapterIndex) {
this.updateMediaSessionMetadata();
}
// Check sleep timer end of chapter
this.checkSleepTimerEndOfChapter();
}
}
@@ -284,7 +282,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
}
onAudioEnded(): void {
// For folder-based audiobooks, play next track
if (this.audiobookInfo.folderBased && this.audiobookInfo.tracks) {
if (this.currentTrackIndex < this.audiobookInfo.tracks.length - 1) {
this.nextTrack();
@@ -293,7 +290,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
this.stopProgressSaveInterval();
this.saveProgress();
this.updateMediaSessionPlaybackState();
// Pause session when audiobook ends
this.audiobookSessionService.pauseSession(Math.round(this.currentTime * 1000));
}
} else {
@@ -301,7 +297,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
this.stopProgressSaveInterval();
this.saveProgress();
this.updateMediaSessionPlaybackState();
// Pause session when audiobook ends
this.audiobookSessionService.pauseSession(Math.round(this.currentTime * 1000));
}
}
@@ -315,7 +310,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
});
}
// Media Session API for background playback
private setupMediaSession(): void {
if (!('mediaSession' in navigator)) return;
@@ -378,12 +372,10 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
position: this.currentTime
});
} catch {
// Ignore errors from invalid position state
}
}
}
// Playback controls
togglePlay(): void {
const audio = this.audioElement?.nativeElement;
if (!audio) return;
@@ -392,12 +384,10 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
audio.pause();
this.stopProgressSaveInterval();
this.saveProgress();
// Pause the listening session
this.audiobookSessionService.pauseSession(Math.round(this.currentTime * 1000));
} else {
audio.play();
this.startProgressSaveInterval();
// Start or resume the listening session
if (this.audiobookSessionService.isSessionActive()) {
this.audiobookSessionService.resumeSession(Math.round(this.currentTime * 1000));
} else {
@@ -415,10 +405,21 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
}
seek(event: SliderChangeEvent): void {
const audio = this.audioElement?.nativeElement;
if (audio && this.duration > 0 && event.value !== undefined) {
audio.currentTime = event.value as number;
this.currentTime = event.value as number;
if (this.duration > 0 && event.value !== undefined) {
const seekTime = event.value as number;
this.isSeeking = true;
this.currentTime = seekTime;
if (this.seekDebounceTimeout) {
clearTimeout(this.seekDebounceTimeout);
}
this.seekDebounceTimeout = setTimeout(() => {
const audio = this.audioElement?.nativeElement;
if (audio) {
audio.currentTime = seekTime;
}
this.isSeeking = false;
}, 150);
}
}
@@ -431,7 +432,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
}
}
// Volume controls
setVolume(event: SliderChangeEvent): void {
const audio = this.audioElement?.nativeElement;
if (event.value !== undefined) {
@@ -459,7 +459,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
}
}
// Playback rate
setPlaybackRate(rate: number): void {
if (rate === undefined || rate === null) return;
const audio = this.audioElement?.nativeElement;
@@ -468,16 +467,14 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
audio.playbackRate = rate;
}
this.updateMediaSessionPositionState();
// Update session with new playback rate
this.audiobookSessionService.updatePlaybackRate(rate);
}
// Track navigation (folder-based)
previousTrack(): void {
if (this.currentTrackIndex > 0) {
this.loadTrack(this.currentTrackIndex - 1);
this.currentTime = 0;
this.savedPosition = 0; // Reset saved position for new track
this.savedPosition = 0;
if (this.isPlaying) {
setTimeout(() => {
this.audioElement?.nativeElement?.play();
@@ -491,7 +488,7 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
if (this.audiobookInfo.tracks && this.currentTrackIndex < this.audiobookInfo.tracks.length - 1) {
this.loadTrack(this.currentTrackIndex + 1);
this.currentTime = 0;
this.savedPosition = 0; // Reset saved position for new track
this.savedPosition = 0;
if (this.isPlaying) {
setTimeout(() => {
this.audioElement?.nativeElement?.play();
@@ -504,7 +501,7 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
selectTrack(track: AudiobookTrack): void {
this.loadTrack(track.index);
this.currentTime = 0;
this.savedPosition = 0; // Reset saved position for new track
this.savedPosition = 0;
this.showTrackList = false;
setTimeout(() => {
this.audioElement?.nativeElement?.play();
@@ -512,7 +509,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
this.startProgressSaveInterval();
this.updateMediaSessionMetadata();
this.updateMediaSessionPlaybackState();
// Start or resume listening session
if (this.audiobookSessionService.isSessionActive()) {
this.audiobookSessionService.resumeSession(0);
} else {
@@ -524,7 +520,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
}, 100);
}
// Chapter navigation (single-file)
selectChapter(chapter: AudiobookChapter): void {
const audio = this.audioElement?.nativeElement;
if (audio) {
@@ -537,7 +532,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
this.isPlaying = true;
this.startProgressSaveInterval();
this.updateMediaSessionPlaybackState();
// Start or resume listening session
if (this.audiobookSessionService.isSessionActive()) {
this.audiobookSessionService.resumeSession(chapter.startTimeMs);
} else {
@@ -599,9 +593,8 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
}
}
// Progress management - save every 5 seconds while playing
private startProgressSaveInterval(): void {
if (this.progressSaveInterval) return; // Already running
if (this.progressSaveInterval) return;
this.progressSaveInterval = setInterval(() => {
if (this.isPlaying) {
@@ -624,11 +617,9 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
const currentPosition = this.getCurrentTotalPosition();
const percentage = totalDuration > 0 ? (currentPosition / totalDuration) * 100 : 0;
// For folder-based: positionMs = track position (for seeking within track)
// For single-file: positionMs = absolute position
const positionMs = this.audiobookInfo.folderBased
? Math.round(this.currentTime * 1000) // Track position
: Math.round(currentPosition * 1000); // Absolute position
? Math.round(this.currentTime * 1000)
: Math.round(currentPosition * 1000);
const progress: AudiobookProgress = {
positionMs: positionMs,
@@ -658,7 +649,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
return this.currentTime;
}
// Utility methods
formatTime(seconds: number): string {
if (!seconds || !isFinite(seconds)) return '0:00';
const h = Math.floor(seconds / 3600);
@@ -684,12 +674,9 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
}
onCoverError(): void {
// Fallback to book cover if embedded cover fails
// Only fallback once to prevent infinite loop
if (this.coverUrl !== this.bookCoverUrl) {
this.coverUrl = this.bookCoverUrl;
} else {
// Both covers failed, use a placeholder
this.coverUrl = undefined;
}
}
@@ -716,8 +703,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
return this.audiobookInfo?.tracks?.[this.currentTrackIndex];
}
// ==================== SLEEP TIMER ====================
setSleepTimer(minutes: number): void {
this.cancelSleepTimer();
this.sleepTimerRemaining = minutes * 60;
@@ -729,7 +714,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
this.sleepTimerInterval = setInterval(() => {
this.sleepTimerRemaining--;
// Fade out volume in last 30 seconds
if (this.sleepTimerRemaining <= 30 && this.sleepTimerRemaining > 0) {
const fadeRatio = this.sleepTimerRemaining / 30;
const audio = this.audioElement?.nativeElement;
@@ -771,7 +755,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
this.sleepTimerInterval = undefined;
}
// Restore original volume if we were fading
if (this.sleepTimerActive && this.originalVolume > 0) {
const audio = this.audioElement?.nativeElement;
if (audio) {
@@ -798,7 +781,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
this.saveProgress();
this.cancelSleepTimer();
this.updateMediaSessionPlaybackState();
// Pause the listening session
this.audiobookSessionService.pauseSession(Math.round(this.currentTime * 1000));
this.messageService.add({
@@ -809,7 +791,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
}
private updateSleepTimerMenuVisibility(): void {
// Show/hide cancel option based on timer state
const cancelItem = this.sleepTimerOptions.find(item => item.label === 'Cancel timer');
if (cancelItem) {
cancelItem.visible = this.sleepTimerActive;
@@ -825,22 +806,18 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
// Check for end of chapter in onTimeUpdate for sleep timer
private checkSleepTimerEndOfChapter(): void {
if (!this.sleepTimerEndOfChapter || !this.sleepTimerActive) return;
const currentChapter = this.getCurrentChapter();
if (currentChapter) {
const currentMs = this.currentTime * 1000;
// If we're within 1 second of chapter end, trigger stop
if (currentMs >= currentChapter.endTimeMs - 1000) {
this.triggerSleepTimerStop();
}
}
}
// ==================== BOOKMARKS ====================
loadBookmarks(): void {
this.bookMarkService.getBookmarksForBook(this.bookId)
.pipe(takeUntil(this.destroy$))
@@ -899,14 +876,11 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
}
goToBookmark(bookmark: BookMark): void {
// Handle track switching for folder-based audiobooks
if (this.audiobookInfo.folderBased && bookmark.trackIndex !== undefined && bookmark.trackIndex !== null) {
if (bookmark.trackIndex !== this.currentTrackIndex) {
this.loadTrack(bookmark.trackIndex);
// Wait for track to load, then seek
this.savedPosition = (bookmark.positionMs || 0) / 1000;
} else {
// Same track, just seek
const audio = this.audioElement?.nativeElement;
if (audio && bookmark.positionMs) {
audio.currentTime = bookmark.positionMs / 1000;
@@ -914,7 +888,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
}
}
} else {
// Single-file audiobook
const audio = this.audioElement?.nativeElement;
if (audio && bookmark.positionMs) {
audio.currentTime = bookmark.positionMs / 1000;
@@ -929,7 +902,6 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy {
this.audioElement?.nativeElement?.play();
this.isPlaying = true;
this.startProgressSaveInterval();
// Start or resume listening session
const positionMs = bookmark.positionMs || 0;
if (this.audiobookSessionService.isSessionActive()) {
this.audiobookSessionService.resumeSession(positionMs);