Files
MeBox/web/src/components/PlayerDanmakuPanel.tsx
T
2026-09-12 10:27:55 +08:00

479 lines
18 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 { useEffect, useState } from 'react'
import { Check, ChevronRight, Film, Hash, Loader2, MessageSquareText, RefreshCw, Search, Sparkles, Tag, X } from 'lucide-react'
import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku'
// 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
opacity: number
onOpacityChange: (v: number) => void
fontSize: number
onFontSizeChange: (v: number) => void
/** 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
}
export function PlayerDanmakuPanel({
open,
onClose,
enabled,
onToggleEnabled,
search,
onSearch,
searching,
area,
onAreaChange,
opacity,
onOpacityChange,
fontSize,
onFontSizeChange,
candidates,
alternatives,
mergeSources,
onMergeSourcesChange,
mergeSaving = false,
selectedSource,
autoMatchTitle,
danmakuInfo,
onSelectEpisode,
onResetAuto,
}: PlayerDanmakuPanelProps) {
const [draft, setDraft] = useState(search)
// 展开的番剧(动画 → 集数两级树),默认全展开便于选择。
const [openAnime, setOpenAnime] = useState<Set<number>>(new Set())
// 面板打开时同步外部搜索词到输入框草稿,并展开全部候选。
useEffect(() => {
if (open) {
setDraft(search)
setOpenAnime(new Set(candidates.map((c) => c.animeId)))
}
}, [open, search, candidates])
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 (
<span className="inline-flex items-center gap-0.5 rounded border border-emerald-500/30 bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-300">
<Hash size={10} /> 哈希精准匹配
</span>
)
case 'filename':
return (
<span className="inline-flex items-center gap-0.5 rounded border border-sky-500/30 bg-sky-500/15 px-1.5 py-0.5 text-[10px] font-medium text-sky-300">
<Tag size={10} /> 文件名匹配
</span>
)
case 'metadata':
return (
<span className="inline-flex items-center gap-0.5 rounded border border-cyan-500/30 bg-cyan-500/15 px-1.5 py-0.5 text-[10px] font-medium text-cyan-300">
<Search size={10} /> 刮削信息匹配
</span>
)
case 'search':
return (
<span className="inline-flex items-center gap-0.5 rounded border border-violet-500/30 bg-violet-500/15 px-1.5 py-0.5 text-[10px] font-medium text-violet-300">
<Sparkles size={10} /> 标题搜索匹配
</span>
)
case 'manual':
return (
<span className="inline-flex items-center gap-0.5 rounded border border-amber-500/30 bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-300">
手动指定
</span>
)
default:
return null
}
}
const isCustomOrManual = Boolean(search || selectedSource || danmakuInfo?.matchMode === 'manual')
// 候选来源摊平成「番剧 + 集」的一维列表:每个来源通常只含命中的那一集。
const alternativeRows = alternatives.flatMap((anime) =>
anime.episodes.map((ep) => ({ anime, ep })),
)
return (
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
<div
onClick={(e) => e.stopPropagation()}
className="absolute inset-x-3 top-12 bottom-3 z-30 w-auto overflow-y-auto rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:right-4 sm:top-16 sm:bottom-auto sm:w-80"
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-semibold">
<MessageSquareText size={16} className="text-rose-400" /> 弹幕设置
</div>
<button
onClick={onClose}
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
title="关闭"
>
<X size={16} />
</button>
</div>
{/* 是否加载弹幕 */}
<label className="mb-3 flex cursor-pointer items-center justify-between rounded-lg bg-white/5 px-2.5 py-2 text-sm transition hover:bg-white/10">
<span className="text-white/85">加载弹幕</span>
<input
type="checkbox"
checked={enabled}
onChange={(e) => onToggleEnabled(e.target.checked)}
className="h-4 w-4 accent-rose-500"
/>
</label>
{/* 当前加载的弹幕信息卡片 */}
{enabled && (
<div className="mb-3">
{searching ? (
<div className="flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/5 py-3 text-xs text-white/70">
<Loader2 size={14} className="animate-spin text-rose-400" />
<span>正在匹配弹幕…</span>
</div>
) : danmakuInfo && (danmakuInfo.totalCount > 0 || danmakuInfo.animeTitle) ? (
<div className="rounded-xl border border-white/15 bg-white/5 p-2.5">
<div className="mb-1 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1 text-xs font-semibold text-white/95">
<Film size={13} className="shrink-0 text-rose-400" />
<span className="truncate" title={danmakuInfo.animeTitle || selectedSource || '未知番剧'}>
{danmakuInfo.animeTitle || selectedSource || '未知番剧'}
</span>
</div>
{danmakuInfo.episodeTitle && (
<div className="mt-0.5 truncate pl-4 text-[11px] text-white/60" title={danmakuInfo.episodeTitle}>
{danmakuInfo.episodeTitle}
</div>
)}
</div>
{isCustomOrManual && (
<button
onClick={onResetAuto}
className="flex shrink-0 items-center gap-1 rounded bg-white/10 px-1.5 py-0.5 text-[10px] text-rose-300 transition hover:bg-white/15 hover:text-rose-200"
title="清除手动搜索与选择,恢复自动匹配"
>
<RefreshCw size={10} />
自动
</button>
)}
</div>
<div className="mt-2 flex items-center justify-between border-t border-white/10 pt-1.5 text-[11px]">
<div>{renderMatchBadge(danmakuInfo.matchMode)}</div>
<div className="font-mono text-white/70">
{danmakuInfo.totalCount > 0 ? `共 ${danmakuInfo.totalCount.toLocaleString()} 条弹幕` : '暂无弹幕内容'}
</div>
</div>
</div>
) : candidates.length === 0 ? (
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 text-xs text-white/50">
<span>未匹配到弹幕,可在下方手动搜索</span>
{isCustomOrManual && (
<button
onClick={onResetAuto}
className="shrink-0 text-rose-300 transition hover:text-rose-200"
title="恢复自动匹配"
>
恢复自动
</button>
)}
</div>
) : null}
</div>
)}
{/* 搜索弹幕 */}
<div className="mb-4">
<div className="mb-1 flex items-center justify-between text-xs text-white/60">
<span>搜索弹幕(留空 = 按视频名自动匹配)</span>
{autoMatchTitle && (
<button
type="button"
onClick={() => setDraft(autoMatchTitle)}
className="text-[11px] text-rose-300 transition hover:text-rose-200"
title="填入当前识别到的视频名"
>
填入当前名
</button>
)}
</div>
<div className="flex items-center gap-1.5">
<input
value={draft}
onChange={(e) => 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"
/>
<button
onClick={() => onSearch(draft.trim())}
disabled={searching}
className="flex items-center gap-1 rounded-lg bg-rose-500 px-2.5 py-1.5 text-xs font-medium text-white transition hover:bg-rose-600 disabled:opacity-50"
>
{searching ? <Loader2 size={13} className="animate-spin" /> : <Search size={13} />}
搜索
</button>
</div>
</div>
{/* 多番剧命中候选列表 */}
{candidates.length > 0 && (
<div className="mb-4 rounded-xl border border-amber-400/25 bg-amber-400/5 p-2.5">
<div className="mb-1.5 px-1 text-xs font-medium text-amber-200">
搜到多部番剧,请选择对应集数:
</div>
<div className="max-h-52 overflow-y-auto pr-1">
{candidates.map((anime, i) => (
<div key={anime.animeId} className="mb-1">
<button
onClick={() => toggleAnime(anime.animeId)}
className="flex w-full items-center gap-1 rounded-md px-1.5 py-1 text-left text-xs font-medium text-white/85 transition hover:bg-white/10"
>
<ChevronRight
size={13}
className={
'shrink-0 transition-transform ' +
(openAnime.has(anime.animeId) ? 'rotate-90' : '')
}
/>
<span className="min-w-0 flex-1 truncate">
{anime.animeTitle || `番剧 ${i + 1}`}
</span>
<span className="shrink-0 text-[10px] text-white/40">
{anime.episodes.length} 集
</span>
</button>
{openAnime.has(anime.animeId) && (
<div className="ml-5 border-l border-white/10 pl-2">
{anime.episodes.map((ep) => (
<EpisodeRow
key={ep.episodeId}
episode={ep}
onSelect={() =>
onSelectEpisode(ep.episodeId, anime.animeTitle, ep.episodeTitle)
}
/>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* 同集其它来源:弹幕已自动加载,这里直接切换即可 */}
{enabled && alternatives.length > 0 && (
<div className="mb-4 rounded-xl border border-sky-400/25 bg-sky-400/5 p-2.5">
<div className="mb-1.5 px-1 text-xs font-medium text-sky-200">
同集其它来源({alternativeRows.length})
</div>
<div className="mb-1.5 px-1 text-[10px] leading-relaxed text-white/45">
当前已自动加载一个来源,点其它条目可直接切换,无需重新搜索。
</div>
<div className="max-h-52 overflow-y-auto pr-1">
{alternativeRows.map(({ anime, ep }, i) => {
const current = String(danmakuInfo?.episodeId ?? '') === String(ep.episodeId)
return (
<button
key={`${anime.animeId}-${ep.episodeId}-${i}`}
onClick={() => onSelectEpisode(ep.episodeId, anime.animeTitle, ep.episodeTitle)}
className={
'mb-1 flex w-full items-start gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition ' +
(current
? 'bg-sky-500/20 text-white'
: 'text-white/70 hover:bg-sky-500/15 hover:text-white')
}
title={`切换到《${anime.animeTitle}》的《${ep.episodeTitle}》`}
>
<span className="mt-0.5 shrink-0">
{current ? (
<Check size={12} className="text-sky-300" />
) : (
<Film size={12} className="text-white/35" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{anime.animeTitle || `来源 ${i + 1}`}</span>
{ep.episodeTitle && (
<span className="mt-0.5 block truncate text-[10px] text-white/50">
{ep.episodeTitle}
</span>
)}
</span>
{current && (
<span className="mt-0.5 shrink-0 text-[10px] text-sky-300">当前</span>
)}
</button>
)
})}
</div>
</div>
)}
{/* 合并多来源:仅在确实存在多个同集来源时才出现,避免无意义的开关 */}
{enabled && alternativeRows.length > 1 && (
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-400/5 p-2.5">
<label className="flex cursor-pointer items-start justify-between gap-2">
<span className="min-w-0">
<span className="block text-xs font-medium text-emerald-200">合并多来源弹幕</span>
<span className="mt-0.5 block text-[10px] leading-relaxed text-white/45">
把以上来源的弹幕合并,并按时间与内容去重后一起显示。该设置会保存到账号。
</span>
</span>
<span className="flex shrink-0 items-center gap-1.5 pt-0.5">
{mergeSaving && <Loader2 size={11} className="animate-spin text-emerald-300" />}
<input
type="checkbox"
checked={mergeSources}
onChange={(e) => onMergeSourcesChange(e.target.checked)}
className="h-4 w-4 accent-emerald-500"
/>
</span>
</label>
{mergeSources && danmakuInfo?.mergedSources ? (
<div className="mt-1.5 border-t border-white/10 pt-1.5 text-[10px] text-emerald-300/80">
已合并 {danmakuInfo.mergedSources} 个来源
</div>
) : null}
</div>
)}
{/* 屏幕占比(显示区域) */}
<SliderRow
label="屏幕占比"
value={area}
min={0.1}
max={1}
step={0.05}
format={(v) => `${Math.round(v * 100)}%`}
onChange={onAreaChange}
/>
{/* 透明度 */}
<SliderRow
label="透明度"
value={opacity}
min={0.1}
max={1}
step={0.05}
format={(v) => `${Math.round(v * 100)}%`}
onChange={onOpacityChange}
/>
{/* 字体大小 */}
<SliderRow
label="字体大小"
value={fontSize}
min={14}
max={48}
step={1}
format={(v) => `${Math.round(v)}px`}
onChange={onFontSizeChange}
/>
</div>
)
}
function SliderRow({
label,
value,
min,
max,
step,
format,
onChange,
}: {
label: string
value: number
min: number
max: number
step: number
format: (v: number) => string
onChange: (v: number) => void
}) {
return (
<div className="mb-2.5">
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-white/60">{label}</span>
<span className="font-mono text-white/85">{format(value)}</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-rose-500"
/>
</div>
)
}
function EpisodeRow({
episode,
onSelect,
}: {
episode: DanmakuEpisode
onSelect: () => void
}) {
return (
<button
onClick={onSelect}
className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-xs text-white/70 transition hover:bg-rose-500/20 hover:text-white"
title={`选择《${episode.episodeTitle}》的弹幕`}
>
<Check size={12} className="shrink-0 text-rose-400" />
<span className="min-w-0 flex-1 truncate">{episode.episodeTitle}</span>
</button>
)
}