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 <noreply@anthropic.com>
This commit is contained in:
parent
3a50e06c63
commit
10e924b4ab
2 changed files with 39 additions and 11 deletions
|
|
@ -42,6 +42,13 @@ export default function RootLayout({
|
|||
}>) {
|
||||
return (
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<head>
|
||||
<link
|
||||
rel='preconnect'
|
||||
href='https://cdn.7tv.app'
|
||||
crossOrigin='anonymous'
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={twJoin(
|
||||
'selection:bg-primary selection:text-primary-foreground min-h-dvh overscroll-none font-sans underline-offset-4 antialiased',
|
||||
|
|
|
|||
|
|
@ -247,11 +247,8 @@ export function EmotePhysicsBackground() {
|
|||
container.querySelectorAll<HTMLImageElement>('[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'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue