mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-19 07:25:34 -05:00
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:
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user