import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from "react"; import { View, Text, FlatList, StyleSheet, TouchableOpacity, ActivityIndicator, useWindowDimensions, InteractionManager, } from "react-native"; import { Image } from "expo-image"; import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"; import { useLocalSearchParams, useRouter } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { VideoPlayer } from "../../components/VideoPlayer"; import { getDanmaku, getUploaderStat } from "../../services/bilibili"; import type { DanmakuItem, VideoItem } from "../../services/types"; import { useVideoDetail } from "../../hooks/useVideoDetail"; import { useRelatedVideos } from "../../hooks/useRelatedVideos"; import { formatCount, formatDuration, formatTime } from "../../utils/format"; import { proxyImageUrl } from "../../utils/imageUrl"; import { DownloadSheet } from "../../components/DownloadSheet"; import { VideoDetailSkeleton } from "../../components/VideoDetailSkeleton"; import { useTheme } from "../../utils/theme"; import { useLiveStore } from "../../store/liveStore"; import { VideoActionRow } from "../../components/VideoActionRow"; import { DescriptionSheet } from "../../components/DescriptionSheet"; import { EngagementSheet, type EngagementTab } from "../../components/EngagementSheet"; import { useFollow } from "../../hooks/useFollow"; export default function VideoDetailScreen() { const { bvid } = useLocalSearchParams<{ bvid: string }>(); const router = useRouter(); const theme = useTheme(); useLayoutEffect(() => { useLiveStore.getState().clearLive(); }, []); // 骨架屏最短展示时长:即便数据秒回,也至少撑够这么久,避免一闪而过。 // 重要:依赖 [bvid] —— 切换合集/推荐时同步复位,让骨架屏盖住老数据残影。 const SKELETON_MIN_MS = 600; const [minSkeletonElapsed, setMinSkeletonElapsed] = useState(false); useEffect(() => { setMinSkeletonElapsed(false); const t = setTimeout(() => setMinSkeletonElapsed(true), SKELETON_MIN_MS); return () => clearTimeout(t); }, [bvid]); const { video, playData, loading: videoLoading, qualities, currentQn, changeQuality, initialTime, } = useVideoDetail(bvid as string); const [danmakus, setDanmakus] = useState([]); const [currentTime, setCurrentTime] = useState(0); const [showDownload, setShowDownload] = useState(false); const [showDescSheet, setShowDescSheet] = useState(false); // 评论/弹幕合并 Sheet:null=关闭,否则记录当前应该打开哪个 Tab const [engagementTab, setEngagementTab] = useState(null); const [uploaderStat, setUploaderStat] = useState<{ follower: number; archiveCount: number; } | null>(null); // 切换视频时把和老视频绑定的瞬态状态全部清掉,避免新页面短暂显示老弹幕/老 UP 主统计/老开着的 Sheet useEffect(() => { setDanmakus([]); setUploaderStat(null); setCurrentTime(0); setShowDescSheet(false); setShowDownload(false); setEngagementTab(null); }, [bvid]); const { following, loading: followLoading, toggle: toggleFollow } = useFollow( video?.owner.mid, ); // Sheet 顶部对齐播放器底部:safe-area 顶 inset + 播放器高度(无 TopBar,返回按钮悬浮在播放器上) const insets = useSafeAreaInsets(); const { width: SCREEN_W } = useWindowDimensions(); const sheetTopOffset = insets.top + SCREEN_W * 0.5625; const { videos: relatedVideos, loading: relatedLoading, load: loadRelated, } = useRelatedVideos(bvid as string); // 推荐视频不参与首屏,等导航动画结束再拉,避免与详情/播放流抢 JS 线程 // 依赖 [bvid]:合集/推荐切到新视频时同步重新拉新推荐列表 useEffect(() => { const handle = InteractionManager.runAfterInteractions(() => { loadRelated(); }); return () => handle.cancel(); }, [bvid, loadRelated]); useEffect(() => { if (!video?.cid) return; const handle = InteractionManager.runAfterInteractions(() => { getDanmaku(video.cid!).then(setDanmakus).catch(() => {}); }); return () => handle.cancel(); }, [video?.cid]); useEffect(() => { if (!video?.owner?.mid) return; const handle = InteractionManager.runAfterInteractions(() => { getUploaderStat(video.owner.mid).then(setUploaderStat).catch(() => {}); }); return () => handle.cancel(); }, [video?.owner?.mid]); const shareUrl = bvid ? `https://www.bilibili.com/video/${bvid}` : undefined; return ( router.back()} coverUrl={video?.pic ? proxyImageUrl(video.pic) : undefined} /> {videoLoading || !video || !minSkeletonElapsed ? ( ) : ( // bvid 是 key —— 切换视频时 FlatList 整体重挂,滚动位置回到顶,避免老内容残影 key={bvid} style={styles.scroll} data={relatedVideos} keyExtractor={(item) => item.bvid} showsVerticalScrollIndicator={false} ListHeaderComponent={ <> {/* 标题 + Meta 行(点击展开简介 Sheet) */} {video.title} setShowDescSheet(true)} > {formatCount(video.stat?.view ?? 0)} 次观看 {video.pubdate ? ` · ${formatTime(video.pubdate)}` : ""} {video.stat?.like ? ` · ${formatCount(video.stat.like)} 点赞` : ""} {video.tname ? ` · ${video.tname}` : ""} 更多 {/* 动作按钮行 */} setShowDownload(true)} onComments={() => setEngagementTab("comments")} onDanmaku={() => setEngagementTab("danmaku")} /> {/* UP 主行 + 关注按钮 */} router.push(`/creator/${video.owner.mid}` as any)} > {video.owner.name} {uploaderStat && ( {formatCount(uploaderStat.follower)}粉丝 ·{" "} {formatCount(uploaderStat.archiveCount)}视频 )} {following ? "已关注" : "关注"} {/* 合集 */} {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 } /> )} setShowDownload(false)} topOffset={sheetTopOffset} bvid={bvid as string} cid={video?.cid ?? 0} title={video?.title ?? ""} cover={video?.pic ?? ""} qualities={qualities} /> setShowDescSheet(false)} topOffset={sheetTopOffset} video={video ?? null} /> setEngagementTab(null)} topOffset={sheetTopOffset} initialTab={engagementTab ?? "comments"} aid={video?.aid ?? 0} replyCount={video?.stat?.reply} danmakus={danmakus} currentTime={currentTime} /> ); } function SeasonSection({ season, currentBvid, onEpisodePress, }: { season: NonNullable; currentBvid: string; onEpisodePress: (bvid: string) => void; }) { const theme = useTheme(); const { width: screenW } = useWindowDimensions(); const episodes = season.sections?.[0]?.episodes ?? []; const currentIndex = episodes.findIndex((ep) => ep.bvid === currentBvid); const listRef = useRef(null); // 初次渲染先隐身,scrollToOffset 完成后再显示,彻底消除"先 0 再跳"的可见闪烁 const [ready, setReady] = useState(currentIndex <= 0); // 计算让当前集水平居中的初始 contentOffset const initialOffset = useMemo(() => { if (currentIndex <= 0) return 0; const ITEM_WIDTH = 120; const STEP = 130; // 120 + 10 gap const PADDING = 12; const itemCenter = PADDING + currentIndex * STEP + ITEM_WIDTH / 2; return Math.max(0, itemCenter - screenW / 2); }, [currentIndex, screenW]); const handleContentSizeChange = useCallback(() => { if (ready) return; if (currentIndex > 0 && initialOffset > 0) { listRef.current?.scrollToOffset({ offset: initialOffset, animated: false }); } requestAnimationFrame(() => setReady(true)); }, [ready, currentIndex, initialOffset]); return ( 合集 · {season.title} {season.ep_count}个视频 ep.bvid} contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }} getItemLayout={(_data, index) => ({ length: 130, offset: 12 + index * 130, index })} contentOffset={{ x: initialOffset, y: 0 }} onContentSizeChange={handleContentSizeChange} onScrollToIndexFailed={() => {}} style={{ opacity: ready ? 1 : 0 }} 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 }, loader: { marginVertical: 30 }, scroll: { flex: 1 }, // Title + Meta(YT 风格) titleBlock: { paddingHorizontal: 14, paddingTop: 12, paddingBottom: 8 }, title: { fontSize: 16, fontWeight: "700", lineHeight: 22 }, metaRow: { flexDirection: "row", alignItems: "center", marginTop: 6, }, metaText: { flex: 1, fontSize: 12, lineHeight: 18 }, metaMore: { fontSize: 12, lineHeight: 18, marginLeft: 6, marginRight: 2 }, // Action row 外壳 actionWrap: { borderTopWidth: StyleSheet.hairlineWidth }, // UP 主行 creatorRow: { flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 12, borderTopWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth, }, creatorLeft: { flex: 1, flexDirection: "row", alignItems: "center" }, avatar: { width: 38, height: 38, borderRadius: 19, marginRight: 10 }, creatorInfo: { flex: 1 }, creatorName: { fontSize: 14, fontWeight: "600", lineHeight: 18 }, creatorStat: { fontSize: 11, marginTop: 2, lineHeight: 16 }, subBtn: { backgroundColor: "#00AEEC", paddingHorizontal: 14, paddingVertical: 6, borderRadius: 16, marginLeft: 12, }, // 已关注:浅灰底 + 灰字(textSub 由 useTheme 提供) subBtnFollowing: { backgroundColor: "rgba(127,127,127,0.18)" }, subBtnTxt: { color: "#fff", fontSize: 13, fontWeight: "600" }, // 合集 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 }, // 推荐列表 relatedHeader: { paddingLeft: 13, paddingBottom: 8, paddingTop: 12 }, 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 }, relatedView: { fontSize: 11 }, });