mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 04:26:36 +08:00
支持按用户保存播放器与弹幕偏好
在 user 记录上新增播放器音量、弹幕开关、透明度、字号、显示区域、合并偏好、自定义弹幕源以及 dandanplay 应用凭据字段,并提供 PUT /danmaku/settings 局部更新接口,带取值校验。 返回脱敏后的完整渲染配置,绝不回传应用密钥,仅在响应中标记密钥是否已配置。 结果缓存键加入用户 ID 与凭据指纹,避免不同用户或凭据串用缓存;旧版实例级设置仅作为未登录与迁移期的回退。
This commit is contained in:
+160
-29
@@ -5,7 +5,13 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI, libraryAPI } from '../api/library'
|
||||
import { hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
|
||||
import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import {
|
||||
danmakuAPI,
|
||||
type DanmakuAnime,
|
||||
type DanmakuConfig,
|
||||
type DanmakuLoadedInfo,
|
||||
type DanmakuSettingsPatch,
|
||||
} from '../api/danmaku'
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
@@ -55,6 +61,12 @@ type PlaybackProgressSession = {
|
||||
sequence: number
|
||||
}
|
||||
|
||||
function normalizePlayerVolume(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
return Math.min(1, Math.max(0, parsed))
|
||||
}
|
||||
|
||||
function newPlaybackProgressSession(mediaId: string): PlaybackProgressSession {
|
||||
const randomID =
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
@@ -94,7 +106,11 @@ export function PlayerPage() {
|
||||
)
|
||||
const [subtitlePosition, setSubtitlePosition] = useState<SubtitlePosition>(loadSubtitlePosition)
|
||||
const [subtitleStyle, setSubtitleStyle] = useState<SubtitleStylePreset>(loadSubtitleStyle)
|
||||
const [playerVolume, setPlayerVolume] = useState(() =>
|
||||
normalizePlayerVolume(authUser?.player_volume),
|
||||
)
|
||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||
const playerVolumeTouchedRef = useRef(false)
|
||||
const subtitlePreferenceTouchedRef = useRef(false)
|
||||
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
@@ -131,6 +147,10 @@ export function PlayerPage() {
|
||||
const [danmakuOpacity, setDanmakuOpacity] = useState(1)
|
||||
const [danmakuFontSize, setDanmakuFontSize] = useState(24)
|
||||
const [danmakuArea, setDanmakuArea] = useState(1)
|
||||
const [danmakuSource, setDanmakuSource] = useState('')
|
||||
const [danmakuAppID, setDanmakuAppID] = useState('')
|
||||
const [danmakuAppKeyConfigured, setDanmakuAppKeyConfigured] = useState(false)
|
||||
const [playerSettingsSaving, setPlayerSettingsSaving] = useState(false)
|
||||
|
||||
// 选集 / 播放列表状态
|
||||
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
|
||||
@@ -181,51 +201,151 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [setAuthUser])
|
||||
|
||||
// 读取宿主机已保存的弹幕参数作为面板初始值(无 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)
|
||||
const applyDanmakuConnectionConfig = useCallback((cfg: DanmakuConfig) => {
|
||||
setDanmakuSource(cfg.source ?? '')
|
||||
setDanmakuAppID(cfg.app_id ?? '')
|
||||
setDanmakuAppKeyConfigured(Boolean(cfg.app_key_configured))
|
||||
}, [])
|
||||
|
||||
// 读取弹幕配置(含按用户存储的合并偏好),初始化面板。
|
||||
const saveDanmakuSettings = useCallback(
|
||||
async (patch: DanmakuSettingsPatch, failureMessage = '播放器设置保存失败,请重试') => {
|
||||
setPlayerSettingsSaving(true)
|
||||
try {
|
||||
const cfg = await danmakuAPI.updateSettings(patch)
|
||||
applyDanmakuConnectionConfig(cfg)
|
||||
return cfg
|
||||
} catch (error) {
|
||||
toast.error(failureMessage)
|
||||
throw error
|
||||
} finally {
|
||||
setPlayerSettingsSaving(false)
|
||||
}
|
||||
},
|
||||
[applyDanmakuConnectionConfig],
|
||||
)
|
||||
|
||||
// 一次读取当前用户的音量、弹幕渲染参数、服务地址和凭据配置状态。
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
danmakuAPI
|
||||
.config()
|
||||
.then((cfg) => {
|
||||
if (cancelled) return
|
||||
const volume = normalizePlayerVolume(cfg.volume)
|
||||
if (!playerVolumeTouchedRef.current) {
|
||||
setPlayerVolume(volume)
|
||||
}
|
||||
setDanmakuEnabled(cfg.enabled)
|
||||
setDanmakuOpacity(Number(cfg.opacity) || 1)
|
||||
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
||||
setDanmakuArea(Number(cfg.area) || 1)
|
||||
setDanmakuMergeSources(Boolean(cfg.merge_sources))
|
||||
applyDanmakuConnectionConfig(cfg)
|
||||
if (!playerVolumeTouchedRef.current) {
|
||||
const current = useAuthStore.getState().user
|
||||
if (current) setAuthUser({ ...current, player_volume: volume })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 配置读取失败时保持默认(不合并),不影响播放。
|
||||
// 配置读取失败时保持本地默认值,不影响播放。
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [applyDanmakuConnectionConfig, setAuthUser, authUser?.id])
|
||||
|
||||
const changePlayerVolume = useCallback((next: number) => {
|
||||
playerVolumeTouchedRef.current = true
|
||||
setPlayerVolume(normalizePlayerVolume(next))
|
||||
}, [])
|
||||
|
||||
const commitPlayerVolume = useCallback(
|
||||
(next: number) => {
|
||||
playerVolumeTouchedRef.current = true
|
||||
const volume = normalizePlayerVolume(next)
|
||||
setPlayerVolume(volume)
|
||||
void danmakuAPI
|
||||
.updateSettings({ volume })
|
||||
.then((cfg) => {
|
||||
const saved = normalizePlayerVolume(cfg.volume)
|
||||
setPlayerVolume(saved)
|
||||
const current = useAuthStore.getState().user
|
||||
if (current) setAuthUser({ ...current, player_volume: saved })
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('音量保存失败,请重试')
|
||||
})
|
||||
},
|
||||
[setAuthUser],
|
||||
)
|
||||
|
||||
const saveDanmakuAdvanced = useCallback(
|
||||
async (values: { source: string; appId: string; appKey: string; clearAppKey: boolean }) => {
|
||||
const patch: DanmakuSettingsPatch = {
|
||||
source: values.source.trim(),
|
||||
app_id: values.appId.trim(),
|
||||
}
|
||||
if (values.clearAppKey) {
|
||||
patch.clear_app_key = true
|
||||
} else if (values.appKey.trim()) {
|
||||
patch.app_key = values.appKey.trim()
|
||||
}
|
||||
const cfg = await saveDanmakuSettings(patch, '弹幕服务设置保存失败,请重试')
|
||||
setDanmakuSource(cfg.source ?? '')
|
||||
setDanmakuAppID(cfg.app_id ?? '')
|
||||
setDanmakuAppKeyConfigured(Boolean(cfg.app_key_configured))
|
||||
// 地址或凭据变化后立即按新配置重新抓取当前对象的弹幕。
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearchTrigger((prev) => prev + 1)
|
||||
toast.success('弹幕服务设置已保存')
|
||||
},
|
||||
[saveDanmakuSettings],
|
||||
)
|
||||
|
||||
// 切换合并开关:先落库,成功后再重新抓取,避免与后端读到的偏好不一致。
|
||||
const danmakuChangeMergeSources = useCallback((next: boolean) => {
|
||||
setDanmakuMergeSaving(true)
|
||||
danmakuAPI
|
||||
.updateSettings({ mergeSources: next })
|
||||
.then(() => {
|
||||
setDanmakuMergeSources(next)
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearchTrigger((prev) => prev + 1)
|
||||
})
|
||||
.catch(() => {
|
||||
// 保存失败时保持原值,用户可重试。
|
||||
})
|
||||
.finally(() => setDanmakuMergeSaving(false))
|
||||
}, [])
|
||||
const danmakuChangeMergeSources = useCallback(
|
||||
(next: boolean) => {
|
||||
setDanmakuMergeSaving(true)
|
||||
saveDanmakuSettings({ merge_sources: next })
|
||||
.then(() => {
|
||||
setDanmakuMergeSources(next)
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearchTrigger((prev) => prev + 1)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setDanmakuMergeSaving(false))
|
||||
},
|
||||
[saveDanmakuSettings],
|
||||
)
|
||||
|
||||
const danmakuChangeEnabled = useCallback(
|
||||
(next: boolean) => {
|
||||
setDanmakuEnabled(next)
|
||||
void saveDanmakuSettings({ enabled: next }).catch(() => undefined)
|
||||
},
|
||||
[saveDanmakuSettings],
|
||||
)
|
||||
|
||||
const commitDanmakuArea = useCallback(
|
||||
(next: number) => {
|
||||
void saveDanmakuSettings({ area: next }).catch(() => undefined)
|
||||
},
|
||||
[saveDanmakuSettings],
|
||||
)
|
||||
|
||||
const commitDanmakuOpacity = useCallback(
|
||||
(next: number) => {
|
||||
void saveDanmakuSettings({ opacity: next }).catch(() => undefined)
|
||||
},
|
||||
[saveDanmakuSettings],
|
||||
)
|
||||
|
||||
const commitDanmakuFontSize = useCallback(
|
||||
(next: number) => {
|
||||
void saveDanmakuSettings({ font_size: next }).catch(() => undefined)
|
||||
},
|
||||
[saveDanmakuSettings],
|
||||
)
|
||||
|
||||
// 用户手动搜索:带关键词重新拉取(search=null 时按视频名)。
|
||||
// loading 状态由 DanmakuStage 拉取完成回调(onLoaded)驱动。
|
||||
@@ -1038,6 +1158,9 @@ export function PlayerPage() {
|
||||
onSubtitleStyleChange={changeSubtitleStyle}
|
||||
videoRef={ref}
|
||||
onVideoError={handleVideoError}
|
||||
playerVolume={playerVolume}
|
||||
onPlayerVolumeChange={changePlayerVolume}
|
||||
onPlayerVolumeCommit={commitPlayerVolume}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
danmakuOpacity={danmakuOpacity}
|
||||
danmakuFontSize={danmakuFontSize}
|
||||
@@ -1076,16 +1199,24 @@ export function PlayerPage() {
|
||||
open={danmakuOpen}
|
||||
onClose={() => setDanmakuOpen(false)}
|
||||
enabled={danmakuEnabled}
|
||||
onToggleEnabled={setDanmakuEnabled}
|
||||
onToggleEnabled={danmakuChangeEnabled}
|
||||
search={danmakuSearch ?? ''}
|
||||
onSearch={searchDanmaku}
|
||||
searching={danmakuSearching}
|
||||
area={danmakuArea}
|
||||
onAreaChange={setDanmakuArea}
|
||||
onAreaCommit={commitDanmakuArea}
|
||||
opacity={danmakuOpacity}
|
||||
onOpacityChange={setDanmakuOpacity}
|
||||
onOpacityCommit={commitDanmakuOpacity}
|
||||
fontSize={danmakuFontSize}
|
||||
onFontSizeChange={setDanmakuFontSize}
|
||||
onFontSizeCommit={commitDanmakuFontSize}
|
||||
source={danmakuSource}
|
||||
appId={danmakuAppID}
|
||||
appKeyConfigured={danmakuAppKeyConfigured}
|
||||
settingsSaving={playerSettingsSaving}
|
||||
onSaveAdvanced={saveDanmakuAdvanced}
|
||||
candidates={danmakuCandidates}
|
||||
alternatives={danmakuAlternatives}
|
||||
mergeSources={danmakuMergeSources}
|
||||
|
||||
@@ -147,6 +147,9 @@ type PlayerVideoStageProps = {
|
||||
onSubtitleStyleChange: (style: SubtitleStylePreset) => void
|
||||
videoRef: RefObject<HTMLVideoElement>
|
||||
onVideoError: () => void
|
||||
playerVolume: number
|
||||
onPlayerVolumeChange: (volume: number) => void
|
||||
onPlayerVolumeCommit: (volume: number) => void
|
||||
danmakuEnabled: boolean
|
||||
danmakuOpacity: number
|
||||
danmakuFontSize: number
|
||||
@@ -192,6 +195,9 @@ export function PlayerVideoStage({
|
||||
onSubtitleStyleChange,
|
||||
videoRef,
|
||||
onVideoError,
|
||||
playerVolume,
|
||||
onPlayerVolumeChange,
|
||||
onPlayerVolumeCommit,
|
||||
danmakuEnabled,
|
||||
danmakuOpacity,
|
||||
danmakuFontSize,
|
||||
@@ -631,6 +637,9 @@ export function PlayerVideoStage({
|
||||
</div>
|
||||
<PlayerControls
|
||||
videoRef={videoRef}
|
||||
volume={playerVolume}
|
||||
onVolumeChange={onPlayerVolumeChange}
|
||||
onVolumeCommit={onPlayerVolumeCommit}
|
||||
uiVisible={controlsVisible}
|
||||
onUiVisibleChange={setControlsVisible}
|
||||
subs={subs}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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.app_id',
|
||||
label: 'AppId(弹弹play 开放 API)',
|
||||
type: 'text',
|
||||
hint: '弹弹play DevCenter 申请的应用 ID(https://doc.dandanplay.com/open/)。官方接口要求应用认证,留空使用内置凭据(签名认证,开箱即用);填写自己的 AppId/AppKey 可覆盖内置凭据。仅对官方 api.dandanplay.net 生效,第三方协议源不会携带凭据。',
|
||||
placeholder: '在 DevCenter 申请的应用 ID',
|
||||
},
|
||||
{
|
||||
key: 'danmaku.app_key',
|
||||
label: 'AppKey(弹弹play 应用密钥)',
|
||||
type: 'text',
|
||||
hint: '与 AppId 配套的 AppSecret,只保存在服务器上用于计算请求签名(base64(sha256(AppId+Timestamp+Path+Secret))),不会下发到播放器。',
|
||||
placeholder: '在 DevCenter 申请的应用密钥',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { adultSettingsGroup } from './settingsGroupAccess'
|
||||
import { apiConfigsSettingsGroup } from './settingsGroupAPIConfigs'
|
||||
import { danmakuSettingsGroup } from './settingsGroupDanmaku'
|
||||
import { generalSettingsGroup } from './settingsGroupGeneral'
|
||||
import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords'
|
||||
import type { SettingGroup } from './settingsGroupTypes'
|
||||
@@ -26,7 +25,6 @@ export const GROUPS: SettingGroup[] = [
|
||||
databaseSettingsGroup,
|
||||
apiConfigsSettingsGroup,
|
||||
recognitionWordsSettingsGroup,
|
||||
danmakuSettingsGroup,
|
||||
adultSettingsGroup,
|
||||
aboutSettingsGroup,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user