442 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|