vod frog, frog with the vods
3
fork

Configure Feed

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

add documentation comments across all source files

+94 -59
+4 -2
README.md
··· 1 1 # vod frog 2 2 3 - A whimsical, frog-themed video player for VODs from [stream.place](https://stream.place), a decentralized video streaming service built on the AT Protocol. Inspired by the playful, hand-crafted feel of late 90s / early 2000s web design. 3 + vod frog, frog with the vods, i take the frogs on the vod. i vod on the frogs. 4 + 5 + svelte app, fair amount of claude code work was done here but i've looked through it and most of it seems reasonable. please submit pull requests for anything sus!! 4 6 5 7 ## Running 6 8 ··· 56 58 57 59 ## Design 58 60 59 - Videos are fetched from across the AT Protocol network via the [UFOs API](https://ufos-api.microcosm.blue), which indexes all `place.stream.video` records. Playback uses HLS via [hls.js](https://github.com/video-dev/hls.js/). 61 + Videos are fetched from across the AT Protocol network via the [UFOs API](https://ufos-api.microcosm.blue) (TODO: REPLACE THIS WITH A QUICKSLICE INSTANCE, UFOS DOES NOT ACTUALLY INDEX 100% OF THE DATA), which indexes all `place.stream.video` records. Playback uses HLS via [hls.js](https://github.com/video-dev/hls.js/). 60 62 61 63 Each video card gets a unique wavy border generated from layered sine waves, seeded by the record's key. Cards are offset with random rotation (±5°) and position jitter (±30px) for an organic, hand-placed feel. The wavy borders use a CSS `clip-path: polygon()` for content masking and an SVG path for the visible stroke, both derived from the same control points. 62 64
+21 -6
src/lib/VideoCard.svelte
··· 1 + <!-- 2 + VideoCard: A single video entry in the grid. 3 + 4 + Features: 5 + - Wavy green border (outer) with wavy blue border (inner thumbnail) 6 + - Thumbnail loaded from the creator's PDS via livestream record 7 + - Hover scrub preview: creates a hidden <video> element, seeks it on mousemove, 8 + and draws frames to a canvas overlaid on the thumbnail 9 + - Hopping frog sprite follows the scrub position 10 + - Creator name links to their profile page 11 + - Card position is jittered (rotation + translate) seeded by the creator DID 12 + --> 1 13 <script lang="ts"> 2 14 import { onMount, onDestroy } from 'svelte'; 3 15 import Hls from 'hls.js'; ··· 23 35 const offsets = getCardOffsets(video.value.creator); 24 36 const cardStyle = `transform: rotate(${offsets.rotation}deg) translate(${offsets.translateX}px, ${offsets.translateY}px);`; 25 37 26 - // Scrub state 38 + // Scrub preview state — an offscreen video element is created on hover 39 + // and seeked as the user moves their mouse across the thumbnail 27 40 let thumbEl: HTMLDivElement | undefined = $state(); 28 41 let canvasEl: HTMLCanvasElement | undefined = $state(); 29 42 let scrubbing = $state(false); ··· 32 45 let scrubLoading = $state(false); 33 46 let hasFrame = $state(false); 34 47 35 - let scrubVideo: HTMLVideoElement | null = null; 36 - let hls: Hls | null = null; 37 - let videoDuration = 0; 38 - let seeking = false; 39 - let pendingSeek: number | null = null; 48 + let scrubVideo: HTMLVideoElement | null = null; // Hidden video element for scrub preview 49 + let hls: Hls | null = null; // hls.js instance for the scrub video 50 + let videoDuration = 0; // Total duration in seconds 51 + let seeking = false; // True while a seek is in progress 52 + let pendingSeek: number | null = null; // Queued seek time if we're already seeking 40 53 41 54 onMount(() => { 42 55 resolveHandle(video.value.creator).then((h) => (creatorHandle = h)); ··· 45 58 46 59 onDestroy(destroyScrub); 47 60 61 + /** Create a hidden video element and attach hls.js to enable scrub preview */ 48 62 function initScrub() { 49 63 if (scrubVideo) return; 50 64 scrubLoading = true; ··· 98 112 if (seeking) pendingSeek = targetTime; else doSeek(targetTime); 99 113 } 100 114 115 + /** Draw the current scrub video frame onto the visible canvas */ 101 116 function drawFrame() { 102 117 if (!scrubVideo || !canvasEl) return; 103 118 const vw = scrubVideo.videoWidth, vh = scrubVideo.videoHeight;
+10 -4
src/lib/VideoPlayer.svelte
··· 3 3 import Hls from "hls.js"; 4 4 import WavyBorder from "./WavyBorder.svelte"; 5 5 6 + // HLS video source URL (m3u8 playlist) 6 7 let { src }: { src: string } = $props(); 7 8 8 9 let videoEl: HTMLVideoElement | undefined = $state(); ··· 14 15 let currentTime = $state(0); 15 16 let duration = $state(0); 16 17 17 - // Frog scrub state 18 + // Frog scrub bar — the frog's position along the bar represents playback progress. 19 + // Users can click the bar or grab the frog to seek. 18 20 let scrubBarEl: HTMLDivElement | undefined = $state(); 19 21 let isScrubbing = $state(false); 20 22 let scrubProgress = $state(0); ··· 93 95 ); 94 96 }); 95 97 96 - let lastHopProgress = 0; 98 + let lastHopProgress = 0; // Track progress to trigger frog hops at intervals 97 99 100 + /** Sync scrub position with playback, and animate the frog hopping */ 98 101 function onTimeUpdate() { 99 102 if (!videoEl || isScrubbing) return; 100 103 currentTime = videoEl.currentTime; ··· 139 142 isFullscreen = !!document.fullscreenElement; 140 143 } 141 144 142 - // Scrub bar — clicking the track or dragging the frog 145 + /** Calculate seek position from a mouse event on the scrub bar */ 143 146 function scrubFromEvent(e: MouseEvent) { 144 147 if (!scrubBarEl) return; 145 148 const rect = scrubBarEl.getBoundingClientRect(); ··· 165 168 } 166 169 } 167 170 168 - let wasPlayingBeforeScrub = false; 171 + let wasPlayingBeforeScrub = false; // Resume playback after scrub if it was playing 169 172 173 + /** Start scrubbing — pause video to prevent buffering issues (especially Firefox) */ 170 174 function onScrubDown(e: MouseEvent) { 171 175 e.preventDefault(); 172 176 isScrubbing = true; ··· 177 181 window.addEventListener("mouseup", onScrubUp); 178 182 } 179 183 184 + /** Start scrubbing by grabbing the frog sprite directly */ 180 185 function onFrogDown(e: MouseEvent) { 181 186 e.preventDefault(); 182 187 e.stopPropagation(); ··· 213 218 return () => window.removeEventListener('keydown', onKeyDown); 214 219 }); 215 220 221 + /** Show controls on mouse activity, auto-hide after 2.5s of inactivity during playback */ 216 222 function onMouseActivity() { 217 223 showControls = true; 218 224 if (hideTimeout) clearTimeout(hideTimeout);
+17 -3
src/lib/WavyBorder.svelte
··· 1 + <!-- 2 + WavyBorder: A procedurally generated wobbly rectangular border. 3 + 4 + The shape is built from layered sine waves along each edge, seeded by a string 5 + so every instance is unique but deterministic. The content is clipped to the 6 + wavy shape using a CSS polygon, while the visible outline is rendered as an 7 + SVG path on top. Both are derived from the same control points to stay aligned. 8 + --> 1 9 <script lang="ts"> 2 10 import { seededRandom } from './theme'; 3 11 ··· 17 25 // Convert the points to a CSS polygon for clipping 18 26 const clipPolygon = `polygon(${pts.map(([x, y]) => `${x.toFixed(2)}% ${y.toFixed(2)}%`).join(', ')})`; 19 27 28 + /** 29 + * Generate the wavy shape for this border. 30 + * Returns both the SVG bezier path (for the stroke) and densely sampled 31 + * polygon points (for the CSS clip-path). All coordinates are in 0-100 space. 32 + */ 20 33 function generateWavyShape(s: string): { pts: [number, number][]; svgPath: string } { 21 - const margin = 1; 22 - const amp = 2.5; 23 - const segs = 16; 34 + const margin = 1; // % inset from the element edges 35 + const amp = 2.5; // % max wobble amplitude 36 + const segs = 16; // control points per edge 24 37 25 38 // Edges 0=top, 1=right, 2=bottom, 3=left 26 39 // Vertical edges (1,3) get lower frequency since they're often shorter in wide boxes ··· 36 49 }; 37 50 }); 38 51 52 + /** Compute the perpendicular wobble offset at position t along an edge */ 39 53 function wobble(edgeIdx: number, t: number): number { 40 54 const p = edgeParams[edgeIdx]; 41 55 const w1 = Math.sin(t * p.freq1 * Math.PI * 2 + p.phase1) * amp * 0.7;
+4
src/lib/WavyCircle.svelte
··· 1 + <!-- 2 + WavyCircle: A procedurally generated wobbly circular border for avatars. 3 + Uses radial sine waves to create an organic blob shape, seeded by a string. 4 + --> 1 5 <script lang="ts"> 2 6 import { seededRandom } from './theme'; 3 7
+19
src/lib/api.ts
··· 1 + /** 2 + * AT Protocol API client for fetching videos, resolving handles/profiles, 3 + * and looking up thumbnails across the network. 4 + */ 5 + 6 + /** The AT Protocol lexicon collection for stream.place videos */ 1 7 export const COLLECTION = 'place.stream.video'; 8 + /** HLS playlist endpoint — takes an at:// URI and returns an m3u8 manifest */ 2 9 export const PLAYBACK_BASE = 3 10 'https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist'; 11 + 12 + /** UFOs API — indexes all AT Protocol records across the network */ 4 13 export const UFOS_API = 'https://ufos-api.microcosm.blue'; 5 14 15 + /** A video record in our normalized format, with an at:// URI */ 6 16 export interface VideoRecord { 7 17 uri: string; 8 18 cid: string; ··· 25 35 }; 26 36 } 27 37 38 + /** Raw record shape from the UFOs API */ 28 39 interface UfosRecord { 29 40 did: string; 30 41 collection: string; ··· 49 60 cursor?: string; 50 61 } 51 62 63 + /** In-memory cache — the UFOs API returns all records at once, so we fetch once and paginate client-side */ 52 64 let allVideosCache: VideoRecord[] | null = null; 53 65 66 + /** Fetch all place.stream.video records from across the AT Protocol network */ 54 67 export async function fetchAllVideos(): Promise<VideoRecord[]> { 55 68 if (allVideosCache) return allVideosCache; 56 69 ··· 71 84 return allVideosCache; 72 85 } 73 86 87 + /** Return a page of videos from the cached full list */ 74 88 export async function listVideos(page = 0, pageSize = 9): Promise<{ records: VideoRecord[]; hasMore: boolean }> { 75 89 const all = await fetchAllVideos(); 76 90 const start = page * pageSize; ··· 78 92 return { records, hasMore: start + pageSize < all.length }; 79 93 } 80 94 95 + /** Build an HLS playlist URL from an at:// video URI */ 81 96 export function getPlaylistUrl(uri: string): string { 82 97 return `${PLAYBACK_BASE}?uri=${encodeURIComponent(uri)}`; 83 98 } 84 99 100 + /** Format nanosecond duration to human-readable "H:MM:SS" or "M:SS" */ 85 101 export function formatDuration(nanos: number): string { 86 102 const totalSeconds = Math.floor(nanos / 1_000_000_000); 87 103 const hours = Math.floor(totalSeconds / 3600); ··· 120 136 createdAt?: string; 121 137 } 122 138 139 + /** Fetch a Bluesky profile by handle or DID via the public API */ 123 140 export async function getProfile(actor: string): Promise<BskyProfile> { 124 141 const params = new URLSearchParams({ actor }); 125 142 const res = await fetch(`${BSKY_PUBLIC_API}/xrpc/app.bsky.actor.getProfile?${params}`); ··· 131 148 132 149 const handleCache = new Map<string, string>(); 133 150 151 + /** Resolve a DID to a human-readable @handle via the PLC directory */ 134 152 export async function resolveHandle(did: string): Promise<string> { 135 153 if (handleCache.has(did)) return handleCache.get(did)!; 136 154 try { ··· 150 168 151 169 const pdsCache = new Map<string, string>(); 152 170 171 + /** Resolve a DID to its PDS (Personal Data Server) endpoint URL */ 153 172 export async function resolvePds(did: string): Promise<string | null> { 154 173 if (pdsCache.has(did)) return pdsCache.get(did)!; 155 174 try {
+19 -44
src/lib/theme.ts
··· 1 - // VodFrog color scheme and theme constants 1 + /** 2 + * VodFrog theme: color scheme, seeded randomness, and card layout utilities. 3 + */ 2 4 5 + /** Brand colors from the design spec */ 3 6 export const colors = { 4 - green: '#39FF44', 5 - blue: '#3992FF', 6 - orange: '#FFA639', 7 - pink: '#FF3992', 8 - lightPink: '#FFDEED', 9 - darkBlue: '#0A182B', 7 + green: '#39FF44', // Main vibrant green 8 + blue: '#3992FF', // Water blue 9 + orange: '#FFA639', // Orange accent 10 + pink: '#FF3992', // Pink accent 11 + lightPink: '#FFDEED', // Light pink for dark regions 12 + darkBlue: '#0A182B', // Near-black blue for text/outlines 10 13 11 - // Derived 14 + // Derived shades 12 15 greenDark: '#1A8C22', 13 16 greenMuted: '#2BBF33', 14 17 blueDark: '#1E4E8C', ··· 16 19 } as const; 17 20 18 21 /** 19 - * Generate a deterministic pseudo-random number from a string seed. 20 - * Uses a simple hash function to produce values in [0, 1). 22 + * Deterministic pseudo-random number from a string seed. 23 + * Same seed + index always produces the same value in [0, 1). 24 + * Used to give each card/border a unique but consistent appearance. 21 25 */ 22 26 export function seededRandom(seed: string, index = 0): number { 23 27 let hash = 0; ··· 26 30 const char = str.charCodeAt(i); 27 31 hash = ((hash << 5) - hash + char) | 0; 28 32 } 29 - // Normalize to [0, 1) 30 33 return (((hash % 65536) + 65536) % 65536) / 65536; 31 34 } 32 35 33 36 /** 34 - * Generate organic offsets for a card based on its URI/DID. 35 - * Returns rotation (deg), translateX (px), translateY (px). 37 + * Generate organic layout offsets for a video card, seeded by the creator's DID. 38 + * Creates a hand-placed, off-grid feeling in the card grid. 36 39 */ 37 40 export function getCardOffsets(seed: string): { 38 41 rotation: number; ··· 44 47 const r3 = seededRandom(seed, 2); 45 48 46 49 return { 47 - rotation: (r1 - 0.5) * 10, // -5 to +5 degrees 48 - translateX: (r2 - 0.5) * 60, // -30 to +30 px 49 - translateY: (r3 - 0.5) * 60, // -30 to +30 px 50 + rotation: (r1 - 0.5) * 10, // ±5 degrees 51 + translateX: (r2 - 0.5) * 60, // ±30px horizontal jitter 52 + translateY: (r3 - 0.5) * 60, // ±30px vertical jitter 50 53 }; 51 54 } 52 - 53 - /** 54 - * Generate an SVG filter for wavy/turbulent borders seeded by a string. 55 - * Returns the filter ID and the full SVG filter definition. 56 - */ 57 - export function generateWavyFilterId(seed: string): string { 58 - // Use the seed to create a unique filter ID 59 - let hash = 0; 60 - for (let i = 0; i < seed.length; i++) { 61 - hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0; 62 - } 63 - return `wavy-${((hash >>> 0) % 999999).toString(36)}`; 64 - } 65 - 66 - export function generateWavyFilterSvg(seed: string): { filterId: string; svgDefs: string } { 67 - const filterId = generateWavyFilterId(seed); 68 - const r1 = seededRandom(seed, 10); 69 - const r2 = seededRandom(seed, 11); 70 - const baseFreq = 0.015 + r1 * 0.015; // 0.015 - 0.03 71 - const seed2 = Math.floor(r2 * 100); 72 - 73 - const svgDefs = `<filter id="${filterId}" x="-5%" y="-5%" width="110%" height="110%"> 74 - <feTurbulence type="turbulence" baseFrequency="${baseFreq.toFixed(4)}" numOctaves="3" seed="${seed2}" result="turbulence"/> 75 - <feDisplacementMap in="SourceGraphic" in2="turbulence" scale="12" xChannelSelector="R" yChannelSelector="G"/> 76 - </filter>`; 77 - 78 - return { filterId, svgDefs }; 79 - }