add account localization and switchable rooms

Centralize control and OBS copy in a shared TOML catalog, persist the selected locale per account, and broadcast language changes to component streams. Allow account owners to atomically switch their Bilibili room and restart the shared listener without changing component URLs.
This commit is contained in:
2026-07-18 23:28:05 -07:00
parent 53ae90ec13
commit f79852d8e6
35 changed files with 1946 additions and 472 deletions
+62 -40
View File
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties, RefObject } from 'react'
import { normalizeSongRequestItem, normalizeSongRequestSettings } from './api'
import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { getOverlayTheme, themeCssVariables } from './themes'
import type { OverlayThemeDefinition } from './themes'
@@ -23,32 +24,33 @@ type SnapshotDraft = {
items: SongRequestItem[]
}
const previewCurrent: SongRequestItem = {
id: 'preview-current',
title: '星河入梦',
requester: { uid: '10001', name: '青玉观众' },
status: 'current',
queuePosition: 0,
requestedAt: new Date().toISOString(),
startedAt: new Date().toISOString(),
averageScore: 4.8,
ratingCount: 26,
}
const previewQueue: SongRequestItem[] = ['晚风告白', '月下花笺', '云海来信', '落星成诗'].map(
(title, index) => ({
id: `preview-${index}`,
title,
requester: {
uid: String(10002 + index),
name: ['星光旅人', '花间客', '小瓷片', '月桂'][index],
function previewData(): { current: SongRequestItem; queued: SongRequestItem[] } {
const now = new Date().toISOString()
const titles = translate('song.overlay.preview_titles').split('|')
const viewers = translate('song.overlay.preview_viewers').split('|')
return {
current: {
id: 'preview-current',
title: translate('song.overlay.preview_title'),
requester: { uid: '10001', name: translate('song.overlay.preview_viewer') },
status: 'current',
queuePosition: 0,
requestedAt: now,
startedAt: now,
averageScore: 4.8,
ratingCount: 26,
},
status: 'queued',
queuePosition: index + 1,
requestedAt: new Date().toISOString(),
ratingCount: 0,
}),
)
queued: titles.map((title, index) => ({
id: `preview-${index}`,
title,
requester: { uid: String(10002 + index), name: viewers[index] ?? viewers[0] },
status: 'queued',
queuePosition: index + 1,
requestedAt: now,
ratingCount: 0,
})),
}
}
function payloadRecord(value: unknown): Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value)
@@ -62,13 +64,14 @@ function ordered(items: SongRequestItem[]): SongRequestItem[] {
.sort((left, right) => left.queuePosition - right.queuePosition)
}
function useSongQueue(preview: boolean, stream?: ComponentStream) {
function useSongQueue(preview: boolean, language: string, stream?: ComponentStream) {
const initialPreview = previewData()
const [settings, setSettings] = useState(defaultSongRequestSettings)
const [queue, setQueue] = useState<QueueState>(() => ({
initialized: preview,
revision: 0,
current: preview ? previewCurrent : undefined,
queued: preview ? previewQueue : [],
current: preview ? initialPreview.current : undefined,
queued: preview ? initialPreview.queued : [],
}))
const queueRef = useRef(queue)
const draftRef = useRef<SnapshotDraft | undefined>(undefined)
@@ -78,6 +81,19 @@ function useSongQueue(preview: boolean, stream?: ComponentStream) {
queueRef.current = queue
}, [queue])
useEffect(() => {
if (!preview) return
const translated = previewData()
const next = {
initialized: true,
revision: 0,
current: translated.current,
queued: translated.queued,
}
queueRef.current = next
setQueue(next)
}, [language, preview])
useEffect(() => {
if (preview || !stream) return
const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current)
@@ -179,8 +195,11 @@ function useSongQueue(preview: boolean, stream?: ComponentStream) {
}
function score(item?: SongRequestItem) {
if (!item?.ratingCount || item.averageScore == null) return '尚无评分'
return `${item.averageScore.toFixed(1)} ★ · ${item.ratingCount} 人`
if (!item?.ratingCount || item.averageScore == null) return translate('song.no_rating')
return translate('song.overlay.score', {
score: item.averageScore.toFixed(1),
count: item.ratingCount,
})
}
/** Theme-owned stars and florets used inside the compact current-song card. */
@@ -254,7 +273,8 @@ export function SongRequestOverlay({
previewSettings?: SongRequestSettings
stream?: ComponentStream
}) {
const { settings: remoteSettings, queue } = useSongQueue(preview, stream)
const { language } = useI18n()
const { settings: remoteSettings, queue } = useSongQueue(preview, language, stream)
const settings = previewSettings ?? remoteSettings
const theme = getOverlayTheme(settings.themeId)
const root = useRef<HTMLElement>(null)
@@ -294,32 +314,34 @@ export function SongRequestOverlay({
>
<section
className="song-current"
aria-label="当前歌曲"
aria-label={translate('song.overlay.current_aria')}
key={queue.current?.id ?? 'empty-current'}
>
<SongParticles theme={theme} />
<div className="song-current-mark" aria-hidden="true">
唱
{translate('song.overlay.sing_mark')}
</div>
{queue.current ? (
<div className="song-current-copy">
<small>正在演唱 · {queue.current.requester.name} 点歌</small>
<small>
{translate('song.overlay.now_singing', { viewer: queue.current.requester.name })}
</small>
<strong>{queue.current.title}</strong>
</div>
) : (
<div className="song-current-copy empty">
<small>等待弹幕点歌</small>
<strong>发送「点歌 歌名」加入队列</strong>
<small>{translate('song.overlay.waiting')}</small>
<strong>{translate('song.overlay.request_help')}</strong>
</div>
)}
<div className="song-score">
<b>{score(queue.current)}</b>
<small>发送「打分 1-5」</small>
<small>{translate('song.overlay.rate_help')}</small>
</div>
</section>
<section className="song-queue" aria-label="待唱队列">
<section className="song-queue" aria-label={translate('song.overlay.queue_aria')}>
<header>
<span>待唱歌单</span>
<span>{translate('song.overlay.queue_title')}</span>
<b>{queue.queued.length}</b>
</header>
<div className="song-queue-viewport" ref={viewport}>
@@ -336,7 +358,7 @@ export function SongRequestOverlay({
</article>
))}
{queue.queued.length === 0 && (
<div className="song-queue-empty">下一首,会由谁来点呢?</div>
<div className="song-queue-empty">{translate('song.overlay.queue_empty')}</div>
)}
</div>
</div>