vod frog, frog with the vods
1<script lang="ts">
2 import { onMount } from "svelte";
3 import Hls from "hls.js";
4 import WavyBorder from "./WavyBorder.svelte";
5 import { playCroak } from "./croak";
6 import { getModelStatus, getCaptionsEnabled, getCurrentCaption, toggleCaptionsDisplay, updateCaptionForTime, destroyCaptions, getCaptionCount, getDebugState, precomputeCaptions, getIsProcessing, getProcessProgress, getAllCaptions, getProcessedRanges, loadExistingCaptions } from "./captions.svelte";
7 import { fetchCaptionsForVideo } from "./caption-records";
8 import CaptionEditor from "./CaptionEditor.svelte";
9
10 // HLS video source URL (m3u8 playlist) and AT URI for caption pre-computation
11 let { src, atUri = '' }: { src: string; atUri?: string } = $props();
12
13 let videoEl: HTMLVideoElement | undefined = $state();
14 let hls: Hls | null = null;
15 let errorMsg = $state("");
16
17 // Caption editor
18 let showEditor = $state(false);
19
20 export function openEditor() { showEditor = true; }
21
22 function onCaptionUpdate(updated: { text: string; start: number; end: number }[]) {
23 console.log('[CC] Captions updated:', updated.length);
24 }
25
26 // CC debug overlay — toggle with Shift+D
27 let ccDebug = $state(false);
28 let debugInfo = $state<any>({});
29 let debugTimer: ReturnType<typeof setInterval> | null = null;
30
31 function toggleDebug() {
32 ccDebug = !ccDebug;
33 if (ccDebug && !debugTimer) {
34 debugTimer = setInterval(() => { debugInfo = getDebugState(currentTime); }, 250);
35 } else if (!ccDebug && debugTimer) {
36 clearInterval(debugTimer);
37 debugTimer = null;
38 }
39 }
40
41 // CC loading animation
42 let ccLoadFrame = $state(1);
43 let ccLoadTimer: ReturnType<typeof setInterval> | null = null;
44 let ccLoading = $state(false);
45
46 function startCcLoadAnim() {
47 if (ccLoadTimer) return;
48 ccLoading = true;
49 ccLoadTimer = setInterval(() => { ccLoadFrame = ccLoadFrame === 1 ? 2 : 1; }, 500);
50 }
51 function stopCcLoadAnim() {
52 if (ccLoadTimer) { clearInterval(ccLoadTimer); ccLoadTimer = null; }
53 ccLoading = false;
54 }
55
56 // Stop loading animation once first captions arrive
57 let prevCaptionCount = 0;
58 $effect(() => {
59 const count = getCaptionCount();
60 if (getCaptionsEnabled() && ccLoading && count > prevCaptionCount) {
61 stopCcLoadAnim();
62 }
63 prevCaptionCount = count;
64 });
65
66 // Playback state
67 let playing = $state(false);
68 let currentTime = $state(0);
69 let duration = $state(0);
70
71 // Frog scrub bar — the frog's position along the bar represents playback progress.
72 // Users can click the bar or grab the frog to seek.
73 let scrubBarEl: HTMLDivElement | undefined = $state();
74 let isScrubbing = $state(false);
75 let scrubProgress = $state(0);
76 let frogFrame = $state(0);
77 let frogFlipped = $state(false);
78 let lastFrogProgress = 0;
79 let showControls = $state(true);
80 let hideTimeout: ReturnType<typeof setTimeout> | null = null;
81
82 // Fullscreen
83 let isFullscreen = $state(false);
84
85 function destroy() {
86 if (hls) {
87 hls.destroy();
88 hls = null;
89 }
90 }
91
92 function setup() {
93 if (!videoEl || !src) return;
94 destroy();
95 errorMsg = "";
96
97 if (Hls.isSupported()) {
98 // Prefer hls.js — plays video on all browsers.
99 // Note: Opus audio won't work on Safari (MSE doesn't support Opus there).
100 // Use hls.js on Chrome, Firefox, etc.
101 hls = new Hls({ enableWorker: true, lowLatencyMode: false });
102 hls.loadSource(src);
103 hls.attachMedia(videoEl);
104 hls.on(Hls.Events.MANIFEST_PARSED, async () => {
105 videoEl?.play().catch(() => {});
106 if (atUri) {
107 // Check for existing captions first
108 console.log('[CC] Checking for existing captions...');
109 const existing = await fetchCaptionsForVideo(atUri);
110 if (existing?.record?.captions?.length) {
111 loadExistingCaptions(existing.record.captions);
112 console.log(`[CC] Loaded ${existing.record.captions.length} existing captions from ${existing.did}`);
113 } else {
114 console.log('[CC] No existing captions found, starting Whisper...');
115 precomputeCaptions(atUri);
116 }
117 }
118 });
119 hls.on(Hls.Events.ERROR, (_event, data) => {
120 console.error("HLS error:", data);
121 if (data.fatal) {
122 switch (data.type) {
123 case Hls.ErrorTypes.NETWORK_ERROR:
124 errorMsg = `Network error: ${data.details}`;
125 hls?.startLoad();
126 break;
127 case Hls.ErrorTypes.MEDIA_ERROR:
128 errorMsg = `Media error: ${data.details}`;
129 hls?.recoverMediaError();
130 break;
131 default:
132 errorMsg = `Fatal error: ${data.details}`;
133 destroy();
134 break;
135 }
136 }
137 });
138 } else {
139 errorMsg = "HLS playback is not supported in this browser.";
140 }
141 }
142
143 $effect(() => {
144 src;
145 if (videoEl) setup();
146 return destroy;
147 });
148
149 $effect(() => {
150 document.addEventListener("fullscreenchange", onFullscreenChange);
151 document.addEventListener("webkitfullscreenchange", onFullscreenChange);
152 return () => {
153 document.removeEventListener("fullscreenchange", onFullscreenChange);
154 document.removeEventListener("webkitfullscreenchange", onFullscreenChange);
155 };
156 });
157
158 let lastHopProgress = 0; // Track progress to trigger frog hops at intervals
159
160 /** Sync scrub position with playback, and animate the frog hopping */
161 function onTimeUpdate() {
162 if (!videoEl || isScrubbing) return;
163 currentTime = videoEl.currentTime;
164 duration = videoEl.duration || 0;
165 const newProgress = duration > 0 ? currentTime / duration : 0;
166
167 // Hop the frog as playback advances
168 const delta = newProgress - lastHopProgress;
169 if (Math.abs(delta) > 0.002) {
170 frogFrame = frogFrame === 0 ? 1 : 0;
171 frogFlipped = delta < 0;
172 lastHopProgress = newProgress;
173 }
174
175 scrubProgress = newProgress;
176
177 // Update captions
178 if (getCaptionsEnabled()) {
179 updateCaptionForTime(currentTime);
180 }
181 }
182
183 function onPlay() {
184 playing = true;
185 }
186 function onPause() {
187 playing = false;
188 }
189
190 function toggleCaptions() {
191 playCroak();
192 toggleCaptionsDisplay();
193 if (getCaptionsEnabled()) {
194 startCcLoadAnim();
195 } else {
196 stopCcLoadAnim();
197 }
198 }
199
200 function togglePlay() {
201 if (!videoEl) return;
202 if (videoEl.paused) videoEl.play().catch(() => {});
203 else videoEl.pause();
204 }
205
206 function getFullscreenElement(): Element | null {
207 return document.fullscreenElement
208 || (document as any).webkitFullscreenElement
209 || null;
210 }
211
212 function toggleFullscreen() {
213 const wrapper = videoEl?.closest(".player-wrapper") as any;
214 if (!wrapper) return;
215 if (!getFullscreenElement()) {
216 if (wrapper.requestFullscreen) {
217 wrapper.requestFullscreen().catch(() => {});
218 } else if (wrapper.webkitRequestFullscreen) {
219 wrapper.webkitRequestFullscreen();
220 }
221 } else {
222 if (document.exitFullscreen) {
223 document.exitFullscreen().catch(() => {});
224 } else if ((document as any).webkitExitFullscreen) {
225 (document as any).webkitExitFullscreen();
226 }
227 }
228 }
229
230 function onFullscreenChange() {
231 isFullscreen = !!getFullscreenElement();
232 }
233
234 /** Calculate seek position from a mouse or touch event on the scrub bar */
235 function scrubFromEvent(e: MouseEvent | TouchEvent) {
236 if (!scrubBarEl) return;
237 const clientX = 'touches' in e ? e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0 : e.clientX;
238 const rect = scrubBarEl.getBoundingClientRect();
239 const pct = Math.max(
240 0,
241 Math.min(1, (clientX - rect.left) / rect.width),
242 );
243 const delta = pct - lastFrogProgress;
244 if (Math.abs(delta) > 0.02) {
245 frogFrame = frogFrame === 0 ? 1 : 0;
246 frogFlipped = delta < 0;
247 lastFrogProgress = pct;
248 }
249 scrubProgress = pct;
250 if (videoEl && duration > 0) {
251 const targetTime = pct * duration;
252 videoEl.currentTime = targetTime;
253 currentTime = targetTime;
254 // Tell hls.js to load segments at the new position
255 if (hls) {
256 hls.startLoad(targetTime);
257 }
258 }
259 }
260
261 let wasPlayingBeforeScrub = false; // Resume playback after scrub if it was playing
262
263 /** Start scrubbing — pause video to prevent buffering issues (especially Firefox) */
264 function onScrubDown(e: MouseEvent | TouchEvent) {
265 e.preventDefault();
266 isScrubbing = true;
267 wasPlayingBeforeScrub = !!(videoEl && !videoEl.paused);
268 if (videoEl && wasPlayingBeforeScrub) videoEl.pause();
269 scrubFromEvent(e);
270 window.addEventListener("mousemove", onScrubMove);
271 window.addEventListener("mouseup", onScrubUp);
272 window.addEventListener("touchmove", onScrubMove, { passive: false });
273 window.addEventListener("touchend", onScrubUp);
274 }
275
276 /** Start scrubbing by grabbing the frog sprite directly */
277 function onFrogDown(e: MouseEvent | TouchEvent) {
278 e.preventDefault();
279 e.stopPropagation();
280 playCroak();
281 isScrubbing = true;
282 wasPlayingBeforeScrub = !!(videoEl && !videoEl.paused);
283 if (videoEl && wasPlayingBeforeScrub) videoEl.pause();
284 window.addEventListener("mousemove", onScrubMove);
285 window.addEventListener("mouseup", onScrubUp);
286 window.addEventListener("touchmove", onScrubMove, { passive: false });
287 window.addEventListener("touchend", onScrubUp);
288 }
289
290 function onScrubMove(e: MouseEvent | TouchEvent) {
291 if (!isScrubbing) return;
292 e.preventDefault();
293 scrubFromEvent(e);
294 }
295
296 function onScrubUp() {
297 isScrubbing = false;
298 if (videoEl && wasPlayingBeforeScrub) {
299 videoEl.play().catch(() => {});
300 }
301 window.removeEventListener("mousemove", onScrubMove);
302 window.removeEventListener("mouseup", onScrubUp);
303 window.removeEventListener("touchmove", onScrubMove);
304 window.removeEventListener("touchend", onScrubUp);
305 }
306
307 function onKeyDown(e: KeyboardEvent) {
308 const tag = (e.target as HTMLElement)?.tagName;
309 if (e.code === "Space" && videoEl && tag !== 'INPUT' && tag !== 'TEXTAREA') {
310 e.preventDefault();
311 togglePlay();
312 }
313 if (e.code === "KeyD" && e.shiftKey) {
314 toggleDebug();
315 }
316 }
317
318 $effect(() => {
319 window.addEventListener("keydown", onKeyDown);
320 return () => window.removeEventListener("keydown", onKeyDown);
321 });
322
323 /** Show controls on mouse activity, auto-hide after 2.5s of inactivity during playback */
324 function onMouseActivity() {
325 showControls = true;
326 if (hideTimeout) clearTimeout(hideTimeout);
327 hideTimeout = setTimeout(() => {
328 if (playing) showControls = false;
329 }, 2500);
330 }
331</script>
332
333<WavyBorder seed="player-main" padding={4}>
334 <div
335 class="player-wrapper"
336 onmousemove={onMouseActivity}
337 onmouseenter={onMouseActivity}
338 ontouchstart={onMouseActivity}
339 onmouseleave={() => {
340 if (playing) showControls = false;
341 }}
342 role="region"
343 >
344 <video
345 bind:this={videoEl}
346 playsinline
347 ontimeupdate={onTimeUpdate}
348 onplay={onPlay}
349 onpause={onPause}
350 onclick={togglePlay}
351 >
352 <track kind="captions" />
353 </video>
354
355 <!-- Minimal controls: frog scrub bar + frogeye fullscreen -->
356 <div class="controls" class:visible={showControls || !playing}>
357 <!-- Frog scrub area (no visible bar — frog position IS the progress) -->
358 <!-- svelte-ignore a11y_no_static_element_interactions -->
359 <div
360 class="scrub-bar"
361 bind:this={scrubBarEl}
362 onmousedown={onScrubDown}
363 ontouchstart={onScrubDown}
364 role="slider"
365 aria-valuenow={currentTime}
366 aria-valuemin={0}
367 aria-valuemax={duration}
368 tabindex={0}
369 >
370 <!-- Lilypad at the start -->
371 <img
372 src="/lilystart.png"
373 alt=""
374 class="lilypad lilypad-start"
375 />
376
377 <!-- svelte-ignore a11y_no_static_element_interactions -->
378 <div
379 class="scrub-frog"
380 style="left: {scrubProgress * 100}%;"
381 class:flipped={frogFlipped}
382 onmousedown={onFrogDown}
383 ontouchstart={onFrogDown}
384 >
385 <img
386 src={!playing && !isScrubbing
387 ? "/froggiepause.png"
388 : frogFrame === 0
389 ? "/froggiestand.png"
390 : "/froggiejump.png"}
391 alt="scrub"
392 class="frog-sprite"
393 draggable="false"
394 />
395 </div>
396
397 <!-- Lilypad at the end -->
398 <img src="/lilyend.png" alt="" class="lilypad lilypad-end" />
399 </div>
400 </div>
401
402 <!-- Frogeye fullscreen toggle — bottom right -->
403 <button
404 class="fullscreen-btn"
405 class:visible={showControls || !playing}
406 onclick={() => { playCroak(); toggleFullscreen(); }}
407 title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
408 >
409 <img src="/frogeye.png" alt="fullscreen" class="frogeye" />
410 </button>
411
412 <!-- CC toggle button — only shows when model is loaded -->
413 {#if getModelStatus() === 'ready'}
414 <button
415 class="cc-btn"
416 class:visible={showControls || !playing}
417 onclick={toggleCaptions}
418 title={getCaptionsEnabled() ? "Disable captions" : "Enable captions"}
419 >
420 <img src={getCaptionsEnabled()
421 ? (ccLoading ? `/cc_load_${ccLoadFrame}.png` : "/cc_on.png")
422 : "/cc_off.png"} alt="captions" class="cc-icon" />
423 </button>
424 {/if}
425
426 <!-- Caption overlay -->
427 {#if getCaptionsEnabled() && getCurrentCaption()}
428 <div class="caption-overlay">
429 <span class="caption-text">{getCurrentCaption()}</span>
430 </div>
431 {/if}
432
433 {#if ccDebug}
434 <div class="cc-debug">
435 <div class="cc-debug-header">CC Debug (Shift+D) | {debugInfo.isProcessing ? `Processing ${debugInfo.processProgress}%` : `Ready`}</div>
436 <div>model: {debugInfo.modelStatus} | enabled: {debugInfo.captionsEnabled} | video: {debugInfo.videoTime?.toFixed(1)}s</div>
437 <div>showing: "{debugInfo.currentCaption?.substring(0, 60)}"</div>
438
439 <!-- Processed ranges timeline -->
440 {#if duration > 0}
441 <div class="cc-debug-timeline">
442 {#each (debugInfo.processedRanges ?? []) as range}
443 <div class="cc-debug-range" style="left: {(range.start / duration) * 100}%; width: {((range.end - range.start) / duration) * 100}%;"></div>
444 {/each}
445 <div class="cc-debug-playhead" style="left: {(debugInfo.videoTime / duration) * 100}%;"></div>
446 </div>
447 {/if}
448
449 <div class="cc-debug-header" style="margin-top: 4px;">Captions ({debugInfo.totalCaptions}):</div>
450 <div class="cc-debug-chunks">
451 {#each (debugInfo.captions ?? []) as cap}
452 {@const vt = debugInfo.videoTime ?? 0}
453 {@const active = vt >= cap.start && vt <= cap.end}
454 <!-- svelte-ignore a11y_no_static_element_interactions -->
455 <div class="cc-debug-chunk" class:active onclick={() => { if (videoEl) { videoEl.currentTime = cap.start; currentTime = cap.start; } }}>
456 <span class="cc-debug-time">{cap.start?.toFixed(1)}s-{cap.end?.toFixed(1)}s</span>
457 <span class="cc-debug-text">{cap.text}</span>
458 {#if active}<span class="cc-debug-active">NOW</span>{/if}
459 </div>
460 {/each}
461 </div>
462 </div>
463 {/if}
464
465 {#if errorMsg}
466 <div class="error-overlay">{errorMsg}</div>
467 {/if}
468 </div>
469</WavyBorder>
470
471{#if showEditor}
472 <CaptionEditor
473 captions={getAllCaptions()}
474 currentTime={currentTime}
475 videoUri={atUri}
476 onClose={() => showEditor = false}
477 onUpdate={onCaptionUpdate}
478 />
479{/if}
480
481<style>
482 .player-wrapper {
483 position: relative;
484 width: 100%;
485 background: #0a182b;
486 overflow: hidden;
487 }
488
489 .player-wrapper:fullscreen {
490 display: flex;
491 align-items: center;
492 justify-content: center;
493 }
494
495 .player-wrapper:fullscreen video {
496 max-height: 100vh;
497 max-width: 100vw;
498 object-fit: contain;
499 }
500
501 video {
502 width: 100%;
503 display: block;
504 max-height: 70vh;
505 cursor: pointer;
506 }
507
508 /* Frog scrub area — no visible bar, frog position is the progress */
509 .controls {
510 position: absolute;
511 bottom: 5%;
512 left: 10%;
513 right: 15%;
514 opacity: 0;
515 transition: opacity 0.25s ease;
516 pointer-events: none;
517 }
518
519 .controls.visible {
520 opacity: 1;
521 pointer-events: auto;
522 }
523
524 .scrub-bar {
525 position: relative;
526 height: 48px;
527 cursor: pointer;
528 touch-action: none;
529 }
530
531 .scrub-frog {
532 position: absolute;
533 bottom: 0;
534 transform: translateX(-50%);
535 cursor: grab;
536 transition: left 0.05s linear;
537 z-index: 2;
538 padding: 6px;
539 touch-action: none;
540 }
541
542 .scrub-frog:active {
543 cursor: grabbing;
544 }
545
546 .scrub-frog.flipped {
547 transform: translateX(-50%) scaleX(-1);
548 }
549
550 .frog-sprite {
551 width: 48px;
552 height: auto;
553 filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
554 pointer-events: none;
555 }
556
557 /* Lilypads at start and end of scrub bar */
558 .lilypad {
559 position: absolute;
560 bottom: -4px;
561 width: 36px;
562 height: auto;
563 pointer-events: none;
564 filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.4));
565 }
566
567 .lilypad-start {
568 left: -18px;
569 }
570
571 .lilypad-end {
572 right: 40px;
573 }
574
575 /* Frogeye fullscreen button */
576 .fullscreen-btn {
577 all: unset;
578 position: absolute;
579 bottom: 5%;
580 right: 8%;
581 cursor: pointer;
582 opacity: 0;
583 transition:
584 opacity 0.25s ease,
585 transform 0.2s ease;
586 z-index: 5;
587 }
588
589 .fullscreen-btn.visible {
590 opacity: 1;
591 }
592
593 .fullscreen-btn:hover {
594 transform: scale(1.15);
595 }
596
597 .frogeye {
598 width: 44px;
599 height: 44px;
600 filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
601 }
602
603 .cc-btn {
604 all: unset;
605 position: absolute;
606 bottom: 5%;
607 right: calc(8% + 56px);
608 cursor: pointer;
609 opacity: 0;
610 transition: opacity 0.25s ease, transform 0.2s ease;
611 z-index: 5;
612 }
613
614 .cc-btn.visible {
615 opacity: 1;
616 }
617
618 .cc-btn:hover {
619 transform: scale(1.15);
620 }
621
622 .cc-icon {
623 width: 40px;
624 height: auto;
625 filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
626 }
627
628 .caption-overlay {
629 position: absolute;
630 bottom: 14%;
631 left: 5%;
632 right: 5%;
633 text-align: center;
634 z-index: 4;
635 pointer-events: none;
636 }
637
638 .caption-text {
639 font-family: 'Fang', system-ui, sans-serif;
640 font-size: clamp(0.9rem, 2vw, 1.2rem);
641 color: #FFDEED;
642 background: rgba(10, 24, 43, 0.8);
643 padding: 4px 12px;
644 border-radius: 4px;
645 line-height: 1.5;
646 }
647
648 .cc-debug {
649 position: absolute;
650 top: 6%;
651 left: 6%;
652 right: 6%;
653 bottom: 6%;
654 background: rgba(0, 0, 0, 0.85);
655 color: #39FF44;
656 font-family: monospace;
657 font-size: 11px;
658 padding: 12px;
659 overflow-y: auto;
660 z-index: 20;
661 line-height: 1.6;
662 border-radius: 8px;
663 }
664
665 .cc-debug-header {
666 color: #FFDEED;
667 font-weight: bold;
668 font-size: 12px;
669 }
670
671 .cc-debug-timeline {
672 position: relative;
673 height: 12px;
674 background: rgba(255, 255, 255, 0.1);
675 border-radius: 3px;
676 margin: 6px 0;
677 overflow: hidden;
678 }
679
680 .cc-debug-range {
681 position: absolute;
682 top: 0;
683 height: 100%;
684 background: #39FF44;
685 opacity: 0.5;
686 }
687
688 .cc-debug-playhead {
689 position: absolute;
690 top: 0;
691 width: 2px;
692 height: 100%;
693 background: #FF3992;
694 z-index: 1;
695 }
696
697 .cc-debug-chunks {
698 max-height: 50%;
699 overflow-y: auto;
700 }
701
702 .cc-debug-chunk {
703 display: flex;
704 gap: 8px;
705 padding: 2px 4px;
706 border-bottom: 1px solid rgba(57, 255, 68, 0.1);
707 cursor: pointer;
708 }
709
710 .cc-debug-chunk:hover {
711 background: rgba(57, 255, 68, 0.1);
712 }
713
714 .cc-debug-chunk.active {
715 background: rgba(57, 255, 68, 0.15);
716 color: #FFDEED;
717 }
718
719 .cc-debug-time {
720 flex-shrink: 0;
721 width: 100px;
722 color: #FFA639;
723 }
724
725 .cc-debug-text {
726 flex: 1;
727 overflow: hidden;
728 text-overflow: ellipsis;
729 white-space: nowrap;
730 }
731
732 .cc-debug-active {
733 color: #FF3992;
734 font-weight: bold;
735 flex-shrink: 0;
736 }
737
738 .error-overlay {
739 position: absolute;
740 bottom: 15%;
741 left: 10%;
742 right: 10%;
743 background: rgba(255, 57, 146, 0.85);
744 color: #ffdeed;
745 padding: 8px 12px;
746 border-radius: 6px;
747 font-family: "Fang", system-ui, sans-serif;
748 font-size: 0.8rem;
749 }
750</style>