// — unified audio/video player, ported from // static/media-player.js. Two modes: simple (a single element) and segment // (dual-element gapless playback across a virtual timeline of main + insertion // segments). One player per page; controls are looked up by id. Config arrives // as JSON in the `#media-player-data` bridge. The virtual-timeline math lives // in media-player.logic.ts (unit-tested); this file wires it to the elements. import { MnwElement, define } from './base.ts'; import { safeGet, safeSet } from '../core/storage.ts'; import { type Segment, formatTime, buildTimeline, mainOffsetMs, segmentIndexAtVirtualMs, chapterToVirtualMs, } from './media-player.logic.ts'; interface Config { segments?: Segment[]; mediaType?: string; itemId?: string; } class MediaPlayer extends MnwElement { protected init(): void { const dataEl = document.getElementById('media-player-data'); if (!dataEl) return; const config = JSON.parse(dataEl.textContent || '{}') as Config; const segments = config.segments ?? null; const mediaType = config.mediaType ?? 'audio'; const itemId = config.itemId || window.location.pathname.split('/').pop() || ''; const isVideo = mediaType === 'video'; const byId = (id: string) => document.getElementById(id); const mediaA = byId('media-a') as HTMLMediaElement; const mediaB = byId('media-b') as HTMLMediaElement | null; const playBtn = byId('play-btn')!; const playIcon = byId('play-icon')!; const pauseIcon = byId('pause-icon')!; const progressBar = byId('progress-bar')!; const progressFill = byId('progress-fill')!; const currentTimeEl = byId('current-time')!; const durationEl = byId('duration')!; const volumeSlider = byId('volume-slider') as HTMLInputElement; const speedButtons = document.querySelectorAll('.speed-button'); const chapterItems = document.querySelectorAll('.chapter-item'); const skipBtn = byId('skip-btn'); const insertionLabel = byId('insertion-label'); // Keyboard shortcuts (shared by both modes). const setupKeyboard = ( getActiveEl: (() => HTMLMediaElement) | null, defaultEl: HTMLMediaElement, virtualSeek: ((ms: number) => void) | null, getVTime: (() => number) | null, ): void => { document.addEventListener('keydown', (e) => { const t = (e.target as HTMLElement | null)?.tagName; if (t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT') return; const el = getActiveEl ? getActiveEl() : defaultEl; switch (e.key) { case ' ': e.preventDefault(); playBtn.click(); break; case 'ArrowLeft': e.preventDefault(); if (virtualSeek && getVTime) virtualSeek(getVTime() * 1000 - 10_000); else el.currentTime = Math.max(0, el.currentTime - 10); break; case 'ArrowRight': e.preventDefault(); if (virtualSeek && getVTime) virtualSeek(getVTime() * 1000 + 10_000); else el.currentTime = Math.min(el.duration || 0, el.currentTime + 10); break; case 'ArrowUp': e.preventDefault(); volumeSlider.value = String(Math.min(100, parseInt(volumeSlider.value, 10) + 10)); volumeSlider.dispatchEvent(new Event('input')); break; case 'ArrowDown': e.preventDefault(); volumeSlider.value = String(Math.max(0, parseInt(volumeSlider.value, 10) - 10)); volumeSlider.dispatchEvent(new Event('input')); break; case 'm': case 'M': e.preventDefault(); el.muted = !el.muted; if (mediaB) mediaB.muted = el.muted; break; case 'f': case 'F': if (isVideo && el.requestFullscreen) { e.preventDefault(); el.requestFullscreen().catch(() => {}); } break; case 's': case 'S': if (skipBtn && skipBtn.style.display !== 'none') { e.preventDefault(); skipBtn.click(); } break; } }); }; // ── Simple mode (no insertions) ── if (!segments || segments.length === 0) { const media = mediaA; playBtn.addEventListener('click', () => { if (media.paused) void media.play(); else media.pause(); }); media.addEventListener('play', () => { playIcon.classList.add('hidden'); pauseIcon.classList.remove('hidden'); }); media.addEventListener('pause', () => { playIcon.classList.remove('hidden'); pauseIcon.classList.add('hidden'); }); media.addEventListener('timeupdate', () => { progressFill.style.width = (media.currentTime / media.duration) * 100 + '%'; currentTimeEl.textContent = formatTime(media.currentTime); }); media.addEventListener('loadedmetadata', () => { durationEl.textContent = formatTime(media.duration); }); progressBar.addEventListener('click', (e) => { const rect = progressBar.getBoundingClientRect(); media.currentTime = ((e.clientX - rect.left) / rect.width) * media.duration; }); volumeSlider.addEventListener('input', () => { media.volume = Number(volumeSlider.value) / 100; }); media.volume = Number(volumeSlider.value) / 100; speedButtons.forEach((btn) => { btn.addEventListener('click', () => { speedButtons.forEach((b) => b.classList.remove('is-selected')); btn.classList.add('is-selected'); media.playbackRate = parseFloat(btn.dataset.speed ?? '1'); }); }); chapterItems.forEach((item) => { item.addEventListener('click', () => { const time = parseFloat(item.dataset.time ?? ''); if (!Number.isNaN(time)) { media.currentTime = time; void media.play(); } }); }); const storageKey = 'media-progress-' + itemId; media.addEventListener('timeupdate', () => safeSet(storageKey, String(media.currentTime))); media.addEventListener('loadedmetadata', () => { const saved = safeGet(storageKey); if (saved) { const t = parseFloat(saved); if (!Number.isNaN(t) && t < media.duration - 5) media.currentTime = t; } }); setupKeyboard(null, media, null, null); return; } // ── Segment mode (dual-element gapless playback) ── const { segStarts, totalMs } = buildTimeline(segments); const totalSec = totalMs / 1000; durationEl.textContent = formatTime(totalSec); let currentSegIndex = 0; let activeEl = mediaA; let standbyEl: HTMLMediaElement | null = mediaB; let playing = false; let currentSpeed = 1; let currentVolume = Number(volumeSlider.value) / 100; const isInsertion = (idx: number) => segments[idx] && segments[idx].segment_type !== 'main'; const syncVideoVisibility = (): void => { if (!isVideo || !standbyEl) return; activeEl.classList.remove('is-standby'); standbyEl.classList.add('is-standby'); }; const loadSegment = (el: HTMLMediaElement, idx: number): void => { if (idx >= segments.length) return; const seg = segments[idx]; el.src = seg.url; el.load(); el.playbackRate = currentSpeed; el.volume = currentVolume; const offMs = mainOffsetMs(segments, idx); if (offMs > 0) { el.addEventListener('loadedmetadata', function seekToOffset() { el.currentTime = offMs / 1000; el.removeEventListener('loadedmetadata', seekToOffset); }); } }; const updateInsertionUI = (): void => { syncVideoVisibility(); if (isInsertion(currentSegIndex) && insertionLabel && skipBtn) { const seg = segments[currentSegIndex]; insertionLabel.textContent = seg.title || seg.segment_type.replace('_', '-'); insertionLabel.style.display = 'block'; skipBtn.style.display = 'inline-block'; } else { if (insertionLabel) insertionLabel.style.display = 'none'; if (skipBtn) skipBtn.style.display = 'none'; } }; const getVirtualTime = (): number => { if (currentSegIndex >= segments.length) return totalSec; const localTime = activeEl.currentTime - mainOffsetMs(segments, currentSegIndex) / 1000; return segStarts[currentSegIndex] / 1000 + Math.max(0, localTime); }; const updateProgress = (): void => { const vt = getVirtualTime(); progressFill.style.width = Math.min((vt / totalSec) * 100, 100) + '%'; currentTimeEl.textContent = formatTime(vt); safeSet('media-progress-' + itemId, String(vt)); }; const advanceSegment = (): void => { currentSegIndex++; if (currentSegIndex >= segments.length) { playing = false; playIcon.style.display = 'block'; pauseIcon.style.display = 'none'; if (insertionLabel) insertionLabel.style.display = 'none'; if (skipBtn) skipBtn.style.display = 'none'; progressFill.style.width = '100%'; currentTimeEl.textContent = formatTime(totalSec); return; } if (standbyEl) { const tmp = activeEl; activeEl = standbyEl; standbyEl = tmp; activeEl.playbackRate = currentSpeed; activeEl.volume = currentVolume; void activeEl.play().catch(() => {}); } else { const seg = segments[currentSegIndex]; activeEl.src = seg.url; activeEl.load(); activeEl.playbackRate = currentSpeed; activeEl.volume = currentVolume; const offMs = mainOffsetMs(segments, currentSegIndex); if (offMs > 0) { activeEl.addEventListener('loadedmetadata', function seekThenPlay() { activeEl.currentTime = offMs / 1000; void activeEl.play().catch(() => {}); activeEl.removeEventListener('loadedmetadata', seekThenPlay); }); } else { void activeEl.play().catch(() => {}); } } if (standbyEl && currentSegIndex + 1 < segments.length) loadSegment(standbyEl, currentSegIndex + 1); updateInsertionUI(); }; const checkSegmentBoundary = (): void => { if (currentSegIndex >= segments.length) return; const seg = segments[currentSegIndex]; if (seg.segment_type !== 'main') return; const endTime = mainOffsetMs(segments, currentSegIndex) / 1000 + seg.duration_ms / 1000; if (activeEl.currentTime >= endTime - 0.05) { activeEl.pause(); advanceSegment(); } }; const seekToVirtualMs = (rawTargetMs: number): void => { const targetMs = Math.max(0, Math.min(rawTargetMs, totalMs)); const targetIdx = segmentIndexAtVirtualMs(segStarts, segments, targetMs); const localOffsetMs = targetMs - segStarts[targetIdx]; currentSegIndex = targetIdx; const seg = segments[targetIdx]; activeEl.src = seg.url; activeEl.load(); activeEl.playbackRate = currentSpeed; activeEl.volume = currentVolume; activeEl.addEventListener('loadedmetadata', function seekHandler() { activeEl.currentTime = localOffsetMs / 1000 + mainOffsetMs(segments, targetIdx) / 1000; if (playing) void activeEl.play().catch(() => {}); activeEl.removeEventListener('loadedmetadata', seekHandler); }); if (standbyEl && targetIdx + 1 < segments.length) loadSegment(standbyEl, targetIdx + 1); updateInsertionUI(); }; mediaA.addEventListener('ended', advanceSegment); if (mediaB) mediaB.addEventListener('ended', advanceSegment); mediaA.addEventListener('timeupdate', () => { if (activeEl === mediaA) { updateProgress(); checkSegmentBoundary(); } }); if (mediaB) { mediaB.addEventListener('timeupdate', () => { if (activeEl === mediaB) { updateProgress(); checkSegmentBoundary(); } }); } loadSegment(mediaA, 0); if (mediaB && segments.length > 1) loadSegment(mediaB, 1); updateInsertionUI(); playBtn.addEventListener('click', () => { if (playing) { activeEl.pause(); playing = false; playIcon.style.display = 'block'; pauseIcon.style.display = 'none'; } else { void activeEl.play().catch(() => {}); playing = true; playIcon.style.display = 'none'; pauseIcon.style.display = 'block'; } }); if (skipBtn) { skipBtn.addEventListener('click', () => { if (isInsertion(currentSegIndex)) { activeEl.pause(); advanceSegment(); } }); } progressBar.addEventListener('click', (e) => { const rect = progressBar.getBoundingClientRect(); seekToVirtualMs(((e.clientX - rect.left) / rect.width) * totalMs); }); volumeSlider.addEventListener('input', () => { currentVolume = Number(volumeSlider.value) / 100; mediaA.volume = currentVolume; if (mediaB) mediaB.volume = currentVolume; }); mediaA.volume = currentVolume; if (mediaB) mediaB.volume = currentVolume; speedButtons.forEach((btn) => { btn.addEventListener('click', () => { speedButtons.forEach((b) => b.classList.remove('is-selected')); btn.classList.add('is-selected'); currentSpeed = parseFloat(btn.dataset.speed ?? '1'); mediaA.playbackRate = currentSpeed; if (mediaB) mediaB.playbackRate = currentSpeed; }); }); chapterItems.forEach((item) => { item.addEventListener('click', () => { const chapterSec = parseFloat(item.dataset.time ?? ''); if (Number.isNaN(chapterSec)) return; const virtualMs = chapterToVirtualMs(segments, chapterSec * 1000); const targetIdx = segmentIndexAtVirtualMs(segStarts, segments, virtualMs); const localOffsetMs = virtualMs - segStarts[targetIdx]; currentSegIndex = targetIdx; const targetSeg = segments[targetIdx]; activeEl.src = targetSeg.url; activeEl.load(); activeEl.playbackRate = currentSpeed; activeEl.volume = currentVolume; activeEl.addEventListener('loadedmetadata', function chapterSeek() { activeEl.currentTime = localOffsetMs / 1000 + mainOffsetMs(segments, targetIdx) / 1000; void activeEl.play().catch(() => {}); 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. const storageKey = 'media-progress-' + itemId; mediaA.addEventListener('loadedmetadata', function restoreOnce() { const saved = safeGet(storageKey); if (saved) { const vt = parseFloat(saved); if (!Number.isNaN(vt) && vt > 0 && vt < totalSec - 5) { const targetMs = vt * 1000; currentSegIndex = segmentIndexAtVirtualMs(segStarts, segments, targetMs); const seg = segments[currentSegIndex]; const localMs = targetMs - segStarts[currentSegIndex]; if (currentSegIndex > 0) { activeEl.src = seg.url; activeEl.load(); } activeEl.addEventListener('loadedmetadata', function restoreSeek() { activeEl.currentTime = localMs / 1000 + mainOffsetMs(segments, currentSegIndex) / 1000; activeEl.removeEventListener('loadedmetadata', restoreSeek); }); if (standbyEl && currentSegIndex + 1 < segments.length) loadSegment(standbyEl, currentSegIndex + 1); updateInsertionUI(); } } mediaA.removeEventListener('loadedmetadata', restoreOnce); }); setupKeyboard(() => activeEl, activeEl, seekToVirtualMs, getVirtualTime); } } define('mnw-media-player', MediaPlayer);