mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-08 07:28:37 +08:00
本项目已收到哔哩哔哩(bilibili)律师函,要求停止对 B 站 API 的调用及相关仿制行为。
为尊重知识产权及相关法律法规,本仓库即日起停止后续维护与更新,不再接受新的 Issue 和 Pull Request。 现有代码仅作学习参考保留,请勿将本项目用于任何商业或违法用途。 感谢所有支持过本项目的朋友。
This commit is contained in:
@@ -10,25 +10,24 @@ import {
|
||||
InteractionManager,
|
||||
} from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
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 { CommentItem } from "../../components/CommentItem";
|
||||
import { getDanmaku, getUploaderStat } from "../../services/bilibili";
|
||||
import { DanmakuItem } from "../../services/types";
|
||||
import DanmakuList from "../../components/DanmakuList";
|
||||
import type { DanmakuItem, VideoItem } from "../../services/types";
|
||||
import { useVideoDetail } from "../../hooks/useVideoDetail";
|
||||
import { useComments } from "../../hooks/useComments";
|
||||
import { useRelatedVideos } from "../../hooks/useRelatedVideos";
|
||||
import { formatCount, formatDuration } from "../../utils/format";
|
||||
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";
|
||||
|
||||
type Tab = "intro" | "comments" | "danmaku";
|
||||
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 }>();
|
||||
@@ -39,13 +38,15 @@ export default function VideoDetailScreen() {
|
||||
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,
|
||||
@@ -56,23 +57,37 @@ export default function VideoDetailScreen() {
|
||||
changeQuality,
|
||||
initialTime,
|
||||
} = useVideoDetail(bvid as string);
|
||||
const [commentSort, setCommentSort] = useState<0 | 2>(2);
|
||||
const {
|
||||
comments,
|
||||
loading: cmtLoading,
|
||||
hasMore: cmtHasMore,
|
||||
load: loadComments,
|
||||
} = useComments(video?.aid ?? 0, commentSort);
|
||||
const [tab, setTab] = useState<Tab>("intro");
|
||||
|
||||
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [showDownload, setShowDownload] = useState(false);
|
||||
const [descExpanded, setDescExpanded] = useState(false);
|
||||
const [descOverflows, setDescOverflows] = useState(false);
|
||||
const [showDescSheet, setShowDescSheet] = useState(false);
|
||||
// 评论/弹幕合并 Sheet:null=关闭,否则记录当前应该打开哪个 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,
|
||||
@@ -80,20 +95,13 @@ export default function VideoDetailScreen() {
|
||||
} = useRelatedVideos(bvid as string);
|
||||
|
||||
// 推荐视频不参与首屏,等导航动画结束再拉,避免与详情/播放流抢 JS 线程
|
||||
// 依赖 [bvid]:合集/推荐切到新视频时同步重新拉新推荐列表
|
||||
useEffect(() => {
|
||||
const handle = InteractionManager.runAfterInteractions(() => {
|
||||
loadRelated();
|
||||
});
|
||||
return () => handle.cancel();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!video?.aid) return;
|
||||
const handle = InteractionManager.runAfterInteractions(() => {
|
||||
loadComments();
|
||||
});
|
||||
return () => handle.cancel();
|
||||
}, [video?.aid, commentSort]);
|
||||
}, [bvid, loadRelated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!video?.cid) return;
|
||||
@@ -111,21 +119,10 @@ export default function VideoDetailScreen() {
|
||||
return () => handle.cancel();
|
||||
}, [video?.owner?.mid]);
|
||||
|
||||
const shareUrl = bvid ? `https://www.bilibili.com/video/${bvid}` : undefined;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}>
|
||||
{/* 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>
|
||||
<Text style={[styles.topTitle, { color: theme.text }]} numberOfLines={1}>
|
||||
{video?.title ?? "视频详情"}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.miniBtn} onPress={() => setShowDownload(true)}>
|
||||
<Ionicons name="cloud-download-outline" size={22} color={theme.text} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<VideoPlayer
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
@@ -136,217 +133,228 @@ export default function VideoDetailScreen() {
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* TabBar */}
|
||||
{video && (
|
||||
<View style={[styles.tabBar, { borderBottomColor: theme.border }]}>
|
||||
{(["intro", "comments", "danmaku"] as Tab[]).map((t) => {
|
||||
const label =
|
||||
t === "intro" ? "简介"
|
||||
: t === "comments" ? `评论${video.stat?.reply ? ` ${formatCount(video.stat.reply)}` : ""}`
|
||||
: `弹幕${danmakus.length ? ` ${formatCount(danmakus.length)}` : ""}`;
|
||||
return (
|
||||
<TouchableOpacity key={t} style={styles.tabItem} onPress={() => setTab(t)}>
|
||||
<Text style={[styles.tabLabel, { color: theme.textSub }, tab === t && styles.tabActive]}>
|
||||
{label}
|
||||
</Text>
|
||||
{tab === t && <View style={styles.tabUnderline} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Tab content */}
|
||||
{videoLoading || !video || !minSkeletonElapsed ? (
|
||||
<VideoDetailSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{tab === "intro" && (
|
||||
<FlatList<import("../../services/types").VideoItem>
|
||||
style={styles.tabScroll}
|
||||
data={relatedVideos}
|
||||
keyExtractor={(item) => item.bvid}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.upRow}
|
||||
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.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>
|
||||
<View style={styles.upStats}>
|
||||
<Ionicons name="play-outline" size={12} color={theme.textSub} />
|
||||
<Text style={styles.upStatTxt}>{formatCount(video.stat?.view ?? 0)}</Text>
|
||||
<Text style={styles.upStatDot}>·</Text>
|
||||
<Ionicons name="heart-outline" size={12} color={theme.textSub} />
|
||||
<Text style={styles.upStatTxt}>{formatCount(video.stat?.like ?? 0)}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={[styles.titleSection, { borderBottomColor: theme.border }]}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>{video.title}</Text>
|
||||
{!!video.tname && (
|
||||
<View style={styles.tnameBadge}>
|
||||
<Text style={styles.tnameText}>{video.tname}</Text>
|
||||
</View>
|
||||
)}
|
||||
{!!video.desc && (
|
||||
<Text
|
||||
style={[styles.subTitle, styles.descMeasure]}
|
||||
onTextLayout={(e) => setDescOverflows(e.nativeEvent.lines.length > 2)}
|
||||
>
|
||||
{video.desc}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
style={[styles.subTitle, { color: theme.textSub }]}
|
||||
numberOfLines={descExpanded ? undefined : 2}
|
||||
>
|
||||
{video.desc || "暂无简介"}
|
||||
</Text>
|
||||
{descOverflows && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setDescExpanded(e => !e)}
|
||||
style={styles.expandBtn}
|
||||
>
|
||||
<Ionicons name={descExpanded ? "chevron-up" : "chevron-down"} size={13} color="#00AEEC" />
|
||||
<Text style={styles.expandBtnTxt}>{descExpanded ? "收起" : "展开"}</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} numberOfLines={1}>{item.owner?.name ?? ""}</Text>
|
||||
<Text style={styles.relatedView}>{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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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={
|
||||
<View style={[styles.sortRow, { borderBottomColor: theme.border }]}>
|
||||
{([2, 0] as const).map((sort) => (
|
||||
<TouchableOpacity
|
||||
key={sort}
|
||||
style={[styles.sortBtn, commentSort === sort && styles.sortBtnActive]}
|
||||
onPress={() => setCommentSort(sort)}
|
||||
>
|
||||
<Text style={[styles.sortBtnTxt, commentSort === sort && styles.sortBtnTxtActive]}>
|
||||
{sort === 2 ? "热门" : "最新"}
|
||||
</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" }]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<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<import("../../services/types").VideoItem["ugc_season"]>;
|
||||
season: NonNullable<VideoItem["ugc_season"]>;
|
||||
currentBvid: string;
|
||||
onEpisodePress: (bvid: string) => void;
|
||||
}) {
|
||||
@@ -368,14 +376,11 @@ function SeasonSection({
|
||||
return Math.max(0, itemCenter - screenW / 2);
|
||||
}, [currentIndex, screenW]);
|
||||
|
||||
// 内容布局完成时(getItemLayout 同步即知尺寸,content size 一就绪就触发)
|
||||
// 同步 scrollToOffset 后立刻显示,避免任何中间帧
|
||||
const handleContentSizeChange = useCallback(() => {
|
||||
if (ready) return;
|
||||
if (currentIndex > 0 && initialOffset > 0) {
|
||||
listRef.current?.scrollToOffset({ offset: initialOffset, animated: false });
|
||||
}
|
||||
// 等到下一帧再显示,确保滚动已落地
|
||||
requestAnimationFrame(() => setReady(true));
|
||||
}, [ready, currentIndex, initialOffset]);
|
||||
|
||||
@@ -430,46 +435,49 @@ function SeasonSection({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1 },
|
||||
topBar: {
|
||||
loader: { marginVertical: 30 },
|
||||
scroll: { flex: 1 },
|
||||
|
||||
// Title + Meta(YT 风格)
|
||||
titleBlock: { paddingHorizontal: 14, paddingTop: 12, paddingBottom: 8 },
|
||||
title: { fontSize: 16, fontWeight: "700", lineHeight: 22 },
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 8,
|
||||
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,
|
||||
},
|
||||
backBtn: { padding: 4 },
|
||||
topTitle: { flex: 1, fontSize: 15, fontWeight: "600", marginLeft: 4 },
|
||||
miniBtn: { padding: 4 },
|
||||
loader: { marginVertical: 30 },
|
||||
titleSection: { padding: 14, borderBottomWidth: StyleSheet.hairlineWidth },
|
||||
title: { fontSize: 13, fontWeight: "600", lineHeight: 22, marginBottom: 6 },
|
||||
tnameBadge: {
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "rgba(0,174,236,0.12)",
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
marginBottom: 8,
|
||||
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,
|
||||
},
|
||||
tnameText: { fontSize: 11, color: "#00AEEC" },
|
||||
subTitle: { fontSize: 12, lineHeight: 18, marginBottom: 4 },
|
||||
descMeasure: { position: "absolute", opacity: 0, left: 0, right: 0 },
|
||||
expandBtn: { flexDirection: "row", alignItems: "center", gap: 3, marginBottom: 8 },
|
||||
expandBtnTxt: { fontSize: 12, color: "#00AEEC" },
|
||||
upRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
avatar: { width: 38, height: 38, borderRadius: 19, marginRight: 10, marginTop: 1 },
|
||||
upInfo: { flex: 1 },
|
||||
upName: { fontSize: 13, fontWeight: "600", lineHeight: 18 },
|
||||
upStat: { fontSize: 11, color: "#999", marginTop: 2, lineHeight: 16 },
|
||||
upStats: { flexDirection: "row", alignItems: "center", gap: 3, marginTop: 3 },
|
||||
upStatTxt: { fontSize: 11, color: "#999" },
|
||||
upStatDot: { fontSize: 11, color: "#ccc" },
|
||||
// 已关注:浅灰底 + 灰字(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",
|
||||
@@ -486,24 +494,9 @@ const styles = StyleSheet.create({
|
||||
epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 },
|
||||
epNumActive: { color: "#00AEEC", fontWeight: "600" },
|
||||
epTitle: { fontSize: 12, paddingHorizontal: 6, paddingBottom: 6, lineHeight: 16 },
|
||||
tabBar: { flexDirection: "row", borderBottomWidth: StyleSheet.hairlineWidth, paddingLeft: 3 },
|
||||
tabItem: { alignItems: "center", paddingVertical: 12, paddingHorizontal: 12, position: "relative" },
|
||||
tabLabel: { fontSize: 13 },
|
||||
tabActive: { color: "#00AEEC" },
|
||||
tabUnderline: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
width: 24,
|
||||
height: 2,
|
||||
backgroundColor: "#00AEEC",
|
||||
borderRadius: 1,
|
||||
},
|
||||
tabScroll: { flex: 1 },
|
||||
descBox: { padding: 16 },
|
||||
descText: { fontSize: 14, lineHeight: 22 },
|
||||
danmakuTab: { flex: 1 },
|
||||
emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 },
|
||||
relatedHeader: { paddingLeft: 13, paddingBottom: 8, paddingTop: 8 },
|
||||
|
||||
// 推荐列表
|
||||
relatedHeader: { paddingLeft: 13, paddingBottom: 8, paddingTop: 12 },
|
||||
relatedHeaderText: { fontSize: 13, fontWeight: "600" as const },
|
||||
relatedCard: {
|
||||
flexDirection: "row",
|
||||
@@ -533,19 +526,6 @@ const styles = StyleSheet.create({
|
||||
relatedDurationText: { color: "#fff", fontSize: 10 },
|
||||
relatedInfo: { flex: 1, justifyContent: "space-between", paddingVertical: 2 },
|
||||
relatedTitle: { fontSize: 13, lineHeight: 18 },
|
||||
relatedOwner: { fontSize: 12, color: "#999" },
|
||||
relatedView: { fontSize: 11, color: "#bbb" },
|
||||
sortRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
gap: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
sortBtn: { paddingHorizontal: 10, paddingVertical: 2, borderRadius: 16, backgroundColor: "#f0f0f0" },
|
||||
sortBtnActive: { backgroundColor: "#00AEEC" },
|
||||
sortBtnTxt: { fontSize: 13, color: "#333", fontWeight: "500" },
|
||||
sortBtnTxtActive: { color: "#fff", fontWeight: "600" as const },
|
||||
relatedOwner: { fontSize: 12 },
|
||||
relatedView: { fontSize: 11 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user