feat: Add comprehensive danmaku settings UI and store integration, and refactor danmaku props from player control components.

This commit is contained in:
kuekhaoyang
2026-02-17 10:38:23 +08:00
parent df37806de7
commit 0750ebc12b
12 changed files with 196 additions and 85 deletions
+31
View File
@@ -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 地址 | - |
## 技术栈
+42
View File
@@ -26,6 +26,11 @@ export function useSettingsPage() {
const [proxyMode, setProxyMode] = useState<ProxyMode>('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,
};
}
+12
View File
@@ -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 */}
-2
View File
@@ -278,8 +278,6 @@ export function DesktopVideoPlayer({
actions={actions}
logic={logic}
refs={refs}
danmakuEnabled={danmakuEnabled}
onToggleDanmaku={() => setDanmakuEnabled(!danmakuEnabled)}
/>
</div>
</div>
@@ -32,8 +32,6 @@ interface DesktopControlsProps {
onProgressMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
onProgressTouchStart: (e: React.TouchEvent<HTMLDivElement>) => void;
formatTime: (seconds: number) => string;
danmakuEnabled?: boolean;
onToggleDanmaku?: () => void;
}
export function DesktopControls(props: DesktopControlsProps) {
@@ -9,11 +9,9 @@ interface DesktopControlsWrapperProps {
actions: ReturnType<typeof useDesktopPlayerState>['actions'];
logic: ReturnType<typeof useDesktopPlayerLogic>;
refs: ReturnType<typeof useDesktopPlayerState>['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}
/>
);
}
+23 -52
View File
@@ -47,12 +47,9 @@ export function DesktopMoreMenu({
setAdFilterMode,
fullscreenType,
setFullscreenType,
danmakuEnabled,
setDanmakuEnabled,
danmakuApiUrl,
setDanmakuApiUrl,
danmakuOpacity,
setDanmakuOpacity,
danmakuFontSize,
setDanmakuFontSize,
} = usePlayerSettings();
const buttonRef = React.useRef<HTMLButtonElement>(null);
@@ -367,57 +364,31 @@ export function DesktopMoreMenu({
{/* Divider */}
<div className="h-px bg-[var(--glass-border)] my-1.5 sm:my-2" />
{/* Danmaku API URL */}
<div className={`${isRotated ? 'px-2 py-1.5' : 'px-3 py-2 sm:px-4 sm:py-2.5'}`}>
<div className={`flex items-center gap-2 text-[var(--text-color)] ${isRotated ? 'text-[11px]' : 'text-xs sm:text-sm'} mb-1.5`}>
<Icons.Danmaku size={isRotated ? 14 : 16} className="sm:w-[18px] sm:h-[18px]" />
<span> API</span>
</div>
<input
type="text"
placeholder="https://example.com"
value={danmakuApiUrl}
onChange={(e) => 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()}
/>
</div>
{/* Danmaku Opacity Slider */}
<div className={`${isRotated ? 'px-2 py-1.5' : 'px-3 py-2 sm:px-4 sm:py-2.5'} flex items-center justify-between gap-3`}>
<div className={`flex items-center gap-2 text-[var(--text-color)] ${isRotated ? 'text-[11px]' : 'text-xs sm:text-sm'} whitespace-nowrap`}>
<span></span>
</div>
<div className="flex items-center gap-2 flex-1 min-w-0">
<input
type="range"
min="10"
max="100"
value={Math.round(danmakuOpacity * 100)}
onChange={(e) => setDanmakuOpacity(parseInt(e.target.value) / 100)}
className={`flex-1 accent-[var(--accent-color)] ${isRotated ? 'h-1' : 'h-1.5'}`}
onClick={(e) => e.stopPropagation()}
/>
<span className={`text-[var(--text-color-secondary)] ${isRotated ? 'text-[9px]' : 'text-[10px] sm:text-xs'} w-7 text-right`}>
{Math.round(danmakuOpacity * 100)}%
</span>
</div>
</div>
{/* Danmaku Font Size */}
{/* Danmaku Toggle */}
<div className={`${isRotated ? 'px-2 py-1.5' : 'px-3 py-2 sm:px-4 sm:py-2.5'} flex items-center justify-between gap-4`}>
<div className={`flex items-center gap-2 text-[var(--text-color)] ${isRotated ? 'text-[11px]' : 'text-xs sm:text-sm'}`}>
<span></span>
<div className={`flex items-center gap-2 ${!danmakuApiUrl ? 'text-[var(--text-color-secondary)]' : 'text-[var(--text-color)]'} ${isRotated ? 'text-[11px]' : 'text-xs sm:text-sm'}`}>
<Icons.Danmaku size={isRotated ? 14 : 16} className="sm:w-[18px] sm:h-[18px]" />
<span></span>
{!danmakuApiUrl && (
<span className={`${isRotated ? 'text-[9px]' : 'text-[10px] sm:text-xs'} text-[var(--text-color-secondary)]`}>()</span>
)}
</div>
<button
onClick={() => {
const sizes = [14, 18, 20, 24, 28];
const idx = sizes.indexOf(danmakuFontSize);
setDanmakuFontSize(sizes[(idx + 1) % sizes.length]);
}}
className={`flex items-center gap-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] rounded-[var(--radius-2xl)] outline-none hover:border-[var(--accent-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)] transition-all cursor-pointer whitespace-nowrap ${isRotated ? 'px-1.5 py-0.5 text-[9px]' : 'px-2 sm:px-2.5 py-1 sm:py-1.5 text-[10px] sm:text-xs'}`}
onClick={() => danmakuApiUrl && setDanmakuEnabled(!danmakuEnabled)}
disabled={!danmakuApiUrl}
className={`relative rounded-full transition-all duration-300 flex-shrink-0 border border-white/20 ${!danmakuApiUrl
? 'bg-white/5 opacity-40 cursor-not-allowed'
: danmakuEnabled
? 'bg-[var(--accent-color)] shadow-[0_0_15px_rgba(var(--accent-color-rgb),0.6)] cursor-pointer'
: 'bg-white/5 hover:bg-white/10 cursor-pointer'
} ${isRotated ? 'w-6 h-3.5' : 'w-8 h-[18px] sm:w-10 sm:h-6'}`}
aria-checked={danmakuEnabled}
role="switch"
>
<span>{danmakuFontSize}px</span>
<span
className={`absolute top-0.5 left-0.5 bg-white rounded-full transition-transform duration-300 shadow-[0_2px_4px_rgba(0,0,0,0.4)] ${isRotated ? 'w-2.5 h-2.5' : 'w-3.5 h-3.5 sm:w-4.5 sm:h-4.5'} ${danmakuEnabled && danmakuApiUrl ? (isRotated ? 'translate-x-2.5' : 'translate-x-3.5 sm:translate-x-4.5') : 'translate-x-0'
}`}
/>
</button>
</div>
@@ -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 (
<div className="relative z-50 flex items-center gap-3">
{/* Danmaku Toggle */}
{onToggleDanmaku && (
<button
onClick={onToggleDanmaku}
className="btn-icon"
aria-label={danmakuEnabled ? '关闭弹幕' : '开启弹幕'}
title={danmakuEnabled ? '关闭弹幕' : '开启弹幕'}
>
<Icons.Danmaku
size={20}
className={danmakuEnabled ? 'text-[var(--accent-color)]' : ''}
/>
</button>
)}
{/* Picture-in-Picture */}
{
isPiPSupported && (
+82
View File
@@ -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 (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
@@ -103,6 +117,74 @@ export function PlayerSettings({
</button>
</div>
</div>
<div className="border-t border-[var(--glass-border)]" />
{/* Danmaku Settings */}
<div>
<h3 className="font-medium text-[var(--text-color)] mb-2 inline-flex items-center gap-2">
<Icons.Danmaku size={18} className="text-[var(--accent-color)]" />
</h3>
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
API
</p>
{/* API URL */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
API
</label>
<input
type="text"
placeholder="https://your-danmu-api.example.com"
value={danmakuApiUrl}
onChange={(e) => 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"
/>
<p className="text-xs text-[var(--text-color-secondary)] mt-1.5">
<a href="https://github.com/huangxd-/danmu_api" target="_blank" rel="noopener noreferrer" className="text-[var(--accent-color)] hover:underline">danmu_api</a>
</p>
</div>
{/* Opacity */}
<div>
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
{Math.round(danmakuOpacity * 100)}%
</label>
<input
type="range"
min="10"
max="100"
value={Math.round(danmakuOpacity * 100)}
onChange={(e) => onDanmakuOpacityChange(parseInt(e.target.value) / 100)}
className="w-full accent-[var(--accent-color)] h-2"
/>
</div>
{/* Font Size */}
<div>
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
</label>
<div className="flex gap-2">
{DANMAKU_FONT_SIZES.map((size) => (
<button
key={size}
onClick={() => onDanmakuFontSizeChange(size)}
className={`px-3 py-1.5 rounded-[var(--radius-2xl)] border text-sm font-medium transition-all duration-200 cursor-pointer ${danmakuFontSize === size
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]'
}`}
>
{size}px
</button>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
+2 -2
View File
@@ -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,
};
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.3.0",
"version": "4.3.1",
"private": true,
"scripts": {
"dev": "next dev",