本项目已收到哔哩哔哩(bilibili)律师函,要求停止对 B 站 API 的调用及相关仿制行为。

为尊重知识产权及相关法律法规,本仓库即日起停止后续维护与更新,不再接受新的 Issue 和 Pull Request。

现有代码仅作学习参考保留,请勿将本项目用于任何商业或违法用途。

感谢所有支持过本项目的朋友。
This commit is contained in:
Developer
2026-05-12 21:35:10 +08:00
parent 6e0dc2729c
commit a869513a78

View File

@@ -1,5 +1,5 @@
import React, { useRef, useEffect, useState, useCallback } from 'react'; import React, { useRef, useEffect, useState, useCallback, memo } from 'react';
import { View, Animated, StyleSheet, Text } from 'react-native'; import { View, Animated, StyleSheet } from 'react-native';
import { DanmakuItem } from '../services/types'; import { DanmakuItem } from '../services/types';
import { danmakuColorToCss } from '../utils/danmaku'; import { danmakuColorToCss } from '../utils/danmaku';
@@ -11,22 +11,91 @@ interface Props {
visible: boolean; visible: boolean;
} }
const LANE_COUNT = 5; // ─── 配置 ───────────────────────────────────────────────────────────────────
const LANE_H = 28; // 字体上限(小一点更接近 PC B 站手感)
// 同屏弹幕上限(原 30提至 60 const FONT_MAX = 16;
const MAX_ACTIVE = 60; // 单条车道高度
const LANE_H = 22;
// 横向车道数
const LANE_COUNT = 6;
// 同屏弹幕上限
const MAX_ACTIVE = 80;
// activated 集合阈值,触达后整体清零防止内存膨胀 // activated 集合阈值,触达后整体清零防止内存膨胀
const ACTIVATED_LIMIT = 1000; const ACTIVATED_LIMIT = 1000;
// 滚动恒定速度(像素 / 秒)—— 长文走得久,视觉上速度一致
const SCROLL_PX_PER_SEC = 160;
// 入/出场淡入淡出时长
const FADE_MS = 200;
// 估算文本像素宽度。中文等表意字符按 1.0x fontSizeASCII 按 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 { interface ActiveDanmaku {
id: string; id: string;
item: DanmakuItem; item: DanmakuItem;
lane: number; lane: number;
fontSize: number;
tx: Animated.Value; tx: Animated.Value;
opacity: 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 (
<Animated.Text
style={{
position: 'absolute',
top: isScrolling
? 16 + d.lane * LANE_H
: isTop
? 16
: screenHeight - 36,
left: isScrolling ? 0 : undefined,
alignSelf: !isScrolling ? 'center' : undefined,
transform: isScrolling ? [{ translateX: d.tx }] : [],
opacity: d.opacity,
color: danmakuColorToCss(d.item.color),
fontSize: d.fontSize,
fontWeight: '700',
// 描边1px 偏移 + radius 0避免模糊带来的额外栅格化
textShadowColor: 'rgba(0,0,0,0.9)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 0,
}}
>
{d.item.text}
</Animated.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<ActiveDanmaku[]>([]); const [activeDanmakus, setActiveDanmakus] = useState<ActiveDanmaku[]>([]);
const laneAvailAt = useRef<number[]>(new Array(LANE_COUNT).fill(0)); const laneAvailAt = useRef<number[]>(new Array(LANE_COUNT).fill(0));
const activated = useRef<Set<string>>(new Set()); const activated = useRef<Set<string>>(new Set());
@@ -34,7 +103,9 @@ export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, scr
const idCounter = useRef(0); const idCounter = useRef(0);
const mountedRef = useRef(true); const mountedRef = useRef(true);
useEffect(() => { useEffect(() => {
return () => { mountedRef.current = false; }; return () => {
mountedRef.current = false;
};
}, []); }, []);
const pickLane = useCallback((): number | null => { const pickLane = useCallback((): number | null => {
@@ -59,77 +130,121 @@ export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, scr
prevTimeRef.current = currentTime; prevTimeRef.current = currentTime;
if (didSeek) { if (didSeek) {
// Clear on seek
activated.current.clear(); activated.current.clear();
laneAvailAt.current.fill(0); laneAvailAt.current.fill(0);
setActiveDanmakus([]); setActiveDanmakus([]);
return; return;
} }
// Find danmakus in the activation window
const window = 0.4; const window = 0.4;
const candidates = danmakus.filter(d => { const candidates = danmakus.filter((d) => {
const key = `${d.time}_${d.text}`; 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; if (candidates.length === 0) return;
const newItems: ActiveDanmaku[] = [];
if (activated.current.size > ACTIVATED_LIMIT) { if (activated.current.size > ACTIVATED_LIMIT) {
activated.current.clear(); // prevent memory leak activated.current.clear();
idCounter.current = 0; idCounter.current = 0;
} }
const newItems: ActiveDanmaku[] = [];
for (const item of candidates) { for (const item of candidates) {
const key = `${item.time}_${item.text}`; const key = `${item.time}_${item.text}`;
activated.current.add(key); activated.current.add(key);
if (item.mode === 1) { const fontSize = Math.min(item.fontSize || 18, FONT_MAX);
// Scrolling danmaku
const lane = pickLane();
if (lane === null) continue; // drop if all lanes full
const charWidth = Math.min(item.fontSize, 22) * 0.8; if (item.mode === 1) {
const textWidth = item.text.length * charWidth; const lane = pickLane();
const duration = 8000; if (lane === null) continue;
// Lane becomes available when tail of this danmaku clears the right edge of screen
// tail starts at screenWidth, text has width textWidth const textWidth = estimateTextWidth(item.text, fontSize);
// tail clears left edge at duration ms const totalDistance = screenWidth + textWidth + 20;
// lane available when head of next can start without overlapping: when tail clears screen right = immediately for next (head starts at screenWidth) // 恒定速度duration 由文本长度推导,长文走久一点,视觉速度统一
// conservative: lane free after textWidth / (2*screenWidth) * duration ms const duration = (totalDistance / SCROLL_PX_PER_SEC) * 1000;
const laneDelay = (textWidth / (screenWidth + textWidth)) * duration; // 车道何时可被复用:尾巴扫过屏幕右边沿即可,与 duration 解耦
laneAvailAt.current[lane] = Date.now() + laneDelay; const laneFreeMs = (textWidth / SCROLL_PX_PER_SEC) * 1000;
laneAvailAt.current[lane] = Date.now() + laneFreeMs;
const tx = new Animated.Value(screenWidth); const tx = new Animated.Value(screenWidth);
const opacity = new Animated.Value(0);
const id = `d_${idCounter.current++}`; 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, { Animated.timing(tx, {
toValue: -textWidth - 20, toValue: -textWidth - 20,
duration, duration,
useNativeDriver: true, useNativeDriver: true,
}).start(() => { }).start(({ finished }) => {
if (mountedRef.current) setActiveDanmakus(prev => prev.filter(d => d.id !== id)); 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 { } else {
// Fixed danmaku (mode 4 = bottom, mode 5 = top) // 固定位置4=底, 5=顶)
const opacity = new Animated.Value(1); const opacity = new Animated.Value(0);
const id = `d_${idCounter.current++}`; 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.sequence([
Animated.delay(2000), Animated.timing(opacity, {
Animated.timing(opacity, { toValue: 0, duration: 500, useNativeDriver: true }), toValue: 1,
duration: FADE_MS,
useNativeDriver: true,
}),
Animated.delay(2200),
Animated.timing(opacity, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start(() => { ]).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) { if (newItems.length > 0) {
setActiveDanmakus(prev => { setActiveDanmakus((prev) => {
const combined = [...prev, ...newItems]; const combined = [...prev, ...newItems];
return combined.slice(Math.max(0, combined.length - MAX_ACTIVE)); return combined.slice(Math.max(0, combined.length - MAX_ACTIVE));
}); });
@@ -140,37 +255,9 @@ export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, scr
return ( return (
<View style={StyleSheet.absoluteFillObject} pointerEvents="none"> <View style={StyleSheet.absoluteFillObject} pointerEvents="none">
{activeDanmakus.map(d => { {activeDanmakus.map((d) => (
const fontSize = Math.min(d.item.fontSize || 25, 22); <DanmakuLine key={d.id} d={d} screenHeight={screenHeight} />
const isScrolling = d.item.mode === 1; ))}
const isTop = d.item.mode === 5;
return (
<Animated.Text
key={d.id}
style={{
position: 'absolute',
top: isScrolling
? 20 + d.lane * LANE_H
: isTop
? 20
: screenHeight - 48,
left: isScrolling ? 0 : undefined,
alignSelf: !isScrolling ? 'center' : undefined,
transform: isScrolling ? [{ translateX: d.tx }] : [],
opacity: d.opacity,
color: danmakuColorToCss(d.item.color),
fontSize,
fontWeight: '700',
textShadowColor: 'rgba(0,0,0,0.8)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 2,
}}
>
{d.item.text}
</Animated.Text>
);
})}
</View> </View>
); );
} }