import React, { useState, useEffect, useLayoutEffect, useRef } from "react"; import { View, Text, FlatList, StyleSheet, TouchableOpacity, Image, ActivityIndicator, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { useLocalSearchParams, useRouter } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { VideoPlayer } from "../../components/VideoPlayer"; import { CommentItem } from "../../components/CommentItem"; import { getDanmaku, getUploaderStat } from "../../services/bilibili"; import { DanmakuItem } from "../../services/types"; import DanmakuList from "../../components/DanmakuList"; import { useVideoDetail } from "../../hooks/useVideoDetail"; import { useComments } from "../../hooks/useComments"; import { useRelatedVideos } from "../../hooks/useRelatedVideos"; import { formatCount, formatDuration } from "../../utils/format"; import { proxyImageUrl } from "../../utils/imageUrl"; import { DownloadSheet } from "../../components/DownloadSheet"; import { useTheme } from "../../utils/theme"; import { useLiveStore } from "../../store/liveStore"; type Tab = "intro" | "comments" | "danmaku"; export default function VideoDetailScreen() { const { bvid } = useLocalSearchParams<{ bvid: string }>(); const router = useRouter(); const theme = useTheme(); // 进入视频详情页时立即清除直播小窗 useLayoutEffect(() => { useLiveStore.getState().clearLive(); }, []); const { video, playData, loading: videoLoading, qualities, currentQn, changeQuality, } = useVideoDetail(bvid as string); const [commentSort, setCommentSort] = useState<0 | 2>(2); const { comments, loading: cmtLoading, hasMore: cmtHasMore, load: loadComments, } = useComments(video?.aid ?? 0, commentSort); const [tab, setTab] = useState("intro"); const [danmakus, setDanmakus] = useState([]); const [currentTime, setCurrentTime] = useState(0); const [showDownload, setShowDownload] = useState(false); const [uploaderStat, setUploaderStat] = useState<{ follower: number; archiveCount: number; } | null>(null); const { videos: relatedVideos, loading: relatedLoading, load: loadRelated, } = useRelatedVideos(bvid as string); useEffect(() => { loadRelated(); }, []); useEffect(() => { if (video?.aid) loadComments(); }, [video?.aid, commentSort]); useEffect(() => { if (!video?.cid) return; getDanmaku(video.cid).then(setDanmakus).catch(() => {}); }, [video?.cid]); useEffect(() => { if (!video?.owner?.mid) return; getUploaderStat(video.owner.mid) .then(setUploaderStat) .catch(() => {}); }, [video?.owner?.mid]); return ( {/* TopBar */} router.back()} style={styles.backBtn}> {video?.title ?? "视频详情"} setShowDownload(true)} > {/* Video player — fixed 16:9 */} setShowDownload(false)} bvid={bvid as string} cid={video?.cid ?? 0} title={video?.title ?? ""} cover={video?.pic ?? ""} qualities={qualities} /> {/* TabBar */} {video && ( setTab("intro")} > 简介 {tab === "intro" && } setTab("comments")} > 评论 {video.stat?.reply > 0 ? ` ${formatCount(video.stat.reply)}` : ""} {tab === "comments" && } setTab("danmaku")} > 弹幕 {danmakus.length > 0 ? ` ${formatCount(danmakus.length)}` : ""} {tab === "danmaku" && } )} {/* Tab content */} {videoLoading ? ( ) : video ? ( <> {tab === "intro" && ( style={styles.tabScroll} data={relatedVideos} keyExtractor={(item) => item.bvid} showsVerticalScrollIndicator={false} ListHeaderComponent={ <> router.push(`/creator/${video.owner.mid}` as any)} > {video.owner.name} {uploaderStat && ( {formatCount(uploaderStat.follower)}粉丝 ·{" "} {formatCount(uploaderStat.archiveCount)}视频 )} 查看主页 {video.title} {video.desc || "暂无简介"} {video.ugc_season && ( router.replace(`/video/${epBvid}`) } /> )} 推荐视频 } renderItem={({ item }) => ( router.replace(`/video/${item.bvid}` as any)} activeOpacity={0.85} > {formatDuration(item.duration)} {item.title} {item.owner?.name ?? ""} {formatCount(item.stat?.view ?? 0)} 播放 )} ListEmptyComponent={ relatedLoading ? ( ) : null } ListFooterComponent={ relatedLoading ? ( ) : null } /> )} {tab === "comments" && ( String(c.rpid)} renderItem={({ item }) => } onEndReached={() => { if (cmtHasMore && !cmtLoading) loadComments(); }} onEndReachedThreshold={0.3} showsVerticalScrollIndicator={false} ListHeaderComponent={ setCommentSort(2)} > 热门 setCommentSort(0)} > 最新 } ListFooterComponent={ cmtLoading ? ( ) : !cmtHasMore && comments.length > 0 ? ( 已加载全部评论 ) : null } ListEmptyComponent={ !cmtLoading ? ( 暂无评论 ) : null } /> )} {/* 弹幕面板:始终挂载,切 tab 时用 display:none 隐藏而不卸载 */} {}} hideHeader={true} style={[ styles.danmakuTab, tab !== "danmaku" && { display: "none" }, ]} /> ) : null} ); } function StatBadge({ icon, count }: { icon: string; count: number }) { return ( {formatCount(count)} ); } function SeasonSection({ season, currentBvid, onEpisodePress, }: { season: NonNullable; currentBvid: string; onEpisodePress: (bvid: string) => void; }) { const theme = useTheme(); const episodes = season.sections?.[0]?.episodes ?? []; const currentIndex = episodes.findIndex((ep) => ep.bvid === currentBvid); const listRef = useRef(null); useEffect(() => { if (currentIndex <= 0 || episodes.length === 0) return; const t = setTimeout(() => { listRef.current?.scrollToIndex({ index: currentIndex, viewPosition: 0.5, animated: false, }); }, 200); return () => clearTimeout(t); }, [currentIndex, episodes.length]); return ( 合集 · {season.title} {season.ep_count}个视频 ep.bvid} contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }} getItemLayout={(_data, index) => ({ length: 130, offset: 12 + index * 130, index, })} onScrollToIndexFailed={() => {}} renderItem={({ item: ep, index }) => { const isCurrent = ep.bvid === currentBvid; return ( !isCurrent && onEpisodePress(ep.bvid)} activeOpacity={0.8} > {ep.arc?.pic && ( )} 第{index + 1}集 {ep.title} ); }} /> ); } const styles = StyleSheet.create({ safe: { flex: 1 }, topBar: { flexDirection: "row", alignItems: "center", paddingHorizontal: 8, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, }, backBtn: { padding: 4 }, topTitle: { flex: 1, fontSize: 15, fontWeight: "600", marginLeft: 4, }, miniBtn: { padding: 4 }, loader: { marginVertical: 30 }, titleSection: { padding: 14, borderBottomWidth: StyleSheet.hairlineWidth, }, title: { fontSize: 13, fontWeight: "600", lineHeight: 22, marginBottom: 8, }, subTitle: { fontSize: 10, marginBottom: 8, }, statsRow: { flexDirection: "row", gap: 16 }, stat: { flexDirection: "row", alignItems: "center", gap: 3 }, statText: { fontSize: 12, color: "#999" }, upRow: { flexDirection: "row", alignItems: "center", paddingHorizontal: 10, paddingBottom: 0, paddingTop: 12, }, avatar: { width: 40, height: 40, borderRadius: 30, marginRight: 10 }, upInfo: { flex: 1, justifyContent: "center" }, upName: { fontSize: 14, fontWeight: "500" }, upStat: { fontSize: 11, color: "#999", marginTop: 2 }, followBtn: { backgroundColor: "#00AEEC", paddingHorizontal: 10, paddingVertical: 3, borderRadius: 14, }, followTxt: { color: "#fff", fontSize: 12, fontWeight: "500" }, seasonBox: { borderTopWidth: StyleSheet.hairlineWidth, paddingVertical: 10, }, seasonHeader: { flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingBottom: 8, gap: 4, }, seasonTitle: { flex: 1, fontSize: 13, fontWeight: "600" }, seasonCount: { fontSize: 12, color: "#999" }, epCard: { width: 120, borderRadius: 6, overflow: "hidden", borderWidth: 1, borderColor: "transparent", }, epCardActive: { borderColor: "#00AEEC", borderWidth: 1.5 }, epThumb: { width: 120, height: 68 }, epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 }, epNumActive: { color: "#00AEEC", fontWeight: "600" }, epTitle: { fontSize: 12, paddingHorizontal: 6, paddingBottom: 6, lineHeight: 16, }, tabBar: { flexDirection: "row", borderBottomWidth: StyleSheet.hairlineWidth, paddingLeft: 3, }, tabItem: { alignItems: "center", paddingVertical: 12, paddingHorizontal: 12, position: "relative", }, tabLabel: { fontSize: 13 }, tabActive: { color: "#00AEEC" }, tabUnderline: { position: "absolute", bottom: 0, width: 24, height: 2, backgroundColor: "#00AEEC", borderRadius: 1, }, tabScroll: { flex: 1 }, descBox: { padding: 16 }, descText: { fontSize: 14, lineHeight: 22 }, danmakuTab: { flex: 1 }, emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 }, relatedHeader: { paddingLeft: 13, paddingBottom: 8, paddingTop: 8, }, relatedHeaderText: { fontSize: 13, fontWeight: "600" as const, }, relatedCard: { flexDirection: "row", paddingHorizontal: 12, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, gap: 10, }, relatedThumbWrap: { position: "relative", width: 120, height: 68, borderRadius: 4, overflow: "hidden", flexShrink: 0, }, relatedThumb: { width: 120, height: 68 }, relatedDuration: { position: "absolute", bottom: 3, right: 3, backgroundColor: "rgba(0,0,0,0.6)", borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1, }, relatedDurationText: { color: "#fff", fontSize: 10 }, relatedInfo: { flex: 1, justifyContent: "space-between", paddingVertical: 2, }, relatedTitle: { fontSize: 13, lineHeight: 18 }, relatedOwner: { fontSize: 12, color: "#999" }, relatedView: { fontSize: 11, color: "#bbb" }, sortRow: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", paddingHorizontal: 14, paddingVertical: 10, gap: 8, borderBottomWidth: StyleSheet.hairlineWidth, }, sortBtn: { paddingHorizontal: 10, paddingVertical: 2, borderRadius: 16, backgroundColor: "#f0f0f0", }, sortBtnActive: { backgroundColor: "#00AEEC" }, sortBtnTxt: { fontSize: 13, color: "#333", fontWeight: "500" }, sortBtnTxtActive: { color: "#fff", fontWeight: "600" as const }, });