mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
Fix mobile danmaku canvas scaling
This commit is contained in:
@@ -1,5 +1,18 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 4.9.6 - 2026-07-09
|
||||||
|
|
||||||
|
- 修复移动端 Safari 网页横屏时弹幕字体被异常拉伸或压缩的问题。
|
||||||
|
- 弹幕画布改为优先读取未受 CSS transform 影响的布局尺寸,避免强制横屏旋转时使用横竖互换的 bounding box。
|
||||||
|
- 横竖屏、窗口尺寸和 `visualViewport` 变化后会重新同步画布 backing store,并按新尺寸重排活跃弹幕。
|
||||||
|
- 新增弹幕画布尺寸工具测试,覆盖 CSS transform 造成视觉尺寸与布局尺寸不一致的场景。
|
||||||
|
|
||||||
|
## 4.9.5 - 2026-07-09
|
||||||
|
|
||||||
|
- Vercel Analytics 现在只会在真实 Vercel 部署中注入,Docker、自托管和 Cloudflare Pages 不再请求 `/_vercel/insights/script.js`。
|
||||||
|
- Cloudflare 部署环境判定优先于 Vercel 风格环境变量,避免适配器环境被误判为 Vercel。
|
||||||
|
- 新增部署环境单元测试,覆盖自托管、Vercel、Cloudflare 和混合环境变量场景。
|
||||||
|
|
||||||
## 4.9.4 - 2026-07-08
|
## 4.9.4 - 2026-07-08
|
||||||
|
|
||||||
- 设置页的代理播放区域显示内置 `/api/proxy` 端点和部署限制,明确该功能不是第三方 HTTP/SOCKS 代理服务器配置。
|
- 设置页的代理播放区域显示内置 `/api/proxy` 端点和部署限制,明确该功能不是第三方 HTTP/SOCKS 代理服务器配置。
|
||||||
|
|||||||
+11
-1
@@ -4,8 +4,18 @@
|
|||||||
"name": "KVideo",
|
"name": "KVideo",
|
||||||
"branch": "main"
|
"branch": "main"
|
||||||
},
|
},
|
||||||
"currentVersion": "4.9.5",
|
"currentVersion": "4.9.6",
|
||||||
"releases": [
|
"releases": [
|
||||||
|
{
|
||||||
|
"version": "4.9.6",
|
||||||
|
"publishedAt": "2026-07-09",
|
||||||
|
"title": "修复移动端横屏弹幕缩放",
|
||||||
|
"notes": [
|
||||||
|
"弹幕画布现在优先使用未受 CSS transform 影响的布局尺寸,避免 iOS Safari 网页横屏时字体被拉伸或压缩。",
|
||||||
|
"横竖屏和 visualViewport 尺寸变化后会重新同步画布 backing store,并按新尺寸重排活跃弹幕。",
|
||||||
|
"新增弹幕画布尺寸工具测试,覆盖 transform 后 bounding box 横竖互换的回归场景。"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "4.9.5",
|
"version": "4.9.5",
|
||||||
"publishedAt": "2026-07-09",
|
"publishedAt": "2026-07-09",
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
import React, { useRef, useEffect, useCallback } from 'react';
|
import React, { useRef, useEffect, useCallback } from 'react';
|
||||||
import type { DanmakuComment } from '@/lib/types/danmaku';
|
import type { DanmakuComment } from '@/lib/types/danmaku';
|
||||||
import { settingsStore } from '@/lib/store/settings-store';
|
import { settingsStore } from '@/lib/store/settings-store';
|
||||||
|
import {
|
||||||
|
clampDanmakuY,
|
||||||
|
haveDanmakuCanvasMetricsChanged,
|
||||||
|
resolveDanmakuCanvasMetrics,
|
||||||
|
scaleDanmakuCoordinate,
|
||||||
|
type DanmakuCanvasMetrics,
|
||||||
|
} from '@/lib/player/danmaku-canvas-utils';
|
||||||
|
|
||||||
interface DanmakuCanvasProps {
|
interface DanmakuCanvasProps {
|
||||||
comments: DanmakuComment[];
|
comments: DanmakuComment[];
|
||||||
@@ -12,7 +19,7 @@ interface DanmakuCanvasProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ActiveDanmaku {
|
interface ActiveDanmaku {
|
||||||
comment: DanmakuComment;
|
comment: DanmakuComment & { _expiry?: number };
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
speed: number;
|
speed: number;
|
||||||
@@ -24,8 +31,58 @@ 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 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 TOP_BOTTOM_DURATION = 4; // seconds for top/bottom comments to stay visible
|
||||||
const MAX_LANES = 20;
|
const MAX_LANES = 20;
|
||||||
|
const DEFAULT_DANMAKU_SETTINGS_SNAPSHOT = '0.7|20|0.5';
|
||||||
|
|
||||||
export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: DanmakuCanvasProps) {
|
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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const activeRef = useRef<ActiveDanmaku[]>([]);
|
const activeRef = useRef<ActiveDanmaku[]>([]);
|
||||||
const lastTimeRef = useRef(currentTime);
|
const lastTimeRef = useRef(currentTime);
|
||||||
@@ -33,43 +90,127 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
|||||||
const rafRef = useRef<number>(0);
|
const rafRef = useRef<number>(0);
|
||||||
const lastSpawnTimeRef = useRef(-1);
|
const lastSpawnTimeRef = useRef(-1);
|
||||||
const laneSlotsRef = useRef<number[]>(new Array(MAX_LANES).fill(0)); // tracks when each lane becomes free
|
const laneSlotsRef = useRef<number[]>(new Array(MAX_LANES).fill(0)); // tracks when each lane becomes free
|
||||||
|
const metricsRef = useRef<DanmakuCanvasMetrics | null>(null);
|
||||||
|
|
||||||
// Settings (read reactively)
|
const settingsSnapshot = React.useSyncExternalStore(
|
||||||
const [opacity, setOpacity] = React.useState(0.7);
|
subscribeToDanmakuSettings,
|
||||||
const [fontSize, setFontSize] = React.useState(20);
|
getDanmakuSettingsSnapshot,
|
||||||
const [displayArea, setDisplayArea] = React.useState(0.5);
|
() => DEFAULT_DANMAKU_SETTINGS_SNAPSHOT,
|
||||||
|
);
|
||||||
|
const { opacity, fontSize, displayArea } = React.useMemo(
|
||||||
|
() => parseDanmakuSettingsSnapshot(settingsSnapshot),
|
||||||
|
[settingsSnapshot],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const syncCanvasSize = useCallback(() => {
|
||||||
const s = settingsStore.getSettings();
|
const canvas = canvasRef.current;
|
||||||
setOpacity(s.danmakuOpacity);
|
if (!canvas) return null;
|
||||||
setFontSize(s.danmakuFontSize);
|
|
||||||
setDisplayArea(s.danmakuDisplayArea);
|
|
||||||
const unsub = settingsStore.subscribe(() => {
|
|
||||||
const ns = settingsStore.getSettings();
|
|
||||||
setOpacity(ns.danmakuOpacity);
|
|
||||||
setFontSize(ns.danmakuFontSize);
|
|
||||||
setDisplayArea(ns.danmakuDisplayArea);
|
|
||||||
});
|
|
||||||
return unsub;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle canvas resize
|
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(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
const resize = () => {
|
let rafId: number | null = null;
|
||||||
const rect = canvas.getBoundingClientRect();
|
let timeoutIds: number[] = [];
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
canvas.width = rect.width * dpr;
|
const clearScheduledResize = () => {
|
||||||
canvas.height = rect.height * dpr;
|
if (rafId !== null) {
|
||||||
|
window.cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const timeoutId of timeoutIds) {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
timeoutIds = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
resize();
|
const scheduleResize = () => {
|
||||||
const observer = new ResizeObserver(resize);
|
syncCanvasSize();
|
||||||
observer.observe(canvas);
|
clearScheduledResize();
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
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)
|
// Clear on seek (when currentTime jumps significantly)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,9 +229,11 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas || !comments.length) return;
|
if (!canvas || !comments.length) return;
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
const metrics = metricsRef.current ?? syncCanvasSize();
|
||||||
const canvasWidth = rect.width;
|
if (!metrics) return;
|
||||||
const effectiveHeight = rect.height * displayArea;
|
|
||||||
|
const canvasWidth = metrics.width;
|
||||||
|
const effectiveHeight = metrics.height * displayArea;
|
||||||
const laneHeight = fontSize * LANE_HEIGHT_FACTOR;
|
const laneHeight = fontSize * LANE_HEIGHT_FACTOR;
|
||||||
|
|
||||||
// Find comments in the time window [lastSpawn, time]
|
// Find comments in the time window [lastSpawn, time]
|
||||||
@@ -164,7 +307,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
|||||||
: effectiveHeight - bestLane * laneHeight - fontSize * 0.4;
|
: effectiveHeight - bestLane * laneHeight - fontSize * 0.4;
|
||||||
|
|
||||||
activeRef.current.push({
|
activeRef.current.push({
|
||||||
comment: { ...c, _expiry: time + TOP_BOTTOM_DURATION } as any,
|
comment: { ...c, _expiry: time + TOP_BOTTOM_DURATION },
|
||||||
x: (canvasWidth - textWidth) / 2,
|
x: (canvasWidth - textWidth) / 2,
|
||||||
y,
|
y,
|
||||||
speed: 0,
|
speed: 0,
|
||||||
@@ -175,7 +318,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastSpawnTimeRef.current = windowEnd;
|
lastSpawnTimeRef.current = windowEnd;
|
||||||
}, [comments, fontSize, displayArea]);
|
}, [comments, displayArea, fontSize, syncCanvasSize]);
|
||||||
|
|
||||||
// Animation loop
|
// Animation loop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -186,17 +329,18 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const metrics = metricsRef.current ?? syncCanvasSize();
|
||||||
const rect = canvas.getBoundingClientRect();
|
if (!metrics) {
|
||||||
const w = rect.width;
|
rafRef.current = requestAnimationFrame(animate);
|
||||||
const h = rect.height;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
if (!isPlaying) {
|
if (!isPlaying) {
|
||||||
// When paused, still draw active comments frozen in place
|
// When paused, still draw active comments frozen in place
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(metrics.dpr, metrics.dpr);
|
||||||
ctx.globalAlpha = opacity;
|
ctx.globalAlpha = opacity;
|
||||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
@@ -235,7 +379,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Top/bottom: remove when expired
|
// Top/bottom: remove when expired
|
||||||
const expiry = (d.comment as any)._expiry || 0;
|
const expiry = d.comment._expiry || 0;
|
||||||
if (currentTime < expiry) {
|
if (currentTime < expiry) {
|
||||||
newActive.push(d);
|
newActive.push(d);
|
||||||
}
|
}
|
||||||
@@ -245,7 +389,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
|||||||
|
|
||||||
// Draw
|
// Draw
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(metrics.dpr, metrics.dpr);
|
||||||
ctx.globalAlpha = opacity;
|
ctx.globalAlpha = opacity;
|
||||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
@@ -268,7 +412,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
|||||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
lastRafTimeRef.current = 0;
|
lastRafTimeRef.current = 0;
|
||||||
};
|
};
|
||||||
}, [isPlaying, currentTime, opacity, fontSize, spawnComments]);
|
}, [isPlaying, currentTime, opacity, fontSize, spawnComments, syncCanvasSize]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
export interface DanmakuCanvasSizeCandidates {
|
||||||
|
computedWidth?: number;
|
||||||
|
computedHeight?: number;
|
||||||
|
clientWidth?: number;
|
||||||
|
clientHeight?: number;
|
||||||
|
offsetWidth?: number;
|
||||||
|
offsetHeight?: number;
|
||||||
|
boundingWidth?: number;
|
||||||
|
boundingHeight?: number;
|
||||||
|
devicePixelRatio?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DanmakuCanvasMetrics {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
dpr: number;
|
||||||
|
bitmapWidth: number;
|
||||||
|
bitmapHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METRIC_EPSILON = 0.5;
|
||||||
|
|
||||||
|
function pickPositiveFinite(values: Array<number | undefined>): number | null {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDpr(value: number | undefined): number {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDanmakuCanvasMetrics(
|
||||||
|
candidates: DanmakuCanvasSizeCandidates,
|
||||||
|
): DanmakuCanvasMetrics | null {
|
||||||
|
const width = pickPositiveFinite([
|
||||||
|
candidates.computedWidth,
|
||||||
|
candidates.clientWidth,
|
||||||
|
candidates.offsetWidth,
|
||||||
|
candidates.boundingWidth,
|
||||||
|
]);
|
||||||
|
const height = pickPositiveFinite([
|
||||||
|
candidates.computedHeight,
|
||||||
|
candidates.clientHeight,
|
||||||
|
candidates.offsetHeight,
|
||||||
|
candidates.boundingHeight,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (width === null || height === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dpr = normalizeDpr(candidates.devicePixelRatio);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
dpr,
|
||||||
|
bitmapWidth: Math.max(1, Math.round(width * dpr)),
|
||||||
|
bitmapHeight: Math.max(1, Math.round(height * dpr)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function haveDanmakuCanvasMetricsChanged(
|
||||||
|
previous: DanmakuCanvasMetrics | null,
|
||||||
|
next: DanmakuCanvasMetrics,
|
||||||
|
): boolean {
|
||||||
|
if (!previous) return true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
Math.abs(previous.width - next.width) > METRIC_EPSILON ||
|
||||||
|
Math.abs(previous.height - next.height) > METRIC_EPSILON ||
|
||||||
|
Math.abs(previous.dpr - next.dpr) > 0.01 ||
|
||||||
|
previous.bitmapWidth !== next.bitmapWidth ||
|
||||||
|
previous.bitmapHeight !== next.bitmapHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scaleDanmakuCoordinate(value: number, previousSize: number, nextSize: number): number {
|
||||||
|
if (!Number.isFinite(value) || previousSize <= 0 || nextSize <= 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value * (nextSize / previousSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampDanmakuY(value: number, fontSize: number, effectiveHeight: number): number {
|
||||||
|
const minY = Math.max(0, fontSize);
|
||||||
|
const maxY = Math.max(minY, effectiveHeight - fontSize * 0.4);
|
||||||
|
|
||||||
|
return Math.min(maxY, Math.max(minY, value));
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "kvideo",
|
"name": "kvideo",
|
||||||
"version": "4.9.5",
|
"version": "4.9.6",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "kvideo",
|
"name": "kvideo",
|
||||||
"version": "4.9.5",
|
"version": "4.9.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kvideo",
|
"name": "kvideo",
|
||||||
"version": "4.9.5",
|
"version": "4.9.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/next-with-lan-access.mjs dev",
|
"dev": "node scripts/next-with-lan-access.mjs dev",
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
clampDanmakuY,
|
||||||
|
resolveDanmakuCanvasMetrics,
|
||||||
|
scaleDanmakuCoordinate,
|
||||||
|
} from '@/lib/player/danmaku-canvas-utils';
|
||||||
|
|
||||||
|
test('resolveDanmakuCanvasMetrics prefers layout dimensions over transformed bounding dimensions', () => {
|
||||||
|
const metrics = resolveDanmakuCanvasMetrics({
|
||||||
|
computedWidth: 320,
|
||||||
|
computedHeight: 568,
|
||||||
|
clientWidth: 320,
|
||||||
|
clientHeight: 568,
|
||||||
|
boundingWidth: 568,
|
||||||
|
boundingHeight: 320,
|
||||||
|
devicePixelRatio: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(metrics, {
|
||||||
|
width: 320,
|
||||||
|
height: 568,
|
||||||
|
dpr: 3,
|
||||||
|
bitmapWidth: 960,
|
||||||
|
bitmapHeight: 1704,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDanmakuCanvasMetrics falls back to bounding dimensions when layout dimensions are unavailable', () => {
|
||||||
|
const metrics = resolveDanmakuCanvasMetrics({
|
||||||
|
boundingWidth: 640,
|
||||||
|
boundingHeight: 360,
|
||||||
|
devicePixelRatio: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(metrics, {
|
||||||
|
width: 640,
|
||||||
|
height: 360,
|
||||||
|
dpr: 2,
|
||||||
|
bitmapWidth: 1280,
|
||||||
|
bitmapHeight: 720,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDanmakuCanvasMetrics ignores invalid sizes and invalid device pixel ratios', () => {
|
||||||
|
const metrics = resolveDanmakuCanvasMetrics({
|
||||||
|
computedWidth: 0,
|
||||||
|
computedHeight: Number.NaN,
|
||||||
|
clientWidth: 375,
|
||||||
|
clientHeight: 211,
|
||||||
|
devicePixelRatio: Number.NaN,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(metrics, {
|
||||||
|
width: 375,
|
||||||
|
height: 211,
|
||||||
|
dpr: 1,
|
||||||
|
bitmapWidth: 375,
|
||||||
|
bitmapHeight: 211,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scaleDanmakuCoordinate preserves relative placement after resize', () => {
|
||||||
|
assert.equal(scaleDanmakuCoordinate(160, 320, 640), 320);
|
||||||
|
assert.equal(scaleDanmakuCoordinate(40, 320, 240), 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clampDanmakuY keeps comments inside the effective display area', () => {
|
||||||
|
assert.equal(clampDanmakuY(4, 20, 120), 20);
|
||||||
|
assert.equal(clampDanmakuY(200, 20, 120), 112);
|
||||||
|
assert.equal(clampDanmakuY(64, 20, 120), 64);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user