/** Full-viewport gift renderer. */ import { useEffect, useRef, useState } from 'react' import type { CSSProperties } from 'react' import { normalizeGiftEffectSettings } from './api' import { useEffectQueue } from './effectQueue' import { getGiftEffectTheme, giftThemeVariables } from './giftThemes' import { translate, useI18n } from './i18n' import type { ComponentStream } from './stream' import { defaultGiftEffectSettings } from './types' import type { GiftEffectSettings, MeteorTierSettings } from './types' import { typographyVariables } from './typography' type Viewer = { uid?: string; name?: string } type Gift = { name?: string totalPrice?: number priceCny?: number imageUrl?: string animationUrl?: string } type EffectPayload = { viewer?: Viewer gift?: Gift quantity?: number settings?: Partial } type EffectEnvelope = { id: string; type: string; payload?: EffectPayload } type VisualEffect = { id: string payload: EffectPayload /** Preview cards force a tier so custom thresholds do not change the selected demo. */ previewTier?: GiftTier } type GiftTier = 'normal' | 'high' | 'featured' export type GiftEffectPreviewMode = GiftTier /** The moonlit theme deliberately reserves a full-scene ceremony for CNY 50+. */ const MOONLIT_CEREMONY_THRESHOLD = 50_000 function hash(value: string): number { let result = 2166136261 for (let index = 0; index < value.length; index += 1) { result ^= value.charCodeAt(index) result = Math.imul(result, 16777619) } return result >>> 0 } function tierFor(effect: VisualEffect, settings: GiftEffectSettings): GiftTier { if (effect.previewTier) return effect.previewTier const value = effect.payload.gift?.totalPrice ?? 0 if (value >= settings.featuredValueThreshold) return 'featured' if (value >= settings.highValueThreshold) return 'high' return 'normal' } function previewEnvelope(mode: GiftEffectPreviewMode, nonce: number): EffectEnvelope { const viewer = { uid: '10001', name: translate('gift.preview.viewer') } const totalPrice = mode === 'featured' ? 300_000 : mode === 'high' ? 30_000 : 1_000 return { id: `preview-${mode}-${nonce}`, type: 'live.gift', payload: { viewer, quantity: 1, gift: { name: translate(`gift.preview.${mode}`), totalPrice, priceCny: totalPrice / 1000, imageUrl: '/pwa/icon-192.png', }, }, } } function toVisualEffect( envelope: EffectEnvelope, previewTier?: GiftTier, ): VisualEffect | undefined { if (envelope.type !== 'live.gift') return undefined return { id: envelope.id, payload: envelope.payload ?? {}, previewTier, } } function useGiftEffects( preview: boolean, stream: ComponentStream | undefined, previewSettings: GiftEffectSettings | undefined, previewMode: GiftEffectPreviewMode, previewNonce: number, language: string, ) { const [settings, setSettings] = useState(defaultGiftEffectSettings) const queue = useEffectQueue() const settingsRef = useRef(settings) const lastSequenceRef = useRef(0) useEffect(() => { settingsRef.current = settings }, [settings]) useEffect(() => { if (preview || !stream) return const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current) for (const message of pending) { lastSequenceRef.current = message.sequence const envelope = message.envelope as EffectEnvelope if ( envelope.type === 'component.settings.snapshot' || envelope.type === 'component.settings.updated' ) { const next = normalizeGiftEffectSettings(envelope.payload?.settings) settingsRef.current = next setSettings(next) continue } const effect = toVisualEffect(envelope) if (!effect) continue queue.enqueue(effect, settingsRef.current.queueCapacity) } }, [preview, stream, stream?.messages]) useEffect(() => { if (!preview) return const effect = toVisualEffect(previewEnvelope(previewMode, previewNonce), previewMode) if (effect) queue.enqueue(effect, (previewSettings ?? settingsRef.current).queueCapacity) }, [language, preview, previewMode, previewNonce]) return { settings, effect: queue.active, pendingCount: queue.pendingCount, completeEffect: queue.complete, } } function GiftImage({ gift }: { gift: Gift }) { const [source, setSource] = useState(gift.animationUrl || gift.imageUrl || '') useEffect( () => setSource(gift.animationUrl || gift.imageUrl || ''), [gift.animationUrl, gift.imageUrl], ) if (!source) return ✦ return ( {gift.name { if (gift.imageUrl && source !== gift.imageUrl) setSource(gift.imageUrl) else setSource('') }} /> ) } function MeteorBurst({ effect, tier, tierSettings, scale, viewportWidth, trailIntensity, lowPerformance, onComplete, }: { effect: VisualEffect tier: GiftTier tierSettings: MeteorTierSettings scale: number viewportWidth: number trailIntensity: number lowPerformance: boolean onComplete: () => void }) { const gift = effect.payload.gift ?? {} const count = lowPerformance ? Math.min(4, tierSettings.count) : tierSettings.count const size = Math.max(20, tierSettings.size * scale) const speed = Math.max(80, tierSettings.speed * scale) const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false const meteors = Array.from({ length: count }, (_, index) => { const seed = hash(`${effect.id}:${index}`) const duration = Math.max(1_600, ((viewportWidth + size * 7) / speed) * 1_000) return { index, startY: 7 + (seed % 78), drift: ((seed >>> 8) % 37) - 18, delay: index * 95 + ((seed >>> 16) % 260), duration, lifetime: (reducedMotion ? Math.max(duration, 6_000) : duration) + index * 95 + ((seed >>> 16) % 260), } }) const lifetime = Math.max(...meteors.map(meteor => meteor.lifetime), 1_600) return (
{ if (event.target === event.currentTarget && event.animationName === 'gift-burst-lifetime') { onComplete() } }} > {meteors.map(({ index, startY, drift, delay, duration }) => { return (
) })}
) } /** * The small-gift treatment for the sparse moonlit theme. It is intentionally * compact and stays out of the centre of a broadcaster's scene: a gift image, * name and a single water-line appear briefly without adding a panel behind it. */ function MoonlitGiftWhisper({ effect, onComplete, }: { effect: VisualEffect onComplete: () => void }) { const gift = effect.payload.gift ?? {} const viewer = effect.payload.viewer?.name || translate('common.viewer') const quantity = effect.payload.quantity || 1 const price = gift.priceCny ?? (gift.totalPrice ?? 0) / 1_000 const seed = hash(effect.id) return (
>> 8) % 10)}%`, ['--moonlit-whisper-delay' as string]: `${(seed >>> 16) % 260}ms`, } as CSSProperties } onAnimationEnd={event => { if ( event.target === event.currentTarget && event.animationName === 'moonlit-whisper-appear' ) onComplete() }} >