import { useEffect, useState } from 'react' import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Film, Hash, KeyRound, Loader2, MessageSquareText, RefreshCw, Search, Server, Settings2, Sparkles, Tag, X } from 'lucide-react' import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku' import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER, PLAYER_SHEET, PLAYER_SHEET_BODY, PLAYER_SHEET_HEADER } from './playerTheme' // PlayerDanmakuPanel — the on-player danmaku control panel. It displays // the matched danmaku details (anime title, episode title, comment count, // match mode), toggles loading, lets the user re-search by a custom keyword, // and adjusts the renderer knobs (display area / opacity / font size) live. type PlayerDanmakuPanelProps = { open: boolean onClose: () => void enabled: boolean onToggleEnabled: (v: boolean) => void search: string onSearch: (kw: string) => void searching: boolean area: number onAreaChange: (v: number) => void onAreaCommit: (v: number) => void opacity: number onOpacityChange: (v: number) => void onOpacityCommit: (v: number) => void fontSize: number onFontSizeChange: (v: number) => void onFontSizeCommit: (v: number) => void /** Per-user danmaku service endpoint and optional application credentials. */ source: string appId: string appKeyConfigured: boolean settingsSaving?: boolean onSaveAdvanced: (values: { source: string appId: string appKey: string clearAppKey: boolean }) => Promise /** Multiple anime matched — user must pick one. */ candidates: DanmakuAnime[] /** * Other libraries holding this same episode. Danmaku is already loaded from * one of them; these exist so the user can switch sources on the spot. */ alternatives: DanmakuAnime[] /** Per-user preference: merge the same episode's sources into one list. */ mergeSources: boolean onMergeSourcesChange: (v: boolean) => void /** True while the merge preference is being persisted. */ mergeSaving?: boolean /** Human-readable label of the currently selected library. */ selectedSource?: string /** Title used by auto-matching (e.g. anime title, media title or filename). */ autoMatchTitle?: string /** Loaded danmaku metadata (title, episode, count, match mode). */ danmakuInfo?: DanmakuLoadedInfo | null onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void onResetAuto: () => void /** * 竖屏剧场模式:弹幕面板改为视频区底部的动作面板,而不是右侧抽屉, * 窄屏下搜索框与滑杆不再被压扁。 */ theater?: boolean } export function PlayerDanmakuPanel({ open, onClose, enabled, onToggleEnabled, search, onSearch, searching, area, onAreaChange, onAreaCommit, opacity, onOpacityChange, onOpacityCommit, fontSize, onFontSizeChange, onFontSizeCommit, source, appId, appKeyConfigured, settingsSaving = false, onSaveAdvanced, candidates, alternatives, mergeSources, onMergeSourcesChange, mergeSaving = false, selectedSource, autoMatchTitle, danmakuInfo, onSelectEpisode, onResetAuto, theater = false, }: PlayerDanmakuPanelProps) { const [draft, setDraft] = useState(search) const [advancedOpen, setAdvancedOpen] = useState(false) const [sourceDraft, setSourceDraft] = useState(source) const [appIdDraft, setAppIdDraft] = useState(appId) const [appKeyDraft, setAppKeyDraft] = useState('') const [showAppKey, setShowAppKey] = useState(false) const [clearAppKey, setClearAppKey] = useState(false) const [advancedSaving, setAdvancedSaving] = useState(false) // 展开的番剧(动画 → 集数两级树),默认全展开便于选择。 const [openAnime, setOpenAnime] = useState>(new Set()) // 面板打开时同步外部搜索词到输入框草稿,并展开全部候选。 useEffect(() => { if (open) { setDraft(search) setOpenAnime(new Set(candidates.map((c) => c.animeId))) setSourceDraft(source) setAppIdDraft(appId) setAppKeyDraft('') setShowAppKey(false) setClearAppKey(false) } }, [open, search, candidates, source, appId]) const saveAdvanced = async () => { setAdvancedSaving(true) try { await onSaveAdvanced({ source: sourceDraft, appId: appIdDraft, appKey: appKeyDraft, clearAppKey, }) setAppKeyDraft('') setShowAppKey(false) setClearAppKey(false) } catch { // 父组件已展示错误提示,这里保持面板打开以便修正后重试。 } finally { setAdvancedSaving(false) } } if (!open) return null const toggleAnime = (animeId: number) => { setOpenAnime((prev) => { const next = new Set(prev) if (next.has(animeId)) { next.delete(animeId) } else { next.add(animeId) } return next }) } // 匹配模式标签显示辅助 const renderMatchBadge = (mode?: string) => { switch (mode) { case 'hash': return ( 哈希精准匹配 ) case 'filename': return ( 文件名匹配 ) case 'metadata': return ( 刮削信息匹配 ) case 'search': return ( 标题搜索匹配 ) case 'manual': return ( 手动指定 ) default: return null } } const isCustomOrManual = Boolean(search || selectedSource || danmakuInfo?.matchMode === 'manual') // 候选来源摊平成「番剧 + 集」的一维列表:每个来源通常只含命中的那一集。 const alternativeRows = alternatives.flatMap((anime) => anime.episodes.map((ep) => ({ anime, ep })), ) return ( // 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。 // 排布和选集抽屉完全一致(整条贴住右边缘、标题栏常驻),两个面板互斥打开时 // 位置不会跳,用户也不用重新找入口。
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} className={ theater ? PLAYER_SHEET : `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}` } >
弹幕设置 {settingsSaving && }
{/* 弹幕总开关。从操作栏的「弹」按钮直接进来就到了这里,所以这一项就是 用户找的「开关弹幕」;文案跟旧的按钮提示保持一致,避免换个说法让人找不到。 */} {/* 当前加载的弹幕信息卡片 */} {enabled && (
{searching ? (
正在匹配弹幕…
) : danmakuInfo && (danmakuInfo.totalCount > 0 || danmakuInfo.animeTitle) ? (
{danmakuInfo.animeTitle || selectedSource || '未知番剧'}
{danmakuInfo.episodeTitle && (
{danmakuInfo.episodeTitle}
)}
{isCustomOrManual && ( )}
{renderMatchBadge(danmakuInfo.matchMode)}
{danmakuInfo.totalCount > 0 ? `共 ${danmakuInfo.totalCount.toLocaleString()} 条弹幕` : '暂无弹幕内容'}
) : candidates.length === 0 ? (
未匹配到弹幕,可在下方手动搜索 {isCustomOrManual && ( )}
) : null}
)} {/* 搜索弹幕 */}
搜索弹幕(留空 = 按视频名自动匹配) {autoMatchTitle && ( )}
setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') onSearch(draft.trim()) }} placeholder={autoMatchTitle ? `自动匹配:${autoMatchTitle}` : '输入番剧或电影名…'} className="min-w-0 flex-1 rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs outline-none placeholder:text-white/40 focus:border-rose-400/60" />
{/* 多番剧命中候选列表 */} {candidates.length > 0 && (
搜到多部番剧,请选择对应集数:
{candidates.map((anime, i) => (
{openAnime.has(anime.animeId) && (
{anime.episodes.map((ep) => ( onSelectEpisode(ep.episodeId, anime.animeTitle, ep.episodeTitle) } /> ))}
)}
))}
)} {/* 同集其它来源:弹幕已自动加载,这里直接切换即可 */} {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}
)} {/* 屏幕占比(显示区域) */} `${Math.round(v * 100)}%`} onChange={onAreaChange} onCommit={onAreaCommit} /> {/* 透明度 */} `${Math.round(v * 100)}%`} onChange={onOpacityChange} onCommit={onOpacityCommit} /> {/* 字体大小 */} `${Math.round(v)}px`} onChange={onFontSizeChange} onCommit={onFontSizeCommit} />
{advancedOpen && (
{appKeyConfigured && ( )}

密钥不会回传到浏览器,留空时保持数据库中的原值;官方源留空凭据会使用内置回退,自定义源需自行提供。修改后仅对当前账号生效。

)}
) } function SliderRow({ label, value, min, max, step, format, onChange, onCommit, }: { label: string value: number min: number max: number step: number format: (v: number) => string onChange: (v: number) => void onCommit: (v: number) => void }) { return (
{label} {format(value)}
onChange(Number(e.target.value))} onPointerUp={(e) => onCommit(Number(e.currentTarget.value))} onKeyUp={(e) => onCommit(Number(e.currentTarget.value))} className="w-full accent-rose-500" />
) } function EpisodeRow({ episode, onSelect, }: { episode: DanmakuEpisode onSelect: () => void }) { return ( ) }