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";
|
2026-03-24 23:08:17 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-26 00:43:37 +08:00
|
|
|
|
// ─── 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) {
|
2026-03-24 23:08:17 +08:00
|
|
|
|
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()!;
|
2026-03-26 00:43:37 +08:00
|
|
|
|
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];
|
2026-03-26 00:43:37 +08:00
|
|
|
|
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) ─────────────────────────────────────
|
2026-03-25 12:03:28 +08:00
|
|
|
|
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 },
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
2026-03-25 15:03:49 +08:00
|
|
|
|
{timeStr ? <Text style={liveStyles.time}>{timeStr}</Text> : null}
|
2026-03-25 12:03:28 +08:00
|
|
|
|
<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>
|
2026-03-25 12:03:28 +08:00
|
|
|
|
)}
|
|
|
|
|
|
{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}>
|
2026-03-25 15:03:49 +08:00
|
|
|
|
<Text style={[liveStyles.medalLv, { color: theme.text }]}>
|
|
|
|
|
|
{item.medalLevel}
|
|
|
|
|
|
</Text>
|
2026-03-25 12:03:28 +08:00
|
|
|
|
</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) ───────────────────────────────────
|
2026-03-25 12:03:28 +08:00
|
|
|
|
const renderVideoItem = useCallback(
|
|
|
|
|
|
({ item }: { item: DisplayedDanmaku }) => {
|
|
|
|
|
|
const dotColor = danmakuColorToCss(item.color);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Animated.View
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.bubble,
|
|
|
|
|
|
{ opacity: item._fadeAnim, backgroundColor: theme.bg },
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text
|
2026-03-25 12:51:14 +08:00
|
|
|
|
style={[styles.bubbleText, { color: theme.text }]}
|
2026-03-25 12:03:28 +08:00
|
|
|
|
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 (
|
2026-03-25 12:03:28 +08:00
|
|
|
|
<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}
|
2026-03-25 12:03:28 +08:00
|
|
|
|
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",
|
2026-03-25 15:03:49 +08:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|