mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 03:36:37 +08:00
添加弹幕功能
添加弹幕功能
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { create, type Manager, type ManagerPlugin } from 'danmu'
|
||||
|
||||
import { danmakuAPI, type DanmakuAnime } from '../api/danmaku'
|
||||
import type { Media } from '../types'
|
||||
import { parseDanmaku, type Comment } from '../utils/parseDanmaku'
|
||||
|
||||
// DanmakuStage overlays danmu (the same engine behind dan-player) onto the
|
||||
// <video> element. The upstream payload (dandanplay Bilibili-format XML) is
|
||||
// fetched by the backend keyed on the video's name; parsing happens here.
|
||||
//
|
||||
// Loading follows danmaku-anywhere's flow: the backend auto-resolves when the
|
||||
// search hits exactly one anime; when several anime match it returns
|
||||
// candidates and the player shows a picker (`onCandidates`). After the user
|
||||
// picks, `episodeId` forces that library.
|
||||
//
|
||||
// The component is controlled by the player page: enabled toggles loading,
|
||||
// opacity / fontSize / area are applied live via the engine's setters, and
|
||||
// `search` re-fetches with a custom keyword (null = use the media's own name).
|
||||
type DanmakuStageProps = {
|
||||
media: Media | null
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
enabled?: boolean
|
||||
opacity?: number
|
||||
fontSize?: number
|
||||
area?: number
|
||||
/** Custom search keyword; null/undefined uses the video name. */
|
||||
search?: string | null
|
||||
/** Explicit danmaku library chosen by the user; null = auto-resolve. */
|
||||
episodeId?: number | string | null
|
||||
/** Called after each fetch attempt (success or error) finishes. */
|
||||
onLoaded?: () => void
|
||||
/** Called when multiple anime matched and the user must pick one. */
|
||||
onCandidates?: (candidates: DanmakuAnime[]) => void
|
||||
}
|
||||
|
||||
// Average of the engine's durationRange (ms). Used to compute how far a
|
||||
// comment should already have flown when it is pushed late after a seek.
|
||||
const TRAVEL_MS = 5000
|
||||
// How long a fixed top/bottom comment stays visible.
|
||||
const FIXED_HOLD_MS = 4500
|
||||
const MAX_VIEW = 200
|
||||
|
||||
export function DanmakuStage({
|
||||
media,
|
||||
videoRef,
|
||||
enabled = true,
|
||||
opacity = 1,
|
||||
fontSize = 24,
|
||||
area = 1,
|
||||
search = null,
|
||||
episodeId = null,
|
||||
onLoaded,
|
||||
onCandidates,
|
||||
}: DanmakuStageProps) {
|
||||
const holderRef = useRef<HTMLDivElement>(null)
|
||||
const managerRef = useRef<Manager<Comment> | null>(null)
|
||||
const fontPxRef = useRef(fontSize)
|
||||
const liveRef = useRef({ opacity, area })
|
||||
|
||||
// Live knobs in refs so the engine effect reads current values without
|
||||
// depending on them (avoiding engine recreation on every slider move).
|
||||
useEffect(() => {
|
||||
fontPxRef.current = fontSize
|
||||
liveRef.current = { opacity, area }
|
||||
}, [opacity, area, fontSize])
|
||||
|
||||
// Engine lifecycle: create once per media + video element, keep alive while
|
||||
// danmaku is enabled.
|
||||
useEffect(() => {
|
||||
const holder = holderRef.current
|
||||
const video = videoRef.current
|
||||
if (!holder || !media || !video || !enabled) return
|
||||
|
||||
let disposed = false
|
||||
let frozen = false
|
||||
let nextIndex = 0
|
||||
let comments: Comment[] = []
|
||||
|
||||
const renderer: ManagerPlugin<Comment> = {
|
||||
name: 'danmaku-stage-render',
|
||||
$createNode: (dm, node) => {
|
||||
const base = fontPxRef.current
|
||||
node.textContent = dm.data.text
|
||||
node.style.color = dm.data.color
|
||||
node.style.fontSize = `${Math.round(base * dm.data.size)}px`
|
||||
node.style.fontWeight = '600'
|
||||
node.style.textShadow = '0 1px 2px rgba(0,0,0,.7)'
|
||||
node.style.whiteSpace = 'nowrap'
|
||||
node.style.lineHeight = '1.2'
|
||||
node.style.userSelect = 'none'
|
||||
node.style.pointerEvents = 'none'
|
||||
node.style.willChange = 'transform'
|
||||
},
|
||||
}
|
||||
|
||||
const manager = create<Comment>({
|
||||
plugin: renderer,
|
||||
mode: 'strict',
|
||||
interval: 250,
|
||||
gap: 4,
|
||||
trackHeight: `${Math.max(20, Math.round(fontPxRef.current * 1.4))}px`,
|
||||
durationRange: [Math.round(TRAVEL_MS * 0.8), Math.round(TRAVEL_MS * 1.2)],
|
||||
direction: 'right',
|
||||
limits: { view: MAX_VIEW, stash: Infinity },
|
||||
})
|
||||
managerRef.current = manager
|
||||
manager.mount(holder)
|
||||
// 弹幕层不拦截播放器控制栏的点击。
|
||||
holder.style.pointerEvents = 'none'
|
||||
|
||||
const applyLiveSettings = () => {
|
||||
const { opacity: liveOpacity, area: liveArea } = liveRef.current
|
||||
manager.setOpacity(liveOpacity)
|
||||
// 显示区域:area ∈ (0,1] 表示弹幕占视频高度的比例。
|
||||
const a = Math.min(1, Math.max(0.05, liveArea))
|
||||
manager.setArea({ y: { start: 0, end: `${Math.round(a * 100)}%` } })
|
||||
}
|
||||
applyLiveSettings()
|
||||
|
||||
const loadDanmaku = async () => {
|
||||
try {
|
||||
const res = await danmakuAPI.fetch(media.id, {
|
||||
kw: search ?? undefined,
|
||||
episodeId: episodeId ?? undefined,
|
||||
})
|
||||
if (disposed) return
|
||||
if (res.candidates && res.candidates.length > 0) {
|
||||
// 多番剧命中:交回播放器展示候选让用户选择(disambiguation)。
|
||||
comments = []
|
||||
onCandidates?.(res.candidates)
|
||||
return
|
||||
}
|
||||
if (res.enabled) {
|
||||
comments = parseDanmaku(res.raw || '', res.source_type)
|
||||
.filter((c) => Number.isFinite(c.time) && c.time >= 0)
|
||||
.sort((a, b) => a.time - b.time)
|
||||
nextIndex = 0
|
||||
} else {
|
||||
comments = []
|
||||
}
|
||||
} catch {
|
||||
// 拉取失败时静默关闭弹幕,不打断播放。
|
||||
comments = []
|
||||
} finally {
|
||||
if (!disposed) onLoaded?.()
|
||||
}
|
||||
}
|
||||
|
||||
const pushFlex = (comment: Comment, progress?: number) => {
|
||||
const isTop = comment.mode === 'top'
|
||||
manager.pushFlexibleDanmaku(comment, {
|
||||
duration: FIXED_HOLD_MS,
|
||||
direction: 'none',
|
||||
progress,
|
||||
position: (dm, container) => ({
|
||||
x: Math.max(4, (container.width - dm.getWidth()) / 2),
|
||||
y: isTop
|
||||
? Math.max(4, Math.round(fontPxRef.current / 2))
|
||||
: Math.max(4, container.height - dm.getHeight() - 4),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// 推入所有时间已到的弹幕;跳转后 `progress` 让弹幕从屏幕中部出现。
|
||||
const pushDue = (now: number) => {
|
||||
while (nextIndex < comments.length && comments[nextIndex].time <= now) {
|
||||
const c = comments[nextIndex]
|
||||
const elapsed = now - c.time
|
||||
if (elapsed <= TRAVEL_MS / 1000) {
|
||||
const progress = Math.min(0.95, Math.max(0, elapsed / (TRAVEL_MS / 1000)))
|
||||
if (c.mode === 'scroll') {
|
||||
manager.push(c, { progress })
|
||||
} else {
|
||||
pushFlex(c, progress)
|
||||
}
|
||||
}
|
||||
// 已飞出屏幕(超过 TRAVEL_MS)的弹幕直接丢弃,不进入队列。
|
||||
nextIndex++
|
||||
}
|
||||
}
|
||||
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
raf = requestAnimationFrame(tick)
|
||||
if (!disposed && !frozen && videoRef.current && !videoRef.current.paused) {
|
||||
pushDue(videoRef.current.currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
const onPlay = () => {
|
||||
frozen = false
|
||||
manager.unfreeze()
|
||||
tick()
|
||||
}
|
||||
const onPause = () => {
|
||||
frozen = true
|
||||
manager.freeze()
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
const onSeeked = () => {
|
||||
// 跳转后屏幕上的弹幕瞬间失效:清空画面与待发布队列,
|
||||
// 由 rAF tick 从新位置按需重新推入(含 progress 中段补放)。
|
||||
manager.clear()
|
||||
nextIndex = 0
|
||||
}
|
||||
|
||||
video.addEventListener('play', onPlay)
|
||||
video.addEventListener('playing', onPlay)
|
||||
video.addEventListener('pause', onPause)
|
||||
video.addEventListener('seeked', onSeeked)
|
||||
|
||||
void loadDanmaku()
|
||||
if (!video.paused) onPlay()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
cancelAnimationFrame(raf)
|
||||
video.removeEventListener('play', onPlay)
|
||||
video.removeEventListener('playing', onPlay)
|
||||
video.removeEventListener('pause', onPause)
|
||||
video.removeEventListener('seeked', onSeeked)
|
||||
manager.freeze()
|
||||
manager.clear()
|
||||
manager.unmount()
|
||||
managerRef.current = null
|
||||
}
|
||||
// search / episodeId 变化时重新拉取弹幕(含媒体/开关切换)。
|
||||
}, [media, videoRef, enabled, search, episodeId, onLoaded, onCandidates])
|
||||
|
||||
// Live renderer knobs: opacity / area / font size without recreating the
|
||||
// engine. font size additionally rescales currently visible comments.
|
||||
useEffect(() => {
|
||||
const manager = managerRef.current
|
||||
if (!manager) return
|
||||
manager.setOpacity(opacity)
|
||||
const a = Math.min(1, Math.max(0.05, area))
|
||||
manager.setArea({ y: { start: 0, end: `${Math.round(a * 100)}%` } })
|
||||
manager.each((dm) => {
|
||||
if (dm.node) {
|
||||
dm.node.style.fontSize = `${Math.round(fontSize * dm.data.size)}px`
|
||||
}
|
||||
})
|
||||
}, [opacity, area, fontSize])
|
||||
|
||||
return <div ref={holderRef} className="pointer-events-none absolute inset-0" />
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Maximize,
|
||||
MessageSquareText,
|
||||
Minimize,
|
||||
Pause,
|
||||
PictureInPicture,
|
||||
Play,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
|
||||
// PlayerControls — custom bottom control bar replacing the native <video
|
||||
// controls> (which cannot host custom buttons). The danmaku toggle sits right
|
||||
// next to the volume control. The bar auto-hides while playing and reappears
|
||||
// on mouse movement; it stays visible while paused.
|
||||
//
|
||||
// Native keyboard shortcuts (space / arrows) still work because they are
|
||||
// element-level defaults on <video>. Subtitles from <track> elements keep
|
||||
// rendering (the default track shows as before); only the track picker UI is
|
||||
// not re-implemented here.
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!Number.isFinite(s) || s < 0) s = 0
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = Math.floor(s % 60)
|
||||
return `${m}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
type PlayerControlsProps = {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
danmakuOpen: boolean
|
||||
danmakuEnabled: boolean
|
||||
onToggleDanmaku: () => void
|
||||
}
|
||||
|
||||
export function PlayerControls({
|
||||
videoRef,
|
||||
danmakuOpen,
|
||||
danmakuEnabled,
|
||||
onToggleDanmaku,
|
||||
}: PlayerControlsProps) {
|
||||
const video = () => videoRef.current
|
||||
const container = () => videoRef.current?.parentElement ?? null
|
||||
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [muted, setMuted] = useState(false)
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [pip, setPip] = useState(false)
|
||||
const [uiVisible, setUiVisible] = useState(true)
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// 播放时 3 秒无操作自动隐藏控制栏;暂停时保持显示。监听挂在视频容器上,
|
||||
// 控制栏隐藏(pointer-events-none)后移动鼠标仍能重新唤起。
|
||||
useEffect(() => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
const parent = el.parentElement
|
||||
const onMove = () => {
|
||||
setUiVisible(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
if (!el.paused) {
|
||||
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000)
|
||||
}
|
||||
}
|
||||
const onLeave = () => {
|
||||
if (el.paused) return
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
setUiVisible(false)
|
||||
}
|
||||
const syncPlay = () => {
|
||||
setPlaying(!el.paused)
|
||||
onMove()
|
||||
}
|
||||
const syncTime = () => setCurrentTime(el.currentTime)
|
||||
const syncMeta = () => {
|
||||
setDuration(el.duration || 0)
|
||||
setCurrentTime(el.currentTime)
|
||||
}
|
||||
const syncVolume = () => {
|
||||
setVolume(el.volume)
|
||||
setMuted(el.muted)
|
||||
}
|
||||
const syncFullscreen = () => setFullscreen(document.fullscreenElement === parent)
|
||||
const syncPip = () => setPip(document.pictureInPictureElement === el)
|
||||
|
||||
parent?.addEventListener('mousemove', onMove)
|
||||
parent?.addEventListener('mouseleave', onLeave)
|
||||
|
||||
el.addEventListener('play', syncPlay)
|
||||
el.addEventListener('playing', syncPlay)
|
||||
el.addEventListener('pause', syncPlay)
|
||||
el.addEventListener('timeupdate', syncTime)
|
||||
el.addEventListener('durationchange', syncMeta)
|
||||
el.addEventListener('loadedmetadata', syncMeta)
|
||||
el.addEventListener('volumechange', syncVolume)
|
||||
document.addEventListener('fullscreenchange', syncFullscreen)
|
||||
el.addEventListener('enterpictureinpicture', syncPip)
|
||||
el.addEventListener('leavepictureinpicture', syncPip)
|
||||
|
||||
syncMeta()
|
||||
syncVolume()
|
||||
syncFullscreen()
|
||||
return () => {
|
||||
parent?.removeEventListener('mousemove', onMove)
|
||||
parent?.removeEventListener('mouseleave', onLeave)
|
||||
el.removeEventListener('play', syncPlay)
|
||||
el.removeEventListener('playing', syncPlay)
|
||||
el.removeEventListener('pause', syncPlay)
|
||||
el.removeEventListener('timeupdate', syncTime)
|
||||
el.removeEventListener('durationchange', syncMeta)
|
||||
el.removeEventListener('loadedmetadata', syncMeta)
|
||||
el.removeEventListener('volumechange', syncVolume)
|
||||
document.removeEventListener('fullscreenchange', syncFullscreen)
|
||||
el.removeEventListener('enterpictureinpicture', syncPip)
|
||||
el.removeEventListener('leavepictureinpicture', syncPip)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoRef])
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
if (el.paused) void el.play()?.catch(() => undefined)
|
||||
else el.pause()
|
||||
}
|
||||
|
||||
const seek = (v: number) => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
el.currentTime = v
|
||||
setCurrentTime(v)
|
||||
}
|
||||
|
||||
const changeVolume = (v: number) => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
el.volume = v
|
||||
el.muted = v === 0
|
||||
setVolume(v)
|
||||
setMuted(v === 0)
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
el.muted = !el.muted
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = container()
|
||||
if (!el) return
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen()
|
||||
} else {
|
||||
void el.requestFullscreen?.()
|
||||
}
|
||||
}
|
||||
|
||||
const togglePip = () => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
if (document.pictureInPictureElement) {
|
||||
void document.exitPictureInPicture()
|
||||
} else {
|
||||
void el.requestPictureInPicture?.()
|
||||
}
|
||||
}
|
||||
|
||||
const pipSupported =
|
||||
typeof document !== 'undefined' &&
|
||||
'pictureInPictureEnabled' in document &&
|
||||
document.pictureInPictureEnabled
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 px-3 pb-2 pt-12 transition-opacity duration-300 ${
|
||||
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={playing ? '暂停' : '播放'}
|
||||
>
|
||||
{playing ? <Pause size={20} /> : <Play size={20} />}
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
value={currentTime}
|
||||
onChange={(e) => seek(Number(e.target.value))}
|
||||
className="min-w-0 flex-1 accent-rose-500"
|
||||
aria-label="播放进度"
|
||||
/>
|
||||
|
||||
<span className="shrink-0 font-mono text-xs tabular-nums text-white/85">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
{pipSupported && (
|
||||
<button
|
||||
onClick={togglePip}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={pip ? '退出画中画' : '画中画'}
|
||||
>
|
||||
<PictureInPicture size={18} className={pip ? 'text-rose-400' : ''} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onToggleDanmaku}
|
||||
className={
|
||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium transition ' +
|
||||
(danmakuOpen || danmakuEnabled
|
||||
? 'bg-rose-500/90 text-white hover:bg-rose-500'
|
||||
: 'bg-white/10 text-white/80 hover:bg-white/20')
|
||||
}
|
||||
title="弹幕设置"
|
||||
>
|
||||
<MessageSquareText size={15} />
|
||||
弹幕
|
||||
{danmakuEnabled && <span className="h-1.5 w-1.5 rounded-full bg-lime-400" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={muted || volume === 0 ? '取消静音' : '静音'}
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX size={18} /> : <Volume2 size={18} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => changeVolume(Number(e.target.value))}
|
||||
className="w-16 accent-rose-500"
|
||||
aria-label="音量"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={fullscreen ? '退出全屏' : '全屏'}
|
||||
>
|
||||
{fullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronRight, Loader2, MessageSquareText, Search, X } from 'lucide-react'
|
||||
|
||||
import type { DanmakuAnime, DanmakuEpisode } from '../api/danmaku'
|
||||
|
||||
// PlayerDanmakuPanel — the on-player danmaku control panel. It toggles
|
||||
// loading, lets the user re-search by a custom keyword, and adjusts the
|
||||
// renderer knobs (display area / opacity / font size) live. Values are
|
||||
// controlled by PlayerPage so the panel is purely presentational.
|
||||
//
|
||||
// The knobs change immediately on drag; a "re-search" only fires when the
|
||||
// user clicks the search button (or presses Enter) so sliders don't trigger
|
||||
// network requests. When the backend returns multiple anime candidates
|
||||
// (disambiguation), the panel shows the picker so the user can choose the
|
||||
// right danmaku library by hand — mirroring danmaku-anywhere's selector.
|
||||
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[]
|
||||
/** Human-readable label of the currently selected library. */
|
||||
selectedSource: string
|
||||
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,
|
||||
selectedSource,
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-4 top-16 z-30 w-72 rounded-2xl border border-white/15 bg-black/80 p-4 text-white shadow-2xl backdrop-blur"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<MessageSquareText size={16} /> 弹幕设置
|
||||
</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 text-sm">
|
||||
<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>
|
||||
|
||||
{/* 搜索弹幕 */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 text-xs text-white/60">搜索弹幕(留空 = 按视频名)</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch(draft.trim())
|
||||
}}
|
||||
placeholder="输入番剧名…"
|
||||
className="min-w-0 flex-1 rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-sm outline-none placeholder:text-white/35 focus:border-rose-400/60"
|
||||
/>
|
||||
<button
|
||||
onClick={() => onSearch(draft.trim())}
|
||||
disabled={searching}
|
||||
className="flex items-center gap-1 rounded-lg bg-rose-500/90 px-2.5 py-1.5 text-xs font-medium transition hover:bg-rose-500 disabled:opacity-50"
|
||||
>
|
||||
{searching ? <Loader2 size={13} className="animate-spin" /> : <Search size={13} />}
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 当前来源 / 候选手动选取 */}
|
||||
{selectedSource && (
|
||||
<div className="mb-3 flex items-center justify-between rounded-lg border border-white/10 bg-white/5 px-2.5 py-1.5 text-xs">
|
||||
<span className="min-w-0 truncate text-white/75" title={selectedSource}>
|
||||
当前: {selectedSource}
|
||||
</span>
|
||||
<button
|
||||
onClick={onResetAuto}
|
||||
className="ml-2 shrink-0 text-rose-300 transition hover:text-rose-200"
|
||||
title="清除手动选择,恢复自动匹配"
|
||||
>
|
||||
自动
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<div className="mb-4 rounded-lg border border-amber-400/25 bg-amber-400/5 p-2">
|
||||
<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-sm 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}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 屏幕占比(显示区域) */}
|
||||
<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-3">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user