Files
lxc-streamutils/apps/overlay/src/main.tsx
T
2026-07-15 22:14:58 -07:00

69 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import './style.css'
import './control.css'
type Settings = { title:string; fontScale:number; showDanmaku:boolean; showEnter:boolean; showGift:boolean; showSuperchat:boolean; showGuard:boolean; showLike:boolean; showShare:boolean; maxVisible:number; collapseAfterSeconds:number; unfoldDurationMs:number; motionIntensity:number; particleCount:number; particleSpeed:number; lowPerformanceMode:boolean; highValueThreshold:number; featuredValueThreshold:number }
type Envelope = { id:string; type:string; payload:any }
type Item = Envelope & { key:string; received:number; decorVariant:number }
type DanmakuSegment = { type:'text'; text:string } | { type:'emoticon'; text:string; unique?:string; url:string; width?:number; height?:number; isDynamic?:boolean; standalone?:boolean }
const defaults: Settings = { title:'洛星瓷专用弹幕猪!', fontScale:140, showDanmaku:true, showEnter:true, showGift:true, showSuperchat:true, showGuard:true, showLike:false, showShare:false, maxVisible:5, collapseAfterSeconds:12, unfoldDurationMs:1000, motionIntensity:70, particleCount:8, particleSpeed:100, lowPerformanceMode:false, highValueThreshold:10000, featuredValueThreshold:100000 }
const cardParticles=['star','floret','star','star','floret','star','floret','star','star','floret','star','floret'] as const
const decorVariantCount=6
function stableHash(value:string) { let hash=2166136261; for(let index=0;index<value.length;index++){hash^=value.charCodeAt(index);hash=Math.imul(hash,16777619)} return hash>>>0 }
function chooseDecorVariant(seed:string,previous?:number) { const hash=stableHash(seed); const base=hash%decorVariantCount; if(previous===undefined||base!==previous)return base; return (base+1+((hash>>>8)%(decorVariantCount-1)))%decorVariantCount }
function CardDecor({count,variant}:{count:number;variant:number}) { const visible=Math.min(cardParticles.length,Math.max(0,Math.round(count||0))); const normalized=((variant%decorVariantCount)+decorVariantCount)%decorVariantCount; return <div className={`card-decor decor-v${normalized}`} aria-hidden="true"><i className="card-decor-surface"/><div className="card-particle-layer">{cardParticles.slice(0,visible).map((kind,index)=><i className={`card-particle ${kind}`} key={`${kind}-${index}`}/>)}</div></div> }
function wsUrl() { const p = new URLSearchParams(location.search); const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${location.host}/ws?token=${encodeURIComponent(p.get('token') || '')}` }
function enabled(type:string, s:Settings) { return (type==='live.danmaku'&&s.showDanmaku)||(type==='live.enter'&&s.showEnter)||(type.startsWith('live.gift')&&s.showGift)||(type==='live.superchat'&&s.showSuperchat)||(type==='live.guard.buy'&&s.showGuard)||(type==='live.like'&&s.showLike)||(type==='live.share'&&s.showShare) }
function useEvents(disabled=false) {
const [settings,setSettings]=useState<Settings>(defaults); const [items,setItems]=useState<Item[]>([]); const [connected,setConnected]=useState(false); const settingsRef=useRef(settings)
useEffect(()=>{settingsRef.current=settings},[settings])
useEffect(()=>{ if(disabled)return; let dead=false; let socket:WebSocket|undefined; let timer=0
const open=()=>{ socket=new WebSocket(wsUrl()); socket.onopen=()=>setConnected(true); socket.onclose=()=>{setConnected(false); if(!dead) timer=window.setTimeout(open,1500)}; socket.onmessage=e=>{ try { const x:Envelope=JSON.parse(e.data); if(x.type==='overlay.settings.snapshot'||x.type==='overlay.settings.updated'){setSettings(x.payload.settings);return} setItems(old=>{ const current=settingsRef.current; if(!enabled(x.type,current))return old; const combo=x.type==='live.gift.combo'&&x.payload.comboId; const key=combo?`combo:${combo}`:x.id; const existing=old.find(v=>v.key===key); const decorVariant=existing?.decorVariant??chooseDecorVariant(`${x.type}:${key}`,old[0]?.decorVariant); const next=[{...x,key,received:Date.now(),decorVariant},...old.filter(v=>v.key!==key)].slice(0,current.maxVisible); return next }) }catch{} } }
open(); return()=>{dead=true;window.clearTimeout(timer);socket?.close()}
},[disabled])
useEffect(()=>{setItems(items=>items.slice(0,settings.maxVisible))},[settings.maxVisible])
return {settings,items,connected,setItems}
}
function giftTier(item:Item,s:Settings){const price=item.payload?.gift?.totalPrice||0;return price>=s.featuredValueThreshold?'featured':price>=s.highValueThreshold?'high':'normal'}
const eventRenderers:Record<string,(payload:any)=>string>={
'live.enter':()=> '踏入了云台','live.superchat':p=>p.message,
'live.guard.buy':p=>`开通 ${p.guardName||'舰长'}`,'live.like':()=> '点亮了一颗星','live.share':()=> '分享了直播间'
}
function DanmakuEmoticon({segment}:{segment:Extract<DanmakuSegment,{type:'emoticon'}>}) { const [failed,setFailed]=useState(false); if(failed)return <>{segment.text}</>; return <img className={`danmaku-emoticon${segment.standalone?' standalone':''}`} src={segment.url} width={segment.width||undefined} height={segment.height||undefined} alt={segment.text} title={segment.text} referrerPolicy="no-referrer" decoding="async" draggable={false} onError={()=>setFailed(true)}/> }
function DanmakuBody({payload}:{payload:any}) { const segments=Array.isArray(payload.segments)?payload.segments as DanmakuSegment[]:undefined; if(!segments?.length)return <>{payload.text||''}</>; return <>{segments.map((segment,index)=>segment.type==='emoticon'&&segment.url?<DanmakuEmoticon segment={segment} key={`${segment.unique||segment.url}:${index}`}/>:<span className="danmaku-text" key={`text:${index}`}>{segment.text}</span>)}</> }
function Card({item,settings,expanded}:{item:Item;settings:Settings;expanded:boolean}) { const p=item.payload||{}; const v=p.viewer||{}; const gift=p.gift; const isDanmaku=item.type==='live.danmaku'; const tier=gift?giftTier(item,settings):''; const body=gift?`献上 ${gift.name} ×${p.quantity||1}`:isDanmaku?<DanmakuBody payload={p}/>:eventRenderers[item.type]?.(p)||'送来了一份互动';
return <article className={`card ${gift?'gift':''} ${item.type==='live.danmaku'?'danmaku':''} ${expanded?'expanded':'compact'} ${tier}`} key={item.key}>
<CardDecor count={settings.particleCount} variant={item.decorVariant}/>
{gift&&<div className="gift-art">{gift.animationUrl||gift.imageUrl?<img src={gift.animationUrl||gift.imageUrl} onError={e=>{const image=e.currentTarget;if(gift.imageUrl&&!image.src.endsWith(gift.imageUrl))image.src=gift.imageUrl;else image.style.display='none'}}/>:<span>✦</span>}</div>}
<div className="copy"><b>{v.name||'直播间观众'}</b><span className={isDanmaku?'danmaku-content':undefined}>{body}</span>{gift?.priceCny>0&&<em>¥ {Number(gift.priceCny).toFixed(2)}</em>}</div>{tier==='featured'&&<div className="particles">✦ ✧ ✦</div>}
</article> }
function Overlay({preview=false,previewSettings}:{preview?:boolean;previewSettings?:Settings}) { const root=useRef<HTMLDivElement>(null); const events=useEvents(preview); const {items,connected,setItems}=events; const settings=previewSettings||events.settings; const [shape,setShape]=useState('standard'); const [expandedKey,setExpandedKey]=useState<string>(); const fontFactor=settings.fontScale/100
useEffect(()=>{if(!root.current)return;const ob=new ResizeObserver(([entry])=>{const {width,height}=entry.contentRect;setShape(width<380?'narrow':height<420?'short':'standard')});ob.observe(root.current);return()=>ob.disconnect()},[])
useEffect(()=>{if(preview&&!items.length)setItems([{id:'text-preview',key:'text-preview',received:Date.now(),decorVariant:0,type:'live.danmaku',payload:{viewer:{name:'青玉观众'},text:'今天也要闪闪发光!'}},{id:'gift-preview',key:'gift-preview',received:Date.now()-1000,decorVariant:3,type:'live.gift',payload:{viewer:{name:'星光旅人'},quantity:1,gift:{name:'甜蜜告白',totalPrice:12000,priceCny:12,imageUrl:'',animationUrl:''}}}])},[preview,items.length,setItems])
useEffect(()=>{const newest=items[0];if(!newest){setExpandedKey(undefined);return}setExpandedKey(newest.key);const densityFactor=shape==='short'?.6:1;const timer=window.setTimeout(()=>setExpandedKey(key=>key===newest.key?undefined:key),settings.collapseAfterSeconds*1000*densityFactor);return()=>window.clearTimeout(timer)},[items[0]?.key,items[0]?.received,settings.collapseAfterSeconds,shape])
return <main ref={root} className={`overlay ${shape} ${settings.lowPerformanceMode?'low-motion':''}`} style={{['--motion' as string]:`${settings.motionIntensity/100}`,['--unfold-duration' as string]:`${settings.unfoldDurationMs||defaults.unfoldDurationMs}ms`,['--particle-duration' as string]:`${400000/Math.min(300,Math.max(25,settings.particleSpeed||defaults.particleSpeed))}ms`,['--font-title' as string]:`${18*fontFactor}px`,['--font-body' as string]:`${18*fontFactor}px`,['--font-expanded' as string]:`${26*fontFactor}px`,['--font-compact' as string]:`${15*fontFactor}px`}}><section className={`wall ${items.length?'awake':''}`}><header><i className={connected?'online':''}/><span>{settings.title}</span></header><div className="cards">{items.map(item=><Card item={item} settings={settings} expanded={item.key===expandedKey} key={item.key}/>)}</div></section></main> }
function api(url:string, init?:RequestInit){return fetch(url,{credentials:'same-origin',headers:{'content-type':'application/json',...(init?.headers||{})},...init})}
async function copyToClipboard(text:string){
if(window.isSecureContext&&navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(text);return true}catch{}}
const input=document.createElement('textarea');input.value=text;input.readOnly=true;input.style.position='fixed';input.style.left='-9999px';input.style.opacity='0';document.body.appendChild(input);input.focus();input.select()
let copied=false;try{copied=document.execCommand('copy')}finally{input.remove()}
return copied
}
const previewPresets=[{label:'窄侧栏',width:360,height:600},{label:'竖屏',width:440,height:760},{label:'高清竖栏',width:600,height:1080},{label:'横向条',width:720,height:320}]
function Control(){
const [password,setPassword]=useState(''); const [settings,setSettings]=useState<Settings>(); const [error,setError]=useState(''); const [message,setMessage]=useState(''); const [obsAddress,setObsAddress]=useState(''); const [previewSize,setPreviewSize]=useState(previewPresets[1])
const load=useCallback(async()=>{const r=await api('/api/admin/overlay-settings');if(!r.ok)throw new Error(r.status===401?'请输入管理员密码':'无法读取设置');setSettings(await r.json())},[])
useEffect(()=>{load().catch(()=>{})},[load])
const login=async(e:React.FormEvent)=>{e.preventDefault();const r=await api('/api/auth/login',{method:'POST',body:JSON.stringify({password})});if(!r.ok){setError('密码不正确');return}setError('');await load()}
const save=async()=>{if(!settings)return;const r=await api('/api/admin/overlay-settings',{method:'PUT',body:JSON.stringify(settings)});if(!r.ok)setError('保存失败');else setSettings((await r.json()).settings)}
const copy=async()=>{setMessage('');const r=await api('/api/admin/obs-url');if(!r.ok){setError('无法获取 OBS 地址,请重新登录');return}const {path}=await r.json();const address=new URL(path,location.origin).toString();setObsAddress(address);if(await copyToClipboard(address)){setError('');setMessage('OBS 地址已复制到剪贴板')}else{setError('浏览器阻止了自动复制,请在下方地址框中手动复制')}}
if(!settings)return <main className="control login"><h1>弹幕猪控制台</h1><form onSubmit={login}><input type="password" autoFocus placeholder="管理员密码" value={password} onChange={e=>setPassword(e.target.value)}/><button>进入</button>{error&&<p>{error}</p>}</form></main>
const edit=(key:keyof Settings,value:any)=>setSettings({...settings,[key]:value})
const labels={showDanmaku:'弹幕',showEnter:'进房',showGift:'礼物',showSuperchat:'醒目留言',showGuard:'舰长',showLike:'点赞',showShare:'分享',lowPerformanceMode:'低性能模式'}
return <main className="control"><section><h1>青玉弹幕姬</h1><p>改动会立即同步到所有 OBS 浏览器源。</p><label>标题<input value={settings.title} onChange={e=>edit('title',e.target.value)}/></label><label>字号 <input type="range" min="50" max="300" step="5" value={settings.fontScale} onChange={e=>edit('fontScale',+e.target.value)}/><output>{settings.fontScale}%</output></label><label>最大可见条数 <input type="range" min="1" max="12" value={settings.maxVisible} onChange={e=>edit('maxVisible',+e.target.value)}/><output>{settings.maxVisible}</output></label><label>自动收缩秒数 <input type="range" min="2" max="60" value={settings.collapseAfterSeconds} onChange={e=>edit('collapseAfterSeconds',+e.target.value)}/><output>{settings.collapseAfterSeconds}s</output></label><label>卷轴展开时长 <input type="range" min="200" max="5000" step="100" value={settings.unfoldDurationMs} onChange={e=>edit('unfoldDurationMs',+e.target.value)}/><output>{(settings.unfoldDurationMs/1000).toFixed(1)}s</output></label><label>动效强度 <input type="range" min="0" max="100" value={settings.motionIntensity} onChange={e=>edit('motionIntensity',+e.target.value)}/><output>{settings.motionIntensity}%</output></label><label>每卡粒子数量 <input type="range" min="0" max="12" step="1" value={settings.particleCount} onChange={e=>edit('particleCount',+e.target.value)}/><output>{settings.particleCount}</output></label><label>粒子动画速度 <input type="range" min="25" max="300" step="25" value={settings.particleSpeed} onChange={e=>edit('particleSpeed',+e.target.value)}/><output>{settings.particleSpeed}%</output></label><fieldset>{(Object.keys(labels) as (keyof typeof labels)[]).map(k=><label key={k}><input type="checkbox" checked={settings[k]} onChange={e=>edit(k,e.target.checked)}/>{labels[k]}</label>)}</fieldset><label>高价值礼物(厘)<input type="number" value={settings.highValueThreshold} onChange={e=>edit('highValueThreshold',+e.target.value)}/></label><label>特别高价值(厘)<input type="number" value={settings.featuredValueThreshold} onChange={e=>edit('featuredValueThreshold',+e.target.value)}/></label><div className="buttons"><button type="button" onClick={save}>保存并同步</button><button type="button" className="secondary" onClick={copy}>复制 OBS 地址</button></div>{obsAddress&&<label className="obs-address">OBS 浏览器源地址<input readOnly value={obsAddress} onFocus={e=>e.currentTarget.select()}/></label>}{message&&<p className="success">{message}</p>}{error&&<p>{error}</p>}</section><section className="preview"><h2>自适应预览</h2><p>选择常用尺寸后仍可拖拽预览框右下角;OBS 中也可使用任意宽高。</p><div className="preset-buttons">{previewPresets.map(size=><button type="button" className={size.label===previewSize.label?'active':'secondary'} onClick={()=>setPreviewSize(size)} key={size.label}>{size.label}<small>{size.width}×{size.height}</small></button>)}</div><div className="preview-viewport"><div className="preview-frame" style={{width:previewSize.width,height:previewSize.height}}><Overlay preview previewSettings={settings}/></div></div></section></main>
}
const isControl=location.pathname.startsWith('/control');createRoot(document.getElementById('root')!).render(isControl?<Control/>:<Overlay/>);