2026-05-12 20:27:30 +08:00
|
|
|
|
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from "react";
|
2026-03-05 18:02:54 +08:00
|
|
|
|
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";
|
2026-05-05 12:16:35 +08:00
|
|
|
|
import { Image } from "expo-image";
|
2026-05-12 21:29:45 +08:00
|
|
|
|
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";
|
2026-03-25 12:03:28 +08:00
|
|
|
|
import { getDanmaku, getUploaderStat } from "../../services/bilibili";
|
2026-05-12 21:29:45 +08:00
|
|
|
|
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";
|
2026-05-12 21:29:45 +08:00
|
|
|
|
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";
|
2026-03-24 21:58:00 +08:00
|
|
|
|
import { useTheme } from "../../utils/theme";
|
2026-03-25 15:03:49 +08:00
|
|
|
|
import { useLiveStore } from "../../store/liveStore";
|
2026-05-12 21:29:45 +08:00
|
|
|
|
import { VideoActionRow } from "../../components/VideoActionRow";
|
|
|
|
|
|
import { DescriptionSheet } from "../../components/DescriptionSheet";
|
|
|
|
|
|
import { EngagementSheet, type EngagementTab } from "../../components/EngagementSheet";
|
|
|
|
|
|
import { useFollow } from "../../hooks/useFollow";
|
2026-03-05 18:02:54 +08:00
|
|
|
|
|
|
|
|
|
|
export default function VideoDetailScreen() {
|
|
|
|
|
|
const { bvid } = useLocalSearchParams<{ bvid: string }>();
|
|
|
|
|
|
const router = useRouter();
|
2026-03-24 21:58:00 +08:00
|
|
|
|
const theme = useTheme();
|
2026-03-25 15:03:49 +08:00
|
|
|
|
|
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
|
useLiveStore.getState().clearLive();
|
|
|
|
|
|
}, []);
|
2026-05-05 12:16:35 +08:00
|
|
|
|
|
2026-05-12 21:29:45 +08:00
|
|
|
|
// 骨架屏最短展示时长:即便数据秒回,也至少撑够这么久,避免一闪而过。
|
|
|
|
|
|
// 重要:依赖 [bvid] —— 切换合集/推荐时同步复位,让骨架屏盖住老数据残影。
|
2026-05-12 20:27:30 +08:00
|
|
|
|
const SKELETON_MIN_MS = 600;
|
|
|
|
|
|
const [minSkeletonElapsed, setMinSkeletonElapsed] = useState(false);
|
|
|
|
|
|
useEffect(() => {
|
2026-05-12 21:29:45 +08:00
|
|
|
|
setMinSkeletonElapsed(false);
|
2026-05-12 20:27:30 +08:00
|
|
|
|
const t = setTimeout(() => setMinSkeletonElapsed(true), SKELETON_MIN_MS);
|
|
|
|
|
|
return () => clearTimeout(t);
|
2026-05-12 21:29:45 +08:00
|
|
|
|
}, [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);
|
2026-05-12 21:29:45 +08:00
|
|
|
|
|
2026-03-10 22:53:35 +08:00
|
|
|
|
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
|
|
|
|
|
|
const [currentTime, setCurrentTime] = useState(0);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
const [showDownload, setShowDownload] = useState(false);
|
2026-05-12 21:29:45 +08:00
|
|
|
|
const [showDescSheet, setShowDescSheet] = useState(false);
|
|
|
|
|
|
// 评论/弹幕合并 Sheet:null=关闭,否则记录当前应该打开哪个 Tab
|
|
|
|
|
|
const [engagementTab, setEngagementTab] = useState<EngagementTab | null>(null);
|
2026-03-25 12:51:14 +08:00
|
|
|
|
const [uploaderStat, setUploaderStat] = useState<{
|
|
|
|
|
|
follower: number;
|
|
|
|
|
|
archiveCount: number;
|
|
|
|
|
|
} | null>(null);
|
2026-05-12 21:29:45 +08:00
|
|
|
|
|
|
|
|
|
|
// 切换视频时把和老视频绑定的瞬态状态全部清掉,避免新页面短暂显示老弹幕/老 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,
|
2026-03-25 12:03:28 +08:00
|
|
|
|
} = useRelatedVideos(bvid as string);
|
2026-03-19 16:34:56 +08:00
|
|
|
|
|
2026-05-12 20:27:30 +08:00
|
|
|
|
// 推荐视频不参与首屏,等导航动画结束再拉,避免与详情/播放流抢 JS 线程
|
2026-05-12 21:29:45 +08:00
|
|
|
|
// 依赖 [bvid]:合集/推荐切到新视频时同步重新拉新推荐列表
|
2026-05-12 20:27:30 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const handle = InteractionManager.runAfterInteractions(() => {
|
|
|
|
|
|
loadRelated();
|
|
|
|
|
|
});
|
|
|
|
|
|
return () => handle.cancel();
|
2026-05-12 21:29:45 +08:00
|
|
|
|
}, [bvid, loadRelated]);
|
2026-03-05 18:02:54 +08:00
|
|
|
|
|
2026-03-10 22:53:35 +08:00
|
|
|
|
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();
|
2026-03-10 22:53:35 +08:00
|
|
|
|
}, [video?.cid]);
|
|
|
|
|
|
|
2026-03-25 12:03:28 +08:00
|
|
|
|
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();
|
2026-03-25 12:03:28 +08:00
|
|
|
|
}, [video?.owner?.mid]);
|
|
|
|
|
|
|
2026-05-12 21:29:45 +08:00
|
|
|
|
const shareUrl = bvid ? `https://www.bilibili.com/video/${bvid}` : undefined;
|
|
|
|
|
|
|
2026-03-05 18:02:54 +08:00
|
|
|
|
return (
|
2026-03-24 21:58:00 +08:00
|
|
|
|
<SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}>
|
2026-03-10 19:04:18 +08:00
|
|
|
|
<VideoPlayer
|
|
|
|
|
|
playData={playData}
|
|
|
|
|
|
qualities={qualities}
|
|
|
|
|
|
currentQn={currentQn}
|
|
|
|
|
|
onQualityChange={changeQuality}
|
2026-03-10 21:48:23 +08:00
|
|
|
|
bvid={bvid as string}
|
|
|
|
|
|
cid={video?.cid}
|
2026-03-10 22:53:35 +08:00
|
|
|
|
danmakus={danmakus}
|
|
|
|
|
|
onTimeUpdate={setCurrentTime}
|
2026-05-12 20:27:30 +08:00
|
|
|
|
initialTime={initialTime}
|
2026-05-12 21:29:45 +08:00
|
|
|
|
onBack={() => router.back()}
|
|
|
|
|
|
coverUrl={video?.pic ? proxyImageUrl(video.pic) : undefined}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
/>
|
2026-03-05 18:02:54 +08:00
|
|
|
|
|
2026-05-12 20:27:30 +08:00
|
|
|
|
{videoLoading || !video || !minSkeletonElapsed ? (
|
|
|
|
|
|
<VideoDetailSkeleton />
|
|
|
|
|
|
) : (
|
2026-05-12 21:29:45 +08:00
|
|
|
|
<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}
|
2026-03-26 00:43:37 +08:00
|
|
|
|
>
|
2026-05-12 21:29:45 +08:00
|
|
|
|
{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>
|
2026-05-05 12:16:35 +08:00
|
|
|
|
|
2026-05-12 21:29:45 +08:00
|
|
|
|
{/* 动作按钮行 */}
|
|
|
|
|
|
<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}>
|
2026-05-05 12:16:35 +08:00
|
|
|
|
<Text
|
2026-05-12 21:29:45 +08:00
|
|
|
|
style={[styles.creatorName, { color: theme.text }]}
|
|
|
|
|
|
numberOfLines={1}
|
2026-05-05 12:16:35 +08:00
|
|
|
|
>
|
2026-05-12 21:29:45 +08:00
|
|
|
|
{video.owner.name}
|
2026-03-24 23:48:04 +08:00
|
|
|
|
</Text>
|
2026-05-12 21:29:45 +08:00
|
|
|
|
{uploaderStat && (
|
|
|
|
|
|
<Text
|
|
|
|
|
|
style={[styles.creatorStat, { color: theme.textSub }]}
|
|
|
|
|
|
numberOfLines={1}
|
2026-05-05 12:16:35 +08:00
|
|
|
|
>
|
2026-05-12 21:29:45 +08:00
|
|
|
|
{formatCount(uploaderStat.follower)}粉丝 ·{" "}
|
|
|
|
|
|
{formatCount(uploaderStat.archiveCount)}视频
|
|
|
|
|
|
</Text>
|
2026-05-05 12:16:35 +08:00
|
|
|
|
)}
|
2026-03-19 16:34:56 +08:00
|
|
|
|
</View>
|
2026-05-12 21:29:45 +08:00
|
|
|
|
</TouchableOpacity>
|
2026-03-16 14:24:32 +08:00
|
|
|
|
<TouchableOpacity
|
2026-05-12 21:29:45 +08:00
|
|
|
|
style={[styles.subBtn, following && styles.subBtnFollowing]}
|
2026-03-19 16:34:56 +08:00
|
|
|
|
activeOpacity={0.85}
|
2026-05-12 21:29:45 +08:00
|
|
|
|
onPress={toggleFollow}
|
|
|
|
|
|
disabled={followLoading}
|
2026-03-11 20:53:18 +08:00
|
|
|
|
>
|
2026-05-12 21:29:45 +08:00
|
|
|
|
<Text
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.subBtnTxt,
|
|
|
|
|
|
following && { color: theme.textSub },
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{following ? "已关注" : "关注"}
|
|
|
|
|
|
</Text>
|
2026-03-19 16:34:56 +08:00
|
|
|
|
</TouchableOpacity>
|
2026-05-12 21:29:45 +08:00
|
|
|
|
</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
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-12 21:29:45 +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>
|
2026-05-12 21:29:45 +08:00
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
2026-03-19 16:34:56 +08:00
|
|
|
|
)}
|
2026-05-12 21:29:45 +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
|
|
|
|
)}
|
2026-05-12 21:29:45 +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}
|
|
|
|
|
|
/>
|
2026-03-05 18:02:54 +08:00
|
|
|
|
</SafeAreaView>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 14:24:32 +08:00
|
|
|
|
function SeasonSection({
|
|
|
|
|
|
season,
|
|
|
|
|
|
currentBvid,
|
|
|
|
|
|
onEpisodePress,
|
|
|
|
|
|
}: {
|
2026-05-12 21:29:45 +08:00
|
|
|
|
season: NonNullable<VideoItem["ugc_season"]>;
|
2026-03-16 14:24:32 +08:00
|
|
|
|
currentBvid: string;
|
|
|
|
|
|
onEpisodePress: (bvid: string) => void;
|
|
|
|
|
|
}) {
|
2026-03-24 21:58:00 +08:00
|
|
|
|
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 (
|
2026-05-05 12:16:35 +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-05-05 12:16:35 +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 }}
|
2026-05-05 12:16:35 +08:00
|
|
|
|
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,
|
2026-03-25 12:51:14 +08:00
|
|
|
|
{ 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) }}
|
2026-03-24 21:58:00 +08:00
|
|
|
|
style={[styles.epThumb, { backgroundColor: theme.card }]}
|
2026-05-05 12:16:35 +08:00
|
|
|
|
contentFit="cover"
|
|
|
|
|
|
recyclingKey={ep.bvid}
|
2026-03-16 14:24:32 +08:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-05-05 12:16:35 +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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 18:02:54 +08:00
|
|
|
|
const styles = StyleSheet.create({
|
2026-03-24 21:58:00 +08:00
|
|
|
|
safe: { flex: 1 },
|
2026-05-12 21:29:45 +08:00
|
|
|
|
loader: { marginVertical: 30 },
|
|
|
|
|
|
scroll: { flex: 1 },
|
|
|
|
|
|
|
|
|
|
|
|
// Title + Meta(YT 风格)
|
|
|
|
|
|
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",
|
2026-05-12 21:29:45 +08:00
|
|
|
|
marginTop: 6,
|
2026-03-24 23:48:04 +08:00
|
|
|
|
},
|
2026-05-12 21:29:45 +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",
|
2026-05-12 21:29:45 +08:00
|
|
|
|
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
|
|
|
|
},
|
2026-05-12 21:29:45 +08:00
|
|
|
|
// 已关注:浅灰底 + 灰字(textSub 由 useTheme 提供)
|
|
|
|
|
|
subBtnFollowing: { backgroundColor: "rgba(127,127,127,0.18)" },
|
|
|
|
|
|
subBtnTxt: { color: "#fff", fontSize: 13, fontWeight: "600" },
|
|
|
|
|
|
|
|
|
|
|
|
// 合集
|
2026-05-05 12:16:35 +08:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
2026-03-24 21:58:00 +08:00
|
|
|
|
seasonTitle: { flex: 1, fontSize: 13, fontWeight: "600" },
|
2026-03-16 14:24:32 +08:00
|
|
|
|
seasonCount: { fontSize: 12, color: "#999" },
|
2026-05-05 12:16:35 +08:00
|
|
|
|
epCard: { width: 120, borderRadius: 6, overflow: "hidden", borderWidth: 1, borderColor: "transparent" },
|
2026-03-25 12:51:14 +08:00
|
|
|
|
epCardActive: { borderColor: "#00AEEC", borderWidth: 1.5 },
|
2026-03-24 21:58:00 +08:00
|
|
|
|
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" },
|
2026-05-05 12:16:35 +08:00
|
|
|
|
epTitle: { fontSize: 12, paddingHorizontal: 6, paddingBottom: 6, lineHeight: 16 },
|
2026-05-12 21:29:45 +08:00
|
|
|
|
|
|
|
|
|
|
// 推荐列表
|
|
|
|
|
|
relatedHeader: { paddingLeft: 13, paddingBottom: 8, paddingTop: 12 },
|
2026-05-05 12:16:35 +08:00
|
|
|
|
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 },
|
2026-05-05 12:16:35 +08:00
|
|
|
|
relatedInfo: { flex: 1, justifyContent: "space-between", paddingVertical: 2 },
|
2026-03-24 21:58:00 +08:00
|
|
|
|
relatedTitle: { fontSize: 13, lineHeight: 18 },
|
2026-05-12 21:29:45 +08:00
|
|
|
|
relatedOwner: { fontSize: 12 },
|
|
|
|
|
|
relatedView: { fontSize: 11 },
|
2026-03-05 18:02:54 +08:00
|
|
|
|
});
|