feat: Implement danmaku (bullet comments) functionality with API integration, settings, and player display.

This commit is contained in:
kuekhaoyang
2026-02-17 10:21:07 +08:00
parent b11463a347
commit df37806de7
19 changed files with 829 additions and 5 deletions
+3
View File
@@ -15,6 +15,9 @@ interface CustomVideoPlayerProps {
currentEpisodeIndex?: number;
onNextEpisode?: () => void;
isReversed?: boolean;
// Danmaku props
videoTitle?: string;
episodeName?: string;
}
/**
+275
View File
@@ -0,0 +1,275 @@
'use client';
import React, { useRef, useEffect, useCallback } from 'react';
import type { DanmakuComment } from '@/lib/types/danmaku';
import { settingsStore } from '@/lib/store/settings-store';
interface DanmakuCanvasProps {
comments: DanmakuComment[];
currentTime: number;
isPlaying: boolean;
duration: number;
}
interface ActiveDanmaku {
comment: DanmakuComment;
x: number;
y: number;
speed: number;
width: number;
lane: number;
}
const SCROLL_DURATION = 8; // seconds for a comment to cross the screen
const LANE_HEIGHT_FACTOR = 1.4; // multiplied by font size for lane height
const TOP_BOTTOM_DURATION = 4; // seconds for top/bottom comments to stay visible
const MAX_LANES = 20;
export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: DanmakuCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const activeRef = useRef<ActiveDanmaku[]>([]);
const lastTimeRef = useRef(currentTime);
const lastRafTimeRef = useRef(0);
const rafRef = useRef<number>(0);
const lastSpawnTimeRef = useRef(-1);
const laneSlotsRef = useRef<number[]>(new Array(MAX_LANES).fill(0)); // tracks when each lane becomes free
// Settings (read reactively)
const [opacity, setOpacity] = React.useState(0.7);
const [fontSize, setFontSize] = React.useState(20);
useEffect(() => {
const s = settingsStore.getSettings();
setOpacity(s.danmakuOpacity);
setFontSize(s.danmakuFontSize);
const unsub = settingsStore.subscribe(() => {
const ns = settingsStore.getSettings();
setOpacity(ns.danmakuOpacity);
setFontSize(ns.danmakuFontSize);
});
return unsub;
}, []);
// Handle canvas resize
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
return () => observer.disconnect();
}, []);
// Clear on seek (when currentTime jumps significantly)
useEffect(() => {
const timeDiff = Math.abs(currentTime - lastTimeRef.current);
if (timeDiff > 2) {
activeRef.current = [];
lastSpawnTimeRef.current = -1;
laneSlotsRef.current = new Array(MAX_LANES).fill(0);
}
lastTimeRef.current = currentTime;
}, [currentTime]);
// Spawn new comments based on currentTime
const spawnComments = useCallback((time: number) => {
const canvas = canvasRef.current;
if (!canvas || !comments.length) return;
const rect = canvas.getBoundingClientRect();
const canvasWidth = rect.width;
const laneHeight = fontSize * LANE_HEIGHT_FACTOR;
// Find comments in the time window [lastSpawn, time]
const windowStart = lastSpawnTimeRef.current;
const windowEnd = time;
if (windowEnd <= windowStart) return;
// Binary search for start index
let lo = 0, hi = comments.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (comments[mid].time < windowStart) lo = mid + 1;
else hi = mid;
}
// Spawn comments in range
for (let i = lo; i < comments.length && comments[i].time <= windowEnd; i++) {
const c = comments[i];
const type = c.type || 'scroll';
// Measure text width
const ctx = canvas.getContext('2d');
if (!ctx) continue;
ctx.font = `bold ${fontSize}px sans-serif`;
const textWidth = ctx.measureText(c.text).width;
if (type === 'scroll') {
// Find available lane
const speed = (canvasWidth + textWidth) / SCROLL_DURATION;
let bestLane = -1;
for (let lane = 0; lane < MAX_LANES; lane++) {
const yPos = lane * laneHeight + fontSize;
if (yPos > rect.height - fontSize) break;
if (laneSlotsRef.current[lane] <= time) {
bestLane = lane;
break;
}
}
if (bestLane === -1) continue; // All lanes busy, drop comment
// Calculate when this lane will be free again
// (when the trailing edge of this comment has moved enough for a new one)
const timeToPassStartPoint = textWidth / speed + 0.5; // add gap
laneSlotsRef.current[bestLane] = time + timeToPassStartPoint;
activeRef.current.push({
comment: c,
x: canvasWidth,
y: bestLane * laneHeight + fontSize,
speed,
width: textWidth,
lane: bestLane,
});
} else {
// Top or bottom: find center lane
const maxLanes = Math.floor(rect.height / laneHeight / 2); // only use top/bottom half
let bestLane = -1;
for (let lane = 0; lane < Math.min(maxLanes, MAX_LANES); lane++) {
const laneKey = type === 'top' ? lane : MAX_LANES - 1 - lane;
if (laneSlotsRef.current[laneKey] <= time) {
bestLane = lane;
laneSlotsRef.current[laneKey] = time + TOP_BOTTOM_DURATION;
break;
}
}
if (bestLane === -1) continue;
const y = type === 'top'
? bestLane * laneHeight + fontSize
: rect.height - bestLane * laneHeight - fontSize * 0.4;
activeRef.current.push({
comment: { ...c, _expiry: time + TOP_BOTTOM_DURATION } as any,
x: (canvasWidth - textWidth) / 2,
y,
speed: 0,
width: textWidth,
lane: bestLane,
});
}
}
lastSpawnTimeRef.current = windowEnd;
}, [comments, fontSize]);
// Animation loop
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const animate = (rafTime: number) => {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!isPlaying) {
// When paused, still draw active comments frozen in place
ctx.save();
ctx.scale(dpr, dpr);
ctx.globalAlpha = opacity;
ctx.font = `bold ${fontSize}px sans-serif`;
ctx.textBaseline = 'middle';
for (const d of activeRef.current) {
ctx.fillStyle = d.comment.color || '#ffffff';
ctx.strokeStyle = 'rgba(0,0,0,0.8)';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.strokeText(d.comment.text, d.x, d.y);
ctx.fillText(d.comment.text, d.x, d.y);
}
ctx.restore();
rafRef.current = requestAnimationFrame(animate);
return;
}
// Calculate time delta for animation
const deltaMs = lastRafTimeRef.current ? rafTime - lastRafTimeRef.current : 16;
lastRafTimeRef.current = rafTime;
const deltaSec = deltaMs / 1000;
// Spawn new comments
spawnComments(currentTime);
// Update & filter active comments
const newActive: ActiveDanmaku[] = [];
for (const d of activeRef.current) {
const type = d.comment.type || 'scroll';
if (type === 'scroll') {
d.x -= d.speed * deltaSec;
if (d.x + d.width > 0) {
newActive.push(d);
}
} else {
// Top/bottom: remove when expired
const expiry = (d.comment as any)._expiry || 0;
if (currentTime < expiry) {
newActive.push(d);
}
}
}
activeRef.current = newActive;
// Draw
ctx.save();
ctx.scale(dpr, dpr);
ctx.globalAlpha = opacity;
ctx.font = `bold ${fontSize}px sans-serif`;
ctx.textBaseline = 'middle';
for (const d of activeRef.current) {
ctx.fillStyle = d.comment.color || '#ffffff';
ctx.strokeStyle = 'rgba(0,0,0,0.8)';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.strokeText(d.comment.text, d.x, d.y);
ctx.fillText(d.comment.text, d.x, d.y);
}
ctx.restore();
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
lastRafTimeRef.current = 0;
};
}, [isPlaying, currentTime, opacity, fontSize, spawnComments]);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 z-[5] pointer-events-none"
style={{ width: '100%', height: '100%' }}
/>
);
}
+26
View File
@@ -8,7 +8,9 @@ import { useAutoSkip } from './hooks/useAutoSkip';
import { useStallDetection } from './hooks/useStallDetection';
import { DesktopControlsWrapper } from './desktop/DesktopControlsWrapper';
import { DesktopOverlayWrapper } from './desktop/DesktopOverlayWrapper';
import { DanmakuCanvas } from './DanmakuCanvas';
import { usePlayerSettings } from './hooks/usePlayerSettings';
import { useDanmaku } from './hooks/useDanmaku';
import { useIsIOS, useIsMobile } from '@/lib/hooks/mobile/useDeviceDetection';
import { useDoubleTap } from '@/lib/hooks/mobile/useDoubleTap';
import './web-fullscreen.css';
@@ -25,6 +27,9 @@ interface DesktopVideoPlayerProps {
currentEpisodeIndex?: number;
onNextEpisode?: () => void;
isReversed?: boolean;
// Danmaku props
videoTitle?: string;
episodeName?: string;
}
export function DesktopVideoPlayer({
@@ -38,12 +43,21 @@ export function DesktopVideoPlayer({
currentEpisodeIndex = 0,
onNextEpisode,
isReversed = false,
videoTitle = '',
episodeName = '',
}: DesktopVideoPlayerProps) {
const { refs, data, actions } = useDesktopPlayerState();
const { fullscreenType: settingsFullscreenType } = usePlayerSettings();
const isIOS = useIsIOS();
const isMobile = useIsMobile();
// Danmaku
const { danmakuEnabled, setDanmakuEnabled, comments: danmakuComments } = useDanmaku({
videoTitle,
episodeName,
episodeIndex: currentEpisodeIndex,
});
// State to track if device is in landscape mode
const [isLandscape, setIsLandscape] = React.useState(true);
@@ -207,6 +221,16 @@ export function DesktopVideoPlayer({
{...({ 'webkit-playsinline': 'true' } as any)} // Legacy iOS support
/>
{/* Danmaku Canvas */}
{danmakuEnabled && danmakuComments.length > 0 && (
<DanmakuCanvas
comments={danmakuComments}
currentTime={currentTime}
isPlaying={isPlaying}
duration={duration}
/>
)}
<DesktopOverlayWrapper
data={data}
actions={actions}
@@ -254,6 +278,8 @@ export function DesktopVideoPlayer({
actions={actions}
logic={logic}
refs={refs}
danmakuEnabled={danmakuEnabled}
onToggleDanmaku={() => setDanmakuEnabled(!danmakuEnabled)}
/>
</div>
</div>
+8 -1
View File
@@ -19,6 +19,9 @@ interface VideoPlayerProps {
onNextEpisode?: () => void;
isReversed?: boolean;
isPremium?: boolean;
// Danmaku props
videoTitle?: string;
episodeName?: string;
}
export function VideoPlayer({
@@ -29,7 +32,9 @@ export function VideoPlayer({
totalEpisodes,
onNextEpisode,
isReversed = false,
isPremium = false
isPremium = false,
videoTitle,
episodeName,
}: VideoPlayerProps) {
const [videoError, setVideoError] = useState<string>('');
const [useProxy, setUseProxy] = useState(false);
@@ -227,6 +232,8 @@ export function VideoPlayer({
currentEpisodeIndex={currentEpisode}
onNextEpisode={onNextEpisode}
isReversed={isReversed}
videoTitle={videoTitle}
episodeName={episodeName}
/>
)}
</Card>
@@ -32,6 +32,8 @@ 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,9 +9,11 @@ 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 }: DesktopControlsWrapperProps) {
export function DesktopControlsWrapper({ src, data, actions, logic, refs, danmakuEnabled, onToggleDanmaku }: DesktopControlsWrapperProps) {
const {
isPlaying,
currentTime,
@@ -76,6 +78,8 @@ export function DesktopControlsWrapper({ src, data, actions, logic, refs }: Desk
onProgressMouseDown={handleProgressMouseDown}
onProgressTouchStart={handleProgressTouchStart}
formatTime={formatTime}
danmakuEnabled={danmakuEnabled}
onToggleDanmaku={onToggleDanmaku}
/>
);
}
@@ -47,6 +47,12 @@ export function DesktopMoreMenu({
setAdFilterMode,
fullscreenType,
setFullscreenType,
danmakuApiUrl,
setDanmakuApiUrl,
danmakuOpacity,
setDanmakuOpacity,
danmakuFontSize,
setDanmakuFontSize,
} = usePlayerSettings();
const buttonRef = React.useRef<HTMLButtonElement>(null);
@@ -358,6 +364,63 @@ export function DesktopMoreMenu({
</div>
</div>
{/* 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 */}
<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>
<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'}`}
>
<span>{danmakuFontSize}px</span>
</button>
</div>
{/* Auto Next Episode Switch */}
<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'}`}>
@@ -9,6 +9,8 @@ interface DesktopRightControlsProps {
isAirPlaySupported: boolean;
isCastAvailable: boolean;
isProxied?: boolean;
danmakuEnabled?: boolean;
onToggleDanmaku?: () => void;
onToggleFullscreen: () => void;
onTogglePictureInPicture: () => void;
onShowAirPlayMenu: () => void;
@@ -21,6 +23,8 @@ export function DesktopRightControls({
isAirPlaySupported,
isCastAvailable,
isProxied,
danmakuEnabled,
onToggleDanmaku,
onToggleFullscreen,
onTogglePictureInPicture,
onShowAirPlayMenu,
@@ -28,6 +32,21 @@ 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 && (
+123
View File
@@ -0,0 +1,123 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { settingsStore } from '@/lib/store/settings-store';
import { parseDanmakuResponse, parseSearchResults, matchEpisode, fuzzyMatchTitle } from '@/lib/utils/danmaku-utils';
import type { DanmakuComment } from '@/lib/types/danmaku';
interface UseDanmakuOptions {
videoTitle: string;
episodeName: string;
episodeIndex?: number;
}
interface UseDanmakuReturn {
danmakuEnabled: boolean;
setDanmakuEnabled: (v: boolean) => void;
comments: DanmakuComment[];
isLoading: boolean;
error: string | null;
}
export function useDanmaku({ videoTitle, episodeName, episodeIndex }: UseDanmakuOptions): UseDanmakuReturn {
const [danmakuEnabled, setDanmakuEnabledState] = useState(false);
const [apiUrl, setApiUrl] = useState('');
const [comments, setComments] = useState<DanmakuComment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchedKeyRef = useRef('');
// Sync with settings store
useEffect(() => {
const s = settingsStore.getSettings();
setDanmakuEnabledState(s.danmakuEnabled);
setApiUrl(s.danmakuApiUrl);
const unsub = settingsStore.subscribe(() => {
const ns = settingsStore.getSettings();
setDanmakuEnabledState(ns.danmakuEnabled);
setApiUrl(ns.danmakuApiUrl);
});
return unsub;
}, []);
const setDanmakuEnabled = useCallback((v: boolean) => {
const s = settingsStore.getSettings();
settingsStore.saveSettings({ ...s, danmakuEnabled: v });
}, []);
// Fetch danmaku comments when enabled and API URL is configured
useEffect(() => {
if (!danmakuEnabled || !apiUrl || !videoTitle) {
setComments([]);
return;
}
const fetchKey = `${apiUrl}|${videoTitle}|${episodeName}`;
if (fetchKey === fetchedKeyRef.current) return;
fetchedKeyRef.current = fetchKey;
let cancelled = false;
async function fetchDanmaku() {
setIsLoading(true);
setError(null);
try {
// Step 1: Search for the anime
const searchUrl = `/api/danmaku?action=search&keyword=${encodeURIComponent(videoTitle)}&apiUrl=${encodeURIComponent(apiUrl)}`;
const searchRes = await fetch(searchUrl);
if (!searchRes.ok) throw new Error(`Search failed: ${searchRes.status}`);
const searchData = await searchRes.json();
if (cancelled) return;
const results = parseSearchResults(searchData);
if (!results.length) {
setComments([]);
setIsLoading(false);
return;
}
// Step 2: Match title
const matched = fuzzyMatchTitle(results, videoTitle);
if (!matched || !matched.episodes.length) {
setComments([]);
setIsLoading(false);
return;
}
// Step 3: Match episode
const ep = matchEpisode(matched.episodes, episodeName, episodeIndex);
if (!ep) {
setComments([]);
setIsLoading(false);
return;
}
// Step 4: Fetch comments
const commentsUrl = `/api/danmaku?action=comments&episodeId=${encodeURIComponent(String(ep.episodeId))}&apiUrl=${encodeURIComponent(apiUrl)}`;
const commentsRes = await fetch(commentsUrl);
if (!commentsRes.ok) throw new Error(`Comments fetch failed: ${commentsRes.status}`);
const commentsData = await commentsRes.json();
if (cancelled) return;
const parsed = parseDanmakuResponse(commentsData);
setComments(parsed);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load danmaku');
setComments([]);
}
} finally {
if (!cancelled) setIsLoading(false);
}
}
fetchDanmaku();
return () => { cancelled = true; };
}, [danmakuEnabled, apiUrl, videoTitle, episodeName, episodeIndex]);
return { danmakuEnabled, setDanmakuEnabled, comments, isLoading, error };
}
@@ -22,6 +22,10 @@ export function usePlayerSettings() {
adKeywords: stored.adKeywords,
fullscreenType: stored.fullscreenType,
proxyMode: stored.proxyMode,
danmakuEnabled: stored.danmakuEnabled,
danmakuApiUrl: stored.danmakuApiUrl,
danmakuOpacity: stored.danmakuOpacity,
danmakuFontSize: stored.danmakuFontSize,
};
});
@@ -41,6 +45,10 @@ export function usePlayerSettings() {
adKeywords: stored.adKeywords,
fullscreenType: stored.fullscreenType,
proxyMode: stored.proxyMode,
danmakuEnabled: stored.danmakuEnabled,
danmakuApiUrl: stored.danmakuApiUrl,
danmakuOpacity: stored.danmakuOpacity,
danmakuFontSize: stored.danmakuFontSize,
});
});
return unsubscribe;
@@ -101,6 +109,22 @@ export function usePlayerSettings() {
updateSetting('proxyMode', value);
}, [updateSetting]);
const setDanmakuEnabled = useCallback((value: boolean) => {
updateSetting('danmakuEnabled', value);
}, [updateSetting]);
const setDanmakuApiUrl = useCallback((value: string) => {
updateSetting('danmakuApiUrl', value);
}, [updateSetting]);
const setDanmakuOpacity = useCallback((value: number) => {
updateSetting('danmakuOpacity', Math.max(0.1, Math.min(1, value)));
}, [updateSetting]);
const setDanmakuFontSize = useCallback((value: number) => {
updateSetting('danmakuFontSize', value);
}, [updateSetting]);
return {
...settings,
setAutoNextEpisode,
@@ -114,5 +138,9 @@ export function usePlayerSettings() {
setAdKeywords,
setFullscreenType,
setProxyMode,
setDanmakuEnabled,
setDanmakuApiUrl,
setDanmakuOpacity,
setDanmakuFontSize,
};
}
+2
View File
@@ -2,9 +2,11 @@
import { MediaIcons } from './icons/media-icons';
import { NavigationIcons } from './icons/navigation-icons';
import { UtilityIcons } from './icons/utility-icons';
import { DanmakuIcons } from './icons/danmaku-icons';
export const Icons = {
...MediaIcons,
...NavigationIcons,
...UtilityIcons,
...DanmakuIcons,
};
+11
View File
@@ -0,0 +1,11 @@
import { IconProps } from './types';
export const DanmakuIcons = {
Danmaku: ({ className = "", size = 24 }: IconProps) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<line x1="7" y1="8" x2="17" y2="8" />
<line x1="7" y1="12" x2="13" y2="12" />
</svg>
),
};