Files
JKVideo/app/video/[bvid].tsx

532 lines
19 KiB
TypeScript
Raw Permalink Normal View History

2026-05-12 20:27:30 +08:00
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from "react";
import {
2026-03-11 20:53:18 +08:00
View,
Text,
2026-03-16 14:24:32 +08:00
FlatList,
2026-03-11 20:53:18 +08:00
StyleSheet,
TouchableOpacity,
ActivityIndicator,
2026-05-12 20:27:30 +08:00
useWindowDimensions,
InteractionManager,
2026-03-11 20:53:18 +08:00
} from "react-native";
import { Image } from "expo-image";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
2026-03-11 20:53:18 +08:00
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";
2026-03-11 20:53:18 +08:00
import { useVideoDetail } from "../../hooks/useVideoDetail";
2026-03-19 16:34:56 +08:00
import { useRelatedVideos } from "../../hooks/useRelatedVideos";
import { formatCount, formatDuration, formatTime } from "../../utils/format";
2026-03-11 20:53:18 +08:00
import { proxyImageUrl } from "../../utils/imageUrl";
2026-03-17 22:18:05 +08:00
import { DownloadSheet } from "../../components/DownloadSheet";
2026-05-12 20:27:30 +08:00
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] —— 切换合集/推荐时同步复位,让骨架屏盖住老数据残影。
2026-05-12 20:27:30 +08:00
const SKELETON_MIN_MS = 600;
const [minSkeletonElapsed, setMinSkeletonElapsed] = useState(false);
useEffect(() => {
setMinSkeletonElapsed(false);
2026-05-12 20:27:30 +08:00
const t = setTimeout(() => setMinSkeletonElapsed(true), SKELETON_MIN_MS);
return () => clearTimeout(t);
}, [bvid]);
2026-05-12 20:27:30 +08:00
2026-03-11 20:53:18 +08:00
const {
video,
playData,
loading: videoLoading,
qualities,
currentQn,
changeQuality,
2026-05-12 20:27:30 +08:00
initialTime,
2026-03-11 20:53:18 +08:00
} = useVideoDetail(bvid as string);
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
const [currentTime, setCurrentTime] = useState(0);
2026-03-17 22:18:05 +08:00
const [showDownload, setShowDownload] = useState(false);
const [showDescSheet, setShowDescSheet] = useState(false);
// 评论/弹幕合并 Sheetnull=关闭,否则记录当前应该打开哪个 Tab
const [engagementTab, setEngagementTab] = useState<EngagementTab | null>(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;
2026-03-19 16:34:56 +08:00
const {
videos: relatedVideos,
loading: relatedLoading,
load: loadRelated,
} = useRelatedVideos(bvid as string);
2026-03-19 16:34:56 +08:00
2026-05-12 20:27:30 +08:00
// 推荐视频不参与首屏,等导航动画结束再拉,避免与详情/播放流抢 JS 线程
// 依赖 [bvid]:合集/推荐切到新视频时同步重新拉新推荐列表
2026-05-12 20:27:30 +08:00
useEffect(() => {
const handle = InteractionManager.runAfterInteractions(() => {
loadRelated();
});
return () => handle.cancel();
}, [bvid, loadRelated]);
useEffect(() => {
if (!video?.cid) return;
2026-05-12 20:27:30 +08:00
const handle = InteractionManager.runAfterInteractions(() => {
getDanmaku(video.cid!).then(setDanmakus).catch(() => {});
});
return () => handle.cancel();
}, [video?.cid]);
useEffect(() => {
if (!video?.owner?.mid) return;
2026-05-12 20:27:30 +08:00
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 (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}>
2026-03-10 19:04:18 +08:00
<VideoPlayer
playData={playData}
qualities={qualities}
currentQn={currentQn}
onQualityChange={changeQuality}
bvid={bvid as string}
cid={video?.cid}
danmakus={danmakus}
onTimeUpdate={setCurrentTime}
2026-05-12 20:27:30 +08:00
initialTime={initialTime}
onBack={() => router.back()}
coverUrl={video?.pic ? proxyImageUrl(video.pic) : undefined}
2026-03-17 22:18:05 +08:00
/>
2026-05-12 20:27:30 +08:00
{videoLoading || !video || !minSkeletonElapsed ? (
<VideoDetailSkeleton />
) : (
<FlatList<VideoItem>
// bvid 是 key —— 切换视频时 FlatList 整体重挂,滚动位置回到顶,避免老内容残影
key={bvid}
style={styles.scroll}
data={relatedVideos}
keyExtractor={(item) => item.bvid}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<>
{/* 标题 + Meta 行(点击展开简介 Sheet */}
<View style={styles.titleBlock}>
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
{video.title}
</Text>
<TouchableOpacity
style={styles.metaRow}
activeOpacity={0.7}
onPress={() => setShowDescSheet(true)}
>
<Text
style={[styles.metaText, { color: theme.textSub }]}
numberOfLines={1}
>
{formatCount(video.stat?.view ?? 0)}
{video.pubdate ? ` · ${formatTime(video.pubdate)}` : ""}
{video.stat?.like ? ` · ${formatCount(video.stat.like)} 点赞` : ""}
{video.tname ? ` · ${video.tname}` : ""}
</Text>
<Text style={[styles.metaMore, { color: theme.textSub }]}></Text>
<Ionicons name="chevron-forward" size={14} color={theme.textSub} />
</TouchableOpacity>
</View>
{/* 动作按钮行 */}
<View style={[styles.actionWrap, { borderTopColor: theme.border }]}>
<VideoActionRow
stat={video.stat ?? undefined}
danmakuCount={danmakus.length || video.stat?.danmaku}
title={video.title}
shareUrl={shareUrl}
onDownload={() => setShowDownload(true)}
onComments={() => setEngagementTab("comments")}
onDanmaku={() => setEngagementTab("danmaku")}
/>
</View>
{/* UP 主行 + 关注按钮 */}
<View
style={[
styles.creatorRow,
{ borderTopColor: theme.border, borderBottomColor: theme.border },
]}
>
<TouchableOpacity
style={styles.creatorLeft}
activeOpacity={0.85}
onPress={() => router.push(`/creator/${video.owner.mid}` as any)}
>
<Image
source={{ uri: proxyImageUrl(video.owner.face) }}
style={styles.avatar}
contentFit="cover"
recyclingKey={String(video.owner.mid)}
/>
<View style={styles.creatorInfo}>
<Text
style={[styles.creatorName, { color: theme.text }]}
numberOfLines={1}
>
{video.owner.name}
2026-03-24 23:48:04 +08:00
</Text>
{uploaderStat && (
<Text
style={[styles.creatorStat, { color: theme.textSub }]}
numberOfLines={1}
>
{formatCount(uploaderStat.follower)} ·{" "}
{formatCount(uploaderStat.archiveCount)}
</Text>
)}
2026-03-19 16:34:56 +08:00
</View>
</TouchableOpacity>
2026-03-16 14:24:32 +08:00
<TouchableOpacity
style={[styles.subBtn, following && styles.subBtnFollowing]}
2026-03-19 16:34:56 +08:00
activeOpacity={0.85}
onPress={toggleFollow}
disabled={followLoading}
2026-03-11 20:53:18 +08:00
>
<Text
style={[
styles.subBtnTxt,
following && { color: theme.textSub },
]}
>
{following ? "已关注" : "关注"}
</Text>
2026-03-19 16:34:56 +08:00
</TouchableOpacity>
</View>
{/* 合集 */}
{video.ugc_season && (
<SeasonSection
season={video.ugc_season}
currentBvid={bvid as string}
onEpisodePress={(epBvid) => router.replace(`/video/${epBvid}`)}
/>
2026-03-19 16:34:56 +08:00
)}
{/* 推荐视频小标题 */}
<View style={[styles.relatedHeader, { backgroundColor: theme.card }]}>
<Text style={[styles.relatedHeaderText, { color: theme.text }]}>
</Text>
</View>
</>
}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.relatedCard,
{ backgroundColor: theme.card, borderBottomColor: theme.border },
]}
onPress={() => router.replace(`/video/${item.bvid}` as any)}
activeOpacity={0.85}
>
<View style={[styles.relatedThumbWrap, { backgroundColor: theme.card }]}>
<Image
source={{ uri: proxyImageUrl(item.pic) }}
style={styles.relatedThumb}
contentFit="cover"
recyclingKey={item.bvid}
transition={200}
/>
<View style={styles.relatedDuration}>
<Text style={styles.relatedDurationText}>
{formatDuration(item.duration)}
</Text>
</View>
</View>
<View style={styles.relatedInfo}>
<Text
style={[styles.relatedTitle, { color: theme.text }]}
numberOfLines={2}
>
{item.title}
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text
style={[styles.relatedOwner, { color: theme.textSub }]}
numberOfLines={1}
>
{item.owner?.name ?? ""}
</Text>
<Text style={[styles.relatedView, { color: theme.textSub }]}>
{formatCount(item.stat?.view ?? 0)}
</Text>
2026-03-19 16:34:56 +08:00
</View>
</View>
</TouchableOpacity>
2026-03-19 16:34:56 +08:00
)}
ListEmptyComponent={
relatedLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : null
}
ListFooterComponent={
relatedLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : null
}
/>
2026-05-12 20:27:30 +08:00
)}
<DownloadSheet
visible={showDownload}
onClose={() => setShowDownload(false)}
topOffset={sheetTopOffset}
bvid={bvid as string}
cid={video?.cid ?? 0}
title={video?.title ?? ""}
cover={video?.pic ?? ""}
qualities={qualities}
/>
<DescriptionSheet
visible={showDescSheet}
onClose={() => setShowDescSheet(false)}
topOffset={sheetTopOffset}
video={video ?? null}
/>
<EngagementSheet
visible={engagementTab !== null}
onClose={() => setEngagementTab(null)}
topOffset={sheetTopOffset}
initialTab={engagementTab ?? "comments"}
aid={video?.aid ?? 0}
replyCount={video?.stat?.reply}
danmakus={danmakus}
currentTime={currentTime}
/>
</SafeAreaView>
);
}
2026-03-16 14:24:32 +08:00
function SeasonSection({
season,
currentBvid,
onEpisodePress,
}: {
season: NonNullable<VideoItem["ugc_season"]>;
2026-03-16 14:24:32 +08:00
currentBvid: string;
onEpisodePress: (bvid: string) => void;
}) {
const theme = useTheme();
2026-05-12 20:27:30 +08:00
const { width: screenW } = useWindowDimensions();
2026-03-16 14:24:32 +08:00
const episodes = season.sections?.[0]?.episodes ?? [];
const currentIndex = episodes.findIndex((ep) => ep.bvid === currentBvid);
const listRef = useRef<FlatList>(null);
2026-05-12 20:27:30 +08:00
// 初次渲染先隐身scrollToOffset 完成后再显示,彻底消除"先 0 再跳"的可见闪烁
const [ready, setReady] = useState(currentIndex <= 0);
2026-03-16 14:24:32 +08:00
2026-05-12 20:27:30 +08:00
// 计算让当前集水平居中的初始 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]);
2026-03-16 14:24:32 +08:00
return (
<View style={[styles.seasonBox, { borderTopColor: theme.border, backgroundColor: theme.card }]}>
2026-03-16 14:24:32 +08:00
<View style={styles.seasonHeader}>
<Text style={[styles.seasonTitle, { color: theme.text }]}> · {season.title}</Text>
2026-03-16 14:24:32 +08:00
<Text style={styles.seasonCount}>{season.ep_count}</Text>
<Ionicons name="chevron-forward" size={14} color="#999" />
</View>
<FlatList
ref={listRef}
horizontal
showsHorizontalScrollIndicator={false}
data={episodes}
keyExtractor={(ep) => ep.bvid}
contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }}
getItemLayout={(_data, index) => ({ length: 130, offset: 12 + index * 130, index })}
2026-05-12 20:27:30 +08:00
contentOffset={{ x: initialOffset, y: 0 }}
onContentSizeChange={handleContentSizeChange}
2026-03-16 14:24:32 +08:00
onScrollToIndexFailed={() => {}}
2026-05-12 20:27:30 +08:00
style={{ opacity: ready ? 1 : 0 }}
2026-03-16 14:24:32 +08:00
renderItem={({ item: ep, index }) => {
const isCurrent = ep.bvid === currentBvid;
return (
<TouchableOpacity
2026-03-24 23:48:04 +08:00
style={[
styles.epCard,
{ backgroundColor: theme.card, borderColor: theme.border },
2026-03-24 23:48:04 +08:00
isCurrent && styles.epCardActive,
]}
2026-03-16 14:24:32 +08:00
onPress={() => !isCurrent && onEpisodePress(ep.bvid)}
activeOpacity={0.8}
>
{ep.arc?.pic && (
<Image
source={{ uri: proxyImageUrl(ep.arc.pic) }}
style={[styles.epThumb, { backgroundColor: theme.card }]}
contentFit="cover"
recyclingKey={ep.bvid}
2026-03-16 14:24:32 +08:00
/>
)}
<Text style={[styles.epNum, isCurrent && styles.epNumActive]}>{index + 1}</Text>
<Text style={[styles.epTitle, { color: theme.text }]} numberOfLines={2}>{ep.title}</Text>
2026-03-16 14:24:32 +08:00
</TouchableOpacity>
);
}}
/>
</View>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
loader: { marginVertical: 30 },
scroll: { flex: 1 },
// Title + MetaYT 风格)
titleBlock: { paddingHorizontal: 14, paddingTop: 12, paddingBottom: 8 },
title: { fontSize: 16, fontWeight: "700", lineHeight: 22 },
metaRow: {
2026-03-11 20:53:18 +08:00
flexDirection: "row",
alignItems: "center",
marginTop: 6,
2026-03-24 23:48:04 +08:00
},
metaText: { flex: 1, fontSize: 12, lineHeight: 18 },
metaMore: { fontSize: 12, lineHeight: 18, marginLeft: 6, marginRight: 2 },
// Action row 外壳
actionWrap: { borderTopWidth: StyleSheet.hairlineWidth },
// UP 主行
creatorRow: {
2026-03-11 20:53:18 +08:00
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,
2026-03-11 20:53:18 +08:00
},
// 已关注:浅灰底 + 灰字textSub 由 useTheme 提供)
subBtnFollowing: { backgroundColor: "rgba(127,127,127,0.18)" },
subBtnTxt: { color: "#fff", fontSize: 13, fontWeight: "600" },
// 合集
seasonBox: { borderTopWidth: StyleSheet.hairlineWidth, paddingVertical: 10 },
2026-03-16 14:24:32 +08:00
seasonHeader: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 14,
paddingBottom: 8,
gap: 4,
},
seasonTitle: { flex: 1, fontSize: 13, fontWeight: "600" },
2026-03-16 14:24:32 +08:00
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 },
2026-03-16 14:24:32 +08:00
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 },
2026-03-19 16:34:56 +08:00
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 },
});