mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: Implement danmaku (bullet comments) functionality with API integration, settings, and player display.
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,8 @@ function PlayerContent() {
|
||||
onNextEpisode={handleNextEpisode}
|
||||
isReversed={isReversed}
|
||||
isPremium={isPremium}
|
||||
videoTitle={videoData?.vod_name || title || ''}
|
||||
episodeName={videoData?.episodes?.[currentEpisode]?.name || ''}
|
||||
/>
|
||||
<div className="hidden lg:block">
|
||||
<VideoMetadata
|
||||
|
||||
@@ -15,6 +15,9 @@ interface CustomVideoPlayerProps {
|
||||
currentEpisodeIndex?: number;
|
||||
onNextEpisode?: () => void;
|
||||
isReversed?: boolean;
|
||||
// Danmaku props
|
||||
videoTitle?: string;
|
||||
episodeName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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<string, number> = {
|
||||
'零': 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];
|
||||
}
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.2.2",
|
||||
"version": "4.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user