Skip to main content

max / makenotwork

15.9 KB · 427 lines History Blame Raw
1 // <mnw-media-player> — unified audio/video player, ported from
2 // static/media-player.js. Two modes: simple (a single element) and segment
3 // (dual-element gapless playback across a virtual timeline of main + insertion
4 // segments). One player per page; controls are looked up by id. Config arrives
5 // as JSON in the `#media-player-data` bridge. The virtual-timeline math lives
6 // in media-player.logic.ts (unit-tested); this file wires it to the elements.
7
8 import { MnwElement, define } from './base.ts';
9 import { safeGet, safeSet } from '../core/storage.ts';
10 import {
11 type Segment,
12 formatTime,
13 buildTimeline,
14 mainOffsetMs,
15 segmentIndexAtVirtualMs,
16 chapterToVirtualMs,
17 } from './media-player.logic.ts';
18
19 interface Config {
20 segments?: Segment[];
21 mediaType?: string;
22 itemId?: string;
23 }
24
25 class MediaPlayer extends MnwElement {
26 protected init(): void {
27 const dataEl = document.getElementById('media-player-data');
28 if (!dataEl) return;
29
30 const config = JSON.parse(dataEl.textContent || '{}') as Config;
31 const segments = config.segments ?? null;
32 const mediaType = config.mediaType ?? 'audio';
33 const itemId = config.itemId || window.location.pathname.split('/').pop() || '';
34 const isVideo = mediaType === 'video';
35
36 const byId = (id: string) => document.getElementById(id);
37 const mediaA = byId('media-a') as HTMLMediaElement;
38 const mediaB = byId('media-b') as HTMLMediaElement | null;
39 const playBtn = byId('play-btn')!;
40 const playIcon = byId('play-icon')!;
41 const pauseIcon = byId('pause-icon')!;
42 const progressBar = byId('progress-bar')!;
43 const progressFill = byId('progress-fill')!;
44 const currentTimeEl = byId('current-time')!;
45 const durationEl = byId('duration')!;
46 const volumeSlider = byId('volume-slider') as HTMLInputElement;
47 const speedButtons = document.querySelectorAll<HTMLElement>('.speed-button');
48 const chapterItems = document.querySelectorAll<HTMLElement>('.chapter-item');
49 const skipBtn = byId('skip-btn');
50 const insertionLabel = byId('insertion-label');
51
52 // Keyboard shortcuts (shared by both modes).
53 const setupKeyboard = (
54 getActiveEl: (() => HTMLMediaElement) | null,
55 defaultEl: HTMLMediaElement,
56 virtualSeek: ((ms: number) => void) | null,
57 getVTime: (() => number) | null,
58 ): void => {
59 document.addEventListener('keydown', (e) => {
60 const t = (e.target as HTMLElement | null)?.tagName;
61 if (t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT') return;
62 const el = getActiveEl ? getActiveEl() : defaultEl;
63 switch (e.key) {
64 case ' ':
65 e.preventDefault();
66 playBtn.click();
67 break;
68 case 'ArrowLeft':
69 e.preventDefault();
70 if (virtualSeek && getVTime) virtualSeek(getVTime() * 1000 - 10_000);
71 else el.currentTime = Math.max(0, el.currentTime - 10);
72 break;
73 case 'ArrowRight':
74 e.preventDefault();
75 if (virtualSeek && getVTime) virtualSeek(getVTime() * 1000 + 10_000);
76 else el.currentTime = Math.min(el.duration || 0, el.currentTime + 10);
77 break;
78 case 'ArrowUp':
79 e.preventDefault();
80 volumeSlider.value = String(Math.min(100, parseInt(volumeSlider.value, 10) + 10));
81 volumeSlider.dispatchEvent(new Event('input'));
82 break;
83 case 'ArrowDown':
84 e.preventDefault();
85 volumeSlider.value = String(Math.max(0, parseInt(volumeSlider.value, 10) - 10));
86 volumeSlider.dispatchEvent(new Event('input'));
87 break;
88 case 'm':
89 case 'M':
90 e.preventDefault();
91 el.muted = !el.muted;
92 if (mediaB) mediaB.muted = el.muted;
93 break;
94 case 'f':
95 case 'F':
96 if (isVideo && el.requestFullscreen) {
97 e.preventDefault();
98 el.requestFullscreen().catch(() => {});
99 }
100 break;
101 case 's':
102 case 'S':
103 if (skipBtn && skipBtn.style.display !== 'none') {
104 e.preventDefault();
105 skipBtn.click();
106 }
107 break;
108 }
109 });
110 };
111
112 // ── Simple mode (no insertions) ──
113 if (!segments || segments.length === 0) {
114 const media = mediaA;
115 playBtn.addEventListener('click', () => {
116 if (media.paused) void media.play();
117 else media.pause();
118 });
119 media.addEventListener('play', () => {
120 playIcon.classList.add('hidden');
121 pauseIcon.classList.remove('hidden');
122 });
123 media.addEventListener('pause', () => {
124 playIcon.classList.remove('hidden');
125 pauseIcon.classList.add('hidden');
126 });
127 media.addEventListener('timeupdate', () => {
128 progressFill.style.width = (media.currentTime / media.duration) * 100 + '%';
129 currentTimeEl.textContent = formatTime(media.currentTime);
130 });
131 media.addEventListener('loadedmetadata', () => {
132 durationEl.textContent = formatTime(media.duration);
133 });
134 progressBar.addEventListener('click', (e) => {
135 const rect = progressBar.getBoundingClientRect();
136 media.currentTime = ((e.clientX - rect.left) / rect.width) * media.duration;
137 });
138 volumeSlider.addEventListener('input', () => {
139 media.volume = Number(volumeSlider.value) / 100;
140 });
141 media.volume = Number(volumeSlider.value) / 100;
142 speedButtons.forEach((btn) => {
143 btn.addEventListener('click', () => {
144 speedButtons.forEach((b) => b.classList.remove('is-selected'));
145 btn.classList.add('is-selected');
146 media.playbackRate = parseFloat(btn.dataset.speed ?? '1');
147 });
148 });
149 chapterItems.forEach((item) => {
150 item.addEventListener('click', () => {
151 const time = parseFloat(item.dataset.time ?? '');
152 if (!Number.isNaN(time)) {
153 media.currentTime = time;
154 void media.play();
155 }
156 });
157 });
158 const storageKey = 'media-progress-' + itemId;
159 media.addEventListener('timeupdate', () => safeSet(storageKey, String(media.currentTime)));
160 media.addEventListener('loadedmetadata', () => {
161 const saved = safeGet(storageKey);
162 if (saved) {
163 const t = parseFloat(saved);
164 if (!Number.isNaN(t) && t < media.duration - 5) media.currentTime = t;
165 }
166 });
167 setupKeyboard(null, media, null, null);
168 return;
169 }
170
171 // ── Segment mode (dual-element gapless playback) ──
172 const { segStarts, totalMs } = buildTimeline(segments);
173 const totalSec = totalMs / 1000;
174 durationEl.textContent = formatTime(totalSec);
175
176 let currentSegIndex = 0;
177 let activeEl = mediaA;
178 let standbyEl: HTMLMediaElement | null = mediaB;
179 let playing = false;
180 let currentSpeed = 1;
181 let currentVolume = Number(volumeSlider.value) / 100;
182
183 const isInsertion = (idx: number) => segments[idx] && segments[idx].segment_type !== 'main';
184
185 const syncVideoVisibility = (): void => {
186 if (!isVideo || !standbyEl) return;
187 activeEl.classList.remove('is-standby');
188 standbyEl.classList.add('is-standby');
189 };
190
191 const loadSegment = (el: HTMLMediaElement, idx: number): void => {
192 if (idx >= segments.length) return;
193 const seg = segments[idx];
194 el.src = seg.url;
195 el.load();
196 el.playbackRate = currentSpeed;
197 el.volume = currentVolume;
198 const offMs = mainOffsetMs(segments, idx);
199 if (offMs > 0) {
200 el.addEventListener('loadedmetadata', function seekToOffset() {
201 el.currentTime = offMs / 1000;
202 el.removeEventListener('loadedmetadata', seekToOffset);
203 });
204 }
205 };
206
207 const updateInsertionUI = (): void => {
208 syncVideoVisibility();
209 if (isInsertion(currentSegIndex) && insertionLabel && skipBtn) {
210 const seg = segments[currentSegIndex];
211 insertionLabel.textContent = seg.title || seg.segment_type.replace('_', '-');
212 insertionLabel.style.display = 'block';
213 skipBtn.style.display = 'inline-block';
214 } else {
215 if (insertionLabel) insertionLabel.style.display = 'none';
216 if (skipBtn) skipBtn.style.display = 'none';
217 }
218 };
219
220 const getVirtualTime = (): number => {
221 if (currentSegIndex >= segments.length) return totalSec;
222 const localTime = activeEl.currentTime - mainOffsetMs(segments, currentSegIndex) / 1000;
223 return segStarts[currentSegIndex] / 1000 + Math.max(0, localTime);
224 };
225
226 const updateProgress = (): void => {
227 const vt = getVirtualTime();
228 progressFill.style.width = Math.min((vt / totalSec) * 100, 100) + '%';
229 currentTimeEl.textContent = formatTime(vt);
230 safeSet('media-progress-' + itemId, String(vt));
231 };
232
233 const advanceSegment = (): void => {
234 currentSegIndex++;
235 if (currentSegIndex >= segments.length) {
236 playing = false;
237 playIcon.style.display = 'block';
238 pauseIcon.style.display = 'none';
239 if (insertionLabel) insertionLabel.style.display = 'none';
240 if (skipBtn) skipBtn.style.display = 'none';
241 progressFill.style.width = '100%';
242 currentTimeEl.textContent = formatTime(totalSec);
243 return;
244 }
245 if (standbyEl) {
246 const tmp = activeEl;
247 activeEl = standbyEl;
248 standbyEl = tmp;
249 activeEl.playbackRate = currentSpeed;
250 activeEl.volume = currentVolume;
251 void activeEl.play().catch(() => {});
252 } else {
253 const seg = segments[currentSegIndex];
254 activeEl.src = seg.url;
255 activeEl.load();
256 activeEl.playbackRate = currentSpeed;
257 activeEl.volume = currentVolume;
258 const offMs = mainOffsetMs(segments, currentSegIndex);
259 if (offMs > 0) {
260 activeEl.addEventListener('loadedmetadata', function seekThenPlay() {
261 activeEl.currentTime = offMs / 1000;
262 void activeEl.play().catch(() => {});
263 activeEl.removeEventListener('loadedmetadata', seekThenPlay);
264 });
265 } else {
266 void activeEl.play().catch(() => {});
267 }
268 }
269 if (standbyEl && currentSegIndex + 1 < segments.length) loadSegment(standbyEl, currentSegIndex + 1);
270 updateInsertionUI();
271 };
272
273 const checkSegmentBoundary = (): void => {
274 if (currentSegIndex >= segments.length) return;
275 const seg = segments[currentSegIndex];
276 if (seg.segment_type !== 'main') return;
277 const endTime = mainOffsetMs(segments, currentSegIndex) / 1000 + seg.duration_ms / 1000;
278 if (activeEl.currentTime >= endTime - 0.05) {
279 activeEl.pause();
280 advanceSegment();
281 }
282 };
283
284 const seekToVirtualMs = (rawTargetMs: number): void => {
285 const targetMs = Math.max(0, Math.min(rawTargetMs, totalMs));
286 const targetIdx = segmentIndexAtVirtualMs(segStarts, segments, targetMs);
287 const localOffsetMs = targetMs - segStarts[targetIdx];
288 currentSegIndex = targetIdx;
289 const seg = segments[targetIdx];
290 activeEl.src = seg.url;
291 activeEl.load();
292 activeEl.playbackRate = currentSpeed;
293 activeEl.volume = currentVolume;
294 activeEl.addEventListener('loadedmetadata', function seekHandler() {
295 activeEl.currentTime = localOffsetMs / 1000 + mainOffsetMs(segments, targetIdx) / 1000;
296 if (playing) void activeEl.play().catch(() => {});
297 activeEl.removeEventListener('loadedmetadata', seekHandler);
298 });
299 if (standbyEl && targetIdx + 1 < segments.length) loadSegment(standbyEl, targetIdx + 1);
300 updateInsertionUI();
301 };
302
303 mediaA.addEventListener('ended', advanceSegment);
304 if (mediaB) mediaB.addEventListener('ended', advanceSegment);
305 mediaA.addEventListener('timeupdate', () => {
306 if (activeEl === mediaA) {
307 updateProgress();
308 checkSegmentBoundary();
309 }
310 });
311 if (mediaB) {
312 mediaB.addEventListener('timeupdate', () => {
313 if (activeEl === mediaB) {
314 updateProgress();
315 checkSegmentBoundary();
316 }
317 });
318 }
319
320 loadSegment(mediaA, 0);
321 if (mediaB && segments.length > 1) loadSegment(mediaB, 1);
322 updateInsertionUI();
323
324 playBtn.addEventListener('click', () => {
325 if (playing) {
326 activeEl.pause();
327 playing = false;
328 playIcon.style.display = 'block';
329 pauseIcon.style.display = 'none';
330 } else {
331 void activeEl.play().catch(() => {});
332 playing = true;
333 playIcon.style.display = 'none';
334 pauseIcon.style.display = 'block';
335 }
336 });
337
338 if (skipBtn) {
339 skipBtn.addEventListener('click', () => {
340 if (isInsertion(currentSegIndex)) {
341 activeEl.pause();
342 advanceSegment();
343 }
344 });
345 }
346
347 progressBar.addEventListener('click', (e) => {
348 const rect = progressBar.getBoundingClientRect();
349 seekToVirtualMs(((e.clientX - rect.left) / rect.width) * totalMs);
350 });
351
352 volumeSlider.addEventListener('input', () => {
353 currentVolume = Number(volumeSlider.value) / 100;
354 mediaA.volume = currentVolume;
355 if (mediaB) mediaB.volume = currentVolume;
356 });
357 mediaA.volume = currentVolume;
358 if (mediaB) mediaB.volume = currentVolume;
359
360 speedButtons.forEach((btn) => {
361 btn.addEventListener('click', () => {
362 speedButtons.forEach((b) => b.classList.remove('is-selected'));
363 btn.classList.add('is-selected');
364 currentSpeed = parseFloat(btn.dataset.speed ?? '1');
365 mediaA.playbackRate = currentSpeed;
366 if (mediaB) mediaB.playbackRate = currentSpeed;
367 });
368 });
369
370 chapterItems.forEach((item) => {
371 item.addEventListener('click', () => {
372 const chapterSec = parseFloat(item.dataset.time ?? '');
373 if (Number.isNaN(chapterSec)) return;
374 const virtualMs = chapterToVirtualMs(segments, chapterSec * 1000);
375 const targetIdx = segmentIndexAtVirtualMs(segStarts, segments, virtualMs);
376 const localOffsetMs = virtualMs - segStarts[targetIdx];
377 currentSegIndex = targetIdx;
378 const targetSeg = segments[targetIdx];
379 activeEl.src = targetSeg.url;
380 activeEl.load();
381 activeEl.playbackRate = currentSpeed;
382 activeEl.volume = currentVolume;
383 activeEl.addEventListener('loadedmetadata', function chapterSeek() {
384 activeEl.currentTime = localOffsetMs / 1000 + mainOffsetMs(segments, targetIdx) / 1000;
385 void activeEl.play().catch(() => {});
386 playing = true;
387 playIcon.style.display = 'none';
388 pauseIcon.style.display = 'block';
389 activeEl.removeEventListener('loadedmetadata', chapterSeek);
390 });
391 if (standbyEl && targetIdx + 1 < segments.length) loadSegment(standbyEl, targetIdx + 1);
392 updateInsertionUI();
393 });
394 });
395
396 // Restore saved position.
397 const storageKey = 'media-progress-' + itemId;
398 mediaA.addEventListener('loadedmetadata', function restoreOnce() {
399 const saved = safeGet(storageKey);
400 if (saved) {
401 const vt = parseFloat(saved);
402 if (!Number.isNaN(vt) && vt > 0 && vt < totalSec - 5) {
403 const targetMs = vt * 1000;
404 currentSegIndex = segmentIndexAtVirtualMs(segStarts, segments, targetMs);
405 const seg = segments[currentSegIndex];
406 const localMs = targetMs - segStarts[currentSegIndex];
407 if (currentSegIndex > 0) {
408 activeEl.src = seg.url;
409 activeEl.load();
410 }
411 activeEl.addEventListener('loadedmetadata', function restoreSeek() {
412 activeEl.currentTime = localMs / 1000 + mainOffsetMs(segments, currentSegIndex) / 1000;
413 activeEl.removeEventListener('loadedmetadata', restoreSeek);
414 });
415 if (standbyEl && currentSegIndex + 1 < segments.length) loadSegment(standbyEl, currentSegIndex + 1);
416 updateInsertionUI();
417 }
418 }
419 mediaA.removeEventListener('loadedmetadata', restoreOnce);
420 });
421
422 setupKeyboard(() => activeEl, activeEl, seekToVirtualMs, getVirtualTime);
423 }
424 }
425
426 define('mnw-media-player', MediaPlayer);
427