diff --git a/app/api/danmaku/route.ts b/app/api/danmaku/route.ts
new file mode 100644
index 0000000..b3bf439
--- /dev/null
+++ b/app/api/danmaku/route.ts
@@ -0,0 +1,85 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+export const runtime = 'edge';
+
+const CORS_HEADERS = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type',
+};
+
+export async function OPTIONS() {
+ return new NextResponse(null, { headers: CORS_HEADERS });
+}
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = request.nextUrl;
+ const action = searchParams.get('action');
+ const apiUrl = searchParams.get('apiUrl');
+
+ if (!action || !apiUrl) {
+ return NextResponse.json(
+ { error: 'Missing action or apiUrl parameter' },
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+
+ // Normalize base URL (remove trailing slash)
+ const baseUrl = apiUrl.replace(/\/+$/, '');
+
+ try {
+ let targetUrl: string;
+
+ if (action === 'search') {
+ const keyword = searchParams.get('keyword');
+ if (!keyword) {
+ return NextResponse.json(
+ { error: 'Missing keyword parameter' },
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+ targetUrl = `${baseUrl}/api/v2/search/episodes?anime=${encodeURIComponent(keyword)}`;
+ } else if (action === 'comments') {
+ const episodeId = searchParams.get('episodeId');
+ if (!episodeId) {
+ return NextResponse.json(
+ { error: 'Missing episodeId parameter' },
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+ targetUrl = `${baseUrl}/api/v2/comment/${encodeURIComponent(episodeId)}?withRelated=true`;
+ } else {
+ return NextResponse.json(
+ { error: 'Invalid action. Use "search" or "comments".' },
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+
+ const response = await fetch(targetUrl, {
+ headers: {
+ 'Accept': 'application/json',
+ 'User-Agent': 'KVideo/1.0',
+ },
+ });
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: `Upstream API returned ${response.status}` },
+ { status: response.status, headers: CORS_HEADERS }
+ );
+ }
+
+ const data = await response.json();
+ return NextResponse.json(data, {
+ headers: {
+ ...CORS_HEADERS,
+ 'Cache-Control': 'public, max-age=3600', // Cache danmaku for 1 hour
+ },
+ });
+ } catch (error) {
+ return NextResponse.json(
+ { error: 'Failed to fetch from danmaku API' },
+ { status: 502, headers: CORS_HEADERS }
+ );
+ }
+}
diff --git a/app/player/page.tsx b/app/player/page.tsx
index f1ff869..4b9acdc 100644
--- a/app/player/page.tsx
+++ b/app/player/page.tsx
@@ -180,6 +180,8 @@ function PlayerContent() {
onNextEpisode={handleNextEpisode}
isReversed={isReversed}
isPremium={isPremium}
+ videoTitle={videoData?.vod_name || title || ''}
+ episodeName={videoData?.episodes?.[currentEpisode]?.name || ''}
/>
void;
isReversed?: boolean;
+ // Danmaku props
+ videoTitle?: string;
+ episodeName?: string;
}
/**
diff --git a/components/player/DanmakuCanvas.tsx b/components/player/DanmakuCanvas.tsx
new file mode 100644
index 0000000..2dcd5d5
--- /dev/null
+++ b/components/player/DanmakuCanvas.tsx
@@ -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(null);
+ const activeRef = useRef([]);
+ const lastTimeRef = useRef(currentTime);
+ const lastRafTimeRef = useRef(0);
+ const rafRef = useRef(0);
+ const lastSpawnTimeRef = useRef(-1);
+ const laneSlotsRef = useRef(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 (
+
+ );
+}
diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx
index db845dc..50c7caa 100644
--- a/components/player/DesktopVideoPlayer.tsx
+++ b/components/player/DesktopVideoPlayer.tsx
@@ -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 && (
+
+ )}
+
setDanmakuEnabled(!danmakuEnabled)}
/>
diff --git a/components/player/VideoPlayer.tsx b/components/player/VideoPlayer.tsx
index 8d22c01..b0b3d88 100644
--- a/components/player/VideoPlayer.tsx
+++ b/components/player/VideoPlayer.tsx
@@ -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('');
const [useProxy, setUseProxy] = useState(false);
@@ -227,6 +232,8 @@ export function VideoPlayer({
currentEpisodeIndex={currentEpisode}
onNextEpisode={onNextEpisode}
isReversed={isReversed}
+ videoTitle={videoTitle}
+ episodeName={episodeName}
/>
)}
diff --git a/components/player/desktop/DesktopControls.tsx b/components/player/desktop/DesktopControls.tsx
index 7d894d6..d57ed7f 100644
--- a/components/player/desktop/DesktopControls.tsx
+++ b/components/player/desktop/DesktopControls.tsx
@@ -32,6 +32,8 @@ 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 0a9deb9..562cdfd 100644
--- a/components/player/desktop/DesktopControlsWrapper.tsx
+++ b/components/player/desktop/DesktopControlsWrapper.tsx
@@ -9,9 +9,11 @@ interface DesktopControlsWrapperProps {
actions: ReturnType['actions'];
logic: ReturnType;
refs: ReturnType['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}
/>
);
}
diff --git a/components/player/desktop/DesktopMoreMenu.tsx b/components/player/desktop/DesktopMoreMenu.tsx
index c1afa62..35ecfb7 100644
--- a/components/player/desktop/DesktopMoreMenu.tsx
+++ b/components/player/desktop/DesktopMoreMenu.tsx
@@ -47,6 +47,12 @@ export function DesktopMoreMenu({
setAdFilterMode,
fullscreenType,
setFullscreenType,
+ danmakuApiUrl,
+ setDanmakuApiUrl,
+ danmakuOpacity,
+ setDanmakuOpacity,
+ danmakuFontSize,
+ setDanmakuFontSize,
} = usePlayerSettings();
const buttonRef = React.useRef(null);
@@ -358,6 +364,63 @@ 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 */}
+
+
+ 字号
+
+
+
+
{/* Auto Next Episode Switch */}
diff --git a/components/player/desktop/DesktopRightControls.tsx b/components/player/desktop/DesktopRightControls.tsx
index 9ccf388..9d119ef 100644
--- a/components/player/desktop/DesktopRightControls.tsx
+++ b/components/player/desktop/DesktopRightControls.tsx
@@ -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 (
+ {/* Danmaku Toggle */}
+ {onToggleDanmaku && (
+
+ )}
+
{/* Picture-in-Picture */}
{
isPiPSupported && (
diff --git a/components/player/hooks/useDanmaku.ts b/components/player/hooks/useDanmaku.ts
new file mode 100644
index 0000000..7f7e1dc
--- /dev/null
+++ b/components/player/hooks/useDanmaku.ts
@@ -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
([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(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 };
+}
diff --git a/components/player/hooks/usePlayerSettings.ts b/components/player/hooks/usePlayerSettings.ts
index 4e5d877..bd3a1ac 100644
--- a/components/player/hooks/usePlayerSettings.ts
+++ b/components/player/hooks/usePlayerSettings.ts
@@ -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,
};
}
diff --git a/components/ui/Icon.tsx b/components/ui/Icon.tsx
index feb5fca..50f8481 100644
--- a/components/ui/Icon.tsx
+++ b/components/ui/Icon.tsx
@@ -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,
};
diff --git a/components/ui/icons/danmaku-icons.tsx b/components/ui/icons/danmaku-icons.tsx
new file mode 100644
index 0000000..1d24a7e
--- /dev/null
+++ b/components/ui/icons/danmaku-icons.tsx
@@ -0,0 +1,11 @@
+import { IconProps } from './types';
+
+export const DanmakuIcons = {
+ Danmaku: ({ className = "", size = 24 }: IconProps) => (
+
+ ),
+};
diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts
index 3df1288..eb25f96 100644
--- a/lib/store/settings-store.ts
+++ b/lib/store/settings-store.ts
@@ -45,6 +45,11 @@ export interface AppSettings {
fullscreenType: 'auto' | 'native' | 'window'; // Fullscreen mode preference: 'auto' (native on desktop, window on mobile) | 'native' | 'window'
proxyMode: ProxyMode; // Proxy behavior: 'retry' | 'none' | 'always'
rememberScrollPosition: boolean; // Remember scroll position when navigating back or refreshing
+ // Danmaku settings
+ danmakuEnabled: boolean; // Show danmaku overlay on video
+ danmakuApiUrl: string; // Self-hosted danmaku API endpoint
+ danmakuOpacity: number; // 0.1 - 1.0
+ danmakuFontSize: number; // px
}
import { exportSettings, importSettings, SEARCH_HISTORY_KEY, WATCH_HISTORY_KEY } from './settings-helpers';
@@ -117,6 +122,10 @@ function getDefaultAppSettings(): AppSettings {
fullscreenType: 'auto',
proxyMode: 'retry',
rememberScrollPosition: true,
+ danmakuEnabled: false,
+ danmakuApiUrl: '',
+ danmakuOpacity: 0.7,
+ danmakuFontSize: 20,
};
}
@@ -193,6 +202,10 @@ export const settingsStore = {
fullscreenType: (parsed.fullscreenType === 'window' || parsed.fullscreenType === 'native' || parsed.fullscreenType === 'auto') ? parsed.fullscreenType : 'auto',
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 : '',
+ danmakuOpacity: typeof parsed.danmakuOpacity === 'number' ? parsed.danmakuOpacity : 0.7,
+ danmakuFontSize: typeof parsed.danmakuFontSize === 'number' ? parsed.danmakuFontSize : 20,
};
} catch {
// Even if localStorage fails, we should return defaults + ENV subscriptions
diff --git a/lib/types/danmaku.ts b/lib/types/danmaku.ts
new file mode 100644
index 0000000..cc7c856
--- /dev/null
+++ b/lib/types/danmaku.ts
@@ -0,0 +1,17 @@
+export interface DanmakuComment {
+ text: string;
+ time: number; // seconds from video start
+ color?: string; // hex color, default white
+ type?: 'scroll' | 'top' | 'bottom'; // default 'scroll'
+}
+
+export interface DanmakuEpisode {
+ episodeId: string | number;
+ episodeTitle: string;
+}
+
+export interface DanmakuSearchResult {
+ animeId: string | number;
+ animeTitle: string;
+ episodes: DanmakuEpisode[];
+}
diff --git a/lib/utils/danmaku-utils.ts b/lib/utils/danmaku-utils.ts
new file mode 100644
index 0000000..0587a6a
--- /dev/null
+++ b/lib/utils/danmaku-utils.ts
@@ -0,0 +1,144 @@
+import type { DanmakuComment, DanmakuSearchResult, DanmakuEpisode } from '@/lib/types/danmaku';
+
+/**
+ * Parse danmu_api response into normalized DanmakuComment[]
+ * Handles both /api/v2/comment/{id} format and raw arrays
+ */
+export function parseDanmakuResponse(data: any): DanmakuComment[] {
+ // The danmu_api /api/v2/comment/{id} returns { count, comments: [...] }
+ const comments = data?.comments || data?.data || (Array.isArray(data) ? data : []);
+
+ return comments
+ .map((c: any) => {
+ // danmu_api format: { p: "time,type,color", m: "text" }
+ // or normalized: { time, type, color, text }
+ if (c.p && c.m) {
+ const parts = c.p.split(',');
+ const time = parseFloat(parts[0]) || 0;
+ const typeNum = parseInt(parts[1]) || 1;
+ const colorNum = parseInt(parts[2]);
+ return {
+ text: c.m,
+ time,
+ type: typeNum === 5 ? 'top' : typeNum === 4 ? 'bottom' : 'scroll',
+ color: colorNum ? `#${colorNum.toString(16).padStart(6, '0')}` : undefined,
+ } as DanmakuComment;
+ }
+
+ if (typeof c.text === 'string' && typeof c.time === 'number') {
+ return {
+ text: c.text,
+ time: c.time,
+ type: c.type || 'scroll',
+ color: c.color,
+ } as DanmakuComment;
+ }
+
+ return null;
+ })
+ .filter((c: DanmakuComment | null): c is DanmakuComment => c !== null)
+ .sort((a: DanmakuComment, b: DanmakuComment) => a.time - b.time);
+}
+
+/**
+ * Parse danmu_api search results into DanmakuSearchResult[]
+ */
+export function parseSearchResults(data: any): DanmakuSearchResult[] {
+ const animes = data?.animes || data?.data || (Array.isArray(data) ? data : []);
+
+ return animes.map((a: any) => ({
+ animeId: a.animeId ?? a.id ?? '',
+ animeTitle: a.animeTitle ?? a.title ?? '',
+ episodes: (a.episodes || []).map((ep: any) => ({
+ episodeId: ep.episodeId ?? ep.id ?? '',
+ episodeTitle: ep.episodeTitle ?? ep.title ?? '',
+ })),
+ }));
+}
+
+// Chinese numeral map
+const CHINESE_NUMS: Record = {
+ '零': 0, '一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
+ '六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
+ '十一': 11, '十二': 12, '十三': 13, '十四': 14, '十五': 15,
+ '十六': 16, '十七': 17, '十八': 18, '十九': 19, '二十': 20,
+ '二十一': 21, '二十二': 22, '二十三': 23, '二十四': 24, '二十五': 25,
+};
+
+function extractNumber(str: string): number | null {
+ // Try Arabic digits first
+ const digitMatch = str.match(/(\d+)/);
+ if (digitMatch) return parseInt(digitMatch[1]);
+
+ // Try Chinese numerals
+ for (const [cn, num] of Object.entries(CHINESE_NUMS)) {
+ if (str.includes(cn)) return num;
+ }
+
+ return null;
+}
+
+/**
+ * Match a local episode name to a danmaku episode list
+ * Uses heuristics: digit extraction, Chinese numerals, fallback to index
+ */
+export function matchEpisode(
+ episodes: DanmakuEpisode[],
+ episodeName: string,
+ episodeIndex?: number
+): DanmakuEpisode | null {
+ if (!episodes.length) return null;
+
+ // Extract episode number from local name
+ const localNum = extractNumber(episodeName);
+
+ if (localNum !== null) {
+ // Try exact number match
+ for (const ep of episodes) {
+ const epNum = extractNumber(ep.episodeTitle);
+ if (epNum === localNum) return ep;
+ }
+ }
+
+ // Try title substring match
+ const normalized = episodeName.trim().toLowerCase();
+ for (const ep of episodes) {
+ if (ep.episodeTitle.trim().toLowerCase() === normalized) return ep;
+ }
+
+ // Fallback: use episode index
+ if (episodeIndex !== undefined && episodeIndex >= 0 && episodeIndex < episodes.length) {
+ return episodes[episodeIndex];
+ }
+
+ return episodes[0] || null;
+}
+
+/**
+ * Find best title match from search results using string similarity
+ */
+export function fuzzyMatchTitle(
+ results: DanmakuSearchResult[],
+ title: string
+): DanmakuSearchResult | null {
+ if (!results.length) return null;
+
+ const normalizedTitle = title.trim().toLowerCase();
+
+ // Exact match
+ const exact = results.find(
+ r => r.animeTitle.trim().toLowerCase() === normalizedTitle
+ );
+ if (exact) return exact;
+
+ // Contains match (title contains search or search contains title)
+ const contains = results.find(
+ r =>
+ r.animeTitle.toLowerCase().includes(normalizedTitle) ||
+ normalizedTitle.includes(r.animeTitle.toLowerCase())
+ );
+ if (contains) return contains;
+
+ // Return first result as fallback
+ return results[0];
+}
diff --git a/package-lock.json b/package-lock.json
index d0fbc5b..8c663e0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "kvideo",
- "version": "4.2.2",
+ "version": "4.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kvideo",
- "version": "4.2.2",
+ "version": "4.3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
diff --git a/package.json b/package.json
index d0ae5fb..31ddbc4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "kvideo",
- "version": "4.2.2",
+ "version": "4.3.0",
"private": true,
"scripts": {
"dev": "next dev",