Skip to main content

max / makenotwork

21.3 KB · 549 lines History Blame Raw
1 // Unified media player — shared by audio and video player pages.
2 // Reads configuration from a data-* attribute bridge on #media-player-data.
3 // Supports two modes: simple (no insertions) and segment (dual-element gapless).
4 (function() {
5 'use strict';
6
7 var dataEl = document.getElementById('media-player-data');
8 if (!dataEl) return;
9
10 var config = JSON.parse(dataEl.textContent || '{}');
11 var segments = config.segments || null;
12 var mediaType = config.mediaType || 'audio';
13 var itemId = config.itemId || window.location.pathname.split('/').pop();
14
15 var mediaA = document.getElementById('media-a');
16 var mediaB = document.getElementById('media-b');
17 var isVideo = mediaType === 'video';
18 var playBtn = document.getElementById('play-btn');
19 var playIcon = document.getElementById('play-icon');
20 var pauseIcon = document.getElementById('pause-icon');
21 var progressBar = document.getElementById('progress-bar');
22 var progressFill = document.getElementById('progress-fill');
23 var currentTimeEl = document.getElementById('current-time');
24 var durationEl = document.getElementById('duration');
25 var volumeSlider = document.getElementById('volume-slider');
26 var speedButtons = document.querySelectorAll('.speed-button');
27 var chapterItems = document.querySelectorAll('.chapter-item');
28 var skipBtn = document.getElementById('skip-btn');
29 var insertionLabel = document.getElementById('insertion-label');
30
31 function formatTime(seconds) {
32 if (isNaN(seconds)) return '0:00';
33 var h = Math.floor(seconds / 3600);
34 var m = Math.floor((seconds % 3600) / 60);
35 var s = Math.floor(seconds % 60);
36 if (h > 0) {
37 return h + ':' + m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0');
38 }
39 return m + ':' + s.toString().padStart(2, '0');
40 }
41
42 // ── Simple mode (no insertions) ──
43 if (!segments || segments.length === 0) {
44 var media = mediaA;
45
46 playBtn.addEventListener('click', function() {
47 if (media.paused) { media.play(); } else { media.pause(); }
48 });
49 media.addEventListener('play', function() { playIcon.classList.add('hidden'); pauseIcon.classList.remove('hidden'); });
50 media.addEventListener('pause', function() { playIcon.classList.remove('hidden'); pauseIcon.classList.add('hidden'); });
51 media.addEventListener('timeupdate', function() {
52 var pct = (media.currentTime / media.duration) * 100;
53 progressFill.style.width = pct + '%';
54 currentTimeEl.textContent = formatTime(media.currentTime);
55 });
56 media.addEventListener('loadedmetadata', function() { durationEl.textContent = formatTime(media.duration); });
57 progressBar.addEventListener('click', function(e) {
58 var rect = progressBar.getBoundingClientRect();
59 media.currentTime = ((e.clientX - rect.left) / rect.width) * media.duration;
60 });
61 volumeSlider.addEventListener('input', function() { media.volume = this.value / 100; });
62 media.volume = volumeSlider.value / 100;
63 speedButtons.forEach(function(btn) {
64 btn.addEventListener('click', function() {
65 speedButtons.forEach(function(b) { b.classList.remove('is-selected'); });
66 btn.classList.add('is-selected');
67 media.playbackRate = parseFloat(btn.dataset.speed);
68 });
69 });
70 chapterItems.forEach(function(item) {
71 item.addEventListener('click', function() {
72 var time = parseFloat(item.dataset.time);
73 if (!isNaN(time)) { media.currentTime = time; media.play(); }
74 });
75 });
76 var storageKey = 'media-progress-' + itemId;
77 media.addEventListener('timeupdate', function() { safeStorageSet(storageKey, media.currentTime); });
78 media.addEventListener('loadedmetadata', function() {
79 var saved = safeStorageGet(storageKey);
80 if (saved) { var t = parseFloat(saved); if (!isNaN(t) && t < media.duration - 5) media.currentTime = t; }
81 });
82
83 // Keyboard shortcuts (no virtual timeline in simple mode)
84 setupKeyboard(media, null, null, null, 0);
85 return;
86 }
87
88 // ── Segment mode (dual-element gapless playback) ──
89
90 // Compute virtual timeline
91 var totalDurationMs = 0;
92 var segStarts = [];
93 for (var i = 0; i < segments.length; i++) {
94 segStarts.push(totalDurationMs);
95 totalDurationMs += segments[i].duration_ms;
96 }
97 var totalDurationSec = totalDurationMs / 1000;
98
99 durationEl.textContent = formatTime(totalDurationSec);
100
101 var currentSegIndex = 0;
102 var activeEl = mediaA;
103 // Video uses single element (no dual-element gapless — brief gap acceptable)
104 var standbyEl = isVideo ? null : mediaB;
105 var playing = false;
106 var currentSpeed = 1;
107 var currentVolume = volumeSlider.value / 100;
108
109 function isInsertionSegment(idx) {
110 return segments[idx] && segments[idx].segment_type !== 'main';
111 }
112
113 function loadSegment(el, idx) {
114 if (idx >= segments.length) return;
115 var seg = segments[idx];
116 el.src = seg.url;
117 el.load();
118 el.playbackRate = currentSpeed;
119 el.volume = currentVolume;
120 if (seg.segment_type === 'main') {
121 var mainOffsetMs = 0;
122 for (var j = 0; j < idx; j++) {
123 if (segments[j].segment_type === 'main' && segments[j].url === seg.url) {
124 mainOffsetMs += segments[j].duration_ms;
125 }
126 }
127 if (mainOffsetMs > 0) {
128 el.addEventListener('loadedmetadata', function seekToOffset() {
129 el.currentTime = mainOffsetMs / 1000;
130 el.removeEventListener('loadedmetadata', seekToOffset);
131 });
132 }
133 }
134 }
135
136 function updateInsertionUI() {
137 if (isInsertionSegment(currentSegIndex)) {
138 var seg = segments[currentSegIndex];
139 insertionLabel.textContent = seg.title || seg.segment_type.replace('_', '-');
140 insertionLabel.style.display = 'block';
141 skipBtn.style.display = 'inline-block';
142 } else {
143 insertionLabel.style.display = 'none';
144 skipBtn.style.display = 'none';
145 }
146 }
147
148 function getVirtualTime() {
149 if (currentSegIndex >= segments.length) return totalDurationSec;
150 var seg = segments[currentSegIndex];
151 var localTime = activeEl.currentTime;
152 if (seg.segment_type === 'main') {
153 var mainOffset = 0;
154 for (var j = 0; j < currentSegIndex; j++) {
155 if (segments[j].segment_type === 'main' && segments[j].url === seg.url) {
156 mainOffset += segments[j].duration_ms / 1000;
157 }
158 }
159 localTime = localTime - mainOffset;
160 }
161 return (segStarts[currentSegIndex] / 1000) + Math.max(0, localTime);
162 }
163
164 function advanceSegment() {
165 currentSegIndex++;
166 if (currentSegIndex >= segments.length) {
167 playing = false;
168 playIcon.style.display = 'block';
169 pauseIcon.style.display = 'none';
170 insertionLabel.style.display = 'none';
171 skipBtn.style.display = 'none';
172 progressFill.style.width = '100%';
173 currentTimeEl.textContent = formatTime(totalDurationSec);
174 return;
175 }
176
177 if (standbyEl) {
178 // Dual-element gapless: swap active/standby
179 var tmp = activeEl;
180 activeEl = standbyEl;
181 standbyEl = tmp;
182 activeEl.playbackRate = currentSpeed;
183 activeEl.volume = currentVolume;
184 activeEl.play().catch(function() {});
185 } else {
186 // Single-element: load next segment, defer play until ready
187 var seg = segments[currentSegIndex];
188 activeEl.src = seg.url;
189 activeEl.load();
190 activeEl.playbackRate = currentSpeed;
191 activeEl.volume = currentVolume;
192 // For main segments with offset, wait for metadata before playing
193 if (seg.segment_type === 'main') {
194 var mainOffMs = 0;
195 for (var j = 0; j < currentSegIndex; j++) {
196 if (segments[j].segment_type === 'main' && segments[j].url === seg.url) {
197 mainOffMs += segments[j].duration_ms;
198 }
199 }
200 if (mainOffMs > 0) {
201 activeEl.addEventListener('loadedmetadata', function seekThenPlay() {
202 activeEl.currentTime = mainOffMs / 1000;
203 activeEl.play().catch(function() {});
204 activeEl.removeEventListener('loadedmetadata', seekThenPlay);
205 });
206 } else {
207 activeEl.play().catch(function() {});
208 }
209 } else {
210 activeEl.play().catch(function() {});
211 }
212 }
213
214 if (standbyEl && currentSegIndex + 1 < segments.length) {
215 loadSegment(standbyEl, currentSegIndex + 1);
216 }
217
218 updateInsertionUI();
219 }
220
221 mediaA.addEventListener('ended', advanceSegment);
222 if (mediaB) mediaB.addEventListener('ended', advanceSegment);
223
224 function checkSegmentBoundary() {
225 if (currentSegIndex >= segments.length) return;
226 var seg = segments[currentSegIndex];
227 if (seg.segment_type === 'main') {
228 var mainOffset = 0;
229 for (var j = 0; j < currentSegIndex; j++) {
230 if (segments[j].segment_type === 'main' && segments[j].url === seg.url) {
231 mainOffset += segments[j].duration_ms / 1000;
232 }
233 }
234 var endTime = mainOffset + (seg.duration_ms / 1000);
235 if (activeEl.currentTime >= endTime - 0.05) {
236 activeEl.pause();
237 advanceSegment();
238 }
239 }
240 }
241
242 mediaA.addEventListener('timeupdate', function() {
243 if (activeEl === mediaA) { updateProgress(); checkSegmentBoundary(); }
244 });
245 if (mediaB) {
246 mediaB.addEventListener('timeupdate', function() {
247 if (activeEl === mediaB) { updateProgress(); checkSegmentBoundary(); }
248 });
249 }
250
251 function updateProgress() {
252 var vt = getVirtualTime();
253 var pct = (vt / totalDurationSec) * 100;
254 progressFill.style.width = Math.min(pct, 100) + '%';
255 currentTimeEl.textContent = formatTime(vt);
256 safeStorageSet('media-progress-' + itemId, vt);
257 }
258
259 // Initialize first segment
260 loadSegment(mediaA, 0);
261 if (mediaB && segments.length > 1) {
262 loadSegment(mediaB, 1);
263 }
264 updateInsertionUI();
265
266 // Play/Pause
267 playBtn.addEventListener('click', function() {
268 if (playing) {
269 activeEl.pause();
270 playing = false;
271 playIcon.style.display = 'block';
272 pauseIcon.style.display = 'none';
273 } else {
274 activeEl.play().catch(function() {});
275 playing = true;
276 playIcon.style.display = 'none';
277 pauseIcon.style.display = 'block';
278 }
279 });
280
281 // Skip insertion
282 skipBtn.addEventListener('click', function() {
283 if (isInsertionSegment(currentSegIndex)) {
284 activeEl.pause();
285 advanceSegment();
286 }
287 });
288
289 // Seek to a virtual time in milliseconds (shared by progress bar + keyboard)
290 function seekToVirtualMs(targetMs) {
291 targetMs = Math.max(0, Math.min(targetMs, totalDurationMs));
292
293 var targetIdx = 0;
294 for (var k = 0; k < segments.length; k++) {
295 if (targetMs < segStarts[k] + segments[k].duration_ms) {
296 targetIdx = k;
297 break;
298 }
299 if (k === segments.length - 1) targetIdx = k;
300 }
301
302 var localOffsetMs = targetMs - segStarts[targetIdx];
303 currentSegIndex = targetIdx;
304
305 var seg = segments[targetIdx];
306 activeEl.src = seg.url;
307 activeEl.load();
308 activeEl.playbackRate = currentSpeed;
309 activeEl.volume = currentVolume;
310
311 activeEl.addEventListener('loadedmetadata', function seekHandler() {
312 var seekTo = localOffsetMs / 1000;
313 if (seg.segment_type === 'main') {
314 var mainOffset = 0;
315 for (var j = 0; j < targetIdx; j++) {
316 if (segments[j].segment_type === 'main' && segments[j].url === seg.url) {
317 mainOffset += segments[j].duration_ms / 1000;
318 }
319 }
320 seekTo += mainOffset;
321 }
322 activeEl.currentTime = seekTo;
323 if (playing) activeEl.play().catch(function() {});
324 activeEl.removeEventListener('loadedmetadata', seekHandler);
325 });
326
327 if (standbyEl && targetIdx + 1 < segments.length) {
328 loadSegment(standbyEl, targetIdx + 1);
329 }
330
331 updateInsertionUI();
332 }
333
334 // Seek on progress bar (virtual timeline)
335 progressBar.addEventListener('click', function(e) {
336 var rect = progressBar.getBoundingClientRect();
337 var pct = (e.clientX - rect.left) / rect.width;
338 seekToVirtualMs(pct * totalDurationMs);
339 });
340
341 // Volume
342 volumeSlider.addEventListener('input', function() {
343 currentVolume = this.value / 100;
344 mediaA.volume = currentVolume;
345 if (mediaB) mediaB.volume = currentVolume;
346 });
347 mediaA.volume = currentVolume;
348 if (mediaB) mediaB.volume = currentVolume;
349
350 // Speed
351 speedButtons.forEach(function(btn) {
352 btn.addEventListener('click', function() {
353 speedButtons.forEach(function(b) { b.classList.remove('is-selected'); });
354 btn.classList.add('is-selected');
355 currentSpeed = parseFloat(btn.dataset.speed);
356 mediaA.playbackRate = currentSpeed;
357 if (mediaB) mediaB.playbackRate = currentSpeed;
358 });
359 });
360
361 // Chapter navigation
362 chapterItems.forEach(function(item) {
363 item.addEventListener('click', function() {
364 var chapterSec = parseFloat(item.dataset.time);
365 if (isNaN(chapterSec)) return;
366
367 var virtualMs = 0;
368 var mainElapsedMs = 0;
369 var chapterMs = chapterSec * 1000;
370
371 for (var k = 0; k < segments.length; k++) {
372 var seg = segments[k];
373 if (seg.segment_type === 'main') {
374 if (mainElapsedMs + seg.duration_ms > chapterMs) {
375 virtualMs += (chapterMs - mainElapsedMs);
376 break;
377 }
378 mainElapsedMs += seg.duration_ms;
379 virtualMs += seg.duration_ms;
380 } else {
381 if (mainElapsedMs <= chapterMs) {
382 virtualMs += seg.duration_ms;
383 }
384 }
385 }
386
387 var targetIdx = 0;
388 for (var k2 = 0; k2 < segments.length; k2++) {
389 if (virtualMs < segStarts[k2] + segments[k2].duration_ms) {
390 targetIdx = k2;
391 break;
392 }
393 if (k2 === segments.length - 1) targetIdx = k2;
394 }
395
396 var localOffsetMs = virtualMs - segStarts[targetIdx];
397 currentSegIndex = targetIdx;
398
399 var targetSeg = segments[targetIdx];
400 activeEl.src = targetSeg.url;
401 activeEl.load();
402 activeEl.playbackRate = currentSpeed;
403 activeEl.volume = currentVolume;
404
405 activeEl.addEventListener('loadedmetadata', function chapterSeek() {
406 var seekTo = localOffsetMs / 1000;
407 if (targetSeg.segment_type === 'main') {
408 var mainOff = 0;
409 for (var j = 0; j < targetIdx; j++) {
410 if (segments[j].segment_type === 'main' && segments[j].url === targetSeg.url) {
411 mainOff += segments[j].duration_ms / 1000;
412 }
413 }
414 seekTo += mainOff;
415 }
416 activeEl.currentTime = seekTo;
417 activeEl.play().catch(function() {});
418 playing = true;
419 playIcon.style.display = 'none';
420 pauseIcon.style.display = 'block';
421 activeEl.removeEventListener('loadedmetadata', chapterSeek);
422 });
423
424 if (standbyEl && targetIdx + 1 < segments.length) {
425 loadSegment(standbyEl, targetIdx + 1);
426 }
427 updateInsertionUI();
428 });
429 });
430
431 // Restore saved position
432 var storageKey = 'media-progress-' + itemId;
433 mediaA.addEventListener('loadedmetadata', function restoreOnce() {
434 var saved = safeStorageGet(storageKey);
435 if (saved) {
436 var vt = parseFloat(saved);
437 if (!isNaN(vt) && vt > 0 && vt < totalDurationSec - 5) {
438 var targetMs = vt * 1000;
439 for (var k = 0; k < segments.length; k++) {
440 if (targetMs < segStarts[k] + segments[k].duration_ms) {
441 currentSegIndex = k;
442 break;
443 }
444 }
445 var seg = segments[currentSegIndex];
446 var localMs = targetMs - segStarts[currentSegIndex];
447
448 if (currentSegIndex > 0) {
449 activeEl.src = seg.url;
450 activeEl.load();
451 }
452 activeEl.addEventListener('loadedmetadata', function restoreSeek() {
453 var seekTo = localMs / 1000;
454 if (seg.segment_type === 'main') {
455 var mainOff = 0;
456 for (var j = 0; j < currentSegIndex; j++) {
457 if (segments[j].segment_type === 'main' && segments[j].url === seg.url) {
458 mainOff += segments[j].duration_ms / 1000;
459 }
460 }
461 seekTo += mainOff;
462 }
463 activeEl.currentTime = seekTo;
464 activeEl.removeEventListener('loadedmetadata', restoreSeek);
465 });
466
467 if (standbyEl && currentSegIndex + 1 < segments.length) {
468 loadSegment(standbyEl, currentSegIndex + 1);
469 }
470 updateInsertionUI();
471 }
472 }
473 mediaA.removeEventListener('loadedmetadata', restoreOnce);
474 });
475
476 // Keyboard shortcuts for segment mode
477 setupKeyboard(activeEl, function() { return activeEl; }, seekToVirtualMs, getVirtualTime, totalDurationMs);
478
479 // ── Keyboard shortcuts (Phase 5) ──
480
481 function setupKeyboard(defaultEl, getActiveEl, virtualSeek, getVTime, totalMs) {
482 document.addEventListener('keydown', function(e) {
483 // Don't handle in form inputs
484 if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
485
486 var el = getActiveEl ? getActiveEl() : defaultEl;
487
488 switch (e.key) {
489 case ' ':
490 e.preventDefault();
491 playBtn.click();
492 break;
493 case 'ArrowLeft':
494 e.preventDefault();
495 if (virtualSeek) {
496 virtualSeek(getVTime() * 1000 - 10000);
497 } else if (el.currentTime !== undefined) {
498 el.currentTime = Math.max(0, el.currentTime - 10);
499 }
500 break;
501 case 'ArrowRight':
502 e.preventDefault();
503 if (virtualSeek) {
504 virtualSeek(getVTime() * 1000 + 10000);
505 } else if (el.currentTime !== undefined) {
506 el.currentTime = Math.min(el.duration || 0, el.currentTime + 10);
507 }
508 break;
509 case 'ArrowUp':
510 e.preventDefault();
511 if (volumeSlider) {
512 volumeSlider.value = Math.min(100, parseInt(volumeSlider.value) + 10);
513 volumeSlider.dispatchEvent(new Event('input'));
514 }
515 break;
516 case 'ArrowDown':
517 e.preventDefault();
518 if (volumeSlider) {
519 volumeSlider.value = Math.max(0, parseInt(volumeSlider.value) - 10);
520 volumeSlider.dispatchEvent(new Event('input'));
521 }
522 break;
523 case 'm':
524 case 'M':
525 e.preventDefault();
526 if (el.muted !== undefined) {
527 el.muted = !el.muted;
528 if (mediaB) mediaB.muted = el.muted;
529 }
530 break;
531 case 'f':
532 case 'F':
533 if (mediaType === 'video' && el.requestFullscreen) {
534 e.preventDefault();
535 el.requestFullscreen().catch(function() {});
536 }
537 break;
538 case 's':
539 case 'S':
540 if (skipBtn && skipBtn.style.display !== 'none') {
541 e.preventDefault();
542 skipBtn.click();
543 }
544 break;
545 }
546 });
547 }
548 })();
549