更新点歌和弹幕微调
This commit is contained in:
+108
-142
@@ -1,7 +1,8 @@
|
||||
/** Full-viewport gift meteor and guard celebration renderer. */
|
||||
/** 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'
|
||||
@@ -21,22 +22,17 @@ type EffectPayload = {
|
||||
viewer?: Viewer
|
||||
gift?: Gift
|
||||
quantity?: number
|
||||
guardName?: string
|
||||
price?: number
|
||||
settings?: Partial<GiftEffectSettings>
|
||||
}
|
||||
type EffectEnvelope = { id: string; type: string; payload?: EffectPayload }
|
||||
type VisualEffect = {
|
||||
id: string
|
||||
kind: 'gift' | 'guard'
|
||||
payload: EffectPayload
|
||||
receivedAt: number
|
||||
expiresAt: number
|
||||
/** 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 | 'guard'
|
||||
export type GiftEffectPreviewMode = GiftTier
|
||||
|
||||
/** The moonlit theme deliberately reserves a full-scene ceremony for CNY 50+. */
|
||||
const MOONLIT_CEREMONY_THRESHOLD = 50_000
|
||||
@@ -59,14 +55,7 @@ function tierFor(effect: VisualEffect, settings: GiftEffectSettings): GiftTier {
|
||||
}
|
||||
|
||||
function previewEnvelope(mode: GiftEffectPreviewMode, nonce: number): EffectEnvelope {
|
||||
const viewer = { uid: 'preview', name: translate('gift.preview.viewer') }
|
||||
if (mode === 'guard') {
|
||||
return {
|
||||
id: `preview-guard-${nonce}`,
|
||||
type: 'live.guard.buy',
|
||||
payload: { viewer, guardName: translate('gift.preview.guard'), quantity: 1, price: 198_000 },
|
||||
}
|
||||
}
|
||||
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}`,
|
||||
@@ -86,23 +75,12 @@ function previewEnvelope(mode: GiftEffectPreviewMode, nonce: number): EffectEnve
|
||||
|
||||
function toVisualEffect(
|
||||
envelope: EffectEnvelope,
|
||||
guardDuration: number,
|
||||
previewTier?: GiftTier,
|
||||
): VisualEffect | undefined {
|
||||
const kind =
|
||||
envelope.type === 'live.gift'
|
||||
? 'gift'
|
||||
: envelope.type === 'live.guard.buy'
|
||||
? 'guard'
|
||||
: undefined
|
||||
if (!kind) return undefined
|
||||
const now = Date.now()
|
||||
if (envelope.type !== 'live.gift') return undefined
|
||||
return {
|
||||
id: envelope.id,
|
||||
kind,
|
||||
payload: envelope.payload ?? {},
|
||||
receivedAt: now,
|
||||
expiresAt: now + (kind === 'guard' ? guardDuration + 1_500 : 45_000),
|
||||
previewTier,
|
||||
}
|
||||
}
|
||||
@@ -116,7 +94,7 @@ function useGiftEffects(
|
||||
language: string,
|
||||
) {
|
||||
const [settings, setSettings] = useState(defaultGiftEffectSettings)
|
||||
const [effects, setEffects] = useState<VisualEffect[]>([])
|
||||
const queue = useEffectQueue<VisualEffect>()
|
||||
const settingsRef = useRef(settings)
|
||||
const lastSequenceRef = useRef(0)
|
||||
|
||||
@@ -139,36 +117,24 @@ function useGiftEffects(
|
||||
setSettings(next)
|
||||
continue
|
||||
}
|
||||
const effect = toVisualEffect(envelope, settingsRef.current.guardEffectDurationMs)
|
||||
const effect = toVisualEffect(envelope)
|
||||
if (!effect) continue
|
||||
setEffects(current =>
|
||||
[effect, ...current.filter(item => item.id !== effect.id)].slice(
|
||||
0,
|
||||
settingsRef.current.maxConcurrentEffects,
|
||||
),
|
||||
)
|
||||
queue.enqueue(effect, settingsRef.current.queueCapacity)
|
||||
}
|
||||
}, [preview, stream, stream?.messages])
|
||||
|
||||
useEffect(() => {
|
||||
if (!preview) return
|
||||
const effect = toVisualEffect(
|
||||
previewEnvelope(previewMode, previewNonce),
|
||||
previewSettings?.guardEffectDurationMs ?? settingsRef.current.guardEffectDurationMs,
|
||||
previewMode === 'guard' ? undefined : previewMode,
|
||||
)
|
||||
if (effect) setEffects(current => [effect, ...current].slice(0, 4))
|
||||
}, [language, preview, previewMode, previewNonce, previewSettings?.guardEffectDurationMs])
|
||||
const effect = toVisualEffect(previewEnvelope(previewMode, previewNonce), previewMode)
|
||||
if (effect) queue.enqueue(effect, (previewSettings ?? settingsRef.current).queueCapacity)
|
||||
}, [language, preview, previewMode, previewNonce])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const now = Date.now()
|
||||
setEffects(current => current.filter(effect => effect.expiresAt > now))
|
||||
}, 1_000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
return { settings, effects }
|
||||
return {
|
||||
settings,
|
||||
effect: queue.active,
|
||||
pendingCount: queue.pendingCount,
|
||||
completeEffect: queue.complete,
|
||||
}
|
||||
}
|
||||
|
||||
function GiftImage({ gift }: { gift: Gift }) {
|
||||
@@ -200,6 +166,7 @@ function MeteorBurst({
|
||||
viewportWidth,
|
||||
trailIntensity,
|
||||
lowPerformance,
|
||||
onComplete,
|
||||
}: {
|
||||
effect: VisualEffect
|
||||
tier: GiftTier
|
||||
@@ -208,19 +175,39 @@ function MeteorBurst({
|
||||
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')}>
|
||||
{Array.from({ length: count }, (_, index) => {
|
||||
const seed = hash(`${effect.id}:${index}`)
|
||||
const startY = 7 + (seed % 78)
|
||||
const drift = ((seed >>> 8) % 37) - 18
|
||||
const delay = index * 95 + ((seed >>> 16) % 260)
|
||||
const duration = Math.max(1_600, ((viewportWidth + size * 7) / speed) * 1_000)
|
||||
<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"
|
||||
@@ -254,7 +241,13 @@ function MeteorBurst({
|
||||
* 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 }: { effect: VisualEffect }) {
|
||||
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
|
||||
@@ -271,6 +264,13 @@ function MoonlitGiftWhisper({ effect }: { effect: VisualEffect }) {
|
||||
['--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">
|
||||
@@ -292,13 +292,31 @@ function MoonlitGiftWhisper({ effect }: { effect: VisualEffect }) {
|
||||
* 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 }: { effect: VisualEffect; tier: GiftTier }) {
|
||||
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">
|
||||
<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 />
|
||||
@@ -338,59 +356,6 @@ function MoonlitGiftCeremony({ effect, tier }: { effect: VisualEffect; tier: Gif
|
||||
)
|
||||
}
|
||||
|
||||
function GuardCelebration({
|
||||
effect,
|
||||
settings,
|
||||
}: {
|
||||
effect: VisualEffect
|
||||
settings: GiftEffectSettings
|
||||
}) {
|
||||
const count = settings.lowPerformanceMode
|
||||
? Math.min(24, settings.guardStarCount)
|
||||
: settings.guardStarCount
|
||||
const viewer = effect.payload.viewer?.name || translate('common.viewer')
|
||||
const guard = effect.payload.guardName || translate('common.guard')
|
||||
return (
|
||||
<section
|
||||
className="guard-celebration"
|
||||
aria-label={translate('gift.guard_aria')}
|
||||
style={
|
||||
{ ['--guard-duration' as string]: `${settings.guardEffectDurationMs}ms` } as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="guard-nebula" />
|
||||
<div className="guard-stars" aria-hidden="true">
|
||||
{Array.from({ length: count }, (_, index) => {
|
||||
const seed = hash(`${effect.id}:guard:${index}`)
|
||||
return (
|
||||
<i
|
||||
key={index}
|
||||
style={
|
||||
{
|
||||
left: `${seed % 100}%`,
|
||||
top: `${(seed >>> 8) % 100}%`,
|
||||
['--star-delay' as string]: `${-((seed >>> 16) % 2_800)}ms`,
|
||||
['--star-size' as string]: `${4 + ((seed >>> 24) % 13)}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="guard-halo" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<div className="guard-copy">
|
||||
<span>{translate('gift.guard_salute')}</span>
|
||||
<strong>{translate('gift.guard_title', { guard })}</strong>
|
||||
<b>{translate('gift.guard_viewer', { viewer })}</b>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function GiftEffectOverlay({
|
||||
preview = false,
|
||||
previewSettings,
|
||||
@@ -428,48 +393,49 @@ export function GiftEffectOverlay({
|
||||
}, [])
|
||||
|
||||
const scale = Math.min(1.5, Math.max(0.35, Math.min(bounds.width / 1920, bounds.height / 1080)))
|
||||
const guard = remote.effects.find(effect => effect.kind === 'guard')
|
||||
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">
|
||||
{remote.effects
|
||||
.filter(effect => effect.kind === 'gift')
|
||||
.map(effect => {
|
||||
const tier = tierFor(effect, settings)
|
||||
const gift = effect.payload.gift
|
||||
const totalPrice = gift?.totalPrice ?? Math.round((gift?.priceCny ?? 0) * 1_000)
|
||||
if (moonlit) {
|
||||
return totalPrice >= MOONLIT_CEREMONY_THRESHOLD ? (
|
||||
<MoonlitGiftCeremony effect={effect} tier={tier} key={effect.id} />
|
||||
) : (
|
||||
<MoonlitGiftWhisper effect={effect} key={effect.id} />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MeteorBurst
|
||||
effect={effect}
|
||||
tier={tier}
|
||||
tierSettings={settings[tier]}
|
||||
scale={scale}
|
||||
viewportWidth={bounds.width}
|
||||
trailIntensity={settings.trailIntensity}
|
||||
lowPerformance={settings.lowPerformanceMode}
|
||||
key={effect.id}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{activeVisual}
|
||||
</div>
|
||||
{guard && <GuardCelebration effect={guard} settings={settings} />}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user