vod frog, frog with the vods
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 750 lines 24 kB view raw
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>