Files
lxc-streamutils/apps/overlay/src/giftEffect.tsx
T
2026-08-19 12:30:30 -07:00

442 lines
14 KiB
TypeScript

/** 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<GiftEffectSettings>
}
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<VisualEffect>()
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 <span className="meteor-fallback">✦</span>
return (
<img
src={source}
alt={gift.name || translate('common.gift')}
decoding="async"
referrerPolicy="no-referrer"
onError={() => {
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 (
<div
className={`meteor-burst tier-${tier}`}
aria-label={gift.name || translate('common.gift')}
style={{ ['--burst-duration' as string]: `${lifetime}ms` } as CSSProperties}
onAnimationEnd={event => {
if (event.target === event.currentTarget && event.animationName === 'gift-burst-lifetime') {
onComplete()
}
}}
>
{meteors.map(({ index, startY, drift, delay, duration }) => {
return (
<div
className="gift-meteor"
key={`${effect.id}:${index}`}
style={
{
['--meteor-size' as string]: `${size}px`,
['--meteor-y' as string]: `${startY}%`,
['--meteor-drift' as string]: `${drift}vh`,
['--meteor-delay' as string]: `${delay}ms`,
['--meteor-duration' as string]: `${duration}ms`,
['--trail-opacity' as string]: `${trailIntensity / 100}`,
} as CSSProperties
}
>
<i className="meteor-tail" />
<i className="meteor-spark meteor-spark-a" />
<i className="meteor-spark meteor-spark-b" />
<span className="meteor-core">
<GiftImage gift={gift} />
</span>
</div>
)
})}
</div>
)
}
/**
* 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 (
<article
className="moonlit-gift-whisper"
aria-label={gift.name || translate('common.gift')}
style={
{
top: `${8 + (seed % 72)}%`,
right: `${3 + ((seed >>> 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()
}}
>
<i className="moonlit-whisper-ripple" aria-hidden="true" />
<span className="moonlit-whisper-image">
<GiftImage gift={gift} />
</span>
<div className="moonlit-whisper-copy">
<b>{viewer}</b>
<span>
{translate('overlay.gift', { gift: gift.name || translate('common.gift'), quantity })}
</span>
</div>
{price > 0 && <small>¥ {price.toFixed(2)}</small>}
</article>
)
}
/**
* A full-viewport but transparent offering ceremony for CNY 50+ gifts. The
* CSS draws moon, water ripples and falling points of light, keeping the gift
* image itself at the centre of the effect and leaving the OBS scene visible.
*/
function MoonlitGiftCeremony({
effect,
tier,
onComplete,
}: {
effect: VisualEffect
tier: GiftTier
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
return (
<section
className={`moonlit-gift-ceremony tier-${tier}`}
aria-live="polite"
onAnimationEnd={event => {
if (
event.target === event.currentTarget &&
event.animationName === 'moonlit-offering-sweep'
)
onComplete()
}}
>
<div className="moonlit-ceremony-moon" aria-hidden="true" />
<div className="moonlit-ceremony-water" aria-hidden="true">
<i />
<i />
<i />
</div>
<div className="moonlit-ceremony-stars" aria-hidden="true">
{Array.from({ length: 14 }, (_, index) => {
const seed = hash(`${effect.id}:ceremony:${index}`)
return (
<i
key={index}
style={
{
left: `${seed % 100}%`,
top: `${(seed >>> 8) % 100}%`,
['--moonlit-delay' as string]: `${-((seed >>> 16) % 2_400)}ms`,
['--moonlit-size' as string]: `${4 + ((seed >>> 24) % 10)}px`,
} as CSSProperties
}
/>
)
})}
</div>
<div className="moonlit-ceremony-copy">
<span>{translate('gift.moonlit.ceremony_label')}</span>
<b>{viewer}</b>
<strong>
{translate('overlay.gift', { gift: gift.name || translate('common.gift'), quantity })}
</strong>
{price > 0 && <small>¥ {price.toFixed(2)}</small>}
</div>
<div className="moonlit-ceremony-image">
<GiftImage gift={gift} />
</div>
</section>
)
}
export function GiftEffectOverlay({
preview = false,
previewSettings,
previewMode = 'high',
previewNonce = 0,
stream,
}: {
preview?: boolean
previewSettings?: GiftEffectSettings
previewMode?: GiftEffectPreviewMode
previewNonce?: number
stream?: ComponentStream
}) {
const { language } = useI18n()
const remote = useGiftEffects(
preview,
stream,
previewSettings,
previewMode,
previewNonce,
language,
)
const settings = previewSettings ?? remote.settings
const theme = getGiftEffectTheme(settings.themeId)
const root = useRef<HTMLElement>(null)
const [bounds, setBounds] = useState({ width: 1920, height: 1080 })
useEffect(() => {
if (!root.current) return
const observer = new ResizeObserver(([entry]) => {
setBounds({ width: entry.contentRect.width, height: entry.contentRect.height })
})
observer.observe(root.current)
return () => observer.disconnect()
}, [])
const scale = Math.min(1.5, Math.max(0.35, Math.min(bounds.width / 1920, bounds.height / 1080)))
const moonlit = theme.id === 'moonlit-water'
const effect = remote.effect
let activeVisual = null
if (effect) {
const tier = tierFor(effect, settings)
const gift = effect.payload.gift
const totalPrice = gift?.totalPrice ?? Math.round((gift?.priceCny ?? 0) * 1_000)
const onComplete = () => remote.completeEffect(effect.id)
activeVisual = moonlit ? (
totalPrice >= MOONLIT_CEREMONY_THRESHOLD ? (
<MoonlitGiftCeremony effect={effect} tier={tier} onComplete={onComplete} key={effect.id} />
) : (
<MoonlitGiftWhisper effect={effect} onComplete={onComplete} key={effect.id} />
)
) : (
<MeteorBurst
effect={effect}
tier={tier}
tierSettings={settings[tier]}
scale={scale}
viewportWidth={bounds.width}
trailIntensity={settings.trailIntensity}
lowPerformance={settings.lowPerformanceMode}
onComplete={onComplete}
key={effect.id}
/>
)
}
return (
<main
ref={root}
className={`gift-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'gift-low-motion' : ''}`}
data-theme={theme.id}
data-connection={stream?.connection || 'idle'}
data-queue-length={remote.pendingCount}
style={{
...giftThemeVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
}}
>
<div className="meteor-sky" aria-live="polite">
{activeVisual}
</div>
</main>
)
}