From 0750ebc12bf54ddcaa7ae6b556d153e6c55b76d8 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 17 Feb 2026 10:38:23 +0800 Subject: [PATCH] feat: Add comprehensive danmaku settings UI and store integration, and refactor danmaku props from player control components. --- README.md | 31 +++++++ app/settings/hooks/useSettingsPage.ts | 42 ++++++++++ app/settings/page.tsx | 12 +++ components/player/DesktopVideoPlayer.tsx | 2 - components/player/desktop/DesktopControls.tsx | 2 - .../player/desktop/DesktopControlsWrapper.tsx | 6 +- components/player/desktop/DesktopMoreMenu.tsx | 75 ++++++----------- .../player/desktop/DesktopRightControls.tsx | 19 ----- components/settings/PlayerSettings.tsx | 82 +++++++++++++++++++ lib/store/settings-store.ts | 4 +- package-lock.json | 4 +- package.json | 2 +- 12 files changed, 196 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index be9a3b9..45e3056 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,15 @@ - **自定义关键词**:支持通过环境变量或文件扩展过滤关键词 - **高性能**:基于流式处理,对播放加载速度几乎无影响 +### 弹幕 (Danmaku) + +- **弹幕聚合**:接入自建弹幕聚合 API(兼容 [danmu_api](https://github.com/huangxd-/danmu_api) 格式),自动匹配当前播放内容 +- **Canvas 渲染**:基于 Canvas 的高性能弹幕渲染,支持数百条弹幕同时滚动,不影响播放交互 +- **滚动/顶部/底部弹幕**:支持三种弹幕类型,自动分配轨道防止重叠 +- **自定义显示**:可调节弹幕透明度(10%-100%)和字号(14/18/20/24/28px) +- **播放器联动**:暂停时弹幕冻结,跳转时自动清除,全屏模式下正常显示 +- **环境变量预设**:可通过 `NEXT_PUBLIC_DANMAKU_API_URL` 为所有用户预设弹幕 API 地址 + ### 数据管理 - **设置导出/导入**:支持将所有设置导出为 JSON 文件,方便备份和迁移 @@ -272,6 +281,27 @@ docker run -d -p 3000:3000 \ --name kvideo kuekhaoyang/kvideo:latest ``` +## 弹幕 API 配置 + +通过环境变量预设弹幕聚合 API 地址,用户无需手动配置即可使用弹幕功能。 + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `NEXT_PUBLIC_DANMAKU_API_URL` | 弹幕聚合 API 地址 | - | + +需要自建或使用兼容 [danmu_api](https://github.com/huangxd-/danmu_api) 格式的弹幕聚合服务。 + +**示例:** + +```bash +# Docker +docker run -d -p 3000:3000 \ + -e NEXT_PUBLIC_DANMAKU_API_URL="https://your-danmu-api.example.com" \ + --name kvideo kuekhaoyang/kvideo:latest +``` + +设置后用户在播放器菜单中即可直接开启弹幕,也可在设置页面中覆盖此地址。 + ## 自定义源 JSON 格式 如果你想创建自己的订阅源或批量导入源,可以使用以下 JSON 格式。 @@ -346,6 +376,7 @@ docker run -d -p 3000:3000 \ | `NEXT_PUBLIC_SUBSCRIPTION_SOURCES` | 自动订阅源配置(客户端) | - | | `AD_KEYWORDS` / `NEXT_PUBLIC_AD_KEYWORDS` | 广告过滤关键词 | - | | `AD_KEYWORDS_FILE` | 广告关键词文件路径 | - | +| `NEXT_PUBLIC_DANMAKU_API_URL` | 弹幕聚合 API 地址 | - | ## 技术栈 diff --git a/app/settings/hooks/useSettingsPage.ts b/app/settings/hooks/useSettingsPage.ts index b5861e9..9e335d9 100644 --- a/app/settings/hooks/useSettingsPage.ts +++ b/app/settings/hooks/useSettingsPage.ts @@ -26,6 +26,11 @@ export function useSettingsPage() { const [proxyMode, setProxyMode] = useState('retry'); const [rememberScrollPosition, setRememberScrollPosition] = useState(true); + // Danmaku settings + const [danmakuApiUrl, setDanmakuApiUrl] = useState(''); + const [danmakuOpacity, setDanmakuOpacity] = useState(0.7); + const [danmakuFontSize, setDanmakuFontSize] = useState(20); + useEffect(() => { const settings = settingsStore.getSettings(); setSources(settings.sources || []); @@ -36,6 +41,9 @@ export function useSettingsPage() { setFullscreenType(settings.fullscreenType); setProxyMode(settings.proxyMode); setRememberScrollPosition(settings.rememberScrollPosition); + setDanmakuApiUrl(settings.danmakuApiUrl); + setDanmakuOpacity(settings.danmakuOpacity); + setDanmakuFontSize(settings.danmakuFontSize); }, []); const handleSourcesChange = (newSources: VideoSource[]) => { @@ -246,6 +254,34 @@ export function useSettingsPage() { }); }; + const handleDanmakuApiUrlChange = (url: string) => { + setDanmakuApiUrl(url); + const currentSettings = settingsStore.getSettings(); + settingsStore.saveSettings({ + ...currentSettings, + danmakuApiUrl: url, + }); + }; + + const handleDanmakuOpacityChange = (value: number) => { + const clamped = Math.max(0.1, Math.min(1, value)); + setDanmakuOpacity(clamped); + const currentSettings = settingsStore.getSettings(); + settingsStore.saveSettings({ + ...currentSettings, + danmakuOpacity: clamped, + }); + }; + + const handleDanmakuFontSizeChange = (value: number) => { + setDanmakuFontSize(value); + const currentSettings = settingsStore.getSettings(); + settingsStore.saveSettings({ + ...currentSettings, + danmakuFontSize: value, + }); + }; + const handleRestoreDefaults = () => { const defaults = getDefaultSources(); handleSourcesChange(defaults); @@ -296,5 +332,11 @@ export function useSettingsPage() { handleProxyModeChange, rememberScrollPosition, handleRememberScrollPositionChange, + danmakuApiUrl, + handleDanmakuApiUrlChange, + danmakuOpacity, + handleDanmakuOpacityChange, + danmakuFontSize, + handleDanmakuFontSizeChange, }; } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index b33de34..978df99 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -53,6 +53,12 @@ export default function SettingsPage() { handleProxyModeChange, rememberScrollPosition, handleRememberScrollPositionChange, + danmakuApiUrl, + handleDanmakuApiUrlChange, + danmakuOpacity, + handleDanmakuOpacityChange, + danmakuFontSize, + handleDanmakuFontSizeChange, } = useSettingsPage(); return ( @@ -71,6 +77,12 @@ export default function SettingsPage() { onFullscreenTypeChange={handleFullscreenTypeChange} proxyMode={proxyMode} onProxyModeChange={handleProxyModeChange} + danmakuApiUrl={danmakuApiUrl} + onDanmakuApiUrlChange={handleDanmakuApiUrlChange} + danmakuOpacity={danmakuOpacity} + onDanmakuOpacityChange={handleDanmakuOpacityChange} + danmakuFontSize={danmakuFontSize} + onDanmakuFontSizeChange={handleDanmakuFontSizeChange} /> {/* Display Settings */} diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index 50c7caa..c021fbd 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -278,8 +278,6 @@ export function DesktopVideoPlayer({ actions={actions} logic={logic} refs={refs} - danmakuEnabled={danmakuEnabled} - onToggleDanmaku={() => setDanmakuEnabled(!danmakuEnabled)} /> diff --git a/components/player/desktop/DesktopControls.tsx b/components/player/desktop/DesktopControls.tsx index d57ed7f..7d894d6 100644 --- a/components/player/desktop/DesktopControls.tsx +++ b/components/player/desktop/DesktopControls.tsx @@ -32,8 +32,6 @@ interface DesktopControlsProps { onProgressMouseDown: (e: React.MouseEvent) => void; onProgressTouchStart: (e: React.TouchEvent) => void; formatTime: (seconds: number) => string; - danmakuEnabled?: boolean; - onToggleDanmaku?: () => void; } export function DesktopControls(props: DesktopControlsProps) { diff --git a/components/player/desktop/DesktopControlsWrapper.tsx b/components/player/desktop/DesktopControlsWrapper.tsx index 562cdfd..0a9deb9 100644 --- a/components/player/desktop/DesktopControlsWrapper.tsx +++ b/components/player/desktop/DesktopControlsWrapper.tsx @@ -9,11 +9,9 @@ interface DesktopControlsWrapperProps { actions: ReturnType['actions']; logic: ReturnType; refs: ReturnType['refs']; - danmakuEnabled?: boolean; - onToggleDanmaku?: () => void; } -export function DesktopControlsWrapper({ src, data, actions, logic, refs, danmakuEnabled, onToggleDanmaku }: DesktopControlsWrapperProps) { +export function DesktopControlsWrapper({ src, data, actions, logic, refs }: DesktopControlsWrapperProps) { const { isPlaying, currentTime, @@ -78,8 +76,6 @@ export function DesktopControlsWrapper({ src, data, actions, logic, refs, danmak onProgressMouseDown={handleProgressMouseDown} onProgressTouchStart={handleProgressTouchStart} formatTime={formatTime} - danmakuEnabled={danmakuEnabled} - onToggleDanmaku={onToggleDanmaku} /> ); } diff --git a/components/player/desktop/DesktopMoreMenu.tsx b/components/player/desktop/DesktopMoreMenu.tsx index 35ecfb7..f21b83b 100644 --- a/components/player/desktop/DesktopMoreMenu.tsx +++ b/components/player/desktop/DesktopMoreMenu.tsx @@ -47,12 +47,9 @@ export function DesktopMoreMenu({ setAdFilterMode, fullscreenType, setFullscreenType, + danmakuEnabled, + setDanmakuEnabled, danmakuApiUrl, - setDanmakuApiUrl, - danmakuOpacity, - setDanmakuOpacity, - danmakuFontSize, - setDanmakuFontSize, } = usePlayerSettings(); const buttonRef = React.useRef(null); @@ -367,57 +364,31 @@ export function DesktopMoreMenu({ {/* Divider */}
- {/* Danmaku API URL */} -
-
- - 弹幕 API -
- setDanmakuApiUrl(e.target.value)} - className={`w-full bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)] ${isRotated ? 'px-2 py-0.5 text-[10px]' : 'px-2.5 py-1 sm:px-3 sm:py-1.5 text-xs sm:text-sm'}`} - onClick={(e) => e.stopPropagation()} - /> -
- - {/* Danmaku Opacity Slider */} -
-
- 透明度 -
-
- setDanmakuOpacity(parseInt(e.target.value) / 100)} - className={`flex-1 accent-[var(--accent-color)] ${isRotated ? 'h-1' : 'h-1.5'}`} - onClick={(e) => e.stopPropagation()} - /> - - {Math.round(danmakuOpacity * 100)}% - -
-
- - {/* Danmaku Font Size */} + {/* Danmaku Toggle */}
-
- 字号 +
+ + 弹幕 + {!danmakuApiUrl && ( + (未配置) + )}
diff --git a/components/player/desktop/DesktopRightControls.tsx b/components/player/desktop/DesktopRightControls.tsx index 9d119ef..9ccf388 100644 --- a/components/player/desktop/DesktopRightControls.tsx +++ b/components/player/desktop/DesktopRightControls.tsx @@ -9,8 +9,6 @@ interface DesktopRightControlsProps { isAirPlaySupported: boolean; isCastAvailable: boolean; isProxied?: boolean; - danmakuEnabled?: boolean; - onToggleDanmaku?: () => void; onToggleFullscreen: () => void; onTogglePictureInPicture: () => void; onShowAirPlayMenu: () => void; @@ -23,8 +21,6 @@ export function DesktopRightControls({ isAirPlaySupported, isCastAvailable, isProxied, - danmakuEnabled, - onToggleDanmaku, onToggleFullscreen, onTogglePictureInPicture, onShowAirPlayMenu, @@ -32,21 +28,6 @@ export function DesktopRightControls({ }: DesktopRightControlsProps) { return (
- {/* Danmaku Toggle */} - {onToggleDanmaku && ( - - )} - {/* Picture-in-Picture */} { isPiPSupported && ( diff --git a/components/settings/PlayerSettings.tsx b/components/settings/PlayerSettings.tsx index 420206c..4fd1390 100644 --- a/components/settings/PlayerSettings.tsx +++ b/components/settings/PlayerSettings.tsx @@ -13,13 +13,27 @@ interface PlayerSettingsProps { onFullscreenTypeChange: (type: 'auto' | 'native' | 'window') => void; proxyMode: ProxyMode; onProxyModeChange: (mode: ProxyMode) => void; + danmakuApiUrl: string; + onDanmakuApiUrlChange: (url: string) => void; + danmakuOpacity: number; + onDanmakuOpacityChange: (value: number) => void; + danmakuFontSize: number; + onDanmakuFontSizeChange: (value: number) => void; } +const DANMAKU_FONT_SIZES = [14, 18, 20, 24, 28]; + export function PlayerSettings({ fullscreenType, onFullscreenTypeChange, proxyMode, onProxyModeChange, + danmakuApiUrl, + onDanmakuApiUrlChange, + danmakuOpacity, + onDanmakuOpacityChange, + danmakuFontSize, + onDanmakuFontSizeChange, }: PlayerSettingsProps) { return (
@@ -103,6 +117,74 @@ export function PlayerSettings({
+ +
+ + {/* Danmaku Settings */} +
+

+ + 弹幕设置 +

+

+ 配置弹幕聚合 API 地址,在播放器菜单中开关弹幕 +

+ + {/* API URL */} +
+
+ + onDanmakuApiUrlChange(e.target.value)} + className="w-full px-4 py-2.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)] transition-colors text-sm" + /> +

+ 兼容 danmu_api 格式的弹幕聚合服务 +

+
+ + {/* Opacity */} +
+ + onDanmakuOpacityChange(parseInt(e.target.value) / 100)} + className="w-full accent-[var(--accent-color)] h-2" + /> +
+ + {/* Font Size */} +
+ +
+ {DANMAKU_FONT_SIZES.map((size) => ( + + ))} +
+
+
+
); diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts index eb25f96..15c027a 100644 --- a/lib/store/settings-store.ts +++ b/lib/store/settings-store.ts @@ -123,7 +123,7 @@ function getDefaultAppSettings(): AppSettings { proxyMode: 'retry', rememberScrollPosition: true, danmakuEnabled: false, - danmakuApiUrl: '', + danmakuApiUrl: process.env.NEXT_PUBLIC_DANMAKU_API_URL || '', danmakuOpacity: 0.7, danmakuFontSize: 20, }; @@ -203,7 +203,7 @@ export const settingsStore = { proxyMode: (parsed.proxyMode === 'retry' || parsed.proxyMode === 'none' || parsed.proxyMode === 'always') ? parsed.proxyMode : 'retry', rememberScrollPosition: parsed.rememberScrollPosition !== undefined ? parsed.rememberScrollPosition : true, danmakuEnabled: parsed.danmakuEnabled !== undefined ? parsed.danmakuEnabled : false, - danmakuApiUrl: typeof parsed.danmakuApiUrl === 'string' ? parsed.danmakuApiUrl : '', + danmakuApiUrl: typeof parsed.danmakuApiUrl === 'string' ? (parsed.danmakuApiUrl || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '') : (process.env.NEXT_PUBLIC_DANMAKU_API_URL || ''), danmakuOpacity: typeof parsed.danmakuOpacity === 'number' ? parsed.danmakuOpacity : 0.7, danmakuFontSize: typeof parsed.danmakuFontSize === 'number' ? parsed.danmakuFontSize : 20, }; diff --git a/package-lock.json b/package-lock.json index 8c663e0..45ffa38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.3.0", + "version": "4.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.3.0", + "version": "4.3.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 31ddbc4..60e46b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.3.0", + "version": "4.3.1", "private": true, "scripts": { "dev": "next dev",