From 10e924b4ab87290267e6c17987a30605b10b1133 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 19 Feb 2026 02:23:42 -0800 Subject: [PATCH] gate emote entrance animations on image load and optimize fetching Emotes now animate (fade + deblur) only after each image loads from cdn.7tv.app, with stagger based on load order. Also reduces network requests by lazily assigning src to only the active particle count, adds preconnect for the CDN, and marks emotes with decoding=async and fetchpriority=low. Co-Authored-By: Claude Opus 4.6 --- web/src/app/layout.tsx | 7 +++ web/src/components/EmotePhysicsBackground.tsx | 43 ++++++++++++++----- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 63670222..bb5b3a14 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -42,6 +42,13 @@ export default function RootLayout({ }>) { return ( + + + ('[data-emote]') if (elements.length === 0) return - // Assign shuffled URLs client-side to avoid hydration mismatch + // Shuffled once so each element keeps a stable URL across resizes const urls = shuffle(EMOTE_URLS) - elements.forEach((el, i) => { - if (i < urls.length) el.src = urls[i]! - }) let cancelled = false let rafId = 0 @@ -263,6 +260,20 @@ export function EmotePhysicsBackground() { let currentPointer: { x: number; y: number } | null = null const particles: ParticleState[] = [] const animations: AnimationPlaybackControls[] = [] + let loadedCount = 0 + const loadCleanups: (() => void)[] = [] + + function revealEmote(el: HTMLImageElement) { + const delay = loadedCount * 0.06 + loadedCount++ + animations.push( + animate( + el, + { opacity: 1, filter: ['blur(4px)', 'blur(0px)'] }, + { duration: 0.5, delay, ease: 'easeOut' }, + ), + ) + } // ── Sync visible particle count to container size ── function syncCount(newW: number, newH: number, initial: boolean) { @@ -289,14 +300,21 @@ export function EmotePhysicsBackground() { } el.style.display = '' el.style.height = `${newSize}px` + // Lazy src assignment — only fetch images that are actually active + if (!el.src && i < urls.length) el.src = urls[i]! if (initial) { - animations.push( - animate( - el, - { opacity: 1, filter: ['blur(4px)', 'blur(0px)'] }, - { duration: 0.5, delay: i * 0.06, ease: 'easeOut' }, - ), - ) + if (el.complete && el.naturalWidth > 0) { + revealEmote(el) + } else { + const onLoad = () => { + if (!cancelled) revealEmote(el) + } + // eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener -- cleaned up via loadCleanups array + el.addEventListener('load', onLoad, { once: true }) + loadCleanups.push(() => { + el.removeEventListener('load', onLoad) + }) + } } else { el.style.opacity = '1' el.style.filter = '' @@ -524,6 +542,7 @@ export function EmotePhysicsBackground() { cancelled = true cancelAnimationFrame(rafId) for (const a of animations) a.stop() + for (const cleanup of loadCleanups) cleanup() ro.disconnect() panel.removeEventListener('mousemove', onMouseMove) panel.removeEventListener('mouseleave', clearPointer) @@ -540,6 +559,8 @@ export function EmotePhysicsBackground() { key={i} data-emote alt='' + decoding='async' + fetchPriority='low' draggable={false} crossOrigin='anonymous' className='pointer-events-none absolute top-0 left-0 select-none'