From bacd3f887e88de6f084e8ac4e6d4802522ff03aa Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Thu, 9 Jul 2026 15:48:38 +0800 Subject: [PATCH] Fix mobile danmaku canvas scaling --- CHANGELOG.md | 13 ++ app-release.json | 12 +- components/player/DanmakuCanvas.tsx | 230 ++++++++++++++++++++++------ lib/player/danmaku-canvas-utils.ts | 96 ++++++++++++ package-lock.json | 4 +- package.json | 2 +- tests/danmaku-canvas-utils.test.ts | 72 +++++++++ 7 files changed, 382 insertions(+), 47 deletions(-) create mode 100644 lib/player/danmaku-canvas-utils.ts create mode 100644 tests/danmaku-canvas-utils.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d7b69..18aaded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # 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 - 设置页的代理播放区域显示内置 `/api/proxy` 端点和部署限制,明确该功能不是第三方 HTTP/SOCKS 代理服务器配置。 diff --git a/app-release.json b/app-release.json index c69f9db..2a34f7b 100644 --- a/app-release.json +++ b/app-release.json @@ -4,8 +4,18 @@ "name": "KVideo", "branch": "main" }, - "currentVersion": "4.9.5", + "currentVersion": "4.9.6", "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", "publishedAt": "2026-07-09", diff --git a/components/player/DanmakuCanvas.tsx b/components/player/DanmakuCanvas.tsx index 6a41721..c214508 100644 --- a/components/player/DanmakuCanvas.tsx +++ b/components/player/DanmakuCanvas.tsx @@ -3,6 +3,13 @@ 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[]; @@ -12,7 +19,7 @@ interface DanmakuCanvasProps { } interface ActiveDanmaku { - comment: DanmakuComment; + comment: DanmakuComment & { _expiry?: number }; x: number; y: 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 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'; -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(null); const activeRef = useRef([]); const lastTimeRef = useRef(currentTime); @@ -33,43 +90,127 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da const rafRef = useRef(0); const lastSpawnTimeRef = useRef(-1); const laneSlotsRef = useRef(new Array(MAX_LANES).fill(0)); // tracks when each lane becomes free + const metricsRef = useRef(null); - // Settings (read reactively) - const [opacity, setOpacity] = React.useState(0.7); - const [fontSize, setFontSize] = React.useState(20); - const [displayArea, setDisplayArea] = React.useState(0.5); + const settingsSnapshot = React.useSyncExternalStore( + subscribeToDanmakuSettings, + getDanmakuSettingsSnapshot, + () => DEFAULT_DANMAKU_SETTINGS_SNAPSHOT, + ); + const { opacity, fontSize, displayArea } = React.useMemo( + () => parseDanmakuSettingsSnapshot(settingsSnapshot), + [settingsSnapshot], + ); - useEffect(() => { - const s = settingsStore.getSettings(); - setOpacity(s.danmakuOpacity); - 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; - }, []); + const syncCanvasSize = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return null; - // 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(() => { 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; + 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 = []; }; - resize(); - const observer = new ResizeObserver(resize); - observer.observe(canvas); - return () => observer.disconnect(); - }, []); + 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(() => { @@ -88,9 +229,11 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da const canvas = canvasRef.current; if (!canvas || !comments.length) return; - const rect = canvas.getBoundingClientRect(); - const canvasWidth = rect.width; - const effectiveHeight = rect.height * displayArea; + 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] @@ -164,7 +307,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da : effectiveHeight - bestLane * laneHeight - fontSize * 0.4; activeRef.current.push({ - comment: { ...c, _expiry: time + TOP_BOTTOM_DURATION } as any, + comment: { ...c, _expiry: time + TOP_BOTTOM_DURATION }, x: (canvasWidth - textWidth) / 2, y, speed: 0, @@ -175,7 +318,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da } lastSpawnTimeRef.current = windowEnd; - }, [comments, fontSize, displayArea]); + }, [comments, displayArea, fontSize, syncCanvasSize]); // Animation loop useEffect(() => { @@ -186,17 +329,18 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da 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; + 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(dpr, dpr); + ctx.scale(metrics.dpr, metrics.dpr); ctx.globalAlpha = opacity; ctx.font = `bold ${fontSize}px sans-serif`; ctx.textBaseline = 'middle'; @@ -235,7 +379,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da } } else { // Top/bottom: remove when expired - const expiry = (d.comment as any)._expiry || 0; + const expiry = d.comment._expiry || 0; if (currentTime < expiry) { newActive.push(d); } @@ -245,7 +389,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da // Draw ctx.save(); - ctx.scale(dpr, dpr); + ctx.scale(metrics.dpr, metrics.dpr); ctx.globalAlpha = opacity; ctx.font = `bold ${fontSize}px sans-serif`; ctx.textBaseline = 'middle'; @@ -268,7 +412,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da if (rafRef.current) cancelAnimationFrame(rafRef.current); lastRafTimeRef.current = 0; }; - }, [isPlaying, currentTime, opacity, fontSize, spawnComments]); + }, [isPlaying, currentTime, opacity, fontSize, spawnComments, syncCanvasSize]); return ( ): 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)); +} diff --git a/package-lock.json b/package-lock.json index 1e031f5..af552ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.9.5", + "version": "4.9.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.9.5", + "version": "4.9.6", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index f701178..affac47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.9.5", + "version": "4.9.6", "private": true, "scripts": { "dev": "node scripts/next-with-lan-access.mjs dev", diff --git a/tests/danmaku-canvas-utils.test.ts b/tests/danmaku-canvas-utils.test.ts new file mode 100644 index 0000000..98bad21 --- /dev/null +++ b/tests/danmaku-canvas-utils.test.ts @@ -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); +});