Files
JKVideo/components/DanmakuList.tsx

605 lines
16 KiB
TypeScript
Raw Permalink Normal View History

2026-03-17 17:26:43 +08:00
import React, { useRef, useState, useEffect, useCallback } from "react";
2026-03-12 21:21:01 +08:00
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Animated,
NativeSyntheticEvent,
NativeScrollEvent,
2026-03-17 17:26:43 +08:00
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { DanmakuItem } from "../services/types";
import { danmakuColorToCss } from "../utils/danmaku";
import { useTheme } from "../utils/theme";
2026-03-10 22:47:11 +08:00
interface Props {
danmakus: DanmakuItem[];
currentTime: number;
visible: boolean;
onToggle: () => void;
2026-03-17 17:26:43 +08:00
style?: object | object[];
hideHeader?: boolean;
2026-03-19 16:34:56 +08:00
isLive?: boolean;
2026-03-17 17:26:43 +08:00
maxItems?: number;
2026-03-17 19:52:26 +08:00
giftCounts?: Record<string, number>;
2026-03-10 22:47:11 +08:00
}
2026-03-12 21:21:01 +08:00
interface DisplayedDanmaku extends DanmakuItem {
_key: number;
_fadeAnim: Animated.Value;
}
const DRIP_INTERVAL = 250;
const FAST_DRIP_INTERVAL = 100;
const QUEUE_FAST_THRESHOLD = 50;
const SEEK_THRESHOLD = 2;
// ─── Animated.Value 对象池,减少频繁创建/GC ──────────────────────────────────
const animPool: Animated.Value[] = [];
const POOL_MAX = 64;
function acquireAnim(): Animated.Value {
const v = animPool.pop();
if (v) { v.setValue(0); return v; }
return new Animated.Value(0);
}
function releaseAnims(items: DisplayedDanmaku[]) {
for (const item of items) {
if (animPool.length < POOL_MAX) {
animPool.push(item._fadeAnim);
}
}
}
2026-03-17 19:52:26 +08:00
// ─── 舰长等级 ───────────────────────────────────────────────────────────────────
const GUARD_LABELS: Record<number, { text: string; color: string }> = {
1: { text: "总督", color: "#E13979" },
2: { text: "提督", color: "#7B68EE" },
3: { text: "舰长", color: "#00D1F1" },
};
2026-03-12 21:21:01 +08:00
function formatTimestamp(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
2026-03-17 17:26:43 +08:00
return `${m}:${s.toString().padStart(2, "0")}`;
2026-03-12 21:21:01 +08:00
}
2026-03-17 19:52:26 +08:00
function formatLiveTime(timeline?: string): string {
if (!timeline) return "";
const parts = timeline.split(" ");
return parts[1]?.slice(0, 5) ?? ""; // "HH:MM"
}
2026-03-17 17:26:43 +08:00
export default function DanmakuList({
danmakus,
currentTime,
visible,
onToggle,
style,
hideHeader,
2026-03-19 16:34:56 +08:00
isLive,
2026-03-17 17:26:43 +08:00
maxItems = 100,
2026-03-17 19:52:26 +08:00
giftCounts,
2026-03-17 17:26:43 +08:00
}: Props) {
const theme = useTheme();
2026-03-10 22:47:11 +08:00
const flatListRef = useRef<FlatList>(null);
2026-03-12 21:21:01 +08:00
const [displayedItems, setDisplayedItems] = useState<DisplayedDanmaku[]>([]);
const [unseenCount, setUnseenCount] = useState(0);
2026-03-10 22:47:11 +08:00
2026-03-12 21:21:01 +08:00
const queueRef = useRef<DanmakuItem[]>([]);
const lastTimeRef = useRef(0);
const processedIndexRef = useRef(0);
const keyCounterRef = useRef(0);
const isAtBottomRef = useRef(true);
const danmakusRef = useRef(danmakus);
2026-03-10 22:47:11 +08:00
2026-03-17 19:52:26 +08:00
// Detect changes in the danmakus array
2026-03-10 22:47:11 +08:00
useEffect(() => {
2026-03-17 19:52:26 +08:00
const prev = danmakusRef.current;
if (prev === danmakus) return;
danmakusRef.current = danmakus;
if (danmakus.length === 0) {
2026-03-12 21:21:01 +08:00
queueRef.current = [];
processedIndexRef.current = 0;
lastTimeRef.current = 0;
setDisplayedItems([]);
setUnseenCount(0);
isAtBottomRef.current = true;
2026-03-17 19:52:26 +08:00
return;
2026-03-10 22:47:11 +08:00
}
2026-03-12 21:21:01 +08:00
2026-03-19 16:34:56 +08:00
if (isLive) {
2026-03-17 19:52:26 +08:00
const newStart = processedIndexRef.current;
if (danmakus.length > newStart) {
queueRef.current.push(...danmakus.slice(newStart));
processedIndexRef.current = danmakus.length;
}
return;
}
queueRef.current = [];
processedIndexRef.current = 0;
lastTimeRef.current = 0;
setDisplayedItems([]);
setUnseenCount(0);
isAtBottomRef.current = true;
2026-03-19 16:34:56 +08:00
}, [danmakus, isLive]);
2026-03-17 19:52:26 +08:00
// Watch currentTime — only used in video mode
2026-03-12 21:21:01 +08:00
useEffect(() => {
2026-03-19 16:34:56 +08:00
if (danmakus.length === 0 || isLive) return;
2026-03-12 21:21:01 +08:00
const prevTime = lastTimeRef.current;
lastTimeRef.current = currentTime;
if (Math.abs(currentTime - prevTime) > SEEK_THRESHOLD) {
queueRef.current = [];
processedIndexRef.current = 0;
setDisplayedItems([]);
setUnseenCount(0);
isAtBottomRef.current = true;
2026-03-17 17:26:43 +08:00
const catchUp = danmakus.filter((d) => d.time <= currentTime);
2026-03-12 21:21:01 +08:00
const tail = catchUp.slice(-20);
queueRef.current = tail;
processedIndexRef.current = danmakus.findIndex(
2026-03-17 17:26:43 +08:00
(d) => d.time > currentTime,
2026-03-12 21:21:01 +08:00
);
if (processedIndexRef.current === -1) {
processedIndexRef.current = danmakus.length;
}
return;
}
2026-03-17 19:52:26 +08:00
const sorted = danmakus;
2026-03-12 21:21:01 +08:00
let i = processedIndexRef.current;
while (i < sorted.length && sorted[i].time <= currentTime) {
queueRef.current.push(sorted[i]);
i++;
}
processedIndexRef.current = i;
2026-03-19 16:34:56 +08:00
}, [currentTime, danmakus, isLive]);
2026-03-12 21:21:01 +08:00
2026-03-19 16:34:56 +08:00
// Drip interval — always running so queue is consumed even when tab is hidden
2026-03-12 21:21:01 +08:00
useEffect(() => {
2026-03-17 17:26:43 +08:00
const id = setInterval(
() => {
if (queueRef.current.length === 0) return;
2026-03-12 21:21:01 +08:00
2026-03-17 17:26:43 +08:00
const item = queueRef.current.shift()!;
const fadeAnim = acquireAnim();
2026-03-17 17:26:43 +08:00
const displayed: DisplayedDanmaku = {
...item,
_key: keyCounterRef.current++,
_fadeAnim: fadeAnim,
};
2026-03-12 21:21:01 +08:00
2026-03-17 17:26:43 +08:00
Animated.timing(fadeAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}).start();
2026-03-12 21:21:01 +08:00
2026-03-17 17:26:43 +08:00
setDisplayedItems((prev) => {
const next = [...prev, displayed];
if (next.length > maxItems) {
const trimCount = next.length - Math.floor(maxItems / 2);
const trimmed = next.slice(trimCount);
releaseAnims(next.slice(0, trimCount));
return trimmed;
}
return next;
2026-03-12 21:21:01 +08:00
});
2026-03-17 17:26:43 +08:00
if (isAtBottomRef.current) {
requestAnimationFrame(() => {
flatListRef.current?.scrollToEnd({ animated: true });
});
} else {
setUnseenCount((c) => c + 1);
}
},
queueRef.current.length > QUEUE_FAST_THRESHOLD
? FAST_DRIP_INTERVAL
: DRIP_INTERVAL,
);
2026-03-12 21:21:01 +08:00
return () => clearInterval(id);
2026-03-19 16:34:56 +08:00
}, []);
2026-03-12 21:21:01 +08:00
const handleScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
const distanceFromBottom =
contentSize.height - layoutMeasurement.height - contentOffset.y;
isAtBottomRef.current = distanceFromBottom < 40;
2026-03-17 19:52:26 +08:00
if (isAtBottomRef.current) setUnseenCount(0);
2026-03-12 21:21:01 +08:00
},
2026-03-17 17:26:43 +08:00
[],
2026-03-12 21:21:01 +08:00
);
const handleScrollBeginDrag = useCallback(() => {
isAtBottomRef.current = false;
}, []);
const handlePillPress = useCallback(() => {
flatListRef.current?.scrollToEnd({ animated: true });
setUnseenCount(0);
isAtBottomRef.current = true;
}, []);
2026-03-17 19:52:26 +08:00
// ─── Live mode render (B站-style chat) ─────────────────────────────────────
const renderLiveItem = useCallback(
({ item }: { item: DisplayedDanmaku }) => {
const guard = item.guardLevel ? GUARD_LABELS[item.guardLevel] : null;
const timeStr = formatLiveTime(item.timeline);
return (
<Animated.View
style={[
liveStyles.row,
{ opacity: item._fadeAnim, borderBottomColor: theme.border },
]}
>
{timeStr ? <Text style={liveStyles.time}>{timeStr}</Text> : null}
<View style={liveStyles.msgBody}>
{guard && (
<View
style={[liveStyles.guardTag, { backgroundColor: guard.color }]}
>
<Text style={liveStyles.guardTagText}>{guard.text}</Text>
2026-03-17 19:52:26 +08:00
</View>
)}
{item.isAdmin && (
<View
style={[liveStyles.guardTag, { backgroundColor: "#e53935" }]}
>
<Text style={liveStyles.guardTagText}></Text>
</View>
)}
{item.medalLevel != null && item.medalName && (
<View style={liveStyles.medalTag}>
<Text style={liveStyles.medalName}>{item.medalName}</Text>
<View style={liveStyles.medalLvBox}>
<Text style={[liveStyles.medalLv, { color: theme.text }]}>
{item.medalLevel}
</Text>
</View>
</View>
)}
<Text style={liveStyles.uname} numberOfLines={1}>
{item.uname ?? "匿名"}
</Text>
<Text style={liveStyles.colon}></Text>
<Text
style={[liveStyles.text, { color: theme.text }]}
numberOfLines={2}
>
{item.text}
</Text>
</View>
</Animated.View>
);
},
[theme],
);
2026-03-17 19:52:26 +08:00
// ─── Video mode render (original bubble) ───────────────────────────────────
const renderVideoItem = useCallback(
({ item }: { item: DisplayedDanmaku }) => {
const dotColor = danmakuColorToCss(item.color);
return (
<Animated.View
style={[
styles.bubble,
{ opacity: item._fadeAnim, backgroundColor: theme.bg },
]}
>
<Text
style={[styles.bubbleText, { color: theme.text }]}
numberOfLines={3}
>
{item.text}
</Text>
<Text style={styles.timestamp}>{formatTimestamp(item.time)}</Text>
</Animated.View>
);
},
[theme],
);
2026-03-12 21:21:01 +08:00
2026-03-17 17:26:43 +08:00
const keyExtractor = useCallback(
(item: DisplayedDanmaku) => String(item._key),
[],
);
2026-03-10 22:47:11 +08:00
return (
<View
style={[
styles.container,
{ backgroundColor: theme.card, borderTopColor: theme.border },
style,
]}
>
2026-03-17 17:26:43 +08:00
{!hideHeader && (
<TouchableOpacity
style={styles.header}
onPress={onToggle}
activeOpacity={0.7}
>
<Ionicons
name={visible ? "chatbubbles" : "chatbubbles-outline"}
size={16}
color="#00AEEC"
/>
<Text style={styles.headerText}>
{danmakus.length > 0 ? `(${danmakus.length})` : ""}
</Text>
<Ionicons
name={visible ? "chevron-up" : "chevron-down"}
size={14}
color="#999"
/>
</TouchableOpacity>
)}
2026-03-10 22:47:11 +08:00
{visible && (
2026-03-12 21:21:01 +08:00
<View style={styles.listWrapper}>
<FlatList
ref={flatListRef}
data={displayedItems}
keyExtractor={keyExtractor}
2026-03-19 16:34:56 +08:00
renderItem={isLive ? renderLiveItem : renderVideoItem}
style={[
isLive ? liveStyles.list : styles.list,
{ backgroundColor: theme.card },
]}
contentContainerStyle={
isLive ? liveStyles.listContent : styles.listContent
}
2026-03-12 21:21:01 +08:00
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
scrollEventThrottle={16}
removeClippedSubviews={true}
ListEmptyComponent={
<Text style={styles.empty}>
2026-03-17 17:26:43 +08:00
{danmakus.length === 0 ? "暂无弹幕" : "弹幕将随视频播放显示"}
2026-03-12 21:21:01 +08:00
</Text>
}
/>
{unseenCount > 0 && (
<TouchableOpacity
style={styles.pill}
onPress={handlePillPress}
activeOpacity={0.8}
2026-03-10 22:47:11 +08:00
>
2026-03-12 21:21:01 +08:00
<Text style={styles.pillText}>{unseenCount} </Text>
</TouchableOpacity>
2026-03-10 22:47:11 +08:00
)}
2026-03-12 21:21:01 +08:00
</View>
2026-03-10 22:47:11 +08:00
)}
</View>
);
}
2026-03-17 19:52:26 +08:00
// ─── Video mode styles ────────────────────────────────────────────────────────
2026-03-10 22:47:11 +08:00
const styles = StyleSheet.create({
container: {
2026-03-17 17:26:43 +08:00
backgroundColor: "#fff",
2026-03-10 22:47:11 +08:00
borderTopWidth: StyleSheet.hairlineWidth,
2026-03-17 17:26:43 +08:00
borderTopColor: "#eee",
2026-03-10 22:47:11 +08:00
},
header: {
2026-03-17 17:26:43 +08:00
flexDirection: "row",
alignItems: "center",
2026-03-10 22:47:11 +08:00
paddingHorizontal: 12,
paddingVertical: 8,
gap: 6,
},
headerText: {
flex: 1,
fontSize: 13,
2026-03-17 17:26:43 +08:00
color: "#212121",
fontWeight: "500",
2026-03-10 22:47:11 +08:00
},
2026-03-12 21:21:01 +08:00
listWrapper: {
2026-03-16 14:24:32 +08:00
flex: 1,
2026-03-17 17:26:43 +08:00
position: "relative",
2026-03-12 21:21:01 +08:00
},
2026-03-10 22:47:11 +08:00
list: {
2026-03-12 21:21:01 +08:00
flex: 1,
2026-03-17 17:26:43 +08:00
backgroundColor: "#fafafa",
2026-03-12 21:21:01 +08:00
},
listContent: {
paddingHorizontal: 8,
paddingVertical: 4,
},
bubble: {
2026-03-17 17:26:43 +08:00
flexDirection: "row",
alignItems: "flex-start",
backgroundColor: "#f8f8f8",
2026-03-12 21:21:01 +08:00
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 6,
marginVertical: 2,
gap: 8,
},
2026-03-17 17:26:43 +08:00
bubbleText: {
2026-03-17 19:52:26 +08:00
flex: 1,
2026-03-10 22:47:11 +08:00
fontSize: 13,
2026-03-17 17:26:43 +08:00
color: "#333",
2026-03-10 22:47:11 +08:00
lineHeight: 18,
},
2026-03-12 21:21:01 +08:00
timestamp: {
fontSize: 11,
2026-03-17 17:26:43 +08:00
color: "#bbb",
2026-03-12 21:21:01 +08:00
marginTop: 1,
flexShrink: 0,
},
pill: {
2026-03-17 17:26:43 +08:00
position: "absolute",
2026-03-12 21:21:01 +08:00
bottom: 8,
2026-03-17 17:26:43 +08:00
alignSelf: "center",
backgroundColor: "#00AEEC",
2026-03-12 21:21:01 +08:00
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 6,
},
pillText: {
fontSize: 12,
2026-03-17 17:26:43 +08:00
color: "#fff",
fontWeight: "600",
2026-03-12 21:21:01 +08:00
},
2026-03-10 22:47:11 +08:00
empty: {
fontSize: 12,
2026-03-17 17:26:43 +08:00
color: "#999",
textAlign: "center",
2026-03-10 22:47:11 +08:00
paddingVertical: 20,
},
});
2026-03-17 19:52:26 +08:00
// ─── Live mode styles (B站-style chat) ────────────────────────────────────────
const liveStyles = StyleSheet.create({
list: {
flex: 1,
backgroundColor: "#f9f9fb",
},
listContent: {
paddingHorizontal: 10,
paddingVertical: 6,
},
row: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
2026-03-17 19:52:26 +08:00
paddingVertical: 5,
},
time: {
fontSize: 10,
color: "#c0c0c0",
width: 34,
marginTop: 2,
flexShrink: 0,
},
msgBody: {
flex: 1,
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
},
guardTag: {
borderRadius: 3,
paddingHorizontal: 4,
paddingVertical: 1,
marginRight: 4,
},
guardTagText: {
color: "#fff",
fontSize: 9,
fontWeight: "700",
},
medalTag: {
flexDirection: "row",
alignItems: "center",
borderRadius: 3,
borderWidth: 1,
borderColor: "#e891ab",
overflow: "hidden",
marginRight: 4,
height: 16,
},
medalName: {
fontSize: 9,
color: "#e891ab",
paddingHorizontal: 3,
},
medalLvBox: {
paddingHorizontal: 3,
height: "100%",
justifyContent: "center",
},
medalLv: {
fontSize: 9,
color: "#fff",
fontWeight: "700",
},
uname: {
fontSize: 12,
color: "#666",
fontWeight: "500",
maxWidth: 90,
},
colon: {
fontSize: 12,
color: "#666",
},
text: {
fontSize: 13,
color: "#212121",
lineHeight: 18,
flexShrink: 1,
},
});
// ─── Gift bar styles ──────────────────────────────────────────────────────────
const giftStyles = StyleSheet.create({
bar: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#eee",
backgroundColor: "#fff",
height: 72,
},
scroll: {
paddingHorizontal: 6,
alignItems: "center",
height: "100%",
},
item: {
alignItems: "center",
justifyContent: "center",
width: 60,
paddingVertical: 6,
},
iconWrap: {
position: "relative",
alignItems: "center",
justifyContent: "center",
},
badge: {
position: "absolute",
top: -8,
alignSelf: "center",
backgroundColor: "#FF6B81",
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 1,
zIndex: 1,
minWidth: 20,
alignItems: "center",
},
badgeText: {
color: "#fff",
fontSize: 10,
fontWeight: "700",
lineHeight: 13,
},
icon: {
fontSize: 22,
lineHeight: 28,
},
name: {
fontSize: 10,
color: "#333",
fontWeight: "500",
marginTop: 2,
},
price: {
fontSize: 9,
color: "#aaa",
marginTop: 1,
},
});