import React, { useRef, useState, useEffect, useCallback } from "react"; import { View, Text, FlatList, TouchableOpacity, StyleSheet, Animated, NativeSyntheticEvent, NativeScrollEvent, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { DanmakuItem } from "../services/types"; import { danmakuColorToCss } from "../utils/danmaku"; interface Props { danmakus: DanmakuItem[]; currentTime: number; visible: boolean; onToggle: () => void; style?: object | object[]; hideHeader?: boolean; maxItems?: number; } interface DisplayedDanmaku extends DanmakuItem { _key: number; _fadeAnim: Animated.Value; } const MAX_DISPLAYED = 100; const DRIP_INTERVAL = 250; const FAST_DRIP_INTERVAL = 100; const QUEUE_FAST_THRESHOLD = 50; const SEEK_THRESHOLD = 2; function formatTimestamp(seconds: number): string { const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${s.toString().padStart(2, "0")}`; } export default function DanmakuList({ danmakus, currentTime, visible, onToggle, style, hideHeader, maxItems = 100, }: Props) { const flatListRef = useRef(null); const [displayedItems, setDisplayedItems] = useState([]); const [unseenCount, setUnseenCount] = useState(0); const queueRef = useRef([]); const lastTimeRef = useRef(0); const processedIndexRef = useRef(0); const keyCounterRef = useRef(0); const isAtBottomRef = useRef(true); const danmakusRef = useRef(danmakus); // Reset everything when danmakus array reference changes (video switch) useEffect(() => { if (danmakusRef.current !== danmakus) { danmakusRef.current = danmakus; queueRef.current = []; processedIndexRef.current = 0; lastTimeRef.current = 0; setDisplayedItems([]); setUnseenCount(0); isAtBottomRef.current = true; } }, [danmakus]); // Watch currentTime, enqueue new danmakus useEffect(() => { if (!visible || danmakus.length === 0) return; const prevTime = lastTimeRef.current; lastTimeRef.current = currentTime; // Seek detection if (Math.abs(currentTime - prevTime) > SEEK_THRESHOLD) { queueRef.current = []; processedIndexRef.current = 0; setDisplayedItems([]); setUnseenCount(0); isAtBottomRef.current = true; // Re-enqueue danmakus up to current time const catchUp = danmakus.filter((d) => d.time <= currentTime); // Only enqueue recent ones to avoid flooding const tail = catchUp.slice(-20); queueRef.current = tail; processedIndexRef.current = danmakus.findIndex( (d) => d.time > currentTime, ); if (processedIndexRef.current === -1) { processedIndexRef.current = danmakus.length; } return; } // Normal progression: enqueue danmakus between prevTime and currentTime const sorted = danmakus; // assumed sorted by time let i = processedIndexRef.current; while (i < sorted.length && sorted[i].time <= currentTime) { queueRef.current.push(sorted[i]); i++; } processedIndexRef.current = i; }, [currentTime, danmakus, visible]); // Drip interval: pop from queue, append to displayed useEffect(() => { if (!visible) return; const id = setInterval( () => { if (queueRef.current.length === 0) return; const item = queueRef.current.shift()!; const fadeAnim = new Animated.Value(0); const displayed: DisplayedDanmaku = { ...item, _key: keyCounterRef.current++, _fadeAnim: fadeAnim, }; Animated.timing(fadeAnim, { toValue: 1, duration: 200, useNativeDriver: true, }).start(); setDisplayedItems((prev) => { const next = [...prev, displayed]; // 超出上限时批量裁剪到一半,减少频繁截断导致的抖动 return next.length > maxItems ? next.slice(-Math.floor(maxItems / 2)) : next; }); if (isAtBottomRef.current) { // Auto-scroll on next frame requestAnimationFrame(() => { flatListRef.current?.scrollToEnd({ animated: true }); }); } else { setUnseenCount((c) => c + 1); } }, queueRef.current.length > QUEUE_FAST_THRESHOLD ? FAST_DRIP_INTERVAL : DRIP_INTERVAL, ); return () => clearInterval(id); }, [visible]); const handleScroll = useCallback( (e: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; const distanceFromBottom = contentSize.height - layoutMeasurement.height - contentOffset.y; isAtBottomRef.current = distanceFromBottom < 40; if (isAtBottomRef.current) { setUnseenCount(0); } }, [], ); const handleScrollBeginDrag = useCallback(() => { isAtBottomRef.current = false; }, []); const handlePillPress = useCallback(() => { flatListRef.current?.scrollToEnd({ animated: true }); setUnseenCount(0); isAtBottomRef.current = true; }, []); const renderItem = useCallback(({ item }: { item: DisplayedDanmaku }) => { const dotColor = danmakuColorToCss(item.color); return ( {!hideHeader && ( )} {item.uname && ( {item.isAdmin && ( 房管 )} {item.guardLevel === 1 && ( 总督 )} {item.guardLevel === 2 && ( 提督 )} {item.guardLevel === 3 && ( 舰长 )} {item.medalLevel != null && ( {item.medalName} {item.medalLevel} )} {item.uname} )} {item.text} {!hideHeader && ( {formatTimestamp(item.time)} )} ); }, [hideHeader]); const keyExtractor = useCallback( (item: DisplayedDanmaku) => String(item._key), [], ); return ( {!hideHeader && ( 弹幕 {danmakus.length > 0 ? `(${danmakus.length})` : ""} )} {visible && ( {danmakus.length === 0 ? "暂无弹幕" : "弹幕将随视频播放显示"} } /> {unseenCount > 0 && ( {unseenCount} 条新弹幕 )} )} ); } const styles = StyleSheet.create({ container: { backgroundColor: "#fff", borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: "#eee", }, header: { flexDirection: "row", alignItems: "center", paddingHorizontal: 12, paddingVertical: 8, gap: 6, }, headerText: { flex: 1, fontSize: 13, color: "#212121", fontWeight: "500", }, listWrapper: { flex: 1, position: "relative", }, list: { flex: 1, backgroundColor: "#fafafa", }, listContent: { paddingHorizontal: 8, paddingVertical: 4, }, bubble: { flexDirection: "row", alignItems: "flex-start", backgroundColor: "#f8f8f8", borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, marginVertical: 2, gap: 8, }, colorDot: { width: 6, height: 6, borderRadius: 3, marginTop: 6, flexShrink: 0, }, bubbleContent: { flex: 1, }, userRow: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 4, marginBottom: 2, }, badge: { borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1, }, badgeAdmin: { backgroundColor: "#e53935" }, badgeGuard1: { backgroundColor: "#9c27b0" }, badgeGuard2: { backgroundColor: "#1565c0" }, badgeGuard3: { backgroundColor: "#1976d2" }, badgeText: { color: "#fff", fontSize: 10, fontWeight: "600" }, medal: { borderRadius: 3, paddingHorizontal: 5, paddingVertical: 1, backgroundColor: "#ff6d9d", }, medalText: { color: "#fff", fontSize: 10, fontWeight: "600" }, uname: { fontSize: 11, color: "#999" }, bubbleText: { fontSize: 13, color: "#333", lineHeight: 18, }, timestamp: { fontSize: 11, color: "#bbb", marginTop: 1, flexShrink: 0, }, pill: { position: "absolute", bottom: 8, alignSelf: "center", backgroundColor: "#00AEEC", borderRadius: 16, paddingHorizontal: 14, paddingVertical: 6, }, pillText: { fontSize: 12, color: "#fff", fontWeight: "600", }, empty: { fontSize: 12, color: "#999", textAlign: "center", paddingVertical: 20, }, });