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} · ${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.