添加弹幕功能

添加弹幕功能
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
+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,
]