mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
425 lines
13 KiB
TypeScript
425 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import React, { useRef, useEffect, useCallback } from 'react';
|
|
import type { DanmakuComment } from '@/lib/types/danmaku';
|
|
import { settingsStore } from '@/lib/store/settings-store';
|
|
import {
|
|
clampDanmakuY,
|
|
haveDanmakuCanvasMetricsChanged,
|
|
resolveDanmakuCanvasMetrics,
|
|
scaleDanmakuCoordinate,
|
|
type DanmakuCanvasMetrics,
|
|
} from '@/lib/player/danmaku-canvas-utils';
|
|
|
|
interface DanmakuCanvasProps {
|
|
comments: DanmakuComment[];
|
|
currentTime: number;
|
|
isPlaying: boolean;
|
|
duration: number;
|
|
}
|
|
|
|
interface ActiveDanmaku {
|
|
comment: DanmakuComment & { _expiry?: number };
|
|
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;
|
|
const DEFAULT_DANMAKU_SETTINGS_SNAPSHOT = '0.7|20|0.5';
|
|
|
|
interface DanmakuSettingsSnapshot {
|
|
opacity: number;
|
|
fontSize: number;
|
|
displayArea: number;
|
|
}
|
|
|
|
function readCssPixelValue(value: string): number | undefined {
|
|
if (!value.endsWith('px')) return undefined;
|
|
|
|
const parsed = Number.parseFloat(value);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
}
|
|
|
|
function readCanvasMetrics(canvas: HTMLCanvasElement): DanmakuCanvasMetrics | null {
|
|
const style = window.getComputedStyle(canvas);
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
return resolveDanmakuCanvasMetrics({
|
|
computedWidth: readCssPixelValue(style.width),
|
|
computedHeight: readCssPixelValue(style.height),
|
|
clientWidth: canvas.clientWidth,
|
|
clientHeight: canvas.clientHeight,
|
|
offsetWidth: canvas.offsetWidth,
|
|
offsetHeight: canvas.offsetHeight,
|
|
boundingWidth: rect.width,
|
|
boundingHeight: rect.height,
|
|
devicePixelRatio: window.devicePixelRatio,
|
|
});
|
|
}
|
|
|
|
function getDanmakuSettingsSnapshot(): string {
|
|
const settings = settingsStore.getSettings();
|
|
return `${settings.danmakuOpacity}|${settings.danmakuFontSize}|${settings.danmakuDisplayArea}`;
|
|
}
|
|
|
|
function subscribeToDanmakuSettings(listener: () => void): () => void {
|
|
return settingsStore.subscribe(listener);
|
|
}
|
|
|
|
function parseDanmakuSettingsSnapshot(snapshot: string): DanmakuSettingsSnapshot {
|
|
const [rawOpacity, rawFontSize, rawDisplayArea] = snapshot.split('|').map(Number);
|
|
|
|
return {
|
|
opacity: Number.isFinite(rawOpacity) ? rawOpacity : 0.7,
|
|
fontSize: Number.isFinite(rawFontSize) ? rawFontSize : 20,
|
|
displayArea: Number.isFinite(rawDisplayArea) ? rawDisplayArea : 0.5,
|
|
};
|
|
}
|
|
|
|
export function DanmakuCanvas({ comments, currentTime, isPlaying }: 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
|
|
const metricsRef = useRef<DanmakuCanvasMetrics | null>(null);
|
|
|
|
const settingsSnapshot = React.useSyncExternalStore(
|
|
subscribeToDanmakuSettings,
|
|
getDanmakuSettingsSnapshot,
|
|
() => DEFAULT_DANMAKU_SETTINGS_SNAPSHOT,
|
|
);
|
|
const { opacity, fontSize, displayArea } = React.useMemo(
|
|
() => parseDanmakuSettingsSnapshot(settingsSnapshot),
|
|
[settingsSnapshot],
|
|
);
|
|
|
|
const syncCanvasSize = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return null;
|
|
|
|
const next = readCanvasMetrics(canvas);
|
|
if (!next) return null;
|
|
|
|
const previous = metricsRef.current;
|
|
if (!haveDanmakuCanvasMetricsChanged(previous, next)) {
|
|
return next;
|
|
}
|
|
|
|
canvas.width = next.bitmapWidth;
|
|
canvas.height = next.bitmapHeight;
|
|
|
|
const dimensionsChanged = Boolean(
|
|
previous &&
|
|
(Math.abs(previous.width - next.width) > 0.5 || Math.abs(previous.height - next.height) > 0.5)
|
|
);
|
|
|
|
if (previous && dimensionsChanged) {
|
|
const effectiveHeight = next.height * displayArea;
|
|
|
|
activeRef.current = activeRef.current.map((danmaku) => {
|
|
const type = danmaku.comment.type || 'scroll';
|
|
const y = clampDanmakuY(
|
|
scaleDanmakuCoordinate(danmaku.y, previous.height, next.height),
|
|
fontSize,
|
|
effectiveHeight,
|
|
);
|
|
|
|
if (type === 'scroll') {
|
|
return {
|
|
...danmaku,
|
|
x: scaleDanmakuCoordinate(danmaku.x, previous.width, next.width),
|
|
y,
|
|
speed: (next.width + danmaku.width) / SCROLL_DURATION,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...danmaku,
|
|
x: (next.width - danmaku.width) / 2,
|
|
y,
|
|
};
|
|
});
|
|
|
|
laneSlotsRef.current = new Array(MAX_LANES).fill(0);
|
|
}
|
|
|
|
metricsRef.current = next;
|
|
return next;
|
|
}, [displayArea, fontSize]);
|
|
|
|
// Handle canvas resize. Use layout dimensions rather than transformed bounding
|
|
// dimensions, otherwise CSS-rotated iOS fullscreen can stretch the canvas.
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
let rafId: number | null = null;
|
|
let timeoutIds: number[] = [];
|
|
|
|
const clearScheduledResize = () => {
|
|
if (rafId !== null) {
|
|
window.cancelAnimationFrame(rafId);
|
|
rafId = null;
|
|
}
|
|
|
|
for (const timeoutId of timeoutIds) {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
timeoutIds = [];
|
|
};
|
|
|
|
const scheduleResize = () => {
|
|
syncCanvasSize();
|
|
clearScheduledResize();
|
|
|
|
rafId = window.requestAnimationFrame(() => {
|
|
rafId = null;
|
|
syncCanvasSize();
|
|
});
|
|
timeoutIds = [
|
|
window.setTimeout(syncCanvasSize, 120),
|
|
window.setTimeout(syncCanvasSize, 360),
|
|
];
|
|
};
|
|
|
|
scheduleResize();
|
|
|
|
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleResize) : null;
|
|
observer?.observe(canvas);
|
|
|
|
const visualViewport = window.visualViewport;
|
|
window.addEventListener('resize', scheduleResize);
|
|
window.addEventListener('orientationchange', scheduleResize);
|
|
visualViewport?.addEventListener('resize', scheduleResize);
|
|
visualViewport?.addEventListener('scroll', scheduleResize);
|
|
|
|
return () => {
|
|
clearScheduledResize();
|
|
observer?.disconnect();
|
|
window.removeEventListener('resize', scheduleResize);
|
|
window.removeEventListener('orientationchange', scheduleResize);
|
|
visualViewport?.removeEventListener('resize', scheduleResize);
|
|
visualViewport?.removeEventListener('scroll', scheduleResize);
|
|
};
|
|
}, [syncCanvasSize]);
|
|
|
|
// Clear on seek (when currentTime jumps significantly)
|
|
useEffect(() => {
|
|
const timeDiff = Math.abs(currentTime - lastTimeRef.current);
|
|
if (timeDiff > 2) {
|
|
activeRef.current = [];
|
|
// Set to currentTime so only comments from the new position forward are spawned
|
|
lastSpawnTimeRef.current = currentTime;
|
|
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 metrics = metricsRef.current ?? syncCanvasSize();
|
|
if (!metrics) return;
|
|
|
|
const canvasWidth = metrics.width;
|
|
const effectiveHeight = metrics.height * displayArea;
|
|
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 > effectiveHeight - 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(effectiveHeight / 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
|
|
: effectiveHeight - bestLane * laneHeight - fontSize * 0.4;
|
|
|
|
activeRef.current.push({
|
|
comment: { ...c, _expiry: time + TOP_BOTTOM_DURATION },
|
|
x: (canvasWidth - textWidth) / 2,
|
|
y,
|
|
speed: 0,
|
|
width: textWidth,
|
|
lane: bestLane,
|
|
});
|
|
}
|
|
}
|
|
|
|
lastSpawnTimeRef.current = windowEnd;
|
|
}, [comments, displayArea, fontSize, syncCanvasSize]);
|
|
|
|
// Animation loop
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const animate = (rafTime: number) => {
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
const metrics = metricsRef.current ?? syncCanvasSize();
|
|
if (!metrics) {
|
|
rafRef.current = requestAnimationFrame(animate);
|
|
return;
|
|
}
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
if (!isPlaying) {
|
|
// When paused, still draw active comments frozen in place
|
|
ctx.save();
|
|
ctx.scale(metrics.dpr, metrics.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._expiry || 0;
|
|
if (currentTime < expiry) {
|
|
newActive.push(d);
|
|
}
|
|
}
|
|
}
|
|
activeRef.current = newActive;
|
|
|
|
// Draw
|
|
ctx.save();
|
|
ctx.scale(metrics.dpr, metrics.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, syncCanvasSize]);
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="absolute inset-0 z-[5] pointer-events-none"
|
|
style={{ width: '100%', height: '100%' }}
|
|
/>
|
|
);
|
|
}
|