From df37806de77484ec86b9e159e055ff719ca8f45c Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 17 Feb 2026 10:21:07 +0800 Subject: [PATCH] feat: Implement danmaku (bullet comments) functionality with API integration, settings, and player display. --- app/api/danmaku/route.ts | 85 ++++++ app/player/page.tsx | 2 + components/player/CustomVideoPlayer.tsx | 3 + components/player/DanmakuCanvas.tsx | 275 ++++++++++++++++++ components/player/DesktopVideoPlayer.tsx | 26 ++ components/player/VideoPlayer.tsx | 9 +- components/player/desktop/DesktopControls.tsx | 2 + .../player/desktop/DesktopControlsWrapper.tsx | 6 +- components/player/desktop/DesktopMoreMenu.tsx | 63 ++++ .../player/desktop/DesktopRightControls.tsx | 19 ++ components/player/hooks/useDanmaku.ts | 123 ++++++++ components/player/hooks/usePlayerSettings.ts | 28 ++ components/ui/Icon.tsx | 2 + components/ui/icons/danmaku-icons.tsx | 11 + lib/store/settings-store.ts | 13 + lib/types/danmaku.ts | 17 ++ lib/utils/danmaku-utils.ts | 144 +++++++++ package-lock.json | 4 +- package.json | 2 +- 19 files changed, 829 insertions(+), 5 deletions(-) create mode 100644 app/api/danmaku/route.ts create mode 100644 components/player/DanmakuCanvas.tsx create mode 100644 components/player/hooks/useDanmaku.ts create mode 100644 components/ui/icons/danmaku-icons.tsx create mode 100644 lib/types/danmaku.ts create mode 100644 lib/utils/danmaku-utils.ts 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",