diff --git a/components/DanmakuOverlay.tsx b/components/DanmakuOverlay.tsx index 2297597..91a0b28 100644 --- a/components/DanmakuOverlay.tsx +++ b/components/DanmakuOverlay.tsx @@ -1,5 +1,5 @@ -import React, { useRef, useEffect, useState, useCallback } from 'react'; -import { View, Animated, StyleSheet, Text } from 'react-native'; +import React, { useRef, useEffect, useState, useCallback, memo } from 'react'; +import { View, Animated, StyleSheet } from 'react-native'; import { DanmakuItem } from '../services/types'; import { danmakuColorToCss } from '../utils/danmaku'; @@ -11,22 +11,91 @@ interface Props { visible: boolean; } -const LANE_COUNT = 5; -const LANE_H = 28; -// 同屏弹幕上限(原 30,提至 60) -const MAX_ACTIVE = 60; +// ─── 配置 ─────────────────────────────────────────────────────────────────── +// 字体上限(小一点更接近 PC B 站手感) +const FONT_MAX = 16; +// 单条车道高度 +const LANE_H = 22; +// 横向车道数 +const LANE_COUNT = 6; +// 同屏弹幕上限 +const MAX_ACTIVE = 80; // activated 集合阈值,触达后整体清零防止内存膨胀 const ACTIVATED_LIMIT = 1000; +// 滚动恒定速度(像素 / 秒)—— 长文走得久,视觉上速度一致 +const SCROLL_PX_PER_SEC = 160; +// 入/出场淡入淡出时长 +const FADE_MS = 200; + +// 估算文本像素宽度。中文等表意字符按 1.0x fontSize,ASCII 按 0.55x。 +function estimateTextWidth(text: string, fontSize: number): number { + let w = 0; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code > 0x2e80) w += fontSize; + else w += fontSize * 0.55; + } + return w; +} interface ActiveDanmaku { id: string; item: DanmakuItem; lane: number; + fontSize: number; tx: Animated.Value; opacity: Animated.Value; } -export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, screenHeight, visible }: Props) { +// ─── 单条弹幕 ────────────────────────────────────────────────────────────── +// React.memo:新弹幕加入触发父级 re-render 时,旧弹幕不会重新走 JSX。 +// 由于 Animated.Value 的引用稳定、其他 props 也都是值类型,自然能命中浅比较。 +const DanmakuLine = memo( + function DanmakuLine({ + d, + screenHeight, + }: { + d: ActiveDanmaku; + screenHeight: number; + }) { + const isScrolling = d.item.mode === 1; + const isTop = d.item.mode === 5; + return ( + + {d.item.text} + + ); + }, + (prev, next) => prev.d === next.d && prev.screenHeight === next.screenHeight, +); + +export default function DanmakuOverlay({ + danmakus, + currentTime, + screenWidth, + screenHeight, + visible, +}: Props) { const [activeDanmakus, setActiveDanmakus] = useState([]); const laneAvailAt = useRef(new Array(LANE_COUNT).fill(0)); const activated = useRef>(new Set()); @@ -34,7 +103,9 @@ export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, scr const idCounter = useRef(0); const mountedRef = useRef(true); useEffect(() => { - return () => { mountedRef.current = false; }; + return () => { + mountedRef.current = false; + }; }, []); const pickLane = useCallback((): number | null => { @@ -59,77 +130,121 @@ export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, scr prevTimeRef.current = currentTime; if (didSeek) { - // Clear on seek activated.current.clear(); laneAvailAt.current.fill(0); setActiveDanmakus([]); return; } - // Find danmakus in the activation window const window = 0.4; - const candidates = danmakus.filter(d => { + const candidates = danmakus.filter((d) => { const key = `${d.time}_${d.text}`; - return d.time >= currentTime - window && d.time <= currentTime + window && !activated.current.has(key); + return ( + d.time >= currentTime - window && + d.time <= currentTime + window && + !activated.current.has(key) + ); }); if (candidates.length === 0) return; - const newItems: ActiveDanmaku[] = []; - if (activated.current.size > ACTIVATED_LIMIT) { - activated.current.clear(); // prevent memory leak + activated.current.clear(); idCounter.current = 0; } + + const newItems: ActiveDanmaku[] = []; + for (const item of candidates) { const key = `${item.time}_${item.text}`; activated.current.add(key); - if (item.mode === 1) { - // Scrolling danmaku - const lane = pickLane(); - if (lane === null) continue; // drop if all lanes full + const fontSize = Math.min(item.fontSize || 18, FONT_MAX); - const charWidth = Math.min(item.fontSize, 22) * 0.8; - const textWidth = item.text.length * charWidth; - const duration = 8000; - // Lane becomes available when tail of this danmaku clears the right edge of screen - // tail starts at screenWidth, text has width textWidth - // tail clears left edge at duration ms - // lane available when head of next can start without overlapping: when tail clears screen right = immediately for next (head starts at screenWidth) - // conservative: lane free after textWidth / (2*screenWidth) * duration ms - const laneDelay = (textWidth / (screenWidth + textWidth)) * duration; - laneAvailAt.current[lane] = Date.now() + laneDelay; + if (item.mode === 1) { + const lane = pickLane(); + if (lane === null) continue; + + const textWidth = estimateTextWidth(item.text, fontSize); + const totalDistance = screenWidth + textWidth + 20; + // 恒定速度:duration 由文本长度推导,长文走久一点,视觉速度统一 + const duration = (totalDistance / SCROLL_PX_PER_SEC) * 1000; + // 车道何时可被复用:尾巴扫过屏幕右边沿即可,与 duration 解耦 + const laneFreeMs = (textWidth / SCROLL_PX_PER_SEC) * 1000; + laneAvailAt.current[lane] = Date.now() + laneFreeMs; const tx = new Animated.Value(screenWidth); + const opacity = new Animated.Value(0); const id = `d_${idCounter.current++}`; - newItems.push({ id, item, lane, tx, opacity: new Animated.Value(1) }); + newItems.push({ id, item, lane, fontSize, tx, opacity }); + // 入场淡入 + Animated.timing(opacity, { + toValue: 1, + duration: FADE_MS, + useNativeDriver: true, + }).start(); + + // 滚动主动画(线性,视觉速度恒定) Animated.timing(tx, { toValue: -textWidth - 20, duration, useNativeDriver: true, - }).start(() => { - if (mountedRef.current) setActiveDanmakus(prev => prev.filter(d => d.id !== id)); + }).start(({ finished }) => { + if (!mountedRef.current) return; + if (!finished) { + setActiveDanmakus((prev) => prev.filter((d) => d.id !== id)); + return; + } + // 滚到尽头前已经看不到了,直接移除即可(避免重叠淡出动画) + setActiveDanmakus((prev) => prev.filter((d) => d.id !== id)); }); + + // 临近终点淡出:duration - FADE_MS 后开始 + Animated.sequence([ + Animated.delay(Math.max(0, duration - FADE_MS)), + Animated.timing(opacity, { + toValue: 0, + duration: FADE_MS, + useNativeDriver: true, + }), + ]).start(); } else { - // Fixed danmaku (mode 4 = bottom, mode 5 = top) - const opacity = new Animated.Value(1); + // 固定位置(4=底, 5=顶) + const opacity = new Animated.Value(0); const id = `d_${idCounter.current++}`; - newItems.push({ id, item, lane: -1, tx: new Animated.Value(0), opacity }); + newItems.push({ + id, + item, + lane: -1, + fontSize, + tx: new Animated.Value(0), + opacity, + }); Animated.sequence([ - Animated.delay(2000), - Animated.timing(opacity, { toValue: 0, duration: 500, useNativeDriver: true }), + Animated.timing(opacity, { + toValue: 1, + duration: FADE_MS, + useNativeDriver: true, + }), + Animated.delay(2200), + Animated.timing(opacity, { + toValue: 0, + duration: 400, + useNativeDriver: true, + }), ]).start(() => { - if (mountedRef.current) setActiveDanmakus(prev => prev.filter(d => d.id !== id)); + if (mountedRef.current) { + setActiveDanmakus((prev) => prev.filter((d) => d.id !== id)); + } }); } } if (newItems.length > 0) { - setActiveDanmakus(prev => { + setActiveDanmakus((prev) => { const combined = [...prev, ...newItems]; return combined.slice(Math.max(0, combined.length - MAX_ACTIVE)); }); @@ -140,37 +255,9 @@ export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, scr return ( - {activeDanmakus.map(d => { - const fontSize = Math.min(d.item.fontSize || 25, 22); - const isScrolling = d.item.mode === 1; - const isTop = d.item.mode === 5; - - return ( - - {d.item.text} - - ); - })} + {activeDanmakus.map((d) => ( + + ))} ); }