A single-page 3D experience for the @MusicTrafficker YouTube channel, built on Wix Headless (Astro + React + three.js).
A giant mirror ball spins in a dark club. Its windows are live thumbnails of the channel's videos — click one and it flies out of the ball into a fullscreen player that starts the video from the beginning, with sound. Close it and the tile flies back into the ball.
Live site: https://music-traf-ab6ea66c-vytenisu.wix-site-host.com
- Astro 5 +
@wix/astro(Wix-managed hosting, server routes) - React 18 island with three.js via
@react-three/fiber+@react-three/drei - Bloom/vignette via
@react-three/postprocessing - YouTube IFrame embed for playback
src/pages/api/videos.ts is an Astro server route (GET /api/videos). No YouTube API key is used:
- Primary source — it fetches
https://www.youtube.com/@MusicTrafficker/videosand parses theytInitialDataJSON embedded in the page. The/videostab lists full videos only (Shorts live in a separate tab), includes durations, and returns ~30 items. - Fallback — if parsing fails (YouTube markup change, consent wall), it falls back to the channel RSS feed (
youtube.com/feeds/videos.xml?channel_id=…, latest ~15 items). Because RSS mixes in Shorts, each item is probed with aHEADrequest to/shorts/<id>— a200there means it's a Short and it gets dropped. - Anything shorter than 90 seconds is also filtered out, results are cached in memory for 1 hour, and the response carries
Cache-Control: s-maxage=3600.
Tiles first load i.ytimg.com/vi/<id>/mqdefault.jpg thumbnails, then upgrade to animated previews; the fullscreen flyer upgrades to maxresdefault.jpg when available.
Every window on the ball plays its video, muted, at low resolution. Two sources, best available wins:
- Real motion (
an_webphover previews) — ~3 s of consecutive real frames at ~10 fps, the clips YouTube shows on thumbnail hover. Available for most (not all) videos; URLs come from the channel page and are proxied viaGET /api/motion-preview?v=<id>. The client decodes the animated WebP with theImageDecoderAPI (Chromium) into a 160×90-cell atlas and plays it at its natural framerate. - Storyboard crossfade — fallback for videos without a hover preview and for browsers without
ImageDecoder. Storyboard frames are ~2 s apart in the source, so instead of hard cuts (which read as sped-up or laggy) each frame holds ~1.3–2.2 s and dissolves into the next via a small crossfade shader.
Storyboard sprite sheets are the seek-preview images every video has: one 800×450 sheet is a 5×5 grid of 160×90 frames sampled across the whole video — a ready-made flipbook atlas.
- The sheet URL (signed) comes from the watch page's
playerStoryboardSpecRenderer.spec. Watch pages are bot-walled from datacenter IPs, so specs are resolved at build time from a developer machine intosrc/server/storyboard-snapshot.json(node scripts/resolve-storyboards.mjs); the runtime falls back to live resolution only for ids missing from the snapshot (e.g. videos published after the last deploy — those show static thumbnails until the next snapshot refresh). GET /api/storyboard-image?v=<id>proxies the sheet bytes (i.ytimg.com sends no CORS headers, so the browser can't texture from it directly) with a long CDN cache.- The client loads each sheet as one texture and animates it by shifting UV offsets over the grid (~8–12 fps, staggered per video) — zero per-frame uploads or decoding.
A button under the ball opens a translucent popup with a single discussion thread, newest messages first. It's backed by real Wix modules:
- Wix Blog hosts one seeded post ("What do you think of AI music?"); Wix Comments stores the messages, keyed to that post (on fresh Blog V3 sites comments key on the post id —
referenceIdonly exists on legacy-migrated posts). GET /api/thread— public read (comments.listCommentsByResource,NEWEST_FIRST) + current-member info;POST /api/thread— member-gated (members.getCurrentMember→comments.createCommentwith a Ricos body).- Login/logout use the built-in
@wix/astroroutes (/api/auth/login,/api/auth/logout) — the Wix-hosted login page registers new members too. A chip in the top-right shows the state. - Wix Members Area app is installed so commenter names/avatars resolve.
The server re-scrapes the channel every 15 minutes; the client re-polls /api/videos (every 10 s until all storyboards are attached, then every 5 minutes) and hot-swaps new videos into the ball without remounting the scene — a swap is deferred while a video is playing.
src/components/disco/DiscoScene.tsx:
- ~1,100 instanced mirror facets + ~82 "video window" planes distributed over latitude bands of the sphere (each of the ~30 videos appears 2–3 times).
- Orbiting colored spotlights, an emissive-lightformer environment map for reflections, particle sparkles, bloom + vignette.
- All animation is time-based (
delta-driven), so it is framerate-independent. - Click → the tile's world transform is captured and a 16:9 "flyer" plane interpolates position/rotation/scale to a rect that exactly matches the letterboxed frame the fullscreen player will use, while the thumbnail un-crops from window aspect to full 16:9. When it arrives, the DOM overlay with the YouTube iframe fades in (
autoplay=1&start=0, sound allowed because it follows a user gesture). Closing reverses the flight. - Drag to orbit the ball manually (OrbitControls, rotation only).
npm install --ignore-scripts # sharp's native build is dev-only dead weight; skip it
npm run dev # wix dev → http://localhost:4321Prefix commands with AI_AGENT=claude (or run interactively) when using the Wix CLI from an agent.
npm run build # wix build
npx wix release # publishes to Wix hosting, prints the live URLThe site and hosting are managed by Wix; wix.config.json carries the site/app ids. .env.local (gitignored) holds the CLI-managed OAuth secret — never commit it.