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

667 lines
21 KiB
TypeScript
Raw Normal View History

2026-03-16 14:24:32 +08:00
import React, { useState, useEffect, useRef } 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,
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";
2026-03-11 20:53:18 +08:00
import { DanmakuItem } from "../../services/types";
import DanmakuList from "../../components/DanmakuList";
import { useVideoDetail } from "../../hooks/useVideoDetail";
import { useComments } from "../../hooks/useComments";
2026-03-19 16:34:56 +08:00
import { useRelatedVideos } from "../../hooks/useRelatedVideos";
import { formatCount, formatDuration } 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";
import { useTheme } from "../../utils/theme";
2026-03-16 14:24:32 +08:00
type Tab = "intro" | "comments" | "danmaku";
export default function VideoDetailScreen() {
const { bvid } = useLocalSearchParams<{ bvid: string }>();
const router = useRouter();
const theme = useTheme();
2026-03-11 20:53:18 +08:00
const {
video,
playData,
loading: videoLoading,
qualities,
currentQn,
changeQuality,
} = useVideoDetail(bvid as string);
2026-03-16 14:24:32 +08:00
const [commentSort, setCommentSort] = useState<0 | 2>(2);
2026-03-11 20:53:18 +08:00
const {
comments,
loading: cmtLoading,
2026-03-16 14:24:32 +08:00
hasMore: cmtHasMore,
2026-03-11 20:53:18 +08:00
load: loadComments,
2026-03-16 14:24:32 +08:00
} = useComments(video?.aid ?? 0, commentSort);
const [tab, setTab] = useState<Tab>("intro");
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
const [currentTime, setCurrentTime] = useState(0);
2026-03-17 22:18:05 +08:00
const [showDownload, setShowDownload] = useState(false);
const [uploaderStat, setUploaderStat] = useState<{ follower: number; archiveCount: number } | null>(null);
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
useEffect(() => {
loadRelated();
}, []);
useEffect(() => {
if (video?.aid) loadComments();
2026-03-16 14:24:32 +08:00
}, [video?.aid, commentSort]);
useEffect(() => {
if (!video?.cid) return;
getDanmaku(video.cid).then(setDanmakus);
}, [video?.cid]);
useEffect(() => {
if (!video?.owner?.mid) return;
getUploaderStat(video.owner.mid).then(setUploaderStat).catch(() => {});
}, [video?.owner?.mid]);
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}>
2026-03-16 14:24:32 +08:00
{/* TopBar */}
<View style={[styles.topBar, { borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity>
2026-03-24 23:48:04 +08:00
<Text
style={[styles.topTitle, { color: theme.text }]}
numberOfLines={1}
>
2026-03-11 20:53:18 +08:00
{video?.title ?? "视频详情"}
</Text>
2026-03-17 22:18:05 +08:00
<TouchableOpacity
style={styles.miniBtn}
onPress={() => setShowDownload(true)}
>
2026-03-24 23:48:04 +08:00
<Ionicons
name="cloud-download-outline"
size={22}
color={theme.text}
/>
2026-03-10 19:04:18 +08:00
</TouchableOpacity>
</View>
2026-03-16 14:24:32 +08:00
{/* Video player — fixed 16:9 */}
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-03-17 22:18:05 +08:00
<DownloadSheet
visible={showDownload}
onClose={() => setShowDownload(false)}
bvid={bvid as string}
cid={video?.cid ?? 0}
title={video?.title ?? ""}
cover={video?.pic ?? ""}
qualities={qualities}
/>
{/* TabBar */}
2026-03-16 14:24:32 +08:00
{video && (
<View style={[styles.tabBar, { borderBottomColor: theme.border }]}>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("intro")}
>
<Text
2026-03-24 23:48:04 +08:00
style={[
styles.tabLabel,
{ color: theme.textSub },
tab === "intro" && styles.tabActive,
]}
>
2026-03-16 14:24:32 +08:00
</Text>
{tab === "intro" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("comments")}
>
<Text
2026-03-24 23:48:04 +08:00
style={[
styles.tabLabel,
{ color: theme.textSub },
tab === "comments" && styles.tabActive,
]}
>
{video.stat?.reply > 0 ? ` ${formatCount(video.stat.reply)}` : ""}
2026-03-16 14:24:32 +08:00
</Text>
{tab === "comments" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("danmaku")}
>
<Text
2026-03-24 23:48:04 +08:00
style={[
styles.tabLabel,
{ color: theme.textSub },
tab === "danmaku" && styles.tabActive,
]}
>
{danmakus.length > 0 ? ` ${formatCount(danmakus.length)}` : ""}
2026-03-16 14:24:32 +08:00
</Text>
{tab === "danmaku" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
</View>
)}
{/* Tab content */}
{videoLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : video ? (
2026-03-19 16:34:56 +08:00
<>
{tab === "intro" && (
<FlatList<import("../../services/types").VideoItem>
style={styles.tabScroll}
data={relatedVideos}
keyExtractor={(item) => item.bvid}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<>
<View style={styles.upRow}>
<Image
source={{ uri: proxyImageUrl(video.owner.face) }}
style={styles.avatar}
/>
<View style={styles.upInfo}>
<Text style={[styles.upName, { color: theme.text }]}>
{video.owner.name}
</Text>
{uploaderStat && (
<Text style={styles.upStat}>
{formatCount(uploaderStat.follower)} · {formatCount(uploaderStat.archiveCount)}
</Text>
)}
</View>
2026-03-19 16:34:56 +08:00
<TouchableOpacity style={styles.followBtn}>
<Text style={styles.followTxt}>+ </Text>
</TouchableOpacity>
</View>
2026-03-24 23:48:04 +08:00
<View
style={[
styles.titleSection,
{ borderBottomColor: theme.border },
]}
>
<Text style={[styles.title, { color: theme.text }]}>
{video.title}
</Text>
<Text style={[styles.subTitle, { color: theme.text }]}>
{video.desc || "暂无简介"}
</Text>
2026-03-19 16:34:56 +08:00
<View style={styles.statsRow}>
<StatBadge icon="play" count={video.stat.view} />
<StatBadge icon="heart" count={video.stat.like} />
<StatBadge icon="star" count={video.stat.favorite} />
<StatBadge icon="chatbubble" count={video.stat.reply} />
</View>
</View>
{video.ugc_season && (
<SeasonSection
season={video.ugc_season}
currentBvid={bvid as string}
onEpisodePress={(epBvid) =>
router.replace(`/video/${epBvid}`)
}
/>
)}
2026-03-24 23:48:04 +08:00
<View
style={[
styles.relatedHeader,
{
backgroundColor: theme.card,
},
]}
>
<Text
style={[styles.relatedHeaderText, { color: theme.text }]}
>
2026-03-19 16:34:56 +08:00
</Text>
</View>
</>
}
renderItem={({ item }) => (
2026-03-16 14:24:32 +08:00
<TouchableOpacity
2026-03-24 23:48:04 +08:00
style={[
styles.relatedCard,
{
backgroundColor: theme.card,
borderBottomColor: theme.border,
},
]}
onPress={() => router.replace(`/video/${item.bvid}` as any)}
2026-03-19 16:34:56 +08:00
activeOpacity={0.85}
2026-03-11 20:53:18 +08:00
>
2026-03-24 23:48:04 +08:00
<View
style={[
styles.relatedThumbWrap,
{ backgroundColor: theme.card },
]}
>
2026-03-19 16:34:56 +08:00
<Image
source={{ uri: proxyImageUrl(item.pic) }}
style={styles.relatedThumb}
resizeMode="cover"
/>
<View style={styles.relatedDuration}>
<Text style={styles.relatedDurationText}>
{formatDuration(item.duration)}
</Text>
</View>
</View>
<View style={styles.relatedInfo}>
2026-03-24 23:48:04 +08:00
<Text
style={[styles.relatedTitle, { color: theme.text }]}
numberOfLines={2}
>
2026-03-19 16:34:56 +08:00
{item.title}
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text style={styles.relatedOwner} numberOfLines={1}>
{item.owner?.name ?? ""}
</Text>
<Text style={styles.relatedView}>
{formatCount(item.stat?.view ?? 0)}
</Text>
</View>
</View>
</TouchableOpacity>
)}
ListEmptyComponent={
relatedLoading ? (
2026-03-19 16:34:56 +08:00
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : null
}
ListFooterComponent={
relatedLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : null
}
/>
)}
{tab === "comments" && (
<FlatList
style={styles.tabScroll}
data={comments}
keyExtractor={(c) => String(c.rpid)}
renderItem={({ item }) => <CommentItem item={item} />}
onEndReached={() => {
if (cmtHasMore && !cmtLoading) loadComments();
}}
onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
2026-03-24 23:48:04 +08:00
<View
style={[styles.sortRow, { borderBottomColor: theme.border }]}
>
2026-03-19 16:34:56 +08:00
<TouchableOpacity
style={[
2026-03-19 16:34:56 +08:00
styles.sortBtn,
commentSort === 2 && styles.sortBtnActive,
]}
2026-03-19 16:34:56 +08:00
onPress={() => setCommentSort(2)}
>
2026-03-19 16:34:56 +08:00
<Text
style={[
styles.sortBtnTxt,
commentSort === 2 && styles.sortBtnTxtActive,
]}
>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
2026-03-19 16:34:56 +08:00
styles.sortBtn,
commentSort === 0 && styles.sortBtnActive,
]}
2026-03-19 16:34:56 +08:00
onPress={() => setCommentSort(0)}
>
2026-03-19 16:34:56 +08:00
<Text
style={[
styles.sortBtnTxt,
commentSort === 0 && styles.sortBtnTxtActive,
]}
>
</Text>
</TouchableOpacity>
</View>
}
ListFooterComponent={
cmtLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : !cmtHasMore && comments.length > 0 ? (
<Text style={styles.emptyTxt}></Text>
) : null
}
ListEmptyComponent={
!cmtLoading ? (
<Text style={styles.emptyTxt}></Text>
) : null
}
/>
)}
{/* 弹幕面板:始终挂载,切 tab 时用 display:none 隐藏而不卸载 */}
<DanmakuList
danmakus={danmakus}
currentTime={currentTime}
visible={tab === "danmaku"}
onToggle={() => {}}
hideHeader={true}
style={[
styles.danmakuTab,
tab !== "danmaku" && { display: "none" },
]}
2026-03-16 14:24:32 +08:00
/>
2026-03-19 16:34:56 +08:00
</>
2026-03-16 14:24:32 +08:00
) : null}
</SafeAreaView>
);
}
function StatBadge({ icon, count }: { icon: string; count: number }) {
return (
<View style={styles.stat}>
<Ionicons name={icon as any} size={14} color="#999" />
<Text style={styles.statText}>{formatCount(count)}</Text>
</View>
);
}
2026-03-16 14:24:32 +08:00
function SeasonSection({
season,
currentBvid,
onEpisodePress,
}: {
season: NonNullable<import("../../services/types").VideoItem["ugc_season"]>;
currentBvid: string;
onEpisodePress: (bvid: string) => void;
}) {
const theme = useTheme();
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);
useEffect(() => {
if (currentIndex <= 0 || episodes.length === 0) return;
2026-03-16 21:13:26 +08:00
const t = setTimeout(() => {
2026-03-16 14:24:32 +08:00
listRef.current?.scrollToIndex({
index: currentIndex,
viewPosition: 0.5,
2026-03-16 14:24:32 +08:00
animated: false,
});
2026-03-16 21:13:26 +08:00
}, 200);
return () => clearTimeout(t);
2026-03-16 14:24:32 +08:00
}, [currentIndex, episodes.length]);
return (
2026-03-24 23:48:04 +08:00
<View
style={[
styles.seasonBox,
{ borderTopColor: theme.border, backgroundColor: theme.card },
]}
>
2026-03-16 14:24:32 +08:00
<View style={styles.seasonHeader}>
2026-03-24 23:48:04 +08:00
<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,
2026-03-16 14:24:32 +08:00
index,
})}
onScrollToIndexFailed={() => {}}
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 },
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 }]}
2026-03-16 14:24:32 +08:00
/>
)}
<Text style={[styles.epNum, isCurrent && styles.epNumActive]}>
{index + 1}
</Text>
2026-03-24 23:48:04 +08:00
<Text
style={[styles.epTitle, { color: theme.text }]}
numberOfLines={2}
>
2026-03-16 14:24:32 +08:00
{ep.title}
</Text>
</TouchableOpacity>
);
}}
/>
</View>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
2026-03-11 20:53:18 +08:00
topBar: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 8,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
backBtn: { padding: 4 },
2026-03-11 20:53:18 +08:00
topTitle: {
flex: 1,
fontSize: 15,
fontWeight: "600",
marginLeft: 4,
},
2026-03-10 19:04:18 +08:00
miniBtn: { padding: 4 },
loader: { marginVertical: 30 },
titleSection: {
padding: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
},
2026-03-11 20:53:18 +08:00
title: {
fontSize: 13,
2026-03-11 20:53:18 +08:00
fontWeight: "600",
lineHeight: 22,
marginBottom: 8,
},
2026-03-24 23:48:04 +08:00
subTitle: {
fontSize: 10,
marginBottom: 8,
},
2026-03-11 20:53:18 +08:00
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,
2026-03-11 20:53:18 +08:00
},
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 },
2026-03-11 20:53:18 +08:00
followBtn: {
backgroundColor: "#00AEEC",
paddingHorizontal: 10,
paddingVertical: 3,
2026-03-11 20:53:18 +08:00
borderRadius: 14,
},
followTxt: { color: "#fff", fontSize: 12, fontWeight: "500" },
2026-03-16 14:24:32 +08:00
seasonBox: {
borderTopWidth: StyleSheet.hairlineWidth,
paddingVertical: 10,
},
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.5,
borderColor: "transparent",
},
epCardActive: { borderColor: "#00AEEC" },
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,
},
2026-03-11 20:53:18 +08:00
tabBar: {
flexDirection: "row",
borderBottomWidth: StyleSheet.hairlineWidth,
paddingLeft: 3,
2026-03-11 20:53:18 +08:00
},
tabItem: {
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 12,
2026-03-11 20:53:18 +08:00
position: "relative",
},
tabLabel: { fontSize: 13 },
tabActive: { color: "#00AEEC" },
2026-03-11 20:53:18 +08:00
tabUnderline: {
position: "absolute",
bottom: 0,
width: 24,
height: 2,
backgroundColor: "#00AEEC",
borderRadius: 1,
},
2026-03-16 14:24:32 +08:00
tabScroll: { flex: 1 },
descBox: { padding: 16 },
descText: { fontSize: 14, lineHeight: 22 },
2026-03-16 14:24:32 +08:00
danmakuTab: { flex: 1 },
2026-03-11 20:53:18 +08:00
emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 },
2026-03-19 16:34:56 +08:00
relatedHeader: {
paddingLeft: 13,
2026-03-24 23:48:04 +08:00
paddingBottom: 8,
paddingTop: 8,
2026-03-19 16:34:56 +08:00
},
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 },
2026-03-19 16:34:56 +08:00
relatedOwner: { fontSize: 12, color: "#999" },
relatedView: { fontSize: 11, color: "#bbb" },
2026-03-16 14:24:32 +08:00
sortRow: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 14,
paddingVertical: 10,
gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
sortBtn: {
paddingHorizontal: 10,
paddingVertical: 2,
borderRadius: 16,
backgroundColor: "#f0f0f0",
2026-03-16 14:24:32 +08:00
},
sortBtnActive: { backgroundColor: "#00AEEC" },
sortBtnTxt: { fontSize: 13, color: "#333", fontWeight: "500" },
sortBtnTxtActive: { color: "#fff", fontWeight: "600" as const },
});