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

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

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

532 lines
19 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, { 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<DanmakuItem[]>([]);
const [currentTime, setCurrentTime] = useState(0);
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;
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 (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}>
<VideoPlayer
playData={playData}
qualities={qualities}
currentQn={currentQn}
onQualityChange={changeQuality}
bvid={bvid as string}
cid={video?.cid}
danmakus={danmakus}
onTimeUpdate={setCurrentTime}
initialTime={initialTime}
onBack={() => router.back()}
coverUrl={video?.pic ? proxyImageUrl(video.pic) : undefined}
/>
{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}
</Text>
{uploaderStat && (
<Text
style={[styles.creatorStat, { color: theme.textSub }]}
numberOfLines={1}
>
{formatCount(uploaderStat.follower)} ·{" "}
{formatCount(uploaderStat.archiveCount)}
</Text>
)}
</View>
</TouchableOpacity>
<TouchableOpacity
style={[styles.subBtn, following && styles.subBtnFollowing]}
activeOpacity={0.85}
onPress={toggleFollow}
disabled={followLoading}
>
<Text
style={[
styles.subBtnTxt,
following && { color: theme.textSub },
]}
>
{following ? "已关注" : "关注"}
</Text>
</TouchableOpacity>
</View>
{/* 合集 */}
{video.ugc_season && (
<SeasonSection
season={video.ugc_season}
currentBvid={bvid as string}
onEpisodePress={(epBvid) => router.replace(`/video/${epBvid}`)}
/>
)}
{/* 推荐视频小标题 */}
<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>
</View>
</View>
</TouchableOpacity>
)}
ListEmptyComponent={
relatedLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : null
}
ListFooterComponent={
relatedLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : null
}
/>
)}
<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>
);
}
function SeasonSection({
season,
currentBvid,
onEpisodePress,
}: {
season: NonNullable<VideoItem["ugc_season"]>;
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<FlatList>(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 (
<View style={[styles.seasonBox, { borderTopColor: theme.border, backgroundColor: theme.card }]}>
<View style={styles.seasonHeader}>
<Text style={[styles.seasonTitle, { color: theme.text }]}> · {season.title}</Text>
<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 })}
contentOffset={{ x: initialOffset, y: 0 }}
onContentSizeChange={handleContentSizeChange}
onScrollToIndexFailed={() => {}}
style={{ opacity: ready ? 1 : 0 }}
renderItem={({ item: ep, index }) => {
const isCurrent = ep.bvid === currentBvid;
return (
<TouchableOpacity
style={[
styles.epCard,
{ backgroundColor: theme.card, borderColor: theme.border },
isCurrent && styles.epCardActive,
]}
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}
/>
)}
<Text style={[styles.epNum, isCurrent && styles.epNumActive]}>{index + 1}</Text>
<Text style={[styles.epTitle, { color: theme.text }]} numberOfLines={2}>{ep.title}</Text>
</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: {
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 },
});