···11# vod frog
2233-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.
33+vod frog, frog with the vods, i take the frogs on the vod. i vod on the frogs.
44+55+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!!
4657## Running
68···56585759## Design
58605959-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/).
6161+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/).
60626163Each 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.
6264
+21-6
src/lib/VideoCard.svelte
···11+<!--
22+ VideoCard: A single video entry in the grid.
33+44+ Features:
55+ - Wavy green border (outer) with wavy blue border (inner thumbnail)
66+ - Thumbnail loaded from the creator's PDS via livestream record
77+ - Hover scrub preview: creates a hidden <video> element, seeks it on mousemove,
88+ and draws frames to a canvas overlaid on the thumbnail
99+ - Hopping frog sprite follows the scrub position
1010+ - Creator name links to their profile page
1111+ - Card position is jittered (rotation + translate) seeded by the creator DID
1212+-->
113<script lang="ts">
214 import { onMount, onDestroy } from 'svelte';
315 import Hls from 'hls.js';
···2335 const offsets = getCardOffsets(video.value.creator);
2436 const cardStyle = `transform: rotate(${offsets.rotation}deg) translate(${offsets.translateX}px, ${offsets.translateY}px);`;
25372626- // Scrub state
3838+ // Scrub preview state — an offscreen video element is created on hover
3939+ // and seeked as the user moves their mouse across the thumbnail
2740 let thumbEl: HTMLDivElement | undefined = $state();
2841 let canvasEl: HTMLCanvasElement | undefined = $state();
2942 let scrubbing = $state(false);
···3245 let scrubLoading = $state(false);
3346 let hasFrame = $state(false);
34473535- let scrubVideo: HTMLVideoElement | null = null;
3636- let hls: Hls | null = null;
3737- let videoDuration = 0;
3838- let seeking = false;
3939- let pendingSeek: number | null = null;
4848+ let scrubVideo: HTMLVideoElement | null = null; // Hidden video element for scrub preview
4949+ let hls: Hls | null = null; // hls.js instance for the scrub video
5050+ let videoDuration = 0; // Total duration in seconds
5151+ let seeking = false; // True while a seek is in progress
5252+ let pendingSeek: number | null = null; // Queued seek time if we're already seeking
40534154 onMount(() => {
4255 resolveHandle(video.value.creator).then((h) => (creatorHandle = h));
···45584659 onDestroy(destroyScrub);
47606161+ /** Create a hidden video element and attach hls.js to enable scrub preview */
4862 function initScrub() {
4963 if (scrubVideo) return;
5064 scrubLoading = true;
···98112 if (seeking) pendingSeek = targetTime; else doSeek(targetTime);
99113 }
100114115115+ /** Draw the current scrub video frame onto the visible canvas */
101116 function drawFrame() {
102117 if (!scrubVideo || !canvasEl) return;
103118 const vw = scrubVideo.videoWidth, vh = scrubVideo.videoHeight;
+10-4
src/lib/VideoPlayer.svelte
···33 import Hls from "hls.js";
44 import WavyBorder from "./WavyBorder.svelte";
5566+ // HLS video source URL (m3u8 playlist)
67 let { src }: { src: string } = $props();
7889 let videoEl: HTMLVideoElement | undefined = $state();
···1415 let currentTime = $state(0);
1516 let duration = $state(0);
16171717- // Frog scrub state
1818+ // Frog scrub bar — the frog's position along the bar represents playback progress.
1919+ // Users can click the bar or grab the frog to seek.
1820 let scrubBarEl: HTMLDivElement | undefined = $state();
1921 let isScrubbing = $state(false);
2022 let scrubProgress = $state(0);
···9395 );
9496 });
95979696- let lastHopProgress = 0;
9898+ let lastHopProgress = 0; // Track progress to trigger frog hops at intervals
9799100100+ /** Sync scrub position with playback, and animate the frog hopping */
98101 function onTimeUpdate() {
99102 if (!videoEl || isScrubbing) return;
100103 currentTime = videoEl.currentTime;
···139142 isFullscreen = !!document.fullscreenElement;
140143 }
141144142142- // Scrub bar — clicking the track or dragging the frog
145145+ /** Calculate seek position from a mouse event on the scrub bar */
143146 function scrubFromEvent(e: MouseEvent) {
144147 if (!scrubBarEl) return;
145148 const rect = scrubBarEl.getBoundingClientRect();
···165168 }
166169 }
167170168168- let wasPlayingBeforeScrub = false;
171171+ let wasPlayingBeforeScrub = false; // Resume playback after scrub if it was playing
169172173173+ /** Start scrubbing — pause video to prevent buffering issues (especially Firefox) */
170174 function onScrubDown(e: MouseEvent) {
171175 e.preventDefault();
172176 isScrubbing = true;
···177181 window.addEventListener("mouseup", onScrubUp);
178182 }
179183184184+ /** Start scrubbing by grabbing the frog sprite directly */
180185 function onFrogDown(e: MouseEvent) {
181186 e.preventDefault();
182187 e.stopPropagation();
···213218 return () => window.removeEventListener('keydown', onKeyDown);
214219 });
215220221221+ /** Show controls on mouse activity, auto-hide after 2.5s of inactivity during playback */
216222 function onMouseActivity() {
217223 showControls = true;
218224 if (hideTimeout) clearTimeout(hideTimeout);
+17-3
src/lib/WavyBorder.svelte
···11+<!--
22+ WavyBorder: A procedurally generated wobbly rectangular border.
33+44+ The shape is built from layered sine waves along each edge, seeded by a string
55+ so every instance is unique but deterministic. The content is clipped to the
66+ wavy shape using a CSS polygon, while the visible outline is rendered as an
77+ SVG path on top. Both are derived from the same control points to stay aligned.
88+-->
19<script lang="ts">
210 import { seededRandom } from './theme';
311···1725 // Convert the points to a CSS polygon for clipping
1826 const clipPolygon = `polygon(${pts.map(([x, y]) => `${x.toFixed(2)}% ${y.toFixed(2)}%`).join(', ')})`;
19272828+ /**
2929+ * Generate the wavy shape for this border.
3030+ * Returns both the SVG bezier path (for the stroke) and densely sampled
3131+ * polygon points (for the CSS clip-path). All coordinates are in 0-100 space.
3232+ */
2033 function generateWavyShape(s: string): { pts: [number, number][]; svgPath: string } {
2121- const margin = 1;
2222- const amp = 2.5;
2323- const segs = 16;
3434+ const margin = 1; // % inset from the element edges
3535+ const amp = 2.5; // % max wobble amplitude
3636+ const segs = 16; // control points per edge
24372538 // Edges 0=top, 1=right, 2=bottom, 3=left
2639 // Vertical edges (1,3) get lower frequency since they're often shorter in wide boxes
···3649 };
3750 });
38515252+ /** Compute the perpendicular wobble offset at position t along an edge */
3953 function wobble(edgeIdx: number, t: number): number {
4054 const p = edgeParams[edgeIdx];
4155 const w1 = Math.sin(t * p.freq1 * Math.PI * 2 + p.phase1) * amp * 0.7;
+4
src/lib/WavyCircle.svelte
···11+<!--
22+ WavyCircle: A procedurally generated wobbly circular border for avatars.
33+ Uses radial sine waves to create an organic blob shape, seeded by a string.
44+-->
15<script lang="ts">
26 import { seededRandom } from './theme';
37
+19
src/lib/api.ts
···11+/**
22+ * AT Protocol API client for fetching videos, resolving handles/profiles,
33+ * and looking up thumbnails across the network.
44+ */
55+66+/** The AT Protocol lexicon collection for stream.place videos */
17export const COLLECTION = 'place.stream.video';
88+/** HLS playlist endpoint — takes an at:// URI and returns an m3u8 manifest */
29export const PLAYBACK_BASE =
310 'https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist';
1111+1212+/** UFOs API — indexes all AT Protocol records across the network */
413export const UFOS_API = 'https://ufos-api.microcosm.blue';
5141515+/** A video record in our normalized format, with an at:// URI */
616export interface VideoRecord {
717 uri: string;
818 cid: string;
···2535 };
2636}
27373838+/** Raw record shape from the UFOs API */
2839interface UfosRecord {
2940 did: string;
3041 collection: string;
···4960 cursor?: string;
5061}
51626363+/** In-memory cache — the UFOs API returns all records at once, so we fetch once and paginate client-side */
5264let allVideosCache: VideoRecord[] | null = null;
53656666+/** Fetch all place.stream.video records from across the AT Protocol network */
5467export async function fetchAllVideos(): Promise<VideoRecord[]> {
5568 if (allVideosCache) return allVideosCache;
5669···7184 return allVideosCache;
7285}
73868787+/** Return a page of videos from the cached full list */
7488export async function listVideos(page = 0, pageSize = 9): Promise<{ records: VideoRecord[]; hasMore: boolean }> {
7589 const all = await fetchAllVideos();
7690 const start = page * pageSize;
···7892 return { records, hasMore: start + pageSize < all.length };
7993}
80949595+/** Build an HLS playlist URL from an at:// video URI */
8196export function getPlaylistUrl(uri: string): string {
8297 return `${PLAYBACK_BASE}?uri=${encodeURIComponent(uri)}`;
8398}
8499100100+/** Format nanosecond duration to human-readable "H:MM:SS" or "M:SS" */
85101export function formatDuration(nanos: number): string {
86102 const totalSeconds = Math.floor(nanos / 1_000_000_000);
87103 const hours = Math.floor(totalSeconds / 3600);
···120136 createdAt?: string;
121137}
122138139139+/** Fetch a Bluesky profile by handle or DID via the public API */
123140export async function getProfile(actor: string): Promise<BskyProfile> {
124141 const params = new URLSearchParams({ actor });
125142 const res = await fetch(`${BSKY_PUBLIC_API}/xrpc/app.bsky.actor.getProfile?${params}`);
···131148132149const handleCache = new Map<string, string>();
133150151151+/** Resolve a DID to a human-readable @handle via the PLC directory */
134152export async function resolveHandle(did: string): Promise<string> {
135153 if (handleCache.has(did)) return handleCache.get(did)!;
136154 try {
···150168151169const pdsCache = new Map<string, string>();
152170171171+/** Resolve a DID to its PDS (Personal Data Server) endpoint URL */
153172export async function resolvePds(did: string): Promise<string | null> {
154173 if (pdsCache.has(did)) return pdsCache.get(did)!;
155174 try {
+19-44
src/lib/theme.ts
···11-// VodFrog color scheme and theme constants
11+/**
22+ * VodFrog theme: color scheme, seeded randomness, and card layout utilities.
33+ */
2455+/** Brand colors from the design spec */
36export const colors = {
44- green: '#39FF44',
55- blue: '#3992FF',
66- orange: '#FFA639',
77- pink: '#FF3992',
88- lightPink: '#FFDEED',
99- darkBlue: '#0A182B',
77+ green: '#39FF44', // Main vibrant green
88+ blue: '#3992FF', // Water blue
99+ orange: '#FFA639', // Orange accent
1010+ pink: '#FF3992', // Pink accent
1111+ lightPink: '#FFDEED', // Light pink for dark regions
1212+ darkBlue: '#0A182B', // Near-black blue for text/outlines
10131111- // Derived
1414+ // Derived shades
1215 greenDark: '#1A8C22',
1316 greenMuted: '#2BBF33',
1417 blueDark: '#1E4E8C',
···1619} as const;
17201821/**
1919- * Generate a deterministic pseudo-random number from a string seed.
2020- * Uses a simple hash function to produce values in [0, 1).
2222+ * Deterministic pseudo-random number from a string seed.
2323+ * Same seed + index always produces the same value in [0, 1).
2424+ * Used to give each card/border a unique but consistent appearance.
2125 */
2226export function seededRandom(seed: string, index = 0): number {
2327 let hash = 0;
···2630 const char = str.charCodeAt(i);
2731 hash = ((hash << 5) - hash + char) | 0;
2832 }
2929- // Normalize to [0, 1)
3033 return (((hash % 65536) + 65536) % 65536) / 65536;
3134}
32353336/**
3434- * Generate organic offsets for a card based on its URI/DID.
3535- * Returns rotation (deg), translateX (px), translateY (px).
3737+ * Generate organic layout offsets for a video card, seeded by the creator's DID.
3838+ * Creates a hand-placed, off-grid feeling in the card grid.
3639 */
3740export function getCardOffsets(seed: string): {
3841 rotation: number;
···4447 const r3 = seededRandom(seed, 2);
45484649 return {
4747- rotation: (r1 - 0.5) * 10, // -5 to +5 degrees
4848- translateX: (r2 - 0.5) * 60, // -30 to +30 px
4949- translateY: (r3 - 0.5) * 60, // -30 to +30 px
5050+ rotation: (r1 - 0.5) * 10, // ±5 degrees
5151+ translateX: (r2 - 0.5) * 60, // ±30px horizontal jitter
5252+ translateY: (r3 - 0.5) * 60, // ±30px vertical jitter
5053 };
5154}
5252-5353-/**
5454- * Generate an SVG filter for wavy/turbulent borders seeded by a string.
5555- * Returns the filter ID and the full SVG filter definition.
5656- */
5757-export function generateWavyFilterId(seed: string): string {
5858- // Use the seed to create a unique filter ID
5959- let hash = 0;
6060- for (let i = 0; i < seed.length; i++) {
6161- hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
6262- }
6363- return `wavy-${((hash >>> 0) % 999999).toString(36)}`;
6464-}
6565-6666-export function generateWavyFilterSvg(seed: string): { filterId: string; svgDefs: string } {
6767- const filterId = generateWavyFilterId(seed);
6868- const r1 = seededRandom(seed, 10);
6969- const r2 = seededRandom(seed, 11);
7070- const baseFreq = 0.015 + r1 * 0.015; // 0.015 - 0.03
7171- const seed2 = Math.floor(r2 * 100);
7272-7373- const svgDefs = `<filter id="${filterId}" x="-5%" y="-5%" width="110%" height="110%">
7474- <feTurbulence type="turbulence" baseFrequency="${baseFreq.toFixed(4)}" numOctaves="3" seed="${seed2}" result="turbulence"/>
7575- <feDisplacementMap in="SourceGraphic" in2="turbulence" scale="12" xChannelSelector="R" yChannelSelector="G"/>
7676- </filter>`;
7777-7878- return { filterId, svgDefs };
7979-}