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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user