mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
本项目已收到哔哩哔哩(bilibili)律师函,要求停止对 B 站 API 的调用及相关仿制行为。
为尊重知识产权及相关法律法规,本仓库即日起停止后续维护与更新,不再接受新的 Issue 和 Pull Request。 现有代码仅作学习参考保留,请勿将本项目用于任何商业或违法用途。 感谢所有支持过本项目的朋友。
This commit is contained in:
@@ -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 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 {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user