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) => (
+
+ ))}
);
}