PageSourceSearch

https://healthylifenetwork.com.au/assets/controllers/pwa-audiobook-player_controller-fadDV0j.js

js healthylifenetwork.com.au collected 2026-09-25 03:34:28 UTC 72,388 bytes, 1,602 lines download raw bytes

1import { Controller } from '@hotwired/stimulus';
2import { saveMediaToDevice } from '../js/save-media-to-device.js';
3
4/**
5 * PWA Audiobook Player Controller — World-Class Edition
6 *
7 * Features:
8 * - M4B chapter extraction (Nero chpl + QuickTime trak parsers)
9 * - Chapter navigation (list panel, next/prev, seek-to-chapter, markers on seek bar)
10 * - Downloads to IndexedDB for offline playback (desktop/Android), streams on iOS
11 * - HTML5 <audio> playback with variable speed, volume, sleep timer
12 * - Position + chapter persistence via localStorage
13 * - Server-side listening tracking via heartbeats + Matomo events
14 *
15 * No external deps — only @hotwired/stimulus (safe for static import).
16 *
17 * @stimulusFetch lazy
18 */
19export default class extends Controller {
20    static targets = [
21        'playBtn', 'playIcon', 'currentTime', 'duration',
22        'speedBtn', 'progressFill', 'progressBar', 'seekFill', 'seekHandle',
23        'volumeSlider', 'volumeIcon',
24        'loadingState', 'playerState', 'sleepBtn', 'sleepDisplay', 'sleepSheet',
25        'chapterName', 'chapterCount', 'chapterPanel', 'chapterList',
26        'chapterMarkers', 'chapterBtn',
27        'inlineChapters', 'inlineChapterList', 'inlineChapterCount',
28        'listenedProgress', 'bufferFill',
29    ];
30    static values = {
31        mediaId: Number,
32        downloadUrl: String,
33        uploadId: String,       // UUID for IndexedDB-stored user uploads (empty otherwise)
34        title: String,
35        format: String,
36    };
37
38    // State
39    audio = null;
40    isPlaying = false;
41    playbackRate = 1;
42    sleepMinutes = 0;
43    sleepRemaining = 0;
44    sleepInterval = null;
45    _blobUrl = null;
46
47    // Chapters
48    chapters = [];
49    currentChapterIndex = -1;
50
51    speeds = [0.75, 1, 1.25, 1.5, 1.75, 2];
52
53    // Tracking state (server-side progress)
54    _trackingSessionStart = 0;
55    _heartbeatInterval = null;
56    _lastHeartbeat = 0;
57    _totalListenedSecs = 0;
58    _listeningStart = 0;
59    _isNewSession = true;
60    _milestonesReached = new Set();
61
62    async connect() {
63        console.log('[AudiobookPlayer] Connected. MediaId:', this.mediaIdValue);
64        this.restoreState();
65        await this.initPlayer();
66    }
67
68    /**
69     * Save the audio file (.mp3 / .m4b) to the user's device. The
70     * audiobook player already has a verified downloadUrl on its
71     * controller value (downloadUrlValue), so saveMediaToDevice
72     * skips the /prepare round-trip and goes straight to fetching
73     * the bytes. Falls through to the IDB cache where available so
74     * already-downloaded books save instantly.
75     */
76    download() {
77        if (!this.mediaIdValue) return;
78        saveMediaToDevice({
79            mediaId: this.mediaIdValue,
80            format: this.formatValue || 'audio',
81            title: this.titleValue || 'audiobook',
82            downloadUrl: this.downloadUrlValue || null,
83        });
84    }
85
86    disconnect() {
87        this.savePosition();
88        this._cleanupTracking();
89        this.clearSleepTimer();
90        // Abort any in-flight background cache downloads
91        if (this._cacheAbortController) {
92            this._cacheAbortController.abort();
93            this._cacheAbortController = null;
94        }
95        if (this._visibilityHandler) document.removeEventListener('visibilitychange', this._visibilityHandler);
96        if (this._beforeUnloadHandler) window.removeEventListener('beforeunload', this._beforeUnloadHandler);
97        if (this.audio) {
98            this.audio.pause();
99            this.audio.removeAttribute('src');
100            this.audio.load();
101            this.audio = null;
102        }
103        if (this._blobUrl) {
104            URL.revokeObjectURL(this._blobUrl);
105            this._blobUrl = null;
106        }
107    }
108
109    // ─── Player Init ─────────────────────────────────────────
110
111    _isIOS() {
112        return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
113            (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
114    }
115
116    async initPlayer() {
117        this._initAttempts = (this._initAttempts || 0) + 1;
118        try {
119            this.showLoading('Preparing audiobook...');
120            if (!this.audio) {
121                this.audio = new Audio();
122            }
123            this.audio.preload = 'auto';
124
125            let audioBuffer = null;
126
127            // Browser-local upload: blob lives in the shared 'ebook-library'
128            // IndexedDB (written by the library page upload handler).
129            // Note this is a DIFFERENT DB than this player's own cache —
130            // pwa_audiobook_cache — so we read directly via _idbReadUpload.
131            if (this.uploadIdValue) {
132                const rec = await this._idbReadUpload(this.uploadIdValue);
133                if (!rec || !rec.blob) {
134                    throw new Error('Upload not found on this device.');
135                }
136                audioBuffer = await rec.blob.arrayBuffer();
137                this._blobUrl = URL.createObjectURL(rec.blob);
138                this.showLoading('Loading player...');
139                await new Promise((resolve, reject) => {
140                    const timeout = setTimeout(() => reject(new Error('Audio metadata timeout')), 30000);
141                    this.audio.addEventListener('loadedmetadata', () => { clearTimeout(timeout); resolve(); }, { once: true });
142                    this.audio.addEventListener('error', () => {
143                        clearTimeout(timeout);
144                        reject(new Error(`Audio error (${this.audio.error?.code}): ${this.audio.error?.message || 'unknown'}`));
145                    }, { once: true });
146                    this.audio.src = this._blobUrl;
147                });
148            } else {
149            const directUrl = this.downloadUrlValue || `/api/pwa/library/${this.mediaIdValue}/download`;
150            const cacheKey = `audiobook_v1_${this.mediaIdValue}`;
151
152            // Check IndexedDB cache first (instant playback for previously played books)
153            let cachedBuffer = null;
154            try {
155                cachedBuffer = await this.idbGet(cacheKey);
156                if (cachedBuffer && cachedBuffer.byteLength > 1000) {
157                    console.log('[AudiobookPlayer] From cache:', (cachedBuffer.byteLength / 1024 / 1024).toFixed(1), 'MB');
158                }  else {
159                    cachedBuffer = null;
160                }
161            } catch (e) {
162                console.warn('[AudiobookPlayer] IndexedDB read error:', e);
163            }
164
165            if (cachedBuffer) {
166                // Play from cache (instant)
167                audioBuffer = cachedBuffer;
168                let mimeType = 'audio/mpeg';
169                if (this.formatValue === 'm4b' || this.formatValue === 'm4a') mimeType = 'audio/mp4';
170                const header = new Uint8Array(audioBuffer, 0, Math.min(12, audioBuffer.byteLength));
171                if (header.length >= 8 && header[4] === 0x66 && header[5] === 0x74 && header[6] === 0x79 && header[7] === 0x70) {
172                    mimeType = 'audio/mp4';
173                }
174                const blob = new Blob([audioBuffer], { type: mimeType });
175                this._blobUrl = URL.createObjectURL(blob);
176                this.showLoading('Loading player...');
177
178                await new Promise((resolve, reject) => {
179                    const timeout = setTimeout(() => reject(new Error('Audio metadata timeout')), 30000);
180                    this.audio.addEventListener('loadedmetadata', () => { clearTimeout(timeout); resolve(); }, { once: true });
181                    this.audio.addEventListener('error', () => {
182                        clearTimeout(timeout);
183                        reject(new Error(`Audio error (${this.audio.error?.code}): ${this.audio.error?.message || 'unknown'}`));
184                    }, { once: true });
185                    this.audio.src = this._blobUrl;
186                });
187            } else {
188                // Stream directly for fast playback start (all platforms)
189                console.log('[AudiobookPlayer] Streaming from URL for instant playback');
190                this.showLoading('Loading audio stream...');
191
192                await new Promise((resolve, reject) => {
193                    const timeout = setTimeout(() => reject(new Error('Audio metadata timeout')), 60000);
194                    this.audio.addEventListener('loadedmetadata', () => { clearTimeout(timeout); resolve(); }, { once: true });
195                    this.audio.addEventListener('error', () => {
196                        clearTimeout(timeout);
197                        reject(new Error(`Audio error (${this.audio.error?.code}): ${this.audio.error?.message || 'unknown'}`));
198                    }, { once: true });
199                    this.audio.src = directUrl;
200                    this.audio.load();
201                });
202
203                // Background: cache to IndexedDB for offline/instant next time
204                this._backgroundCacheInProgress = true;
205                this._cacheInBackground(directUrl, cacheKey);
206
207                // For chapter parsing: fetch last 2MB (moov box is at end of MP4/M4B files)
208                try {
209                    console.log('[AudiobookPlayer] Fetching tail for chapter parsing...');
210                    const headResp = await fetch(directUrl, { method: 'HEAD' });
211                    const fileSize = parseInt(headResp.headers.get('content-length') || '0', 10);
212                    if (fileSize > 0) {
213                        const tailSize = Math.min(fileSize, 2 * 1024 * 1024);
214                        const tailStart = fileSize - tailSize;
215                        const tailResp = await fetch(directUrl, {
216                            headers: { Range: `bytes=${tailStart}-${fileSize - 1}` },
217                        });
218                        if (tailResp.ok || tailResp.status === 206) {
219                            audioBuffer = await tailResp.arrayBuffer();
220                            console.log('[AudiobookPlayer] Got', (audioBuffer.byteLength / 1024 / 1024).toFixed(1), 'MB tail for chapters');
221                        }
222                    }
223                } catch (e) {
224                    console.warn('[AudiobookPlayer] Chapter tail fetch failed:', e);
225                }
226            }
227
228            }
229
230            console.log('[AudiobookPlayer] Audio loaded. Duration:', this.formatTime(this.audio.duration));
231
232            // Parse M4B chapters from downloaded buffer
233            if (audioBuffer && audioBuffer.byteLength > 0) {
234                this.showLoading('Extracting chapters...');
235                try {
236                    this.chapters = this._parseChapters(audioBuffer);
237                    if (this.chapters.length > 0) {
238                        // Set end times: each chapter ends where next begins, last at duration
239                        for (let i = 0; i < this.chapters.length; i++) {
240                            this.chapters[i].end = (i < this.chapters.length - 1)
241                                ? this.chapters[i + 1].start
242                                : this.audio.duration;
243                        }
244                        console.log('[AudiobookPlayer] Found', this.chapters.length, 'chapters');
245                    }
246                } catch (e) {
247                    console.warn('[AudiobookPlayer] Chapter parsing error:', e);
248                    this.chapters = [];
249                }
250            }
251
252            // Event listeners
253            this.audio.addEventListener('timeupdate', () => this._onTimeUpdate());
254            this.audio.addEventListener('play', () => {
255                this.isPlaying = true;
256                this.updatePlayButton();
257                this._listeningStart = Date.now();
258            });
259            this.audio.addEventListener('pause', () => {
260                this.isPlaying = false;
261                this.updatePlayButton();
262                this.savePosition();
263                this._onPause();
264            });
265            this.audio.addEventListener('ended', () => {
266                this.isPlaying = false;
267                this.updatePlayButton();
268                this.clearSleepTimer();
269                this.savePosition();
270                this._onEnded();
271            });
272
273            // Persist position on background/close
274            this._visibilityHandler = () => { if (document.visibilityState === 'hidden') this.savePosition(); };
275            document.addEventListener('visibilitychange', this._visibilityHandler);
276            this._beforeUnloadHandler = () => this.savePosition();
277            window.addEventListener('beforeunload', this._beforeUnloadHandler);
278
279            // Restore saved position — try server first (cross-device), fall back to localStorage
280            let savedPos = 0;
281            try {
282                const resp = await fetch(`/api/pwa/tracking/progress/${this.mediaIdValue}`, { credentials: 'include' });
283                if (resp.ok) {
284                    const serverData = await resp.json();
285                    const serverPos = parseInt(serverData.location_data || '0', 10);
286                    const localPos = this.getSavedPosition();
287                    // Use whichever is further ahead
288                    savedPos = Math.max(serverPos, localPos);
289                    console.log('[AudiobookPlayer] Position: server=' + serverPos + 's, local=' + localPos + 's, using=' + savedPos + 's');
290                }
291            } catch (e) {
292                savedPos = this.getSavedPosition();
293                console.log('[AudiobookPlayer] Server position unavailable, using local:', savedPos);
294            }
295            if (savedPos > 0 && savedPos < this.audio.duration) {
296                this.audio.currentTime = savedPos;
297            }
298            this.audio.playbackRate = this.playbackRate;
299
300            // Update UI
301            this.updateDuration();
302            this._initChapterUI();
303            this.showPlayer();
304            this._initTracking();
305
306        } catch (error) {
307            console.warn('[AudiobookPlayer] Init error (will retry):', error.message);
308            // Don't show error on first attempt — Stimulus reconnects and retries
309            if (this._initAttempts > 1) {
310                this.showError('Failed to load: ' + (error.message || 'Unknown error'));
311            }
312        }
313    }
314
315    _onTimeUpdate() {
316        if (!this.audio) return;
317        const t = this.audio.currentTime;
318        this.updateCurrentTime(t);
319        this.updateProgressBar(t);
320        this._updateChapterHighlight();
321        this._updateListenedProgress(t);
322        this._checkSleepEndOfChapter();
323
324        if (this.audio.duration > 0) {
325            this.dispatch('progress', { detail: { percent: Math.round((t / this.audio.duration) * 100) } });
326        }
327        // Save position every ~10s
328        if (this._lastPositionSave === undefined) this._lastPositionSave = 0;
329        if (Date.now() - this._lastPositionSave > 10000) {
330            this._lastPositionSave = Date.now();
331            this.savePosition();
332        }
333    }
334
335    // ═══════════════════════════════════════════════════════════
336    //  M4B CHAPTER PARSING — Nero (chpl) + QuickTime (trak) formats
337    // ═══════════════════════════════════════════════════════════
338
339    _parseChapters(buffer) {
340        try {
341            console.log('[AudiobookPlayer] Parsing chapters from', (buffer.byteLength / 1024 / 1024).toFixed(1), 'MB buffer');
342            const view = new DataView(buffer);
343
344            // Log top-level boxes for debugging
345            this._logTopLevelBoxes(view, buffer.byteLength);
346
347            // Try Nero chapters first (moov > udta > chpl) — most common for M4B
348            const nero = this._parseNeroChapters(view, buffer);
349            if (nero.length > 0) return nero;
350
351            // Try QuickTime chapter track (moov > trak with text handler)
352            const qt = this._parseQuickTimeChapters(view, buffer);
353            if (qt.length > 0) return qt;
354
355            console.log('[AudiobookPlayer] No chapters found (tried Nero + QuickTime)');
356            return [];
357        } catch (e) {
358            console.warn('[AudiobookPlayer] Chapter parse error:', e);
359            return [];
360        }
361    }
362
363    /**
364     * Find the moov box even in a partial buffer (e.g., tail of file).
365     * Tries standard box walking first; falls back to scanning for 'moov' bytes.
366     */
367    _findMoov(view, buffer) {
368        // Standard box walk from offset 0
369        const standard = this._findBox(view, 0, buffer.byteLength, 'moov');
370        if (standard) return standard;
371
372        // Scan for 'moov' signature (bytes 6d 6f 6f 76)
373        const u8 = new Uint8Array(buffer);
374        for (let i = 4; i < u8.length - 8; i++) {
375            if (u8[i] === 0x6d && u8[i+1] === 0x6f && u8[i+2] === 0x6f && u8[i+3] === 0x76) {
376                // Verify: 4 bytes before should be the box size
377                const size = view.getUint32(i - 4);
378                if (size >= 100 && i - 4 + size <= buffer.byteLength + 1024) {
379                    const boxEnd = Math.min(i - 4 + size, buffer.byteLength);
380                    console.log('[AudiobookPlayer] Found moov via scan at offset', i - 4, 'size', size);
381                    return { dataStart: i + 4, dataEnd: boxEnd };
382                }
383            }
384        }
385        return null;
386    }
387
388    /** Log top-level MP4 boxes to help debug chapter detection */
389    _logTopLevelBoxes(view, totalLen) {
390        const boxes = [];
391        let offset = 0;
392        while (offset < totalLen - 8) {
393            let size = view.getUint32(offset);
394            const type = String.fromCharCode(
395                view.getUint8(offset + 4), view.getUint8(offset + 5),
396                view.getUint8(offset + 6), view.getUint8(offset + 7)
397            );
398            if (size === 0) break;
399            if (size === 1 && offset + 16 <= totalLen) {
400                size = view.getUint32(offset + 8) * 0x100000000 + view.getUint32(offset + 12);
401            }
402            if (size < 8) break;
403            boxes.push(`${type}(${size}@${offset})`);
404            offset += size;
405            if (boxes.length > 20) break;
406        }
407        console.log('[AudiobookPlayer] Top-level boxes:', boxes.join(', '));
408    }
409
410    /** Find an MP4 box by type within a range. Returns {dataStart, dataEnd} or null. */
411    _findBox(view, start, end, type) {
412        let offset = start;
413        const bufLen = view.byteLength;
414        while (offset < end - 8 && offset < bufLen - 8) {
415            let size = view.getUint32(offset);
416            const t = String.fromCharCode(
417                view.getUint8(offset + 4), view.getUint8(offset + 5),
418                view.getUint8(offset + 6), view.getUint8(offset + 7)
419            );
420
421            if (size === 0) break;
422            if (size === 1 && offset + 16 <= bufLen) {
423                size = view.getUint32(offset + 8) * 0x100000000 + view.getUint32(offset + 12);
424            }
425            if (size < 8) break;
426
427            if (t === type) {
428                const boxEnd = Math.min(offset + size, end);
429                return { dataStart: offset + 8, dataEnd: boxEnd };
430            }
431            // Skip past this box even if it extends beyond our buffer
432            // (e.g., mdat is huge but moov comes after it)
433            offset += size;
434        }
435        return null;
436    }
437
438    /** Find ALL boxes of a type within range. */
439    _findAllBoxes(view, start, end, type) {
440        const results = [];
441        let offset = start;
442        const bufLen = view.byteLength;
443        while (offset < end - 8 && offset < bufLen - 8) {
444            let size = view.getUint32(offset);
445            const t = String.fromCharCode(
446                view.getUint8(offset + 4), view.getUint8(offset + 5),
447                view.getUint8(offset + 6), view.getUint8(offset + 7)
448            );
449            if (size === 0) break;
450            if (size === 1 && offset + 16 <= bufLen) {
451                size = view.getUint32(offset + 8) * 0x100000000 + view.getUint32(offset + 12);
452            }
453            if (size < 8) break;
454            if (t === type) {
455                const boxEnd = Math.min(offset + size, end);
456                results.push({ dataStart: offset + 8, dataEnd: boxEnd });
457            }
458            offset += size;
459        }
460        return results;
461    }
462
463    /** Parse Nero chapters: moov > udta > chpl */
464    _parseNeroChapters(view, buffer) {
465        const moov = this._findMoov(view, buffer);
466        if (!moov) { console.log('[AudiobookPlayer] Nero: no moov box found'); return []; }
467        console.log('[AudiobookPlayer] Nero: moov at', moov.dataStart, '-', moov.dataEnd);
468        const udta = this._findBox(view, moov.dataStart, moov.dataEnd, 'udta');
469        if (!udta) { console.log('[AudiobookPlayer] Nero: no udta box in moov'); return []; }
470        const chpl = this._findBox(view, udta.dataStart, udta.dataEnd, 'chpl');
471        if (!chpl) { console.log('[AudiobookPlayer] Nero: no chpl box in udta'); return []; }
472
473        let offset = chpl.dataStart;
474        const end = chpl.dataEnd;
475        if (offset + 4 > end) return [];
476
477        const version = view.getUint8(offset);
478        offset += 4; // version(1) + flags(3)
479
480        // Some encoders add 4 reserved bytes for version >= 1
481        if (version >= 1 && offset + 4 <= end) offset += 4;
482
483        if (offset >= end) return [];
484
485        // Count is ALWAYS 1 byte in chpl (version 1 only adds reserved bytes, not wider count)
486        // Try 1-byte count first; if result is sane, use it. Otherwise try 4-byte.
487        let count = view.getUint8(offset);
488        if (count > 0 && count <= 500) {
489            offset += 1;
490        } else if (offset + 4 <= end) {
491            count = view.getUint32(offset);
492            offset += 4;
493        }
494        console.log('[AudiobookPlayer] Nero: version', version, 'count', count);
495        if (count === 0 || count > 500) return [];
496
497        const chapters = [];
498        for (let i = 0; i < count && offset + 9 <= end; i++) {
499            const hi = view.getUint32(offset);
500            const lo = view.getUint32(offset + 4);
501            const startSecs = (hi * 0x100000000 + lo) / 10000000;
502            offset += 8;
503
504            const titleLen = view.getUint8(offset);
505            offset += 1;
506            if (offset + titleLen > end) break;
507
508            const titleBytes = new Uint8Array(buffer, offset, titleLen);
509            const title = new TextDecoder('utf-8').decode(titleBytes);
510            offset += titleLen;
511
512            chapters.push({ title: title || `Chapter ${i + 1}`, start: startSecs, end: 0, index: i });
513        }
514
515        console.log('[AudiobookPlayer] Nero chapters:', chapters.length);
516        return chapters;
517    }
518
519    /** Parse QuickTime chapters: moov > trak (text handler referenced by chap atom) */
520    _parseQuickTimeChapters(view, buffer) {
521        const moov = this._findMoov(view, buffer);
522        if (!moov) { console.log('[AudiobookPlayer] QT: no moov box'); return []; }
523
524        // Get movie timescale from mvhd
525        const mvhd = this._findBox(view, moov.dataStart, moov.dataEnd, 'mvhd');
526        if (!mvhd) return [];
527        const mvhdVer = view.getUint8(mvhd.dataStart);
528        const mvhdTimescale = view.getUint32(mvhd.dataStart + 4 + (mvhdVer === 0 ? 8 : 16));
529        if (!mvhdTimescale) return [];
530
531        const traks = this._findAllBoxes(view, moov.dataStart, moov.dataEnd, 'trak');
532        if (traks.length === 0) return [];
533
534        // Find chapter track ID via tref > chap
535        let chapterTrackId = null;
536        for (const trak of traks) {
537            const tref = this._findBox(view, trak.dataStart, trak.dataEnd, 'tref');
538            if (tref) {
539                const chap = this._findBox(view, tref.dataStart, tref.dataEnd, 'chap');
540                if (chap && chap.dataStart + 4 <= chap.dataEnd) {
541                    chapterTrackId = view.getUint32(chap.dataStart);
542                    break;
543                }
544            }
545        }
546
547        // Find the chapter track
548        let chapterTrak = null;
549        for (const trak of traks) {
550            const tkhd = this._findBox(view, trak.dataStart, trak.dataEnd, 'tkhd');
551            if (!tkhd) continue;
552
553            const tkhdVer = view.getUint8(tkhd.dataStart);
554            const trackId = view.getUint32(tkhd.dataStart + 4 + (tkhdVer === 0 ? 8 : 16) + 4);
555
556            if (chapterTrackId && trackId === chapterTrackId) {
557                chapterTrak = trak; break;
558            }
559            // Fallback: check handler type = 'text' or 'sbtl'
560            if (!chapterTrackId) {
561                const mdia = this._findBox(view, trak.dataStart, trak.dataEnd, 'mdia');
562                if (mdia) {
563                    const hdlr = this._findBox(view, mdia.dataStart, mdia.dataEnd, 'hdlr');
564                    if (hdlr && hdlr.dataStart + 12 <= hdlr.dataEnd) {
565                        const ht = String.fromCharCode(
566                            view.getUint8(hdlr.dataStart + 8), view.getUint8(hdlr.dataStart + 9),
567                            view.getUint8(hdlr.dataStart + 10), view.getUint8(hdlr.dataStart + 11)
568                        );
569                        if (ht === 'text' || ht === 'sbtl') { chapterTrak = trak; break; }
570                    }
571                }
572            }
573        }
574        if (!chapterTrak) return [];
575
576        // Get track timescale from mdia > mdhd
577        const mdia = this._findBox(view, chapterTrak.dataStart, chapterTrak.dataEnd, 'mdia');
578        if (!mdia) return [];
579        const mdhd = this._findBox(view, mdia.dataStart, mdia.dataEnd, 'mdhd');
580        let trackTs = mvhdTimescale;
581        if (mdhd) {
582            const mdhdVer = view.getUint8(mdhd.dataStart);
583            trackTs = view.getUint32(mdhd.dataStart + 4 + (mdhdVer === 0 ? 8 : 16)) || mvhdTimescale;
584        }
585
586        // Get sample table
587        const minf = this._findBox(view, mdia.dataStart, mdia.dataEnd, 'minf');
588        if (!minf) return [];
589        const stbl = this._findBox(view, minf.dataStart, minf.dataEnd, 'stbl');
590        if (!stbl) return [];
591
592        const stts = this._findBox(view, stbl.dataStart, stbl.dataEnd, 'stts');
593        const stco = this._findBox(view, stbl.dataStart, stbl.dataEnd, 'stco');
594        const co64 = this._findBox(view, stbl.dataStart, stbl.dataEnd, 'co64');
595        const stsz = this._findBox(view, stbl.dataStart, stbl.dataEnd, 'stsz');
596        if (!stts || (!stco && !co64) || !stsz) return [];
597
598        // Parse time-to-sample
599        const sttsOff = stts.dataStart + 4;
600        const sttsCount = view.getUint32(sttsOff);
601        const durations = [];
602        let sttsPos = sttsOff + 4;
603        for (let i = 0; i < sttsCount && sttsPos + 8 <= stts.dataEnd; i++) {
604            const sc = view.getUint32(sttsPos);
605            const sd = view.getUint32(sttsPos + 4);
606            for (let j = 0; j < sc; j++) durations.push(sd);
607            sttsPos += 8;
608        }
609
610        // Parse chunk offsets
611        const offsets = [];
612        if (stco) {
613            let p = stco.dataStart + 4;
614            const n = view.getUint32(p); p += 4;
615            for (let i = 0; i < n && p + 4 <= stco.dataEnd; i++) { offsets.push(view.getUint32(p)); p += 4; }
616        } else {
617            let p = co64.dataStart + 4;
618            const n = view.getUint32(p); p += 4;
619            for (let i = 0; i < n && p + 8 <= co64.dataEnd; i++) {
620                offsets.push(view.getUint32(p) * 0x100000000 + view.getUint32(p + 4)); p += 8;
621            }
622        }
623
624        // Parse sample sizes
625        const szOff = stsz.dataStart + 4;
626        const defSize = view.getUint32(szOff);
627        const szCount = view.getUint32(szOff + 4);
628        const sizes = [];
629        if (defSize > 0) { for (let i = 0; i < szCount; i++) sizes.push(defSize); }
630        else { let p = szOff + 8; for (let i = 0; i < szCount && p + 4 <= stsz.dataEnd; i++) { sizes.push(view.getUint32(p)); p += 4; } }
631
632        // Build chapters
633        const chapters = [];
634        let curTime = 0;
635        const num = Math.min(durations.length, offsets.length, sizes.length);
636        for (let i = 0; i < num; i++) {
637            const startSecs = curTime / trackTs;
638            let title = `Chapter ${i + 1}`;
639            const co = offsets[i], cs = sizes[i];
640            if (co + cs <= buffer.byteLength && cs > 2) {
641                try {
642                    const tl = view.getUint16(co);
643                    if (tl > 0 && tl <= cs - 2) {
644                        title = new TextDecoder('utf-8').decode(new Uint8Array(buffer, co + 2, tl)) || title;
645                    }
646                } catch (_) {}
647            }
648            chapters.push({ title, start: startSecs, end: 0, index: i });
649            curTime += durations[i];
650        }
651
652        console.log('[AudiobookPlayer] QuickTime chapters:', chapters.length);
653        return chapters;
654    }
655
656    // ═══════════════════════════════════════════════════════════
657    //  CHAPTER UI
658    // ═══════════════════════════════════════════════════════════
659
660    _initChapterUI() {
661        if (this.chapters.length === 0) {
662            // Show appropriate message depending on whether we're still downloading
663            if (this.hasInlineChapterListTarget) {
664                if (this._backgroundCacheInProgress) {
665                    this.inlineChapterListTarget.innerHTML = `
666                        <div style="padding:24px 16px;text-align:center;color:rgba(255,255,255,0.3);font-size:13px;">
667                            <span class="loading loading-dots loading-sm" style="color:rgba(255,255,255,0.2);"></span>
668                            <p style="margin-top:4px;">Detecting chapters (loading in background)...</p>
669                        </div>`;
670                } else {
671                    this.inlineChapterListTarget.innerHTML = `
672                        <div style="padding:24px 16px;text-align:center;color:rgba(255,255,255,0.25);font-size:13px;">
673                            No chapter markers found in this audiobook.
674                        </div>`;
675                }
676            }
677            return;
678        }
679
680        // Store chapter count (header badge kept hidden — count shown in inline list)
681        if (this.hasChapterCountTarget) {
682            this.chapterCountTarget.textContent = this.chapters.length;
683        }
684
685        // Render chapter list in the overlay panel (kept for quick-jump)
686        this._renderChapterList();
687
688        // Render inline chapter list (always visible below controls)
689        this._renderInlineChapterList();
690
691        // Render chapter markers on seek bar
692        this._renderChapterMarkers();
693
694        // Set initial chapter highlight
695        this._updateChapterHighlight();
696
697        // Populate transcript panel with chapter names if no alignment data
698        this._populateTranscriptWithChapters();
699    }
700
701    _renderChapterList() {
702        if (!this.hasChapterListTarget) return;
703        const esc = (s) => this._escapeHtml(s);
704        let html = '';
705        this.chapters.forEach((ch, i) => {
706            const dur = this.formatTime(ch.end - ch.start);
707            const start = this.formatTime(ch.start);
708            html += `
709                <button class="ch-item" data-index="${i}" data-action="click->pwa-audiobook-player#seekToChapter"
710                    aria-label="Jump to chapter ${i + 1}: ${esc(ch.title)}" role="listitem"
711                    style="display:flex;align-items:center;gap:12px;width:100%;padding:14px 16px;border:none;background:transparent;color:#e0e0e0;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.06);text-align:left;transition:background 0.15s;">
712                    <span class="ch-num" style="flex-shrink:0;width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.5);transition:all 0.2s;">${i + 1}</span>
713                    <div style="flex:1;min-width:0;">
714                        <div class="ch-title" style="font-size:14px;font-weight:500;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#e0e0e0;">${esc(ch.title)}</div>
715                        <div style="font-size:11px;color:rgba(255,255,255,0.35);margin-top:2px;">${start}</div>
716                        <div style="font-size:11px;color:rgba(255,255,255,0.35);margin-top:1px;"><strong style="font-weight:700;color:rgba(255,255,255,0.55);">Length:</strong> ${dur}</div>
717                    </div>
718                    <div class="ch-ring" style="flex-shrink:0;width:34px;height:34px;">
719                        <svg viewBox="0 0 36 36" style="width:100%;height:100%;transform:rotate(-90deg);">
720                            <circle cx="18" cy="18" r="15" fill="none" stroke="rgba(255,255,255,0.06)" stroke-width="2.5"/>
721                            <circle class="ch-arc" cx="18" cy="18" r="15" fill="none" stroke="var(--app-primary,#7c3aed)" stroke-width="2.5" stroke-dasharray="94.25" stroke-dashoffset="94.25" stroke-linecap="round" style="transition:stroke-dashoffset 0.3s;"/>
722                        </svg>
723                    </div>
724                </button>`;
725        });
726        this.chapterListTarget.innerHTML = html;
727    }
728
729    _renderInlineChapterList() {
730        if (!this.hasInlineChapterListTarget) return;
731        const esc = (s) => this._escapeHtml(s);
732
733        // Update count
734        if (this.hasInlineChapterCountTarget) {
735            this.inlineChapterCountTarget.textContent = `${this.chapters.length} chapters`;
736        }
737
738        let html = '';
739        this.chapters.forEach((ch, i) => {
740            const dur = this.formatTime(ch.end - ch.start);
741            const start = this.formatTime(ch.start);
742            html += `
743                <button class="inline-ch" data-index="${i}" data-action="click->pwa-audiobook-player#seekToChapter"
744                    style="display:flex;align-items:center;gap:10px;width:100%;padding:10px 16px;border:none;background:transparent;color:#e0e0e0;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.04);text-align:left;transition:background 0.15s;">
745                    <span class="inline-ch-num" style="flex-shrink:0;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;background:rgba(255,255,255,0.06);color:rgba(255,255,255,0.4);transition:all 0.2s;">${i + 1}</span>
746                    <div style="flex:1;min-width:0;">
747                        <div class="inline-ch-title" style="font-size:13px;font-weight:500;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#e0e0e0;">${esc(ch.title)}</div>
748                        <div style="font-size:10px;color:rgba(255,255,255,0.3);margin-top:1px;">${start}</div>
749                        <div style="font-size:10px;color:rgba(255,255,255,0.3);margin-top:1px;"><strong style="font-weight:700;color:rgba(255,255,255,0.5);">Length:</strong> ${dur}</div>
750                    </div>
751                    <svg class="inline-ch-playing" style="flex-shrink:0;width:16px;height:16px;display:none;color:var(--app-primary,#7c3aed);" fill="currentColor" viewBox="0 0 24 24">
752                        <path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
753                    </svg>
754                </button>`;
755        });
756        this.inlineChapterListTarget.innerHTML = html;
757    }
758
759    _renderChapterMarkers() {
760        if (!this.hasChapterMarkersTarget || !this.audio?.duration) return;
761        const dur = this.audio.duration;
762        let html = '';
763        this.chapters.forEach((ch, i) => {
764            if (i === 0) return;
765            const pct = (ch.start / dur) * 100;
766            html += `<div style="position:absolute;left:${pct}%;top:-1px;width:2px;height:calc(100% + 2px);background:rgba(255,255,255,0.25);border-radius:1px;pointer-events:none;" title="${this._escapeHtml(ch.title)}"></div>`;
767        });
768        this.chapterMarkersTarget.innerHTML = html;
769    }
770
771    _updateChapterHighlight() {
772        if (this.chapters.length === 0 || !this.audio) return;
773        const t = this.audio.currentTime;
774
775        // Find current chapter (last whose start <= t)
776        let newIdx = 0;
777        for (let i = this.chapters.length - 1; i >= 0; i--) {
778            if (t >= this.chapters[i].start) { newIdx = i; break; }
779        }
780
781        if (newIdx !== this.currentChapterIndex) {
782            this.currentChapterIndex = newIdx;
783            const ch = this.chapters[newIdx];
784
785            // Expose current chapter info for sibling controllers (e.g. pwa-reader-chat)
786            this.element.dataset.currentChapterTitle = ch.title || '';
787            this.element.dataset.currentChapterIndex = String(newIdx);
788
789            // Refresh seek bar duration to show current chapter length
790            this.updateDuration();
791
792            // Update chapter name display
793            if (this.hasChapterNameTarget) {
794                this.chapterNameTarget.textContent = ch.title;
795                this.chapterNameTarget.classList.remove('hidden');
796            }
797
798            // Highlight active chapter in overlay list
799            if (this.hasChapterListTarget) {
800                this.chapterListTarget.querySelectorAll('.ch-item').forEach((el, i) => {
801                    const num = el.querySelector('.ch-num');
802                    if (i === newIdx) {
803                        el.style.background = 'rgba(124,58,237,0.12)';
804                        if (num) { num.style.background = 'var(--app-primary,#7c3aed)'; num.style.color = 'white'; }
805                    } else {
806                        el.style.background = 'transparent';
807                        if (num) { num.style.background = 'rgba(255,255,255,0.08)'; num.style.color = 'rgba(255,255,255,0.5)'; }
808                    }
809                });
810            }
811
812            // Highlight active chapter in inline list + auto-scroll
813            if (this.hasInlineChapterListTarget) {
814                this.inlineChapterListTarget.querySelectorAll('.inline-ch').forEach((el, i) => {
815                    const num = el.querySelector('.inline-ch-num');
816                    const icon = el.querySelector('.inline-ch-playing');
817                    if (i === newIdx) {
818                        el.style.background = 'rgba(124,58,237,0.1)';
819                        if (num) { num.style.background = 'var(--app-primary,#7c3aed)'; num.style.color = 'white'; }
820                        if (icon) icon.style.display = 'block';
821                        // Auto-scroll to active chapter
822                        setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 50);
823                    } else {
824                        el.style.background = 'transparent';
825                        if (num) { num.style.background = 'rgba(255,255,255,0.06)'; num.style.color = 'rgba(255,255,255,0.4)'; }
826                        if (icon) icon.style.display = 'none';
827                    }
828                });
829            }
830
831            // Update transcript panel
832            this._highlightTranscriptChapter(newIdx);
833        }
834
835        // Update progress rings
836        this._updateChapterRings();
837    }
838
839    _updateChapterRings() {
840        if (!this.hasChapterListTarget || !this.audio) return;
841        const t = this.audio.currentTime;
842        const C = 94.25;
843        this.chapterListTarget.querySelectorAll('.ch-item').forEach((el, i) => {
844            const ch = this.chapters[i];
845            if (!ch) return;
846            const arc = el.querySelector('.ch-arc');
847            if (!arc) return;
848            let p = 0;
849            if (t >= ch.end) p = 1;
850            else if (t >= ch.start) p = (t - ch.start) / (ch.end - ch.start);
851            arc.style.strokeDashoffset = C * (1 - p);
852        });
853    }
854
855    _populateTranscriptWithChapters() {
856        const panel = document.getElementById('readAlongPanel');
857        if (!panel) return;
858        const statusEl = panel.querySelector('[data-pwa-read-along-target="statusText"]');
859        if (!statusEl || !statusEl.textContent.includes('not yet available')) return;
860
861        // Replace "not yet available" with chapter list
862        const textPanel = panel.querySelector('[data-pwa-read-along-target="textPanel"]');
863        if (!textPanel) return;
864
865        const headerTitle = panel.querySelector('.ra-header-title');
866        if (headerTitle) headerTitle.textContent = 'Chapters';
867
868        let html = '<div style="padding:4px 0;">';
869        this.chapters.forEach((ch, i) => {
870            const start = this.formatTime(ch.start);
871            const dur = this.formatTime(ch.end - ch.start);
872            html += `
873                <button class="tc-item" data-chapter-idx="${i}"
874                    data-action="click->pwa-audiobook-player#seekToChapterFromTranscript"
875                    style="display:flex;align-items:center;gap:10px;width:100%;padding:10px 8px;border:none;background:transparent;color:#e0e0e0;cursor:pointer;border-radius:8px;text-align:left;transition:background 0.15s;margin-bottom:2px;">
876                    <span class="tc-num" style="flex-shrink:0;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.5);transition:all 0.2s;">${i + 1}</span>
877                    <div style="flex:1;min-width:0;">
878                        <div style="font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${this._escapeHtml(ch.title)}</div>
879                        <div style="font-size:10px;color:rgba(255,255,255,0.35);margin-top:1px;">${start} &middot; ${dur}</div>
880                    </div>
881                    <svg class="tc-playing hidden" style="flex-shrink:0;width:16px;height:16px;color:var(--app-primary,#7c3aed);" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
882                </button>`;
883        });
884        html += '</div>';
885        textPanel.innerHTML = html;
886
887        // Keep panel collapsed — user opens it via transcript button in header.
888        // Don't auto-expand: it eats too much screen space on mobile.
889    }
890
891    _highlightTranscriptChapter(activeIdx) {
892        document.querySelectorAll('.tc-item').forEach((el, i) => {
893            const num = el.querySelector('.tc-num');
894            const icon = el.querySelector('.tc-playing');
895            if (i === activeIdx) {
896                el.style.background = 'rgba(124,58,237,0.12)';
897                if (num) { num.style.background = 'var(--app-primary,#7c3aed)'; num.style.color = 'white'; }
898                if (icon) icon.classList.remove('hidden');
899            } else {
900                el.style.background = 'transparent';
901                if (num) { num.style.background = 'rgba(255,255,255,0.08)'; num.style.color = 'rgba(255,255,255,0.5)'; }
902                if (icon) icon.classList.add('hidden');
903            }
904        });
905        // Auto-scroll active into view
906        const active = document.querySelectorAll('.tc-item')[activeIdx];
907        if (active) active.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
908    }
909
910    // ─── Chapter Navigation ────────────────────────────────
911
912    toggleChapters() {
913        if (!this.hasChapterPanelTarget) return;
914        const panel = this.chapterPanelTarget;
915        const isOpen = panel.classList.contains('open');
916        if (isOpen) {
917            panel.classList.remove('open');
918        } else {
919            panel.classList.add('open');
920            const active = panel.querySelector('.ch-item[style*="rgba(124"]');
921            if (active) setTimeout(() => active.scrollIntoView({ behavior: 'smooth', block: 'center' }), 100);
922        }
923    }
924
925    seekToChapter(event) {
926        const idx = parseInt(event.currentTarget.dataset.index, 10);
927        this._goToChapter(idx);
928    }
929
930    seekToChapterFromTranscript(event) {
931        const idx = parseInt(event.currentTarget.dataset.chapterIdx, 10);
932        this._goToChapter(idx);
933    }
934
935    _goToChapter(index) {
936        if (index >= 0 && index < this.chapters.length && this.audio) {
937            this.audio.currentTime = this.chapters[index].start;
938            if (!this.isPlaying) this.audio.play();
939            this._trackMatomo('Audiobook', 'Chapter Seek', this.chapters[index].title, index);
940        }
941    }
942
943    nextChapter() {
944        if (this.chapters.length === 0) {
945            this.skipForward(); return;
946        }
947        if (this.currentChapterIndex < this.chapters.length - 1) {
948            this._goToChapter(this.currentChapterIndex + 1);
949        }
950    }
951
952    prevChapter() {
953        if (this.chapters.length === 0) {
954            this.skipBack(); return;
955        }
956        if (!this.audio) return;
957        const ch = this.chapters[this.currentChapterIndex];
958        // If >3s into chapter, restart it; else go to previous
959        if (ch && this.audio.currentTime - ch.start > 3) {
960            this.audio.currentTime = ch.start;
961        } else if (this.currentChapterIndex > 0) {
962            this._goToChapter(this.currentChapterIndex - 1);
963        } else if (ch) {
964            this.audio.currentTime = ch.start;
965        }
966    }
967
968    // ─── File Download & Caching ─────────────────────────────
969
970    /**
971     * Download audio in the background and cache to IndexedDB.
972     * Does not block playback — runs silently after streaming starts.
973     */
974    async _cacheInBackground(url, cacheKey) {
975        try {
976            console.log('[AudiobookPlayer] Background caching started');
977            this._cacheAbortController = new AbortController();
978            const response = await fetch(url, {
979                credentials: 'include',
980                headers: { 'X-Requested-With': 'XMLHttpRequest' },
981                signal: this._cacheAbortController.signal
982            });
983            if (!response.ok) return;
984            const data = await response.arrayBuffer();
985            if (data.byteLength > 1000) {
986                await this.idbPut(cacheKey, data);
987                console.log('[AudiobookPlayer] Background cache complete:', (data.byteLength / 1024 / 1024).toFixed(1), 'MB');
988                this._backgroundCacheInProgress = false;
989
990                // If chapters weren't found earlier (streaming path), try parsing from the full file
991                if (this.chapters.length === 0 && this.audio) {
992                    try {
993                        const chapters = this._parseChapters(data);
994                        if (chapters.length > 0) {
995                            for (let i = 0; i < chapters.length; i++) {
996                                chapters[i].end = (i < chapters.length - 1)
997                                    ? chapters[i + 1].start
998                                    : this.audio.duration;
999                            }
1000                            this.chapters = chapters;
1001                            console.log('[AudiobookPlayer] Chapters found from cached file:', chapters.length);
1002                            this._initChapterUI();
1003                        }
1004                    } catch (e) {
1005                        console.warn('[AudiobookPlayer] Chapter re-parse failed:', e);
1006                    }
1007                }
1008            }
1009        } catch (e) {
1010            console.warn('[AudiobookPlayer] Background cache failed:', e);
1011        }
1012    }
1013
1014    async getAudioData() {
1015        const cacheKey = `audiobook_v1_${this.mediaIdValue}`;
1016
1017        try {
1018            const cached = await this.idbGet(cacheKey);
1019            if (cached && cached.byteLength > 1000) {
1020                console.log('[AudiobookPlayer] From cache:', (cached.byteLength / 1024 / 1024).toFixed(1), 'MB');
1021                return cached;
1022            }
1023        } catch (e) {
1024            console.warn('[AudiobookPlayer] IndexedDB read error:', e);
1025        }
1026
1027        const url = this.downloadUrlValue || `/api/pwa/library/${this.mediaIdValue}/download`;
1028        console.log('[AudiobookPlayer] Downloading:', url);
1029
1030        const response = await fetch(url, {
1031            credentials: 'include',
1032            headers: { 'X-Requested-With': 'XMLHttpRequest' }
1033        });
1034        if (!response.ok) throw new Error(`Download failed: ${response.status}`);
1035
1036        const ct = response.headers.get('content-type') || '';
1037        if (ct.includes('text/html')) throw new Error('Session expired. Please go back and try again.');
1038
1039        const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
1040        let data;
1041        if (contentLength > 0 && response.body) {
1042            data = await this.downloadWithProgress(response.body, contentLength);
1043        } else {
1044            data = await response.arrayBuffer();
1045        }
1046        console.log('[AudiobookPlayer] Downloaded:', (data.byteLength / 1024 / 1024).toFixed(1), 'MB');
1047
1048        try { await this.idbPut(cacheKey, data); } catch (_) {}
1049        return data;
1050    }
1051
1052    async downloadWithProgress(body, totalBytes) {
1053        const reader = body.getReader();
1054        const chunks = [];
1055        let received = 0;
1056
1057        while (true) {
1058            const { done, value } = await reader.read();
1059            if (done) break;
1060            chunks.push(value);
1061            received += value.length;
1062            const pct = Math.round((received / totalBytes) * 100);
1063            this.showLoading(`Downloading... ${pct}%  (${(received / 1024 / 1024).toFixed(1)} MB)`);
1064        }
1065
1066        const result = new Uint8Array(received);
1067        let offset = 0;
1068        for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; }
1069        return result.buffer;
1070    }
1071
1072    // ─── Playback Controls ───────────────────────────────────
1073
1074    togglePlay() {
1075        if (!this.audio) return;
1076        if (this.isPlaying) {
1077            this.audio.pause();
1078        } else {
1079            this.audio.play();
1080            this._trackMatomo('Audiobook', 'Play', this.titleValue, Math.round(this.audio.currentTime));
1081        }
1082    }
1083
1084    skipForward() {
1085        if (!this.audio) return;
1086        this.audio.currentTime = Math.min(this.audio.currentTime + 15, this.audio.duration);
1087    }
1088
1089    skipBack() {
1090        if (!this.audio) return;
1091        this.audio.currentTime = Math.max(this.audio.currentTime - 15, 0);
1092    }
1093
1094    seek(event) {
1095        if (!this.audio || !this.hasProgressBarTarget) return;
1096        const rect = this.progressBarTarget.getBoundingClientRect();
1097        const pct = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
1098
1099        // Seek within current chapter when chapters are available
1100        if (this.chapters.length > 0 && this.currentChapterIndex >= 0) {
1101            const ch = this.chapters[this.currentChapterIndex];
1102            if (ch && ch.end > ch.start) {
1103                this.audio.currentTime = ch.start + pct * (ch.end - ch.start);
1104                return;
1105            }
1106        }
1107        this.audio.currentTime = pct * this.audio.duration;
1108    }
1109
1110    cycleSpeed() {
1111        const idx = this.speeds.indexOf(this.playbackRate);
1112        this.playbackRate = this.speeds[(idx + 1) % this.speeds.length];
1113        if (this.audio) this.audio.playbackRate = this.playbackRate;
1114        if (this.hasSpeedBtnTarget) this.speedBtnTarget.textContent = `${this.playbackRate}x`;
1115        this._trackMatomo('Audiobook', 'Speed', this.titleValue + ' (' + this.playbackRate + 'x)');
1116    }
1117
1118    setVolume(event) {
1119        if (!this.audio) return;
1120        const vol = parseFloat(event.target.value) / 100;
1121        this.audio.volume = vol;
1122        this.updateVolumeIcon(vol);
1123    }
1124
1125    toggleMute() {
1126        if (!this.audio) return;
1127        if (this.audio.volume > 0) {
1128            this._savedVolume = this.audio.volume;
1129            this.audio.volume = 0;
1130            if (this.hasVolumeSliderTarget) this.volumeSliderTarget.value = 0;
1131            this.updateVolumeIcon(0);
1132        } else {
1133            const v = this._savedVolume || 1;
1134            this.audio.volume = v;
1135            if (this.hasVolumeSliderTarget) this.volumeSliderTarget.value = v * 100;
1136            this.updateVolumeIcon(v);
1137        }
1138    }
1139
1140    // ─── Sleep Timer ─────────────────────────────────────────
1141
1142    // Open the sleep-timer picker sheet. Tap an option → setSleepTimer().
1143    openSleepSheet() {
1144        if (this.hasSleepSheetTarget) {
1145            this.sleepSheetTarget.classList.add('open');
1146            this._markActiveSleepOption();
1147        }
1148    }
1149
1150    closeSleepSheet() {
1151        if (this.hasSleepSheetTarget) {
1152            this.sleepSheetTarget.classList.remove('open');
1153        }
1154    }
1155
1156    // Apply a sleep-timer choice from the sheet.
1157    // minutes: 0 = off, -1 = end of chapter, otherwise positive minutes.
1158    setSleepTimer(event) {
1159        const raw = event?.params?.minutes ?? event?.currentTarget?.dataset?.pwaAudiobookPlayerMinutesParam;
1160        const minutes = Number.parseInt(raw, 10);
1161        // Clear FIRST — clearSleepTimer() resets sleepMinutes to 0, so the
1162        // new value would be wiped if we set it before clearing.
1163        this.clearSleepTimer();
1164        this.sleepMinutes = Number.isFinite(minutes) ? minutes : 0;
1165
1166        if (this.sleepMinutes === -1) {
1167            this._sleepEndOfChapter = true;
1168        } else if (this.sleepMinutes > 0) {
1169            this._sleepEndOfChapter = false;
1170            this.sleepRemaining = this.sleepMinutes * 60;
1171            this.sleepInterval = setInterval(() => {
1172                // Only count down while audio is actually playing.
1173                if (!this.isPlaying || !this.audio || this.audio.paused) return;
1174                this.sleepRemaining--;
1175                this.updateSleepDisplay();
1176                if (this.sleepRemaining <= 0) {
1177                    this.clearSleepTimer();
1178                    if (this.audio && !this.audio.paused) {
1179                        this.audio.pause();
1180                    }
1181                }
1182            }, 1000);
1183        } else {
1184            this._sleepEndOfChapter = false;
1185        }
1186
1187        this.updateSleepDisplay();
1188        this._updateSleepLabel();
1189        this.closeSleepSheet();
1190    }
1191
1192    // Mark the row in the sheet that matches the current setting so the
1193    // user sees what's active when they reopen.
1194    _markActiveSleepOption() {
1195        if (!this.hasSleepSheetTarget) return;
1196        const current = this._sleepEndOfChapter ? -1 : (this.sleepMinutes || 0);
1197        this.sleepSheetTarget.querySelectorAll('[data-pwa-audiobook-player-minutes-param]').forEach(el => {
1198            const v = Number.parseInt(el.dataset.pwaAudiobookPlayerMinutesParam, 10);
1199            el.classList.toggle('active', v === current);
1200        });
1201    }
1202
1203    /**
1204     * Check if we should pause at end of chapter (called from _onTimeUpdate).
1205     */
1206    _checkSleepEndOfChapter() {
1207        if (!this._sleepEndOfChapter || !this.audio || this.chapters.length === 0) return;
1208        const t = this.audio.currentTime;
1209        const ch = this.chapters[this.currentChapterIndex];
1210        if (ch && t >= ch.end - 0.5) {
1211            this.clearSleepTimer();
1212            this.audio.pause();
1213        }
1214    }
1215
1216    clearSleepTimer() {
1217        if (this.sleepInterval) { clearInterval(this.sleepInterval); this.sleepInterval = null; }
1218        this.sleepRemaining = 0;
1219        this.sleepMinutes = 0;
1220        this._sleepEndOfChapter = false;
1221        this.updateSleepDisplay();
1222        this._updateSleepLabel();
1223    }
1224
1225    _updateSleepLabel() {
1226        if (!this.hasSleepBtnTarget) return;
1227        const svg = this.sleepBtnTarget.querySelector('svg');
1228        const existingLabel = this.sleepBtnTarget.querySelector('.sleep-label');
1229        if (existingLabel) existingLabel.remove();
1230
1231        if (this.sleepMinutes === -1) {
1232            this.sleepBtnTarget.style.color = '#fcd34d';
1233            const label = document.createElement('span');
1234            label.className = 'sleep-label';
1235            label.style.cssText = 'font-size:7px;font-weight:700;display:block;margin-top:-2px;';
1236            label.textContent = 'Ch.';
1237            this.sleepBtnTarget.appendChild(label);
1238        } else if (this.sleepMinutes > 0) {
1239            this.sleepBtnTarget.style.color = '#fcd34d';
1240            const label = document.createElement('span');
1241            label.className = 'sleep-label';
1242            label.style.cssText = 'font-size:7px;font-weight:700;display:block;margin-top:-2px;';
1243            label.textContent = this.sleepMinutes + 'm';
1244            this.sleepBtnTarget.appendChild(label);
1245        } else {
1246            this.sleepBtnTarget.style.color = 'rgba(255,255,255,0.5)';
1247        }
1248    }
1249
1250    updateSleepDisplay() {
1251        if (!this.hasSleepDisplayTarget) return;
1252        if (this._sleepEndOfChapter) {
1253            this.sleepDisplayTarget.textContent = 'End Ch.';
1254            this.sleepDisplayTarget.classList.remove('hidden');
1255        } else if (this.sleepRemaining > 0) {
1256            const m = Math.floor(this.sleepRemaining / 60);
1257            const s = this.sleepRemaining % 60;
1258            this.sleepDisplayTarget.textContent = `${m}:${String(s).padStart(2, '0')}`;
1259            this.sleepDisplayTarget.classList.remove('hidden');
1260        } else {
1261            this.sleepDisplayTarget.classList.add('hidden');
1262        }
1263    }
1264
1265    // ─── UI Updates ──────────────────────────────────────────
1266
1267    updatePlayButton() {
1268        if (!this.hasPlayIconTarget) return;
1269        this.playIconTarget.innerHTML = this.isPlaying
1270            ? '<path d="M6 4h4v16H6zm8 0h4v16h-4z"/>'
1271            : '<path d="M8 5v14l11-7z"/>';
1272    }
1273
1274    updateCurrentTime(seconds) {
1275        if (!this.hasCurrentTimeTarget) return;
1276        if (this.chapters.length > 0 && this.currentChapterIndex >= 0) {
1277            const ch = this.chapters[this.currentChapterIndex];
1278            if (ch) {
1279                this.currentTimeTarget.textContent = this.formatTime(Math.max(0, seconds - ch.start));
1280                return;
1281            }
1282        }
1283        this.currentTimeTarget.textContent = this.formatTime(seconds);
1284    }
1285
1286    updateDuration() {
1287        if (!this.hasDurationTarget || !this.audio) return;
1288        if (this.chapters.length > 0 && this.currentChapterIndex >= 0) {
1289            const ch = this.chapters[this.currentChapterIndex];
1290            if (ch && ch.end > ch.start) {
1291                this.durationTarget.textContent = this.formatTime(ch.end - ch.start);
1292                return;
1293            }
1294        }
1295        this.durationTarget.textContent = this.formatTime(this.audio.duration);
1296    }
1297
1298    _updateListenedProgress(t) {
1299        if (!this.hasListenedProgressTarget || !this.audio?.duration) return;
1300        const pct = Math.round((t / this.audio.duration) * 100);
1301        const totalMin = Math.floor(t / 60);
1302        const hrs = Math.floor(totalMin / 60);
1303        const mins = totalMin % 60;
1304        const timeStr = hrs > 0 ? `${hrs} hour${hrs !== 1 ? 's' : ''} ${mins} minute${mins !== 1 ? 's' : ''}` : `${mins} minute${mins !== 1 ? 's' : ''}`;
1305        this.listenedProgressTarget.textContent = `Listened to ${timeStr} (${pct}%)`;
1306    }
1307
1308    scrollToChapters() {
1309        if (this.hasInlineChaptersTarget) {
1310            this.inlineChaptersTarget.scrollIntoView({ behavior: 'smooth', block: 'start' });
1311        }
1312    }
1313
1314    updateProgressBar(currentTime) {
1315        if (!this.audio) return;
1316        const bookPct = (currentTime / this.audio.duration) * 100;
1317
1318        // Thin overall progress bar stays book-level
1319        if (this.hasProgressFillTarget) this.progressFillTarget.style.width = `${bookPct}%`;
1320
1321        // Seek bar: chapter-relative when chapters are available
1322        let seekPct = bookPct;
1323        if (this.chapters.length > 0 && this.currentChapterIndex >= 0) {
1324            const ch = this.chapters[this.currentChapterIndex];
1325            if (ch && ch.end > ch.start) {
1326                seekPct = Math.max(0, Math.min(100, ((currentTime - ch.start) / (ch.end - ch.start)) * 100));
1327            }
1328        }
1329        if (this.hasSeekFillTarget) this.seekFillTarget.style.width = `${seekPct}%`;
1330        if (this.hasSeekHandleTarget) this.seekHandleTarget.style.left = `${seekPct}%`;
1331
1332        // Buffer indicator (book-level — buffered bytes don't map cleanly to chapter %)
1333        if (this.hasBufferFillTarget && this.audio.buffered.length > 0) {
1334            const bufferedEnd = this.audio.buffered.end(this.audio.buffered.length - 1);
1335            const bufferPct = (bufferedEnd / this.audio.duration) * 100;
1336            this.bufferFillTarget.style.width = `${bufferPct}%`;
1337        }
1338    }
1339
1340    updateVolumeIcon(volume) {
1341        if (!this.hasVolumeIconTarget) return;
1342        if (volume === 0) {
1343            this.volumeIconTarget.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"/>';
1344        } else if (volume < 0.5) {
1345            this.volumeIconTarget.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"/>';
1346        } else {
1347            this.volumeIconTarget.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"/>';
1348        }
1349    }
1350
1351    showLoading(message) {
1352        if (this.hasLoadingStateTarget) {
1353            this.loadingStateTarget.classList.remove('hidden');
1354            const p = this.loadingStateTarget.querySelector('p');
1355            if (p) p.textContent = message;
1356        }
1357        if (this.hasPlayerStateTarget) this.playerStateTarget.classList.add('hidden');
1358    }
1359
1360    showPlayer() {
1361        if (this.hasLoadingStateTarget) this.loadingStateTarget.classList.add('hidden');
1362        if (this.hasPlayerStateTarget) {
1363            this.playerStateTarget.classList.remove('hidden');
1364            this.playerStateTarget.scrollTop = 0;
1365        }
1366        window.scrollTo(0, 0);
1367    }
1368
1369    showError(message) {
1370        if (this.hasLoadingStateTarget) {
1371            const safe = this._escapeHtml(message);
1372            this.loadingStateTarget.innerHTML = `
1373                <div class="flex flex-col items-center justify-center text-center px-8">
1374                    <svg class="w-16 h-16 text-red-400/40 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1375                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
1376                    </svg>
1377                    <p class="text-white/60 text-sm">${safe}</p>
1378                    <button onclick="location.reload()" class="mt-4 px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-semibold">Retry</button>
1379                </div>`;
1380        }
1381    }
1382
1383    _escapeHtml(str) {
1384        const d = document.createElement('div');
1385        d.textContent = String(str);
1386        return d.innerHTML;
1387    }
1388
1389    // ─── Position Persistence ────────────────────────────────
1390
1391    get stateKey() { return `audiobook_pos_${this.mediaIdValue}`; }
1392
1393    getSavedPosition() {
1394        try {
1395            const raw = localStorage.getItem(this.stateKey) || sessionStorage.getItem(this.stateKey) || '{}';
1396            return JSON.parse(raw).position || 0;
1397        } catch { return 0; }
1398    }
1399
1400    savePosition() {
1401        if (!this.audio) return;
1402        const data = JSON.stringify({
1403            position: this.audio.currentTime,
1404            playbackRate: this.playbackRate,
1405            lastPlayed: Date.now(),
1406        });
1407        try {
1408            localStorage.setItem(this.stateKey, data);
1409        } catch {
1410            // localStorage full — fall back to sessionStorage
1411            try {
1412                sessionStorage.setItem(this.stateKey, data);
1413                console.warn('[AudiobookPlayer] localStorage full, using sessionStorage fallback');
1414            } catch { /* both full, position lost this session */ }
1415        }
1416    }
1417
1418    restoreState() {
1419        try {
1420            const raw = localStorage.getItem(this.stateKey) || sessionStorage.getItem(this.stateKey) || '{}';
1421            this.playbackRate = JSON.parse(raw).playbackRate || 1;
1422        } catch { /* defaults fine */ }
1423    }
1424
1425    // ─── IndexedDB helpers ───────────────────────────────────
1426
1427    _openDb() {
1428        return new Promise((resolve, reject) => {
1429            const req = indexedDB.open('pwa_audiobook_cache', 1);
1430            req.onupgradeneeded = () => req.result.createObjectStore('files');
1431            req.onsuccess = () => resolve(req.result);
1432            req.onerror = () => reject(req.error);
1433        });
1434    }
1435
1436    // Read a user upload (blob record) from the shared 'ebook-library' DB
1437    // — that's where the library page's upload button stores files.
1438    _idbReadUpload(uploadId) {
1439        return new Promise((resolve, reject) => {
1440            const open = indexedDB.open('ebook-library', 1);
1441            open.onupgradeneeded = () => {
1442                const db = open.result;
1443                if (!db.objectStoreNames.contains('files')) db.createObjectStore('files');
1444            };
1445            open.onsuccess = () => {
1446                const db = open.result;
1447                const tx = db.transaction('files', 'readonly');
1448                const req = tx.objectStore('files').get('user-upload:' + uploadId);
1449                req.onsuccess = () => resolve(req.result);
1450                req.onerror = () => reject(req.error);
1451            };
1452            open.onerror = () => reject(open.error);
1453        });
1454    }
1455
1456    async idbGet(key) {
1457        const db = await this._openDb();
1458        return new Promise((resolve, reject) => {
1459            const tx = db.transaction('files', 'readonly');
1460            const req = tx.objectStore('files').get(key);
1461            req.onsuccess = () => resolve(req.result);
1462            req.onerror = () => reject(req.error);
1463        });
1464    }
1465
1466    async idbPut(key, value) {
1467        const db = await this._openDb();
1468        return new Promise((resolve, reject) => {
1469            const tx = db.transaction('files', 'readwrite');
1470            const req = tx.objectStore('files').put(value, key);
1471            req.onsuccess = () => resolve();
1472            req.onerror = () => reject(req.error);
1473        });
1474    }
1475
1476    // ─── Utilities ───────────────────────────────────────────
1477
1478    formatTime(seconds) {
1479        if (!seconds || isNaN(seconds)) return '0:00';
1480        const s = Math.floor(seconds);
1481        const h = Math.floor(s / 3600);
1482        const m = Math.floor((s % 3600) / 60);
1483        const sec = s % 60;
1484        if (h > 0) return `${h}:${String(m).padStart(2, '0')}
1484:${String(sec).padStart(2, '0')}`;
1485        return `${m}:${String(sec).padStart(2, '0')}`;
1486    }
1487
1488    // ─── Server-Side Listening Tracking ──────────────────────
1489
1490    _initTracking() {
1491        this._trackingSessionStart = Date.now();
1492        this._totalListenedSecs = 0;
1493        this._listeningStart = 0;
1494        this._lastHeartbeat = 0;
1495        this._isNewSession = true;
1496        this._milestonesReached = new Set();
1497        this._heartbeatInterval = setInterval(() => {
1498            if (this.isPlaying) this._sendHeartbeat();
1499        }, 60000);
1500    }
1501
1502    _cleanupTracking() {
1503        if (this._heartbeatInterval) { clearInterval(this._heartbeatInterval); this._heartbeatInterval = null; }
1504        if (this._listeningStart > 0 && this.isPlaying) {
1505            this._totalListenedSecs += Math.round((Date.now() - this._listeningStart) / 1000);
1506        }
1507        this._sendFinalBeacon();
1508    }
1509
1510    _getProgressPercent() {
1511        if (!this.audio?.duration) return 0;
1512        return Math.round((this.audio.currentTime / this.audio.duration) * 100);
1513    }
1514
1515    _onPause() {
1516        let secs = 0;
1517        if (this._listeningStart > 0) {
1518            secs = Math.round((Date.now() - this._listeningStart) / 1000);
1519            this._totalListenedSecs += secs;
1520            this._listeningStart = 0;
1521        }
1522        this._trackMatomo('Audiobook', 'Pause', this.titleValue, secs);
1523        this._sendHeartbeat();
1524    }
1525
1526    _onEnded() {
1527        if (this._listeningStart > 0) {
1528            this._totalListenedSecs += Math.round((Date.now() - this._listeningStart) / 1000);
1529            this._listeningStart = 0;
1530        }
1531        this._trackMatomo('Audiobook', 'Complete', this.titleValue, this._totalListenedSecs);
1532        this._sendHeartbeat();
1533    }
1534
1535    _sendHeartbeat() {
1536        const now = Date.now();
1537        if (now - this._lastHeartbeat < 30000) return;
1538        this._lastHeartbeat = now;
1539
1540        const pct = this._getProgressPercent();
1541        for (const m of [25, 50, 75, 100]) {
1542            if (pct >= m && !this._milestonesReached.has(m)) {
1543                this._milestonesReached.add(m);
1544                this._trackMatomo('Audiobook', 'Milestone ' + m + '%', this.titleValue, m);
1545            }
1546        }
1547
1548        const payload = {
1549            media_id: this.mediaIdValue,
1550            book_id: this.mediaIdValue,
1551            format: 'audiobook',
1552            progress_percent: pct,
1553            location_data: this.audio ? String(Math.round(this.audio.currentTime)) : null,
1554            total_duration_secs: this.audio ? Math.round(this.audio.duration) : null,
1555            current_position: this.audio ? Math.round(this.audio.currentTime) : null,
1556            session_duration_secs: 60,
1557            playback_rate: this.playbackRate,
1558            is_new_session: this._isNewSession,
1559            current_chapter: this.chapters[this.currentChapterIndex]?.title || null,
1560        };
1561        this._isNewSession = false;
1562
1563        try {
1564            fetch('/api/pwa/tracking/heartbeat', {
1565                method: 'POST',
1566                headers: { 'Content-Type': 'application/json' },
1567                body: JSON.stringify(payload),
1568                keepalive: true,
1569                credentials: 'include',
1570            }).catch(() => {});
1571        } catch (_) {}
1572    }
1573
1574    _trackMatomo(category, action, name, value) {
1575        if (typeof window._paq !== 'undefined') {
1576            const args = ['trackEvent', category, action];
1577            if (name) args.push(name);
1578            if (value !== undefined) args.push(value);
1579            window._paq.push(args);
1580        }
1581    }
1582
1583    _sendFinalBeacon() {
1584        const payload = {
1585            media_id: this.mediaIdValue,
1586            book_id: this.mediaIdValue,
1587            format: 'audiobook',
1588            progress_percent: this._getProgressPercent(),
1589            location_data: this.audio ? String(Math.round(this.audio.currentTime)) : null,
1590            total_duration_secs: this.audio ? Math.round(this.audio.duration) : null,
1591            current_position: this.audio ? Math.round(this.audio.currentTime) : null,
1592            session_duration_secs: this._totalListenedSecs,
1593            playback_rate: this.playbackRate,
1594            is_new_session: false,
1595            current_chapter: this.chapters[this.currentChapterIndex]?.title || null,
1596        };
1597        try {
1598            navigator.sendBeacon('/api/pwa/tracking/heartbeat',
1599                new Blob([JSON.stringify(payload)], { type: 'application/json' }));
1600        } catch (_) {}
1601    }
1602}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.