Files
JKVideo/components/EngagementSheet.tsx
Developer 6e0dc2729c 本项目已收到哔哩哔哩(bilibili)律师函,要求停止对 B 站 API 的调用及相关仿制行为。
为尊重知识产权及相关法律法规,本仓库即日起停止后续维护与更新,不再接受新的 Issue 和 Pull Request。

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

感谢所有支持过本项目的朋友。
2026-05-12 21:29:45 +08:00

249 lines
7.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
Animated,
StyleSheet,
FlatList,
ActivityIndicator,
InteractionManager,
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { CommentItem } from "./CommentItem";
import DanmakuList from "./DanmakuList";
import { useComments } from "../hooks/useComments";
import { useTheme } from "../utils/theme";
import { formatCount } from "../utils/format";
import { useSheetTransition } from "../utils/useSheetTransition";
import type { DanmakuItem } from "../services/types";
export type EngagementTab = "comments" | "danmaku";
interface Props {
visible: boolean;
onClose: () => void;
/** Sheet 顶部对齐到屏幕的绝对 Y一般是播放器底部 */
topOffset: number;
initialTab: EngagementTab;
aid: number;
replyCount?: number;
danmakus: DanmakuItem[];
currentTime: number;
}
export function EngagementSheet({
visible,
onClose,
topOffset,
initialTab,
aid,
replyCount,
danmakus,
currentTime,
}: Props) {
const theme = useTheme();
const { height } = useWindowDimensions();
const sheetH = Math.max(120, height - topOffset);
const { rendered, slideAnim } = useSheetTransition(visible, sheetH);
const [tab, setTab] = useState<EngagementTab>(initialTab);
const [commentSort, setCommentSort] = useState<0 | 2>(2);
const { comments, loading, hasMore, load } = useComments(aid, commentSort);
// 每次打开根据外部 initialTab 切到对应 Tab
useEffect(() => {
if (visible) setTab(initialTab);
}, [visible, initialTab]);
// 评论懒加载:仅当 Sheet 可见且选中评论 Tab 时拉
useEffect(() => {
if (!visible || !aid || tab !== "comments") return;
const handle = InteractionManager.runAfterInteractions(() => load());
return () => handle.cancel();
}, [visible, aid, commentSort, tab, load]);
if (!rendered) return null;
return (
<Modal
visible
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onClose}
>
<Animated.View
style={[
styles.sheet,
{
top: topOffset,
backgroundColor: theme.sheetBg,
transform: [{ translateY: slideAnim }],
},
]}
>
{/* Tab 栏 + 关闭按钮 */}
<View style={[styles.tabBar, { borderBottomColor: theme.modalBorder }]}>
{(["comments", "danmaku"] as EngagementTab[]).map((t) => {
const label =
t === "comments"
? `评论${replyCount ? ` ${formatCount(replyCount)}` : ""}`
: `弹幕${danmakus.length ? ` ${formatCount(danmakus.length)}` : ""}`;
const active = tab === t;
return (
<TouchableOpacity
key={t}
style={styles.tabItem}
onPress={() => setTab(t)}
activeOpacity={0.7}
>
<Text
style={[
styles.tabLabel,
{ color: theme.modalTextSub },
active && { color: theme.modalText, fontWeight: "700" },
]}
>
{label}
</Text>
{active && <View style={styles.tabUnderline} />}
</TouchableOpacity>
);
})}
<View style={{ flex: 1 }} />
<TouchableOpacity onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<Ionicons name="close" size={22} color={theme.modalTextSub} />
</TouchableOpacity>
</View>
{/* 评论 Tab */}
{tab === "comments" && (
<>
<View style={[styles.sortRow, { borderBottomColor: theme.modalBorder }]}>
{([2, 0] as const).map((sort) => (
<TouchableOpacity
key={sort}
style={[
styles.sortBtn,
{ backgroundColor: theme.inputBg },
commentSort === sort && styles.sortBtnActive,
]}
onPress={() => setCommentSort(sort)}
>
<Text
style={[
styles.sortBtnTxt,
{ color: theme.modalTextSub },
commentSort === sort && styles.sortBtnTxtActive,
]}
>
{sort === 2 ? "热门" : "最新"}
</Text>
</TouchableOpacity>
))}
</View>
<FlatList
style={styles.list}
data={comments}
keyExtractor={(c) => String(c.rpid)}
renderItem={({ item }) => <CommentItem item={item} />}
onEndReached={() => {
if (hasMore && !loading) load();
}}
onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false}
ListFooterComponent={
loading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : !hasMore && comments.length > 0 ? (
<Text style={[styles.emptyTxt, { color: theme.modalTextSub }]}>
</Text>
) : null
}
ListEmptyComponent={
!loading ? (
<Text style={[styles.emptyTxt, { color: theme.modalTextSub }]}>
</Text>
) : null
}
/>
</>
)}
{/* 弹幕 Tab —— hideHeader=true 让弹幕列表填满header 由本 Sheet 顶部 Tab 提供 */}
{tab === "danmaku" && (
<DanmakuList
danmakus={danmakus}
currentTime={currentTime}
visible
onToggle={() => {}}
hideHeader
style={styles.danmakuList}
/>
)}
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
sheet: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
overflow: "hidden",
},
tabBar: {
flexDirection: "row",
alignItems: "center",
borderBottomWidth: StyleSheet.hairlineWidth,
paddingHorizontal: 4,
},
tabItem: {
paddingVertical: 12,
paddingHorizontal: 14,
position: "relative",
alignItems: "center",
},
tabLabel: { fontSize: 14 },
tabUnderline: {
position: "absolute",
bottom: 0,
left: 14,
right: 14,
height: 2,
backgroundColor: "#00AEEC",
borderRadius: 1,
},
closeBtn: { padding: 10 },
sortRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: 14,
paddingVertical: 8,
gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
sortBtn: {
paddingHorizontal: 12,
paddingVertical: 3,
borderRadius: 16,
},
sortBtnActive: { backgroundColor: "#00AEEC" },
sortBtnTxt: { fontSize: 13, fontWeight: "500" },
sortBtnTxtActive: { color: "#fff", fontWeight: "600" },
list: { flex: 1 },
loader: { marginVertical: 30 },
emptyTxt: { textAlign: "center", padding: 30, fontSize: 13 },
danmakuList: { flex: 1, borderTopWidth: 0 },
});