添加弹幕功能

添加弹幕功能
This commit is contained in:
truewhile
2026-08-24 00:37:25 +08:00
parent 71bf60c69c
commit cd1297a30f
20 changed files with 2038 additions and 59 deletions
+65
View File
@@ -0,0 +1,65 @@
import { api } from './client'
// DanmakuEpisode / DanmakuAnime mirror the dandanplay search response. When
// multiple anime match, the backend returns them as candidates and the player
// asks the user which to load (disambiguation).
export interface DanmakuEpisode {
episodeId: number
episodeTitle: string
}
export interface DanmakuAnime {
animeId: number
animeTitle: string
episodes: DanmakuEpisode[]
}
// DanmakuFetchResult mirrors the backend /api/danmaku/:id response: renderer
// knobs (resolved from runtime settings) plus the raw upstream payload. The
// dandanplay protocol delivers Bilibili-format XML or its own JSON shape
// ({comments:[{p,m,t}]}); source_type is sniffed from the body by the backend
// ("auto" when there is nothing to sniff). When several anime matched,
// candidates is non-empty and raw is empty.
export interface DanmakuFetchResult {
enabled: boolean
source_type: 'auto' | 'xml' | 'json'
opacity: string
font_size: string
area: string
raw?: string
candidates?: DanmakuAnime[]
}
export type DanmakuFetchOptions = {
/** Overrides the media-derived search keyword (manual search). */
kw?: string
/** Forces a specific danmaku library chosen by the user. */
episodeId?: number | string
}
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
// the configured dandanplay source by the video's name (search for episode,
// then fetch its comment library) and returns the raw Bilibili-format XML;
// parsing into comment objects happens client-side in utils/parseDanmaku.
export const danmakuAPI = {
fetch: (mediaId: string, options: DanmakuFetchOptions = {}) =>
api
.get<DanmakuFetchResult>(`/danmaku/${encodeURIComponent(mediaId)}`, {
params: { kw: options.kw || undefined, episodeId: options.episodeId || undefined },
timeout: 20_000,
})
.then((r) => r.data),
// config returns the renderer knobs so the player can initialize its
// danmaku control panel without admin privileges.
config: () =>
api
.get<{
enabled: boolean
source?: string
opacity: string
font_size: string
area: string
}>('/danmaku/config')
.then((r) => r.data),
}
+247
View File
@@ -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" />
}
+263
View File
@@ -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>
)
}
+286
View File
@@ -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>
)
}
+99
View File
@@ -5,6 +5,7 @@ import toast from 'react-hot-toast'
import { mediaAPI } from '../api/library'
import { api, hlsURL, streamURL } from '../api/client'
import { danmakuAPI, type DanmakuAnime } from '../api/danmaku'
import { playbackAPI } from '../api/playback'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { systemAPI } from '../api/system'
@@ -13,6 +14,7 @@ import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
import { pickPlayerMode, needsTranscodeForBrowser, type PlayerMode } from './playerPageModel'
import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage'
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
// Fullscreen, dark-themed video page.
//
@@ -43,6 +45,21 @@ export function PlayerPage() {
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
const [directOnly, setDirectOnly] = useState(false)
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
const [danmakuOpen, setDanmakuOpen] = useState(false)
const [danmakuEnabled, setDanmakuEnabled] = useState(true)
const [danmakuSearch, setDanmakuSearch] = useState<string | null>(null)
const [danmakuSearching, setDanmakuSearching] = useState(false)
// 用户从候选列表选定的弹幕库;null = 自动匹配。
const [danmakuEpisodeId, setDanmakuEpisodeId] = useState<number | string | null>(null)
// 自动匹配歧义(多番剧命中)时的候选列表。
const [danmakuCandidates, setDanmakuCandidates] = useState<DanmakuAnime[]>([])
// 用户当前选定的弹幕来源描述(面板中展示)。
const [danmakuSelectedSource, setDanmakuSelectedSource] = useState('')
const [danmakuOpacity, setDanmakuOpacity] = useState(1)
const [danmakuFontSize, setDanmakuFontSize] = useState(24)
const [danmakuArea, setDanmakuArea] = useState(1)
const teardownHls = useCallback((mediaId?: string, stopServer = false) => {
if (hlsRef.current) {
hlsRef.current.destroy()
@@ -75,6 +92,57 @@ export function PlayerPage() {
.catch(() => setDirectOnly(false))
}, [])
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
useEffect(() => {
danmakuAPI
.config()
.then((cfg) => {
setDanmakuEnabled(cfg.enabled)
setDanmakuOpacity(Number(cfg.opacity) || 1)
setDanmakuFontSize(Number(cfg.font_size) || 24)
setDanmakuArea(Number(cfg.area) || 1)
})
.catch(() => undefined)
}, [])
// 用户手动搜索:带关键词重新拉取(search=null 时按视频名)。
// loading 状态由 DanmakuStage 拉取完成回调(onLoaded)驱动。
const searchDanmaku = useCallback((kw: string) => {
setDanmakuSearching(true)
setDanmakuCandidates([])
setDanmakuEpisodeId(null)
setDanmakuSearch(kw || null)
}, [])
const danmakuLoaded = useCallback(() => {
setDanmakuSearching(false)
}, [])
// 多番剧命中(disambiguation):展示候选让用户选择。
const danmakuGotCandidates = useCallback((candidates: DanmakuAnime[]) => {
setDanmakuCandidates(candidates)
setDanmakuSearching(false)
// 候选是静默返回的(此时没有任何弹幕);自动打开面板提示用户选择来源。
if (candidates.length > 0) setDanmakuOpen(true)
}, [])
// 用户选定某个弹幕库:显式 episodeId 重新拉取。
const danmakuSelectEpisode = useCallback((episodeId: number, animeTitle: string, episodeTitle: string) => {
setDanmakuEpisodeId(episodeId)
setDanmakuCandidates([])
setDanmakuSearching(true)
// 展示当前所选来源(面板标题处可见)。
setDanmakuSelectedSource(`${animeTitle}・${episodeTitle}`)
}, [])
// 回到自动匹配(清除用户手动选择)。
const danmakuResetAuto = useCallback(() => {
setDanmakuEpisodeId(null)
setDanmakuCandidates([])
setDanmakuSearching(true)
setDanmakuSearch(null)
}, [])
// Load metadata and pick a default mode.
useEffect(() => {
if (!id) return
@@ -215,6 +283,37 @@ export function PlayerPage() {
subs={subs}
videoRef={ref}
onVideoError={handleVideoError}
danmakuEnabled={danmakuEnabled}
danmakuOpacity={danmakuOpacity}
danmakuFontSize={danmakuFontSize}
danmakuArea={danmakuArea}
danmakuSearch={danmakuSearch}
danmakuEpisodeId={danmakuEpisodeId}
danmakuOpen={danmakuOpen}
onToggleDanmaku={() => setDanmakuOpen((v) => !v)}
onDanmakuLoaded={danmakuLoaded}
onDanmakuCandidates={danmakuGotCandidates}
danmakuPanel={
<PlayerDanmakuPanel
open={danmakuOpen}
onClose={() => setDanmakuOpen(false)}
enabled={danmakuEnabled}
onToggleEnabled={setDanmakuEnabled}
search={danmakuSearch ?? ''}
onSearch={searchDanmaku}
searching={danmakuSearching}
area={danmakuArea}
onAreaChange={setDanmakuArea}
opacity={danmakuOpacity}
onOpacityChange={setDanmakuOpacity}
fontSize={danmakuFontSize}
onFontSizeChange={setDanmakuFontSize}
candidates={danmakuCandidates}
selectedSource={danmakuSelectedSource}
onSelectEpisode={danmakuSelectEpisode}
onResetAuto={danmakuResetAuto}
/>
}
/>
</div>
)
+33 -26
View File
@@ -9,7 +9,12 @@ type PlayerTopBarProps = {
onToggleMode: () => void
}
export function PlayerTopBar({ directOnly, mode, onBack, onToggleMode }: PlayerTopBarProps) {
export function PlayerTopBar({
directOnly,
mode,
onBack,
onToggleMode,
}: PlayerTopBarProps) {
return (
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">
<button
@@ -19,30 +24,32 @@ export function PlayerTopBar({ directOnly, mode, onBack, onToggleMode }: PlayerT
<ArrowLeft size={16} /> 返回
</button>
{directOnly ? (
<span
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
title="宿主机不转码,由客户端本地解码直连"
>
<Sparkles size={14} /> 客户端直连解码
</span>
) : (
<button
onClick={onToggleMode}
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
title="切换播放模式"
>
{mode === 'hls' ? (
<>
<RefreshCw size={14} /> HLS 转码中
</>
) : (
<>
<Sparkles size={14} /> 直接播放
</>
)}
</button>
)}
<div className="pointer-events-auto flex items-center gap-2">
{directOnly ? (
<span
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
title="宿主机不转码,由客户端本地解码直连"
>
<Sparkles size={14} /> 客户端直连解码
</span>
) : (
<button
onClick={onToggleMode}
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
title="切换播放模式"
>
{mode === 'hls' ? (
<>
<RefreshCw size={14} /> HLS 转码中
</>
) : (
<>
<Sparkles size={14} /> 直接播放
</>
)}
</button>
)}
</div>
</div>
)
}
}
+86 -22
View File
@@ -1,7 +1,10 @@
import type { RefObject } from 'react'
import type { ReactNode, RefObject } from 'react'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { type DanmakuAnime } from '../api/danmaku'
import type { Media } from '../types'
import { DanmakuStage } from '../components/DanmakuStage'
import { PlayerControls } from '../components/PlayerControls'
type PlayerVideoStageProps = {
media: Media | null
@@ -9,6 +12,18 @@ type PlayerVideoStageProps = {
subs: SubtitleTrack[]
videoRef: RefObject<HTMLVideoElement>
onVideoError: () => void
danmakuEnabled: boolean
danmakuOpacity: number
danmakuFontSize: number
danmakuArea: number
danmakuSearch: string | null
danmakuEpisodeId: number | string | null
danmakuOpen: boolean
onToggleDanmaku: () => void
onDanmakuLoaded: () => void
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
/** Danmaku settings panel; rendered inside the stage so it stays visible in fullscreen. */
danmakuPanel: ReactNode
}
export function PlayerVideoStage({
@@ -17,29 +32,78 @@ export function PlayerVideoStage({
subs,
videoRef,
onVideoError,
danmakuEnabled,
danmakuOpacity,
danmakuFontSize,
danmakuArea,
danmakuSearch,
danmakuEpisodeId,
danmakuOpen,
onToggleDanmaku,
onDanmakuLoaded,
onDanmakuCandidates,
danmakuPanel,
}: PlayerVideoStageProps) {
// 点击视频切换播放/暂停;双击切换全屏(控制栏事件自行阻止冒泡)。
const togglePlay = () => {
const video = videoRef.current
if (!video) return
if (video.paused) void video.play()?.catch(() => undefined)
else video.pause()
}
const toggleFullscreen = () => {
const stage = videoRef.current?.parentElement
if (!stage) return
if (document.fullscreenElement) void document.exitFullscreen()
else void stage.requestFullscreen?.()
}
return (
<div className="flex flex-1 items-center justify-center">
<div
className="relative flex flex-1 items-center justify-center overflow-hidden bg-black"
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
>
{media ? (
<video
ref={videoRef}
controls
autoPlay
playsInline
className="max-h-screen w-full max-w-[1600px] bg-black"
onError={onVideoError}
>
{subs.map((track, index) => (
<track
key={track.path}
kind="subtitles"
src={subtitlesAPI.url(media.id, track.path)}
srcLang={track.lang}
label={track.label || track.lang}
default={index === 0}
/>
))}
</video>
<>
<video
ref={videoRef}
autoPlay
playsInline
className="relative z-0 max-h-screen w-full max-w-[1600px] bg-black"
onError={onVideoError}
>
{subs.map((track, index) => (
<track
key={track.path}
kind="subtitles"
src={subtitlesAPI.url(media.id, track.path)}
srcLang={track.lang}
label={track.label || track.lang}
default={index === 0}
/>
))}
</video>
<DanmakuStage
media={media}
videoRef={videoRef}
enabled={danmakuEnabled}
opacity={danmakuOpacity}
fontSize={danmakuFontSize}
area={danmakuArea}
search={danmakuSearch}
episodeId={danmakuEpisodeId}
onLoaded={onDanmakuLoaded}
onCandidates={onDanmakuCandidates}
/>
<PlayerControls
videoRef={videoRef}
danmakuOpen={danmakuOpen}
danmakuEnabled={danmakuEnabled}
onToggleDanmaku={onToggleDanmaku}
/>
{danmakuPanel}
</>
) : (
<p className="text-sand-500">加载中…</p>
)}
@@ -50,4 +114,4 @@ export function PlayerVideoStage({
) : null}
</div>
)
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { SettingGroup } from './settingsGroupTypes'
// 弹幕配置:对接 dandanplay(弹弹play)协议弹幕源 + 渲染通用功能。
// 来源地址留空时使用官方 https://api.dandanplay.net;填写任何符合
// dandanplay 协议(/api/v2/search/episodes + /api/v2/comment/:id)的服务地址均可。
export const danmakuSettingsGroup: SettingGroup = {
key: 'danmaku',
label: '弹幕',
description: '弹幕来源(dandanplay 协议)与渲染参数(透明度 / 字号 / 显示区域)',
items: [
{
key: 'danmaku.enabled',
label: '启用弹幕',
type: 'toggle',
hint: '关闭后播放页不拉取、不渲染弹幕',
defaultValue: 'true',
},
{
key: 'danmaku.source',
label: '弹幕服务地址(dandanplay 协议)',
type: 'text',
hint: '留空使用官方 https://api.dandanplay.net。可填写自建或第三方符合 dandanplay 协议的服务地址(含 /api/v2/search/episodes 搜索与 /api/v2/comment/:id 弹幕接口)。播放时按视频名称搜索番剧并拉取 Bilibili 格式 XML 弹幕。',
placeholder: 'https://api.dandanplay.net',
},
{
key: 'danmaku.opacity',
label: '弹幕透明度',
type: 'number',
hint: '0.1 ~ 1,数值越小越透明(1 为完全不透明)',
defaultValue: '1',
placeholder: '1',
},
{
key: 'danmaku.font_size',
label: '弹幕字号',
type: 'number',
hint: '弹幕渲染字号(px),默认 24',
defaultValue: '24',
placeholder: '24',
},
{
key: 'danmaku.area',
label: '弹幕显示区域',
type: 'number',
hint: '0 ~ 1,弹幕占视频高度比例(1 为全屏,0.5 为上半屏)',
defaultValue: '1',
placeholder: '1',
},
],
}
+2
View File
@@ -1,5 +1,6 @@
import { adultSettingsGroup } from './settingsGroupAccess'
import { apiConfigsSettingsGroup } from './settingsGroupAPIConfigs'
import { danmakuSettingsGroup } from './settingsGroupDanmaku'
import { generalSettingsGroup } from './settingsGroupGeneral'
import { librarySettingsGroup } from './settingsGroupLibrary'
import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords'
@@ -12,6 +13,7 @@ export const GROUPS: SettingGroup[] = [
librarySettingsGroup,
apiConfigsSettingsGroup,
recognitionWordsSettingsGroup,
danmakuSettingsGroup,
adultSettingsGroup,
]
+187
View File
@@ -0,0 +1,187 @@
// Danmaku parsing utilities. Normalizes Bilibili XML and custom JSON danmaku
// sources into a single Comment[] shape that the player renders.
//
// The Bilibili <d> element carries a `p` attribute:
// p="time, mode, fontSize, color, timestamp, pool, senderHash, dmid"
// where mode: 1=scroll, 4=bottom, 5=top.
//
// Custom JSON sources may look like either `{ comments: [...] }` or a bare
// array; each comment may be an object `{ time, text, mode, color, size }` or
// the classic CCL tuple `[time, mode, size, color, text]`. The dandanplay
// protocol's own JSON shape `{ comments: [{ p: "time,mode,color,user", m:
// text, t: seconds }] }` is detected and parsed too.
export type DanmakuMode = 'scroll' | 'top' | 'bottom'
export interface Comment {
/** Offset in seconds relative to the clip start. */
time: number
text: string
mode: DanmakuMode
/** CSS color, e.g. "#ffffff" or a hex int string. */
color: string
/** Font size ratio relative to the default (1 = default). */
size: number
}
const BILI_MODES: Record<string, DanmakuMode> = { '1': 'scroll', '4': 'bottom', '5': 'top' }
function biliColorToCSS(color: string): string {
if (color && color !== '16777215') {
return `#${Number(color).toString(16).padStart(6, '0')}`
}
return '#ffffff'
}
/** Parse a Bilibili XML danmaku document into normalized comments. */
//
// Two flavors of `p` attribute exist:
// Bilibili: time, mode, fontSize, color, timestamp, pool, senderHash, dmid
// dandanplay: time, mode, color, userId (4 fields only)
// Field count disambiguates: >=5 fields means Bilibili (color at index 3),
// 4 fields means dandanplay (color at index 2).
function parseBilibiliXml(xml: string): Comment[] {
const comments: Comment[] = []
const pattern = /<d\s+p="([^"]*)"[^>]*>([\s\S]*?)<\/d>/g
let match: RegExpExecArray | null
while ((match = pattern.exec(xml)) !== null) {
const fields = match[1].split(',')
const text = match[2].trim()
const t = Number(fields[0])
if (!text || !Number.isFinite(t) || t < 0) continue
const modeStr = fields[1]
const isDandanplay = fields.length >= 4 && fields.length < 5
const sizeStr = isDandanplay ? '' : fields[2]
const colorStr = isDandanplay ? fields[2] : fields[3]
comments.push({
time: t,
text,
mode: BILI_MODES[modeStr] ?? 'scroll',
color: biliColorToCSS(colorStr),
size: clampSize(Number(sizeStr)),
})
}
return comments
}
interface JsonComment {
time?: number
text?: string
mode?: number | string
color?: string | number
size?: number
/** dandanplay JSON 弹幕(弹弹play 新接口 / 自建源):p="time,mode,color,user" */
p?: string
m?: string
t?: number
}
/** dandanplay JSON items look like { p, m, t, cid, like } — no time/text keys. */
function isDandanplayJson(list: JsonComment[]): boolean {
const first = list[0]
return (
!!first &&
typeof first === 'object' &&
!Array.isArray(first) &&
typeof first.p === 'string' &&
typeof first.m === 'string'
)
}
/** Parse the dandanplay JSON shape: { comments: [{ p, m, t, ... }] }. */
function parseDandanplayJson(list: JsonComment[]): Comment[] {
const comments: Comment[] = []
for (const item of list) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
const text = String(item.m ?? '').trim()
if (!text) continue
const fields = String(item.p ?? '').split(',')
const t = Number(fields[0])
if (!Number.isFinite(t) || t < 0) continue
comments.push({
time: t,
text,
mode: BILI_MODES[fields[1]] ?? 'scroll',
color: biliColorToCSS(fields[2]),
size: 1,
})
}
return comments
}
function clampSize(size: number): number {
if (!Number.isFinite(size) || size <= 0) return 1
// Bilibili font sizes are absolute 12-64; fold them onto a ~0.75-1.5 ratio.
return Math.min(1.6, Math.max(0.6, size / 25))
}
/** Parse custom JSON danmaku data into normalized comments. */
function parseJson(raw: string): Comment[] {
const data = JSON.parse(raw) as JsonComment[] | { comments?: JsonComment[] } | unknown
const list = Array.isArray(data) ? data : (data as { comments?: JsonComment[] })?.comments
if (!Array.isArray(list)) return []
if (isDandanplayJson(list)) return parseDandanplayJson(list)
const comments: Comment[] = []
for (const rawComment of list) {
let keyed: JsonComment
if (Array.isArray(rawComment)) {
// Classic CCL tuple: [time, mode, size, color, text]
keyed = {
time: Number(rawComment[0]),
mode: rawComment[1],
size: Number(rawComment[2]),
color: rawComment[3],
text: rawComment[4],
}
} else {
keyed = rawComment as JsonComment
}
const t = Number(keyed.time)
const text = String(keyed.text ?? '').trim()
if (!text || !Number.isFinite(t) || t < 0) continue
// Accept both Bilibili mode ints and explicit "scroll"/"top"/"bottom".
let mode: DanmakuMode
if (typeof keyed.mode === 'string') {
mode = (['scroll', 'top', 'bottom'] as const).includes(keyed.mode as DanmakuMode)
? (keyed.mode as DanmakuMode)
: 'scroll'
} else {
mode = BILI_MODES[String(keyed.mode)] ?? 'scroll'
}
let color = '#ffffff'
if (typeof keyed.color === 'string') {
if (/^#/.test(keyed.color)) {
color = keyed.color
} else {
const n = Number(keyed.color)
if (Number.isFinite(n)) color = `#${n.toString(16).padStart(6, '0')}`
}
} else if (typeof keyed.color === 'number' && Number.isFinite(keyed.color)) {
color = `#${keyed.color.toString(16).padStart(6, '0')}`
}
comments.push({ time: t, text, mode, color, size: clampSize(Number(keyed.size)) })
}
return comments
}
/**
* Parse danmaku payload into normalized comments. Auto-detects the format when
* `sourceType` is "auto" (tries JSON first, falls back to XML).
*/
export function parseDanmaku(raw: string, sourceType: 'auto' | 'xml' | 'json'): Comment[] {
if (sourceType === 'json' || (sourceType === 'auto' && looksLikeJson(raw))) {
try {
return parseJson(raw)
} catch {
if (sourceType === 'json') return []
}
}
if (sourceType === 'xml') return parseBilibiliXml(raw)
// auto: JSON parsing failed or didn't look like JSON — try XML.
return parseBilibiliXml(raw)
}
function looksLikeJson(raw: string): boolean {
const trimmed = raw.trimStart()
return trimmed.startsWith('[') || trimmed.startsWith('{')
}