)}
+ {/* 同集其它来源:弹幕已自动加载,这里直接切换即可 */}
+ {enabled && alternatives.length > 0 && (
+
+
+ 同集其它来源({alternativeRows.length})
+
+
+ 当前已自动加载一个来源,点其它条目可直接切换,无需重新搜索。
+
+
+ {alternativeRows.map(({ anime, ep }, i) => {
+ const current = String(danmakuInfo?.episodeId ?? '') === String(ep.episodeId)
+ return (
+
+ )
+ })}
+
+
+ )}
+
+ {/* 合并多来源:仅在确实存在多个同集来源时才出现,避免无意义的开关 */}
+ {enabled && alternativeRows.length > 1 && (
+
+
+ {mergeSources && danmakuInfo?.mergedSources ? (
+
+ 已合并 {danmakuInfo.mergedSources} 个来源
+
+ ) : null}
+
+ )}
+
{/* 屏幕占比(显示区域) */}
= expiry - TOKEN_EXPIRY_SKEW_MS
+}
+
// useWebSocket opens a single connection to /api/ws and dispatches every
// message to the supplied handler. Auto-reconnects with back-off while the
// auth token is present; after the fast retries are exhausted it keeps a
@@ -28,6 +54,13 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
const open = () => {
if (closed) return
+ // 过期的 token 握手必然 401。此前这里会以 60s 间隔无限重试,服务端
+ // 日志里表现为每分钟一条 401。改为先走刷新流程:成功会更新 token 并
+ // 让本 effect 重建连接,失败则清空会话停止重连。
+ if (isTokenExpired(token)) {
+ void useAuthStore.getState().tokenRefresh()
+ return
+ }
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${proto}//${window.location.host}/api/ws?token=${encodeURIComponent(token)}`
const ws = new WebSocket(url)
@@ -47,6 +80,11 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
}
ws.onclose = () => {
if (closed) return
+ // token 过期时不要继续退避重试,交给刷新流程处理。
+ if (isTokenExpired(token)) {
+ void useAuthStore.getState().tokenRefresh()
+ return
+ }
reconnectAttempts += 1
// 快速阶段保持原有线性退避,之后固定 60s 慢速重试;
// timer 始终只有一个在途,cleanup 时统一清除,不会堆积。
diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx
index d227914..8197bcd 100644
--- a/web/src/pages/PlayerPage.tsx
+++ b/web/src/pages/PlayerPage.tsx
@@ -98,10 +98,15 @@ export function PlayerPage() {
const [danmakuEpisodeId, setDanmakuEpisodeId] = useState(null)
// 自动匹配歧义(多番剧命中)时的候选列表。
const [danmakuCandidates, setDanmakuCandidates] = useState([])
+ // 同一集的其它可选来源(弹幕已自动加载,用户可随时切换,无需重新搜索)。
+ const [danmakuAlternatives, setDanmakuAlternatives] = useState([])
// 已加载弹幕的元数据信息(番剧名、单集名、条数、匹配模式等)。
const [danmakuInfo, setDanmakuInfo] = useState(null)
// 用户当前选定的弹幕来源描述(面板中展示)。
const [danmakuSelectedSource, setDanmakuSelectedSource] = useState('')
+ // 弹幕合并偏好:从 /danmaku/config 读取(按用户落库),切换后写回并重新抓取。
+ const [danmakuMergeSources, setDanmakuMergeSources] = useState(false)
+ const [danmakuMergeSaving, setDanmakuMergeSaving] = useState(false)
const [danmakuOpacity, setDanmakuOpacity] = useState(1)
const [danmakuFontSize, setDanmakuFontSize] = useState(24)
const [danmakuArea, setDanmakuArea] = useState(1)
@@ -168,11 +173,45 @@ export function PlayerPage() {
.catch(() => undefined)
}, [])
+ // 读取弹幕配置(含按用户存储的合并偏好),初始化面板。
+ useEffect(() => {
+ let cancelled = false
+ danmakuAPI
+ .config()
+ .then((cfg) => {
+ if (cancelled) return
+ setDanmakuMergeSources(Boolean(cfg.merge_sources))
+ })
+ .catch(() => {
+ // 配置读取失败时保持默认(不合并),不影响播放。
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ // 切换合并开关:先落库,成功后再重新抓取,避免与后端读到的偏好不一致。
+ const danmakuChangeMergeSources = useCallback((next: boolean) => {
+ setDanmakuMergeSaving(true)
+ danmakuAPI
+ .updateSettings({ mergeSources: next })
+ .then(() => {
+ setDanmakuMergeSources(next)
+ setDanmakuSearching(true)
+ setDanmakuSearchTrigger((prev) => prev + 1)
+ })
+ .catch(() => {
+ // 保存失败时保持原值,用户可重试。
+ })
+ .finally(() => setDanmakuMergeSaving(false))
+ }, [])
+
// 用户手动搜索:带关键词重新拉取(search=null 时按视频名)。
// loading 状态由 DanmakuStage 拉取完成回调(onLoaded)驱动。
const searchDanmaku = useCallback((kw: string) => {
setDanmakuSearching(true)
setDanmakuCandidates([])
+ setDanmakuAlternatives([])
setDanmakuEpisodeId(null)
setDanmakuInfo(null)
setDanmakuSearch(kw || null)
@@ -184,6 +223,11 @@ export function PlayerPage() {
setDanmakuInfo(info)
}, [])
+ // 同一集的其它来源:弹幕已自动加载好,这里只更新可切换列表。
+ const danmakuGotAlternatives = useCallback((alternatives: DanmakuAnime[]) => {
+ setDanmakuAlternatives(alternatives)
+ }, [])
+
// 多番剧命中(disambiguation):展示候选让用户选择。
const danmakuGotCandidates = useCallback((candidates: DanmakuAnime[]) => {
setDanmakuCandidates(candidates)
@@ -197,6 +241,8 @@ export function PlayerPage() {
const danmakuSelectEpisode = useCallback((episodeId: number, animeTitle: string, episodeTitle: string) => {
setDanmakuEpisodeId(episodeId)
setDanmakuCandidates([])
+ // 刻意不清空 danmakuAlternatives:切到别的来源后仍要能继续切换回去,
+ // 否则用户每次都得重新搜一遍。
setDanmakuSearching(true)
// 展示当前所选来源(面板标题处可见)。
setDanmakuSelectedSource(episodeTitle ? `${animeTitle}・${episodeTitle}` : animeTitle)
@@ -207,6 +253,7 @@ export function PlayerPage() {
const danmakuResetAuto = useCallback(() => {
setDanmakuEpisodeId(null)
setDanmakuCandidates([])
+ setDanmakuAlternatives([])
setDanmakuSearching(true)
setDanmakuSearch(null)
setDanmakuSelectedSource('')
@@ -221,6 +268,7 @@ export function PlayerPage() {
setHlsStartSec(0)
setDanmakuEpisodeId(null)
setDanmakuCandidates([])
+ setDanmakuAlternatives([])
setDanmakuSearch(null)
setDanmakuSelectedSource('')
setDanmakuInfo(null)
@@ -947,6 +995,7 @@ export function PlayerPage() {
onToggleDanmaku={toggleDanmakuOpen}
onDanmakuLoaded={danmakuLoaded}
onDanmakuCandidates={danmakuGotCandidates}
+ onDanmakuAlternatives={danmakuGotAlternatives}
hasPrevEpisode={Boolean(prevEpisode)}
hasNextEpisode={Boolean(nextEpisode)}
onPrevEpisode={handlePrevEpisode}
@@ -984,6 +1033,10 @@ export function PlayerPage() {
fontSize={danmakuFontSize}
onFontSizeChange={setDanmakuFontSize}
candidates={danmakuCandidates}
+ alternatives={danmakuAlternatives}
+ mergeSources={danmakuMergeSources}
+ onMergeSourcesChange={danmakuChangeMergeSources}
+ mergeSaving={danmakuMergeSaving}
selectedSource={danmakuSelectedSource}
autoMatchTitle={danmakuAutoTitle}
danmakuInfo={danmakuInfo}
diff --git a/web/src/pages/PlayerVideoStage.tsx b/web/src/pages/PlayerVideoStage.tsx
index 273b61d..f129259 100644
--- a/web/src/pages/PlayerVideoStage.tsx
+++ b/web/src/pages/PlayerVideoStage.tsx
@@ -158,6 +158,7 @@ type PlayerVideoStageProps = {
onToggleDanmaku: () => void
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
+ onDanmakuAlternatives: (alternatives: DanmakuAnime[]) => void
/** Danmaku settings panel; rendered inside the stage so it stays visible in fullscreen. */
danmakuPanel: ReactNode
/** Playlist drawer / panel; rendered inside the stage so it stays visible in fullscreen. */
@@ -202,6 +203,7 @@ export function PlayerVideoStage({
onToggleDanmaku,
onDanmakuLoaded,
onDanmakuCandidates,
+ onDanmakuAlternatives,
danmakuPanel,
playlistPanel,
hasPrevEpisode,
@@ -588,6 +590,7 @@ export function PlayerVideoStage({
searchTrigger={danmakuSearchTrigger}
onLoaded={onDanmakuLoaded}
onCandidates={onDanmakuCandidates}
+ onAlternatives={onDanmakuAlternatives}
/>
{tracksArmed && media && assTrack && assFallbackPath !== assTrack.path ? (