// Unified media player — shared by audio and video player pages. // Reads configuration from a data-* attribute bridge on #media-player-data. // Supports two modes: simple (no insertions) and segment (dual-element gapless). (function() { 'use strict'; var dataEl = document.getElementById('media-player-data'); if (!dataEl) return; var config = JSON.parse(dataEl.textContent || '{}'); var segments = config.segments || null; var mediaType = config.mediaType || 'audio'; var itemId = config.itemId || window.location.pathname.split('/').pop(); var mediaA = document.getElementById('media-a'); var mediaB = document.getElementById('media-b'); var isVideo = mediaType === 'video'; var playBtn = document.getElementById('play-btn'); var playIcon = document.getElementById('play-icon'); var pauseIcon = document.getElementById('pause-icon'); var progressBar = document.getElementById('progress-bar'); var progressFill = document.getElementById('progress-fill'); var currentTimeEl = document.getElementById('current-time'); var durationEl = document.getElementById('duration'); var volumeSlider = document.getElementById('volume-slider'); var speedButtons = document.querySelectorAll('.speed-button'); var chapterItems = document.querySelectorAll('.chapter-item'); var skipBtn = document.getElementById('skip-btn'); var insertionLabel = document.getElementById('insertion-label'); function formatTime(seconds) { if (isNaN(seconds)) return '0:00'; var h = Math.floor(seconds / 3600); var m = Math.floor((seconds % 3600) / 60); var s = Math.floor(seconds % 60); if (h > 0) { return h + ':' + m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0'); } return m + ':' + s.toString().padStart(2, '0'); } // ── Simple mode (no insertions) ── if (!segments || segments.length === 0) { var media = mediaA; playBtn.addEventListener('click', function() { if (media.paused) { media.play(); } else { media.pause(); } }); media.addEventListener('play', function() { playIcon.classList.add('hidden'); pauseIcon.classList.remove('hidden'); }); media.addEventListener('pause', function() { playIcon.classList.remove('hidden'); pauseIcon.classList.add('hidden'); }); media.addEventListener('timeupdate', function() { var pct = (media.currentTime / media.duration) * 100; progressFill.style.width = pct + '%'; currentTimeEl.textContent = formatTime(media.currentTime); }); media.addEventListener('loadedmetadata', function() { durationEl.textContent = formatTime(media.duration); }); progressBar.addEventListener('click', function(e) { var rect = progressBar.getBoundingClientRect(); media.currentTime = ((e.clientX - rect.left) / rect.width) * media.duration; }); volumeSlider.addEventListener('input', function() { media.volume = this.value / 100; }); media.volume = volumeSlider.value / 100; speedButtons.forEach(function(btn) { btn.addEventListener('click', function() { speedButtons.forEach(function(b) { b.classList.remove('is-selected'); }); btn.classList.add('is-selected'); media.playbackRate = parseFloat(btn.dataset.speed); }); }); chapterItems.forEach(function(item) { item.addEventListener('click', function() { var time = parseFloat(item.dataset.time); if (!isNaN(time)) { media.currentTime = time; media.play(); } }); }); var storageKey = 'media-progress-' + itemId; media.addEventListener('timeupdate', function() { safeStorageSet(storageKey, media.currentTime); }); media.addEventListener('loadedmetadata', function() { var saved = safeStorageGet(storageKey); if (saved) { var t = parseFloat(saved); if (!isNaN(t) && t < media.duration - 5) media.currentTime = t; } }); // Keyboard shortcuts (no virtual timeline in simple mode) setupKeyboard(media, null, null, null, 0); return; } // ── Segment mode (dual-element gapless playback) ── // Compute virtual timeline var totalDurationMs = 0; var segStarts = []; for (var i = 0; i < segments.length; i++) { segStarts.push(totalDurationMs); totalDurationMs += segments[i].duration_ms; } var totalDurationSec = totalDurationMs / 1000; durationEl.textContent = formatTime(totalDurationSec); var currentSegIndex = 0; var activeEl = mediaA; // Video uses single element (no dual-element gapless — brief gap acceptable) var standbyEl = isVideo ? null : mediaB; var playing = false; var currentSpeed = 1; var currentVolume = volumeSlider.value / 100; function isInsertionSegment(idx) { return segments[idx] && segments[idx].segment_type !== 'main'; } function loadSegment(el, idx) { if (idx >= segments.length) return; var seg = segments[idx]; el.src = seg.url; el.load(); el.playbackRate = currentSpeed; el.volume = currentVolume; if (seg.segment_type === 'main') { var mainOffsetMs = 0; for (var j = 0; j < idx; j++) { if (segments[j].segment_type === 'main' && segments[j].url === seg.url) { mainOffsetMs += segments[j].duration_ms; } } if (mainOffsetMs > 0) { el.addEventListener('loadedmetadata', function seekToOffset() { el.currentTime = mainOffsetMs / 1000; el.removeEventListener('loadedmetadata', seekToOffset); }); } } } function updateInsertionUI() { if (isInsertionSegment(currentSegIndex)) { var seg = segments[currentSegIndex]; insertionLabel.textContent = seg.title || seg.segment_type.replace('_', '-'); insertionLabel.style.display = 'block'; skipBtn.style.display = 'inline-block'; } else { insertionLabel.style.display = 'none'; skipBtn.style.display = 'none'; } } function getVirtualTime() { if (currentSegIndex >= segments.length) return totalDurationSec; var seg = segments[currentSegIndex]; var localTime = activeEl.currentTime; if (seg.segment_type === 'main') { var mainOffset = 0; for (var j = 0; j < currentSegIndex; j++) { if (segments[j].segment_type === 'main' && segments[j].url === seg.url) { mainOffset += segments[j].duration_ms / 1000; } } localTime = localTime - mainOffset; } return (segStarts[currentSegIndex] / 1000) + Math.max(0, localTime); } function advanceSegment() { currentSegIndex++; if (currentSegIndex >= segments.length) { playing = false; playIcon.style.display = 'block'; pauseIcon.style.display = 'none'; insertionLabel.style.display = 'none'; skipBtn.style.display = 'none'; progressFill.style.width = '100%'; currentTimeEl.textContent = formatTime(totalDurationSec); return; } if (standbyEl) { // Dual-element gapless: swap active/standby var tmp = activeEl; activeEl = standbyEl; standbyEl = tmp; activeEl.playbackRate = currentSpeed; activeEl.volume = currentVolume; activeEl.play().catch(function() {}); } else { // Single-element: load next segment, defer play until ready var seg = segments[currentSegIndex]; activeEl.src = seg.url; activeEl.load(); activeEl.playbackRate = currentSpeed; activeEl.volume = currentVolume; // For main segments with offset, wait for metadata before playing if (seg.segment_type === 'main') { var mainOffMs = 0; for (var j = 0; j < currentSegIndex; j++) { if (segments[j].segment_type === 'main' && segments[j].url === seg.url) { mainOffMs += segments[j].duration_ms; } } if (mainOffMs > 0) { activeEl.addEventListener('loadedmetadata', function seekThenPlay() { activeEl.currentTime = mainOffMs / 1000; activeEl.play().catch(function() {}); activeEl.removeEventListener('loadedmetadata', seekThenPlay); }); } else { activeEl.play().catch(function() {}); } } else { activeEl.play().catch(function() {}); } } if (standbyEl && currentSegIndex + 1 < segments.length) { loadSegment(standbyEl, currentSegIndex + 1); } updateInsertionUI(); } mediaA.addEventListener('ended', advanceSegment); if (mediaB) mediaB.addEventListener('ended', advanceSegment); function checkSegmentBoundary() { if (currentSegIndex >= segments.length) return; var seg = segments[currentSegIndex]; if (seg.segment_type === 'main') { var mainOffset = 0; for (var j = 0; j < currentSegIndex; j++) { if (segments[j].segment_type === 'main' && segments[j].url === seg.url) { mainOffset += segments[j].duration_ms / 1000; } } var endTime = mainOffset + (seg.duration_ms / 1000); if (activeEl.currentTime >= endTime - 0.05) { activeEl.pause(); advanceSegment(); } } } mediaA.addEventListener('timeupdate', function() { if (activeEl === mediaA) { updateProgress(); checkSegmentBoundary(); } }); if (mediaB) { mediaB.addEventListener('timeupdate', function() { if (activeEl === mediaB) { updateProgress(); checkSegmentBoundary(); } }); } function updateProgress() { var vt = getVirtualTime(); var pct = (vt / totalDurationSec) * 100; progressFill.style.width = Math.min(pct, 100) + '%'; currentTimeEl.textContent = formatTime(vt); safeStorageSet('media-progress-' + itemId, vt); } // Initialize first segment loadSegment(mediaA, 0); if (mediaB && segments.length > 1) { loadSegment(mediaB, 1); } updateInsertionUI(); // Play/Pause playBtn.addEventListener('click', function() { if (playing) { activeEl.pause(); playing = false; playIcon.style.display = 'block'; pauseIcon.style.display = 'none'; } else { activeEl.play().catch(function() {}); playing = true; playIcon.style.display = 'none'; pauseIcon.style.display = 'block'; } }); // Skip insertion skipBtn.addEventListener('click', function() { if (isInsertionSegment(currentSegIndex)) { activeEl.pause(); advanceSegment(); } }); // Seek to a virtual time in milliseconds (shared by progress bar + keyboard) function seekToVirtualMs(targetMs) { targetMs = Math.max(0, Math.min(targetMs, totalDurationMs)); var targetIdx = 0; for (var k = 0; k < segments.length; k++) { if (targetMs < segStarts[k] + segments[k].duration_ms) { targetIdx = k; break; } if (k === segments.length - 1) targetIdx = k; } var localOffsetMs = targetMs - segStarts[targetIdx]; currentSegIndex = targetIdx; var seg = segments[targetIdx]; activeEl.src = seg.url; activeEl.load(); activeEl.playbackRate = currentSpeed; activeEl.volume = currentVolume; activeEl.addEventListener('loadedmetadata', function seekHandler() { var seekTo = localOffsetMs / 1000; if (seg.segment_type === 'main') { var mainOffset = 0; for (var j = 0; j < targetIdx; j++) { if (segments[j].segment_type === 'main' && segments[j].url === seg.url) { mainOffset += segments[j].duration_ms / 1000; } } seekTo += mainOffset; } activeEl.currentTime = seekTo; if (playing) activeEl.play().catch(function() {}); activeEl.removeEventListener('loadedmetadata', seekHandler); }); if (standbyEl && targetIdx + 1 < segments.length) { loadSegment(standbyEl, targetIdx + 1); } updateInsertionUI(); } // Seek on progress bar (virtual timeline) progressBar.addEventListener('click', function(e) { var rect = progressBar.getBoundingClientRect(); var pct = (e.clientX - rect.left) / rect.width; seekToVirtualMs(pct * totalDurationMs); }); // Volume volumeSlider.addEventListener('input', function() { currentVolume = this.value / 100; mediaA.volume = currentVolume; if (mediaB) mediaB.volume = currentVolume; }); mediaA.volume = currentVolume; if (mediaB) mediaB.volume = currentVolume; // Speed speedButtons.forEach(function(btn) { btn.addEventListener('click', function() { speedButtons.forEach(function(b) { b.classList.remove('is-selected'); }); btn.classList.add('is-selected'); currentSpeed = parseFloat(btn.dataset.speed); mediaA.playbackRate = currentSpeed; if (mediaB) mediaB.playbackRate = currentSpeed; }); }); // Chapter navigation chapterItems.forEach(function(item) { item.addEventListener('click', function() { var chapterSec = parseFloat(item.dataset.time); if (isNaN(chapterSec)) return; var virtualMs = 0; var mainElapsedMs = 0; var chapterMs = chapterSec * 1000; for (var k = 0; k < segments.length; k++) { var seg = segments[k]; if (seg.segment_type === 'main') { if (mainElapsedMs + seg.duration_ms > chapterMs) { virtualMs += (chapterMs - mainElapsedMs); break; } mainElapsedMs += seg.duration_ms; virtualMs += seg.duration_ms; } else { if (mainElapsedMs <= chapterMs) { virtualMs += seg.duration_ms; } } } var targetIdx = 0; for (var k2 = 0; k2 < segments.length; k2++) { if (virtualMs < segStarts[k2] + segments[k2].duration_ms) { targetIdx = k2; break; } if (k2 === segments.length - 1) targetIdx = k2; } var localOffsetMs = virtualMs - segStarts[targetIdx]; currentSegIndex = targetIdx; var targetSeg = segments[targetIdx]; activeEl.src = targetSeg.url; activeEl.load(); activeEl.playbackRate = currentSpeed; activeEl.volume = currentVolume; activeEl.addEventListener('loadedmetadata', function chapterSeek() { var seekTo = localOffsetMs / 1000; if (targetSeg.segment_type === 'main') { var mainOff = 0; for (var j = 0; j < targetIdx; j++) { if (segments[j].segment_type === 'main' && segments[j].url === targetSeg.url) { mainOff += segments[j].duration_ms / 1000; } } seekTo += mainOff; } activeEl.currentTime = seekTo; activeEl.play().catch(function() {}); playing = true; playIcon.style.display = 'none'; pauseIcon.style.display = 'block'; activeEl.removeEventListener('loadedmetadata', chapterSeek); }); if (standbyEl && targetIdx + 1 < segments.length) { loadSegment(standbyEl, targetIdx + 1); } updateInsertionUI(); }); }); // Restore saved position var storageKey = 'media-progress-' + itemId; mediaA.addEventListener('loadedmetadata', function restoreOnce() { var saved = safeStorageGet(storageKey); if (saved) { var vt = parseFloat(saved); if (!isNaN(vt) && vt > 0 && vt < totalDurationSec - 5) { var targetMs = vt * 1000; for (var k = 0; k < segments.length; k++) { if (targetMs < segStarts[k] + segments[k].duration_ms) { currentSegIndex = k; break; } } var seg = segments[currentSegIndex]; var localMs = targetMs - segStarts[currentSegIndex]; if (currentSegIndex > 0) { activeEl.src = seg.url; activeEl.load(); } activeEl.addEventListener('loadedmetadata', function restoreSeek() { var seekTo = localMs / 1000; if (seg.segment_type === 'main') { var mainOff = 0; for (var j = 0; j < currentSegIndex; j++) { if (segments[j].segment_type === 'main' && segments[j].url === seg.url) { mainOff += segments[j].duration_ms / 1000; } } seekTo += mainOff; } activeEl.currentTime = seekTo; activeEl.removeEventListener('loadedmetadata', restoreSeek); }); if (standbyEl && currentSegIndex + 1 < segments.length) { loadSegment(standbyEl, currentSegIndex + 1); } updateInsertionUI(); } } mediaA.removeEventListener('loadedmetadata', restoreOnce); }); // Keyboard shortcuts for segment mode setupKeyboard(activeEl, function() { return activeEl; }, seekToVirtualMs, getVirtualTime, totalDurationMs); // ── Keyboard shortcuts (Phase 5) ── function setupKeyboard(defaultEl, getActiveEl, virtualSeek, getVTime, totalMs) { document.addEventListener('keydown', function(e) { // Don't handle in form inputs if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return; var el = getActiveEl ? getActiveEl() : defaultEl; switch (e.key) { case ' ': e.preventDefault(); playBtn.click(); break; case 'ArrowLeft': e.preventDefault(); if (virtualSeek) { virtualSeek(getVTime() * 1000 - 10000); } else if (el.currentTime !== undefined) { el.currentTime = Math.max(0, el.currentTime - 10); } break; case 'ArrowRight': e.preventDefault(); if (virtualSeek) { virtualSeek(getVTime() * 1000 + 10000); } else if (el.currentTime !== undefined) { el.currentTime = Math.min(el.duration || 0, el.currentTime + 10); } break; case 'ArrowUp': e.preventDefault(); if (volumeSlider) { volumeSlider.value = Math.min(100, parseInt(volumeSlider.value) + 10); volumeSlider.dispatchEvent(new Event('input')); } break; case 'ArrowDown': e.preventDefault(); if (volumeSlider) { volumeSlider.value = Math.max(0, parseInt(volumeSlider.value) - 10); volumeSlider.dispatchEvent(new Event('input')); } break; case 'm': case 'M': e.preventDefault(); if (el.muted !== undefined) { el.muted = !el.muted; if (mediaB) mediaB.muted = el.muted; } break; case 'f': case 'F': if (mediaType === 'video' && el.requestFullscreen) { e.preventDefault(); el.requestFullscreen().catch(function() {}); } break; case 's': case 'S': if (skipBtn && skipBtn.style.display !== 'none') { e.preventDefault(); skipBtn.click(); } break; } }); } })();