This commit is contained in:
Developer
2026-03-16 14:24:32 +08:00
parent 9a70dd0c4d
commit 47d2e8479e
8 changed files with 316 additions and 97 deletions

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import {
View,
Text,
ScrollView,
FlatList,
StyleSheet,
TouchableOpacity,
Image,
@@ -22,7 +23,7 @@ import { useVideoStore } from "../../store/videoStore";
import { formatCount } from "../../utils/format";
import { proxyImageUrl } from "../../utils/imageUrl";
type Tab = "intro" | "comments";
type Tab = "intro" | "comments" | "danmaku";
export default function VideoDetailScreen() {
const { bvid } = useLocalSearchParams<{ bvid: string }>();
@@ -35,15 +36,17 @@ export default function VideoDetailScreen() {
currentQn,
changeQuality,
} = useVideoDetail(bvid as string);
const [commentSort, setCommentSort] = useState<0 | 2>(2);
const flatListHeightRef = useRef(0);
const {
comments,
loading: cmtLoading,
hasMore: cmtHasMore,
load: loadComments,
} = useComments(video?.aid ?? 0);
const [tab, setTab] = useState<Tab>("comments");
} = useComments(video?.aid ?? 0, commentSort);
const [tab, setTab] = useState<Tab>("intro");
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
const [currentTime, setCurrentTime] = useState(0);
const [showDanmakuList, setShowDanmakuList] = useState(true);
const { setVideo, clearVideo } = useVideoStore();
useEffect(() => {
@@ -52,7 +55,7 @@ export default function VideoDetailScreen() {
useEffect(() => {
if (video?.aid) loadComments();
}, [video?.aid]);
}, [video?.aid, commentSort]);
useEffect(() => {
if (!video?.cid) return;
@@ -68,6 +71,7 @@ export default function VideoDetailScreen() {
return (
<SafeAreaView style={styles.safe}>
{/* TopBar */}
<View style={styles.topBar}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color="#212121" />
@@ -80,6 +84,7 @@ export default function VideoDetailScreen() {
</TouchableOpacity>
</View>
{/* Video player — fixed 16:9 */}
<VideoPlayer
playData={playData}
qualities={qualities}
@@ -91,18 +96,38 @@ export default function VideoDetailScreen() {
danmakus={danmakus}
onTimeUpdate={setCurrentTime}
/>
<DanmakuList
danmakus={danmakus}
currentTime={currentTime}
visible={showDanmakuList}
onToggle={() => setShowDanmakuList((v) => !v)}
/>
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
{videoLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : video ? (
<>
{/* TabBar — sits directly below player, always visible once video loads */}
{video && (
<View style={styles.tabBar}>
<TouchableOpacity style={styles.tabItem} onPress={() => setTab("intro")}>
<Text style={[styles.tabLabel, tab === "intro" && styles.tabActive]}>
</Text>
{tab === "intro" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
<TouchableOpacity style={styles.tabItem} onPress={() => setTab("comments")}>
<Text style={[styles.tabLabel, tab === "comments" && styles.tabActive]}>
{video.stat?.reply > 0 ? ` ${formatCount(video.stat.reply)}` : ""}
</Text>
{tab === "comments" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
<TouchableOpacity style={styles.tabItem} onPress={() => setTab("danmaku")}>
<Text style={[styles.tabLabel, tab === "danmaku" && styles.tabActive]}>
{danmakus.length > 0 ? ` ${formatCount(danmakus.length)}` : ""}
</Text>
{tab === "danmaku" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
</View>
)}
{/* Tab content */}
{videoLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : video ? (
tab === "intro" ? (
// 简介:视频信息 + 合集 + 简介文本
<ScrollView style={styles.tabScroll} showsVerticalScrollIndicator={false}>
<View style={styles.titleSection}>
<Text style={styles.title}>{video.title}</Text>
<View style={styles.statsRow}>
@@ -112,7 +137,6 @@ export default function VideoDetailScreen() {
<StatBadge icon="chatbubble" count={video.stat.reply} />
</View>
</View>
<View style={styles.upRow}>
<Image
source={{ uri: proxyImageUrl(video.owner.face) }}
@@ -123,64 +147,70 @@ export default function VideoDetailScreen() {
<Text style={styles.followTxt}>+ </Text>
</TouchableOpacity>
</View>
<View style={styles.tabBar}>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("intro")}
>
<Text
style={[styles.tabLabel, tab === "intro" && styles.tabActive]}
>
</Text>
{tab === "intro" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("comments")}
>
<Text
style={[
styles.tabLabel,
tab === "comments" && styles.tabActive,
]}
>
{" "}
{video.stat.reply > 0 ? formatCount(video.stat.reply) : ""}
</Text>
{tab === "comments" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
</View>
{tab === "intro" ? (
<View style={styles.descBox}>
<Text style={styles.descText}>{video.desc || "暂无简介"}</Text>
</View>
) : (
<>
{comments.map((c) => (
<CommentItem key={c.rpid} item={c} />
))}
{cmtLoading && (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
)}
{!cmtLoading && comments.length > 0 && (
<TouchableOpacity
style={styles.loadMore}
onPress={loadComments}
>
<Text style={styles.loadMoreTxt}></Text>
</TouchableOpacity>
)}
{!cmtLoading && comments.length === 0 && !videoLoading && (
<Text style={styles.emptyTxt}></Text>
)}
</>
{video.ugc_season && (
<SeasonSection
season={video.ugc_season}
currentBvid={bvid as string}
onEpisodePress={(epBvid) => router.replace(`/video/${epBvid}`)}
/>
)}
</>
) : null}
</ScrollView>
<View style={styles.descBox}>
<Text style={styles.descText}>{video.desc || "暂无简介"}</Text>
</View>
</ScrollView>
) : tab === "danmaku" ? (
<DanmakuList
danmakus={danmakus}
currentTime={currentTime}
visible={true}
onToggle={() => {}}
style={styles.danmakuTab}
/>
) : (
<FlatList
style={styles.tabScroll}
data={comments}
keyExtractor={(c) => String(c.rpid)}
renderItem={({ item }) => <CommentItem item={item} />}
onEndReached={() => { if (cmtHasMore && !cmtLoading) loadComments(); }}
onEndReachedThreshold={0.3}
onLayout={({ nativeEvent }) => { flatListHeightRef.current = nativeEvent.layout.height; }}
onContentSizeChange={(_, contentHeight) => {
if (contentHeight < flatListHeightRef.current && cmtHasMore && !cmtLoading) {
loadComments();
}
}}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View style={styles.sortRow}>
<Text style={styles.sortLabel}></Text>
<TouchableOpacity
style={[styles.sortBtn, commentSort === 2 && styles.sortBtnActive]}
onPress={() => setCommentSort(2)}
>
<Text style={[styles.sortBtnTxt, commentSort === 2 && styles.sortBtnTxtActive]}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.sortBtn, commentSort === 0 && styles.sortBtnActive]}
onPress={() => setCommentSort(0)}
>
<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
}
/>
)
) : null}
</SafeAreaView>
);
}
@@ -194,6 +224,78 @@ function StatBadge({ icon, count }: { icon: string; count: number }) {
);
}
function SeasonSection({
season,
currentBvid,
onEpisodePress,
}: {
season: NonNullable<import("../../services/types").VideoItem["ugc_season"]>;
currentBvid: string;
onEpisodePress: (bvid: string) => void;
}) {
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;
// 等布局完成再滚动
listRef.current?.scrollToIndex({
index: currentIndex,
viewPosition: 0.5, // 居中
animated: false,
});
}, [currentIndex, episodes.length]);
return (
<View style={styles.seasonBox}>
<View style={styles.seasonHeader}>
<Text style={styles.seasonTitle}> · {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 }}
// 每个卡片宽 120gap 10让 FlatList 直接算任意索引的偏移量
getItemLayout={(_data, index) => ({
length: 130,
offset: 12 + index * 130,
index,
})}
onScrollToIndexFailed={() => {}}
renderItem={({ item: ep, index }) => {
const isCurrent = ep.bvid === currentBvid;
return (
<TouchableOpacity
style={[styles.epCard, isCurrent && styles.epCardActive]}
onPress={() => !isCurrent && onEpisodePress(ep.bvid)}
activeOpacity={0.8}
>
{ep.arc?.pic && (
<Image
source={{ uri: proxyImageUrl(ep.arc.pic) }}
style={styles.epThumb}
/>
)}
<Text style={[styles.epNum, isCurrent && styles.epNumActive]}>
{index + 1}
</Text>
<Text style={styles.epTitle} numberOfLines={2}>
{ep.title}
</Text>
</TouchableOpacity>
);
}}
/>
</View>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: "#fff" },
topBar: {
@@ -213,7 +315,6 @@ const styles = StyleSheet.create({
color: "#212121",
},
miniBtn: { padding: 4 },
scroll: { flex: 1 },
loader: { marginVertical: 30 },
titleSection: { padding: 14 },
title: {
@@ -243,6 +344,39 @@ const styles = StyleSheet.create({
borderRadius: 14,
},
followTxt: { color: "#fff", fontSize: 12, fontWeight: "600" },
seasonBox: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#f0f0f0",
paddingVertical: 10,
},
seasonHeader: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 14,
paddingBottom: 8,
gap: 4,
},
seasonTitle: { flex: 1, fontSize: 13, fontWeight: "600", color: "#212121" },
seasonCount: { fontSize: 12, color: "#999" },
epCard: {
width: 120,
borderRadius: 6,
overflow: "hidden",
backgroundColor: "#f8f8f8",
borderWidth: 1.5,
borderColor: "transparent",
},
epCardActive: { borderColor: "#00AEEC" },
epThumb: { width: 120, height: 68, backgroundColor: "#eee" },
epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 },
epNumActive: { color: "#00AEEC", fontWeight: "600" },
epTitle: {
fontSize: 12,
color: "#333",
paddingHorizontal: 6,
paddingBottom: 6,
lineHeight: 16,
},
tabBar: {
flexDirection: "row",
borderBottomWidth: StyleSheet.hairlineWidth,
@@ -264,9 +398,29 @@ const styles = StyleSheet.create({
backgroundColor: "#00AEEC",
borderRadius: 1,
},
tabScroll: { flex: 1 },
descBox: { padding: 16 },
descText: { fontSize: 14, color: "#555", lineHeight: 22 },
loadMore: { alignItems: "center", padding: 16 },
loadMoreTxt: { color: "#00AEEC", fontSize: 13 },
danmakuTab: { flex: 1 },
emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 },
sortRow: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 14,
paddingVertical: 10,
gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#f0f0f0",
},
sortLabel: { fontSize: 13, color: "#999", marginRight: 4 },
sortBtn: {
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
borderWidth: 1,
borderColor: "#e0e0e0",
},
sortBtnActive: { borderColor: "#00AEEC", backgroundColor: "#e8f7fd" },
sortBtnTxt: { fontSize: 12, color: "#666" },
sortBtnTxtActive: { color: "#00AEEC", fontWeight: "600" as const },
});