From 67269ffe4ced2eeb1b337f2be37704e335fa95de Mon Sep 17 00:00:00 2001 From: ACX <8075870+acx10@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:45:10 -0700 Subject: [PATCH] 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 --- .../service/FileStreamingService.java | 43 +----- .../audiobook-reader.component.ts | 138 +++++++----------- 2 files changed, 57 insertions(+), 124 deletions(-) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/FileStreamingService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/FileStreamingService.java index 415fd3116..802665844 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/FileStreamingService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/FileStreamingService.java @@ -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) {} } diff --git a/booklore-ui/src/app/features/readers/audiobook-reader/audiobook-reader.component.ts b/booklore-ui/src/app/features/readers/audiobook-reader/audiobook-reader.component.ts index 2a3c25025..32eefdac5 100644 --- a/booklore-ui/src/app/features/readers/audiobook-reader/audiobook-reader.component.ts +++ b/booklore-ui/src/app/features/readers/audiobook-reader/audiobook-reader.component.ts @@ -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; 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; + private seekDebounceTimeout?: ReturnType; + 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);