Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375<!-- VideoCard: A single video entry in the grid.
Features: - Wavy green border (outer) with wavy blue border (inner thumbnail) - Thumbnail loaded from the creator's PDS via livestream record - Hover scrub preview: creates a hidden <video> element, seeks it on mousemove, and draws frames to a canvas overlaid on the thumbnail - Hopping frog sprite follows the scrub position - Creator name links to their profile page - Card position is jittered (rotation + translate) seeded by the creator DID--><script lang="ts"> import { onMount, onDestroy } from 'svelte'; import Hls from 'hls.js'; import { formatDuration, formatDate, resolveHandle, getThumbnailUrl, getPlaylistUrl, getProfile, type VideoRecord } from './api'; import { getCardOffsets, seededRandom } from './theme'; import WavyBorder from './WavyBorder.svelte'; import WavyCircle from './WavyCircle.svelte';
let { video, onSelect }: { video: VideoRecord; onSelect: (video: VideoRecord) => void } = $props();
let creatorHandle = $state(''); let creatorAvatar = $state(''); let thumbnailUrl: string | null = $state(null);
// Extract rkey from URI for seeding const rkey = $derived(video.uri.split('/').pop() || video.uri); const offsets = $derived(getCardOffsets(video.value.creator)); const cardStyle = $derived(`transform: rotate(${offsets.rotation}deg) translate(${offsets.translateX}px, ${offsets.translateY}px);`);
// Scrub preview state — an offscreen video element is created on hover // and seeked as the user moves their mouse across the thumbnail let thumbEl: HTMLDivElement | undefined = $state(); let canvasEl: HTMLCanvasElement | undefined = $state(); let scrubbing = $state(false); let scrubProgress = $state(0); let scrubReady = $state(false); let scrubLoading = $state(false); let hasFrame = $state(false);
let scrubVideo: HTMLVideoElement | null = null; // Hidden video element for scrub preview let hls: Hls | null = null; // hls.js instance for the scrub video let videoDuration = 0; // Total duration in seconds let seeking = false; // True while a seek is in progress let pendingSeek: number | null = null; // Queued seek time if we're already seeking
onMount(() => { creatorHandle = video.value.creator; resolveHandle(video.value.creator).then((h) => { creatorHandle = h; const handle = h.replace('@', ''); getProfile(handle).then((p) => { if (p.avatar) creatorAvatar = p.avatar; }).catch(() => {}); }); getThumbnailUrl(video).then((url) => (thumbnailUrl = url)); });
onDestroy(destroyScrub);
/** Create a hidden video element and attach hls.js to enable scrub preview */ function initScrub() { if (scrubVideo) return; scrubLoading = true;
scrubVideo = document.createElement('video'); scrubVideo.muted = true; scrubVideo.playsInline = true; scrubVideo.preload = 'auto'; scrubVideo.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:320px;height:180px;pointer-events:none;'; document.body.appendChild(scrubVideo); scrubVideo.addEventListener('seeked', onSeeked);
const src = getPlaylistUrl(video.uri);
if (Hls.isSupported()) { hls = new Hls({ enableWorker: false, lowLatencyMode: false, startLevel: 0 }); hls.loadSource(src); hls.attachMedia(scrubVideo); hls.on(Hls.Events.LEVEL_LOADED, (_e, data) => { videoDuration = data.details.totalduration; scrubReady = true; seekToProgress(scrubProgress); }); hls.on(Hls.Events.ERROR, (_e, data) => { if (data.fatal) scrubLoading = false; }); } }
function destroyScrub() { if (hls) { hls.destroy(); hls = null; } if (scrubVideo) { scrubVideo.removeEventListener('seeked', onSeeked); scrubVideo.remove(); scrubVideo = null; } scrubReady = false; scrubLoading = false; hasFrame = false; seeking = false; pendingSeek = null; videoDuration = 0; }
function onSeeked() { seeking = false; scrubLoading = false; drawFrame(); if (pendingSeek !== null) { const t = pendingSeek; pendingSeek = null; doSeek(t); } }
function doSeek(time: number) { if (!scrubVideo || !scrubReady) return; seeking = true; scrubVideo.currentTime = time; }
function seekToProgress(pct: number) { if (!scrubReady || !videoDuration) return; const targetTime = pct * videoDuration; if (seeking) pendingSeek = targetTime; else doSeek(targetTime); }
/** Draw the current scrub video frame onto the visible canvas */ function drawFrame() { if (!scrubVideo || !canvasEl) return; const vw = scrubVideo.videoWidth, vh = scrubVideo.videoHeight; if (!vw || !vh) return; const rect = canvasEl.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvasEl.width = rect.width * dpr; canvasEl.height = rect.height * dpr; const ctx = canvasEl.getContext('2d'); if (!ctx) return; ctx.drawImage(scrubVideo, 0, 0, canvasEl.width, canvasEl.height); hasFrame = true; }
let frogFrame = $state(0); // alternates 0/1 for stand/jump let lastFrogProgress = 0; let frogFlipped = $state(false); // true when scrubbing backward
function onMouseEnter() { scrubbing = true; initScrub(); } function onMouseMove(e: MouseEvent) { if (!thumbEl) return; const rect = thumbEl.getBoundingClientRect(); const newProgress = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const delta = newProgress - lastFrogProgress; // Toggle frog frame every ~3% of scrub distance if (Math.abs(delta) > 0.03) { frogFrame = frogFrame === 0 ? 1 : 0; frogFlipped = delta < 0; lastFrogProgress = newProgress; } scrubProgress = newProgress; seekToProgress(scrubProgress); } function onMouseLeave() { scrubbing = false; }</script>
<button class="card" style={cardStyle} onclick={() => onSelect(video)}> <WavyBorder seed={rkey} fill="#39FF44" strokeColor="#0A182B" strokeWidth={2.2} padding="clamp(24px, 4vw, 36px)"> <span class="duration-badge">{formatDuration(video.value.duration)}</span> <div class="thumb-wrapper"> <WavyBorder seed={rkey + '-thumb'} fill="#3992FF" strokeColor="#0A182B" strokeWidth={1.5} padding={0}> <div class="thumbnail" bind:this={thumbEl} onmouseenter={onMouseEnter} onmousemove={onMouseMove} onmouseleave={onMouseLeave} role="img" > {#if thumbnailUrl} <img src={thumbnailUrl} alt={video.value.title} class="thumb-img" /> {/if} <canvas bind:this={canvasEl} class="scrub-canvas" class:visible={scrubbing && hasFrame} ></canvas> {#if scrubbing && scrubLoading && !hasFrame} <div class="loading-spinner"><div class="spinner"></div></div> {/if} {#if scrubbing} <div class="scrub-frog" style="left: {scrubProgress * 100}%;" class:flipped={frogFlipped} > <img src={frogFrame === 0 ? '/froggiestand.png' : '/froggiejump.png'} alt="hop" class="frog-sprite" /> </div> {/if} <div class="thumb-shadow"></div> </div> </WavyBorder> </div> <div class="info"> <h3 class="title">{video.value.title}</h3> <a href="/profile/{creatorHandle.replace('@', '')}" class="creator-row" onclick={(e: MouseEvent) => e.stopPropagation()}> {#if creatorAvatar} <WavyCircle seed={video.value.creator} fill="#FFDEED" strokeColor="#0A182B" strokeWidth={1.2} size={24}> <img src={creatorAvatar} alt="" /> </WavyCircle> {/if} <span class="creator-name">{creatorHandle}</span> </a> <p class="date">{formatDate(video.value.createdAt)}</p> </div> </WavyBorder></button>
<style> .card { all: unset; cursor: url('/frogcursor-small.png') 4 2, pointer; display: flex; flex-direction: column; transition: transform 0.2s ease; position: relative; }
.duration-badge { display: block; font-family: 'PicNic', cursive, system-ui; font-size: 1.4rem; color: #FF3992; font-weight: bold; letter-spacing: 0.5px; margin-bottom: 8px; text-align: right; }
.thumb-wrapper { margin: 0; }
.thumbnail { position: relative; width: 100%; aspect-ratio: 16 / 9; background: linear-gradient(135deg, #3992FF, #1E4E8C); display: flex; align-items: center; justify-content: center; overflow: hidden; }
.thumb-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transition: opacity 0.15s; } .scrub-canvas { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; pointer-events: none; } .scrub-canvas.visible { opacity: 1; }
.scrub-frog { position: absolute; bottom: 0%; z-index: 4; pointer-events: none; transform: translateX(-50%); transition: left 0.05s linear; }
.scrub-frog.flipped { transform: translateX(-50%) scaleX(-1); }
.frog-sprite { width: 48px; height: auto; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4)); image-rendering: auto; }
.thumb-shadow { position: absolute; inset: 0; box-shadow: inset 0 3px 16px rgba(10, 24, 43, 0.5); pointer-events: none; }
.loading-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 3; } .spinner { width: 28px; height: 28px; border: 3px solid rgba(255, 255, 255, 0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }
.info { padding: 14px 10px 8px; }
.title { margin: 0; font-family: 'Fang', system-ui, sans-serif; font-size: 0.9rem; font-weight: 700; color: #0A182B; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.creator-row { display: flex; align-items: center; gap: 6px; margin: 6px 0 0; text-decoration: none; transition: transform 0.15s; }
.creator-row:hover { transform: translateX(2px); }
.creator-row:hover .creator-name { color: #FF3992; }
.creator-name { font-family: 'Fang', system-ui, sans-serif; font-size: 0.8rem; color: #0A182B; opacity: 0.8; text-decoration: underline; text-decoration-color: #FF3992; transition: color 0.15s; }
.date { margin: 2px 0 0; font-family: 'Fang', system-ui, sans-serif; font-size: 0.75rem; color: #FF3992; font-style: italic; }</style>