mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +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,52 +133,68 @@ export default function VideoDetailScreen() {
|
||||
danmakus={danmakus}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
initialTime={initialTime}
|
||||
/>
|
||||
<DownloadSheet
|
||||
visible={showDownload}
|
||||
onClose={() => setShowDownload(false)}
|
||||
bvid={bvid as string}
|
||||
cid={video?.cid ?? 0}
|
||||
title={video?.title ?? ""}
|
||||
cover={video?.pic ?? ""}
|
||||
qualities={qualities}
|
||||
onBack={() => router.back()}
|
||||
coverUrl={video?.pic ? proxyImageUrl(video.pic) : undefined}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
<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.upRow}
|
||||
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)}
|
||||
>
|
||||
@@ -191,55 +204,42 @@ export default function VideoDetailScreen() {
|
||||
contentFit="cover"
|
||||
recyclingKey={String(video.owner.mid)}
|
||||
/>
|
||||
<View style={styles.upInfo}>
|
||||
<Text style={[styles.upName, { color: theme.text }]}>{video.owner.name}</Text>
|
||||
<View style={styles.creatorInfo}>
|
||||
<Text
|
||||
style={[styles.creatorName, { color: theme.text }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{video.owner.name}
|
||||
</Text>
|
||||
{uploaderStat && (
|
||||
<Text style={styles.upStat}>
|
||||
{formatCount(uploaderStat.follower)}粉丝 · {formatCount(uploaderStat.archiveCount)}视频
|
||||
<Text
|
||||
style={[styles.creatorStat, { color: theme.textSub }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{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}
|
||||
style={[styles.subBtn, following && styles.subBtnFollowing]}
|
||||
activeOpacity={0.85}
|
||||
onPress={toggleFollow}
|
||||
disabled={followLoading}
|
||||
>
|
||||
<Ionicons name={descExpanded ? "chevron-up" : "chevron-down"} size={13} color="#00AEEC" />
|
||||
<Text style={styles.expandBtnTxt}>{descExpanded ? "收起" : "展开"}</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.subBtnTxt,
|
||||
following && { color: theme.textSub },
|
||||
]}
|
||||
>
|
||||
{following ? "已关注" : "关注"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 合集 */}
|
||||
{video.ugc_season && (
|
||||
<SeasonSection
|
||||
season={video.ugc_season}
|
||||
@@ -247,14 +247,21 @@ export default function VideoDetailScreen() {
|
||||
onEpisodePress={(epBvid) => router.replace(`/video/${epBvid}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 推荐视频小标题 */}
|
||||
<View style={[styles.relatedHeader, { backgroundColor: theme.card }]}>
|
||||
<Text style={[styles.relatedHeaderText, { color: theme.text }]}>推荐视频</Text>
|
||||
<Text style={[styles.relatedHeaderText, { color: theme.text }]}>
|
||||
推荐视频
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.relatedCard, { backgroundColor: theme.card, borderBottomColor: theme.border }]}
|
||||
style={[
|
||||
styles.relatedCard,
|
||||
{ backgroundColor: theme.card, borderBottomColor: theme.border },
|
||||
]}
|
||||
onPress={() => router.replace(`/video/${item.bvid}` as any)}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
@@ -267,86 +274,87 @@ export default function VideoDetailScreen() {
|
||||
transition={200}
|
||||
/>
|
||||
<View style={styles.relatedDuration}>
|
||||
<Text style={styles.relatedDurationText}>{formatDuration(item.duration)}</Text>
|
||||
<Text style={styles.relatedDurationText}>
|
||||
{formatDuration(item.duration)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.relatedInfo}>
|
||||
<Text style={[styles.relatedTitle, { color: theme.text }]} numberOfLines={2}>
|
||||
<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
|
||||
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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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 ? (
|
||||
relatedLoading ? (
|
||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||
) : null
|
||||
}
|
||||
ListFooterComponent={
|
||||
relatedLoading ? (
|
||||
<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
|
||||
<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}
|
||||
visible={tab === "danmaku"}
|
||||
onToggle={() => {}}
|
||||
hideHeader={true}
|
||||
style={[styles.danmakuTab, tab !== "danmaku" && { display: "none" }]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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 },
|
||||
});
|
||||
|
||||
158
components/DescriptionSheet.tsx
Normal file
158
components/DescriptionSheet.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { VideoItem } from "../services/types";
|
||||
import { useTheme } from "../utils/theme";
|
||||
import { formatCount, formatTime } from "../utils/format";
|
||||
import { useSheetTransition } from "../utils/useSheetTransition";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
/** Sheet 顶部对齐到屏幕的绝对 Y(一般是播放器底部) */
|
||||
topOffset: number;
|
||||
video: VideoItem | null;
|
||||
}
|
||||
|
||||
export function DescriptionSheet({ visible, onClose, topOffset, video }: Props) {
|
||||
const theme = useTheme();
|
||||
const { height } = useWindowDimensions();
|
||||
const sheetH = Math.max(120, height - topOffset);
|
||||
const { rendered, slideAnim } = useSheetTransition(visible, sheetH);
|
||||
|
||||
if (!rendered || !video) return null;
|
||||
const stat = video.stat;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{
|
||||
top: topOffset,
|
||||
backgroundColor: theme.sheetBg,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.header, { borderBottomColor: theme.modalBorder }]}>
|
||||
<Text style={[styles.headerTitle, { color: theme.modalText }]}>简介</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
||||
<Ionicons name="close" size={22} color={theme.modalTextSub} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
|
||||
<Text style={[styles.title, { color: theme.modalText }]}>{video.title}</Text>
|
||||
|
||||
{!!video.tname && (
|
||||
<View style={styles.tnameBadge}>
|
||||
<Text style={styles.tnameText}>{video.tname}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!!video.pubdate && (
|
||||
<Text style={[styles.pubdate, { color: theme.modalTextSub }]}>
|
||||
发布于 {formatTime(video.pubdate)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!stat && (
|
||||
<View style={styles.statGrid}>
|
||||
<StatCell label="播放" value={stat.view} theme={theme} />
|
||||
<StatCell label="弹幕" value={stat.danmaku} theme={theme} />
|
||||
<StatCell label="评论" value={stat.reply} theme={theme} />
|
||||
<StatCell label="点赞" value={stat.like} theme={theme} />
|
||||
<StatCell label="投币" value={stat.coin} theme={theme} />
|
||||
<StatCell label="收藏" value={stat.favorite} theme={theme} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.descDivider, { backgroundColor: theme.modalBorder }]} />
|
||||
|
||||
<Text style={[styles.descText, { color: theme.modalText }]}>
|
||||
{video.desc?.trim() || "暂无简介"}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCell({
|
||||
label,
|
||||
value,
|
||||
theme,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
theme: ReturnType<typeof useTheme>;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.statCell}>
|
||||
<Text style={[styles.statValue, { color: theme.modalText }]}>
|
||||
{formatCount(value ?? 0)}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, { color: theme.modalTextSub }]}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sheet: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
overflow: "hidden",
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 14,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
headerTitle: { fontSize: 16, fontWeight: "700" },
|
||||
closeBtn: { padding: 4 },
|
||||
body: { padding: 20, paddingBottom: 40 },
|
||||
title: { fontSize: 17, fontWeight: "700", lineHeight: 24, marginBottom: 10 },
|
||||
tnameBadge: {
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "rgba(0,174,236,0.12)",
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
marginBottom: 10,
|
||||
},
|
||||
tnameText: { fontSize: 12, color: "#00AEEC" },
|
||||
pubdate: { fontSize: 12, marginBottom: 14 },
|
||||
statGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
marginVertical: 4,
|
||||
},
|
||||
statCell: { width: "33.333%", alignItems: "center", paddingVertical: 8 },
|
||||
statValue: { fontSize: 15, fontWeight: "700" },
|
||||
statLabel: { fontSize: 11, marginTop: 2 },
|
||||
descDivider: { height: StyleSheet.hairlineWidth, marginVertical: 16 },
|
||||
descText: { fontSize: 14, lineHeight: 22 },
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -6,14 +6,19 @@ import {
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useDownload } from "../hooks/useDownload";
|
||||
import { useTheme } from "../utils/theme";
|
||||
import { useSheetTransition } from "../utils/useSheetTransition";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
/** Sheet 顶部对齐到屏幕的绝对 Y(一般是播放器底部) */
|
||||
topOffset: number;
|
||||
bvid: string;
|
||||
cid: number;
|
||||
title: string;
|
||||
@@ -24,6 +29,7 @@ interface Props {
|
||||
export function DownloadSheet({
|
||||
visible,
|
||||
onClose,
|
||||
topOffset,
|
||||
bvid,
|
||||
cid,
|
||||
title,
|
||||
@@ -32,46 +38,51 @@ export function DownloadSheet({
|
||||
}: Props) {
|
||||
const { tasks, startDownload, taskKey } = useDownload();
|
||||
const theme = useTheme();
|
||||
const slideAnim = useRef(new Animated.Value(300)).current;
|
||||
const { height } = useWindowDimensions();
|
||||
const sheetH = Math.max(120, height - topOffset);
|
||||
const { rendered, slideAnim } = useSheetTransition(visible, sheetH);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: visible ? 0 : 300,
|
||||
duration: 260,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [visible]);
|
||||
|
||||
if (qualities.length === 0) return null;
|
||||
if (!rendered || qualities.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
visible
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.overlay}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[styles.sheet, { backgroundColor: theme.sheetBg, transform: [{ translateY: slideAnim }] }]}
|
||||
style={[
|
||||
styles.sheet,
|
||||
{
|
||||
top: topOffset,
|
||||
backgroundColor: theme.sheetBg,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.header, { borderBottomColor: theme.modalBorder }]}>
|
||||
<Text style={[styles.headerTitle, { color: theme.modalText }]}>下载视频</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
|
||||
<Ionicons name="close" size={20} color={theme.modalTextSub} />
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
||||
<Ionicons name="close" size={22} color={theme.modalTextSub} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={[styles.divider, { backgroundColor: theme.modalBorder }]} />
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.body}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{qualities.map((q) => {
|
||||
const key = taskKey(bvid, q.qn);
|
||||
const task = tasks[key];
|
||||
return (
|
||||
<View key={q.qn} style={styles.row}>
|
||||
<Text style={[styles.qualityLabel, { color: theme.modalText }]}>{q.desc}</Text>
|
||||
<View
|
||||
key={q.qn}
|
||||
style={[styles.row, { borderBottomColor: theme.modalBorder }]}
|
||||
>
|
||||
<Text style={[styles.qualityLabel, { color: theme.modalText }]}>
|
||||
{q.desc}
|
||||
</Text>
|
||||
<View style={styles.right}>
|
||||
{!task && (
|
||||
<TouchableOpacity
|
||||
@@ -95,18 +106,14 @@ export function DownloadSheet({
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.progressTxt}>
|
||||
<Text style={[styles.progressTxt, { color: theme.modalTextSub }]}>
|
||||
{Math.round(task.progress * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{task?.status === "done" && (
|
||||
<View style={styles.doneRow}>
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={16}
|
||||
color="#00AEEC"
|
||||
/>
|
||||
<Ionicons name="checkmark-circle" size={16} color="#00AEEC" />
|
||||
<Text style={styles.doneTxt}>已下载</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -132,48 +139,42 @@ export function DownloadSheet({
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<View style={styles.footer} />
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
},
|
||||
sheet: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "#fff",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
bottom: 0,
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
overflow: "hidden",
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 14,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
headerTitle: { fontSize: 16, fontWeight: "700", color: "#212121" },
|
||||
headerTitle: { fontSize: 16, fontWeight: "700" },
|
||||
closeBtn: { padding: 4 },
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: "#eee",
|
||||
marginBottom: 4,
|
||||
},
|
||||
body: { paddingHorizontal: 20, paddingBottom: 40 },
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
qualityLabel: { fontSize: 15, color: "#212121" },
|
||||
qualityLabel: { fontSize: 15 },
|
||||
right: { flexDirection: "row", alignItems: "center" },
|
||||
downloadBtn: {
|
||||
backgroundColor: "#00AEEC",
|
||||
@@ -191,12 +192,11 @@ const styles = StyleSheet.create({
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressFill: { height: 4, backgroundColor: "#00AEEC", borderRadius: 2 },
|
||||
progressTxt: { fontSize: 12, color: "#666", minWidth: 32 },
|
||||
progressTxt: { fontSize: 12, minWidth: 32 },
|
||||
doneRow: { flexDirection: "row", alignItems: "center", gap: 4 },
|
||||
doneTxt: { fontSize: 13, color: "#00AEEC" },
|
||||
errorWrap: { alignItems: "flex-end", gap: 2 },
|
||||
errorMsg: { fontSize: 11, color: "#f44", maxWidth: 160, textAlign: "right" },
|
||||
retryBtn: { flexDirection: "row", alignItems: "center", gap: 4 },
|
||||
retryTxt: { fontSize: 13, color: "#f44" },
|
||||
footer: { height: 24 },
|
||||
});
|
||||
|
||||
248
components/EngagementSheet.tsx
Normal file
248
components/EngagementSheet.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
InteractionManager,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { CommentItem } from "./CommentItem";
|
||||
import DanmakuList from "./DanmakuList";
|
||||
import { useComments } from "../hooks/useComments";
|
||||
import { useTheme } from "../utils/theme";
|
||||
import { formatCount } from "../utils/format";
|
||||
import { useSheetTransition } from "../utils/useSheetTransition";
|
||||
import type { DanmakuItem } from "../services/types";
|
||||
|
||||
export type EngagementTab = "comments" | "danmaku";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
/** Sheet 顶部对齐到屏幕的绝对 Y(一般是播放器底部) */
|
||||
topOffset: number;
|
||||
initialTab: EngagementTab;
|
||||
aid: number;
|
||||
replyCount?: number;
|
||||
danmakus: DanmakuItem[];
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
export function EngagementSheet({
|
||||
visible,
|
||||
onClose,
|
||||
topOffset,
|
||||
initialTab,
|
||||
aid,
|
||||
replyCount,
|
||||
danmakus,
|
||||
currentTime,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const { height } = useWindowDimensions();
|
||||
const sheetH = Math.max(120, height - topOffset);
|
||||
const { rendered, slideAnim } = useSheetTransition(visible, sheetH);
|
||||
|
||||
const [tab, setTab] = useState<EngagementTab>(initialTab);
|
||||
const [commentSort, setCommentSort] = useState<0 | 2>(2);
|
||||
const { comments, loading, hasMore, load } = useComments(aid, commentSort);
|
||||
|
||||
// 每次打开根据外部 initialTab 切到对应 Tab
|
||||
useEffect(() => {
|
||||
if (visible) setTab(initialTab);
|
||||
}, [visible, initialTab]);
|
||||
|
||||
// 评论懒加载:仅当 Sheet 可见且选中评论 Tab 时拉
|
||||
useEffect(() => {
|
||||
if (!visible || !aid || tab !== "comments") return;
|
||||
const handle = InteractionManager.runAfterInteractions(() => load());
|
||||
return () => handle.cancel();
|
||||
}, [visible, aid, commentSort, tab, load]);
|
||||
|
||||
if (!rendered) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{
|
||||
top: topOffset,
|
||||
backgroundColor: theme.sheetBg,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Tab 栏 + 关闭按钮 */}
|
||||
<View style={[styles.tabBar, { borderBottomColor: theme.modalBorder }]}>
|
||||
{(["comments", "danmaku"] as EngagementTab[]).map((t) => {
|
||||
const label =
|
||||
t === "comments"
|
||||
? `评论${replyCount ? ` ${formatCount(replyCount)}` : ""}`
|
||||
: `弹幕${danmakus.length ? ` ${formatCount(danmakus.length)}` : ""}`;
|
||||
const active = tab === t;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={t}
|
||||
style={styles.tabItem}
|
||||
onPress={() => setTab(t)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
{ color: theme.modalTextSub },
|
||||
active && { color: theme.modalText, fontWeight: "700" },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{active && <View style={styles.tabUnderline} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
<View style={{ flex: 1 }} />
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
||||
<Ionicons name="close" size={22} color={theme.modalTextSub} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 评论 Tab */}
|
||||
{tab === "comments" && (
|
||||
<>
|
||||
<View style={[styles.sortRow, { borderBottomColor: theme.modalBorder }]}>
|
||||
{([2, 0] as const).map((sort) => (
|
||||
<TouchableOpacity
|
||||
key={sort}
|
||||
style={[
|
||||
styles.sortBtn,
|
||||
{ backgroundColor: theme.inputBg },
|
||||
commentSort === sort && styles.sortBtnActive,
|
||||
]}
|
||||
onPress={() => setCommentSort(sort)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.sortBtnTxt,
|
||||
{ color: theme.modalTextSub },
|
||||
commentSort === sort && styles.sortBtnTxtActive,
|
||||
]}
|
||||
>
|
||||
{sort === 2 ? "热门" : "最新"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<FlatList
|
||||
style={styles.list}
|
||||
data={comments}
|
||||
keyExtractor={(c) => String(c.rpid)}
|
||||
renderItem={({ item }) => <CommentItem item={item} />}
|
||||
onEndReached={() => {
|
||||
if (hasMore && !loading) load();
|
||||
}}
|
||||
onEndReachedThreshold={0.3}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListFooterComponent={
|
||||
loading ? (
|
||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||
) : !hasMore && comments.length > 0 ? (
|
||||
<Text style={[styles.emptyTxt, { color: theme.modalTextSub }]}>
|
||||
已加载全部评论
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
ListEmptyComponent={
|
||||
!loading ? (
|
||||
<Text style={[styles.emptyTxt, { color: theme.modalTextSub }]}>
|
||||
暂无评论
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 弹幕 Tab —— hideHeader=true 让弹幕列表填满,header 由本 Sheet 顶部 Tab 提供 */}
|
||||
{tab === "danmaku" && (
|
||||
<DanmakuList
|
||||
danmakus={danmakus}
|
||||
currentTime={currentTime}
|
||||
visible
|
||||
onToggle={() => {}}
|
||||
hideHeader
|
||||
style={styles.danmakuList}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sheet: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
overflow: "hidden",
|
||||
},
|
||||
tabBar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
tabItem: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
position: "relative",
|
||||
alignItems: "center",
|
||||
},
|
||||
tabLabel: { fontSize: 14 },
|
||||
tabUnderline: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 14,
|
||||
right: 14,
|
||||
height: 2,
|
||||
backgroundColor: "#00AEEC",
|
||||
borderRadius: 1,
|
||||
},
|
||||
closeBtn: { padding: 10 },
|
||||
sortRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
gap: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
sortBtn: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 16,
|
||||
},
|
||||
sortBtnActive: { backgroundColor: "#00AEEC" },
|
||||
sortBtnTxt: { fontSize: 13, fontWeight: "500" },
|
||||
sortBtnTxtActive: { color: "#fff", fontWeight: "600" },
|
||||
list: { flex: 1 },
|
||||
loader: { marginVertical: 30 },
|
||||
emptyTxt: { textAlign: "center", padding: 30, fontSize: 13 },
|
||||
danmakuList: { flex: 1, borderTopWidth: 0 },
|
||||
});
|
||||
@@ -87,7 +87,7 @@ export function LoginModal({ visible, onClose }: Props) {
|
||||
if (result.code === 0 && result.cookie) {
|
||||
clearInterval(pollRef.current!);
|
||||
try {
|
||||
await login(result.cookie, "", "");
|
||||
await login(result.cookie, "", "", result.csrf);
|
||||
} catch {
|
||||
if (!cancelled) setStatus("error");
|
||||
return;
|
||||
|
||||
@@ -82,6 +82,12 @@ interface Props {
|
||||
onTimeUpdate?: (t: number) => void;
|
||||
initialTime?: number;
|
||||
forcePaused?: boolean;
|
||||
/** 点击播放器右上角"弹幕列表"图标,由外层打开 Sheet。仅在小窗口生效。 */
|
||||
onDanmakuListPress?: () => void;
|
||||
/** 点击播放器左上角返回箭头,跟随播放器控制栏一起显隐。仅在小窗口生效。 */
|
||||
onBack?: () => void;
|
||||
/** 视频封面 URL:作为 poster 覆盖在 <Video> 上,首帧出来前用来盖住黑屏 */
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
@@ -100,6 +106,9 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
onTimeUpdate,
|
||||
initialTime,
|
||||
forcePaused,
|
||||
onDanmakuListPress,
|
||||
onBack,
|
||||
coverUrl,
|
||||
}: Props,
|
||||
ref,
|
||||
) {
|
||||
@@ -109,6 +118,12 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
|
||||
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>();
|
||||
const isDash = !!playData?.dash;
|
||||
// 封面挡片:用于盖住 <Video> 从挂载到 onLoad 出首帧之间的黑屏
|
||||
// resolvedUrl 变化(新视频 / 换清晰度)时重新置 true,等下一次 onLoad 再撤
|
||||
const [coverVisible, setCoverVisible] = useState(true);
|
||||
useEffect(() => {
|
||||
setCoverVisible(true);
|
||||
}, [resolvedUrl]);
|
||||
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -488,6 +503,8 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
setBuffered(buf);
|
||||
}}
|
||||
onLoad={() => {
|
||||
// 首帧已就绪,撤掉 poster 挡片,让 <Video> 透出
|
||||
setCoverVisible(false);
|
||||
// 切清晰度后跳回原进度
|
||||
const pending = pendingSeekRef.current;
|
||||
let didSeek = false;
|
||||
@@ -531,7 +548,26 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholder} />
|
||||
// 没拿到 resolvedUrl 之前用主题色 + 封面占位,避免页面背景 → 纯黑的撞色
|
||||
<View style={[styles.placeholder, { backgroundColor: theme.card }]}>
|
||||
{!!coverUrl && (
|
||||
<Image
|
||||
source={{ uri: coverUrl }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* poster 挡片:<Video> 已挂载但首帧未到达时显示封面 */}
|
||||
{!!resolvedUrl && coverVisible && !!coverUrl && (
|
||||
<Image
|
||||
source={{ uri: coverUrl }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="cover"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{switching && (
|
||||
@@ -562,7 +598,32 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
|
||||
colors={["rgba(0,0,0,0.55)", "transparent"]}
|
||||
style={styles.topBar}
|
||||
pointerEvents="box-none"
|
||||
></LinearGradient>
|
||||
>
|
||||
{onBack && (
|
||||
<TouchableOpacity
|
||||
style={styles.topBtn}
|
||||
onPress={() => {
|
||||
onBack();
|
||||
showAndReset();
|
||||
}}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<View style={{ flex: 1 }} />
|
||||
{onDanmakuListPress && (
|
||||
<TouchableOpacity
|
||||
style={styles.topBtn}
|
||||
onPress={() => {
|
||||
onDanmakuListPress();
|
||||
showAndReset();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="list-outline" size={20} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</LinearGradient>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.centerBtn}
|
||||
|
||||
170
components/VideoActionRow.tsx
Normal file
170
components/VideoActionRow.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Share,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useTheme } from "../utils/theme";
|
||||
import { formatCount } from "../utils/format";
|
||||
import { toast } from "../utils/toast";
|
||||
|
||||
interface Props {
|
||||
/** 视频统计:点赞/投币/收藏/评论数。读不到时按钮下方不显示数字。 */
|
||||
stat?: {
|
||||
like: number;
|
||||
coin: number;
|
||||
favorite: number;
|
||||
reply?: number;
|
||||
} | null;
|
||||
/** 已加载的弹幕条数(拿不到时按钮显示纯文字"弹幕")。 */
|
||||
danmakuCount?: number;
|
||||
/** 分享时显示的视频标题。 */
|
||||
title?: string;
|
||||
/** 分享时附带的链接(一般是 https://www.bilibili.com/video/BVxxx)。 */
|
||||
shareUrl?: string;
|
||||
onDownload: () => void;
|
||||
onComments: () => void;
|
||||
onDanmaku: () => void;
|
||||
}
|
||||
|
||||
export const VideoActionRow = React.memo(function VideoActionRow({
|
||||
stat,
|
||||
danmakuCount,
|
||||
title,
|
||||
shareUrl,
|
||||
onDownload,
|
||||
onComments,
|
||||
onDanmaku,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!shareUrl) {
|
||||
toast("暂无分享链接");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Share.share({
|
||||
message: title ? `${title}\n${shareUrl}` : shareUrl,
|
||||
url: shareUrl,
|
||||
});
|
||||
} catch {
|
||||
// 用户取消或系统拒绝,无需处理
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.row}
|
||||
>
|
||||
{/* <Btn
|
||||
icon="thumbs-up-outline"
|
||||
label={stat?.like ? formatCount(stat.like) : "点赞"}
|
||||
onPress={() => toast("暂未实现")}
|
||||
color={theme.text}
|
||||
bg={theme.inputBg}
|
||||
/> */}
|
||||
{/* <Btn
|
||||
icon="thumbs-down-outline"
|
||||
label="不喜欢"
|
||||
onPress={() => toast("暂未实现")}
|
||||
color={theme.text}
|
||||
bg={theme.inputBg}
|
||||
/> */}
|
||||
<Btn
|
||||
icon="chatbubble-outline"
|
||||
label={stat?.reply ? formatCount(stat.reply) : "评论"}
|
||||
onPress={onComments}
|
||||
color={theme.text}
|
||||
bg={theme.inputBg}
|
||||
/>
|
||||
<Btn
|
||||
icon="chatbox-ellipses-outline"
|
||||
label={danmakuCount ? formatCount(danmakuCount) : "弹幕"}
|
||||
onPress={onDanmaku}
|
||||
color={theme.text}
|
||||
bg={theme.inputBg}
|
||||
/>
|
||||
{/* <Btn
|
||||
icon="logo-bitcoin"
|
||||
label={stat?.coin ? formatCount(stat.coin) : "投币"}
|
||||
onPress={() => toast("暂未实现")}
|
||||
color={theme.text}
|
||||
bg={theme.inputBg}
|
||||
/> */}
|
||||
<Btn
|
||||
icon="cloud-download-outline"
|
||||
label="下载"
|
||||
onPress={onDownload}
|
||||
color={theme.text}
|
||||
bg={theme.inputBg}
|
||||
/>
|
||||
<Btn
|
||||
icon="arrow-redo-outline"
|
||||
label="分享"
|
||||
onPress={handleShare}
|
||||
color={theme.text}
|
||||
bg={theme.inputBg}
|
||||
/>
|
||||
<Btn
|
||||
icon="star-outline"
|
||||
label={stat?.favorite ? formatCount(stat.favorite) : "收藏"}
|
||||
onPress={() => toast("暂未实现")}
|
||||
color={theme.text}
|
||||
bg={theme.inputBg}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
});
|
||||
|
||||
function Btn({
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
color,
|
||||
bg,
|
||||
}: {
|
||||
icon: React.ComponentProps<typeof Ionicons>["name"];
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
color: string;
|
||||
bg: string;
|
||||
}) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, { backgroundColor: bg }]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Ionicons name={icon} size={16} color={color} />
|
||||
<Text style={[styles.btnLabel, { color }]} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
gap: 8,
|
||||
},
|
||||
btn: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 7,
|
||||
borderRadius: 18,
|
||||
},
|
||||
btnLabel: { fontSize: 13, fontWeight: "600" },
|
||||
});
|
||||
@@ -2,9 +2,9 @@ import React, { useEffect, useRef, memo } from "react";
|
||||
import { View, StyleSheet, Animated } from "react-native";
|
||||
import { useTheme } from "../utils/theme";
|
||||
|
||||
// 注意:原版本每个 SkeletonBlock 都调 useTheme,导致 40+ 个 store 订阅
|
||||
// + 整体 6 张卡片 + opacity loop,进入页面瞬间产生明显抖动。
|
||||
// 现优化:颜色由顶层 useTheme 一次拿到,所有 block 走 inline style。
|
||||
// 性能优化:颜色由顶层 useTheme 一次拿到,所有 block 走 inline style,避免 40+ store 订阅。
|
||||
// 布局:与详情页 ListHeaderComponent 结构同步 ——
|
||||
// 标题块 → Meta 行 → 动作按钮行 → 创作者行 → 推荐视频标题 → 推荐卡片
|
||||
|
||||
interface BlockProps {
|
||||
width: number | `${number}%`;
|
||||
@@ -43,6 +43,11 @@ const RelatedCardSkeleton = memo(function RelatedCardSkeleton({ bg, border }: {
|
||||
);
|
||||
});
|
||||
|
||||
// 一个动作按钮占位(pill 形状,匹配 VideoActionRow 实际按钮形态)
|
||||
const ActionPillSkeleton = memo(function ActionPillSkeleton({ bg, width }: { bg: string; width: number }) {
|
||||
return <SkeletonBlock width={width} height={28} radius={14} bg={bg} />;
|
||||
});
|
||||
|
||||
const CARD_COUNT = 4;
|
||||
|
||||
export function VideoDetailSkeleton() {
|
||||
@@ -65,22 +70,30 @@ export function VideoDetailSkeleton() {
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, { backgroundColor: theme.card, opacity }]}>
|
||||
{/* UP 主行 */}
|
||||
<View style={styles.upRow}>
|
||||
<SkeletonBlock width={38} height={38} radius={19} bg={bg} />
|
||||
<View style={styles.upInfo}>
|
||||
<SkeletonBlock width={120} height={13} bg={bg} />
|
||||
<SkeletonBlock width={160} height={11} bg={bg} style={{ marginTop: 6 }} />
|
||||
</View>
|
||||
{/* 标题块(两行) + Meta 行 */}
|
||||
<View style={styles.titleBlock}>
|
||||
<SkeletonBlock width={"92%"} height={16} bg={bg} />
|
||||
<SkeletonBlock width={"60%"} height={16} bg={bg} style={{ marginTop: 8 }} />
|
||||
<SkeletonBlock width={"75%"} height={12} bg={bg} style={{ marginTop: 10 }} />
|
||||
</View>
|
||||
|
||||
{/* 标题 + 简介 */}
|
||||
<View style={[styles.titleSection, { borderBottomColor: border }]}>
|
||||
<SkeletonBlock width={"95%"} height={14} bg={bg} />
|
||||
<SkeletonBlock width={"60%"} height={14} bg={bg} style={{ marginTop: 8 }} />
|
||||
<SkeletonBlock width={48} height={16} radius={4} bg={bg} style={{ marginTop: 12 }} />
|
||||
<SkeletonBlock width={"100%"} height={12} bg={bg} style={{ marginTop: 12 }} />
|
||||
<SkeletonBlock width={"80%"} height={12} bg={bg} style={{ marginTop: 6 }} />
|
||||
{/* 动作按钮行 */}
|
||||
<View style={[styles.actionRow, { borderTopColor: border }]}>
|
||||
<ActionPillSkeleton bg={bg} width={72} />
|
||||
<ActionPillSkeleton bg={bg} width={72} />
|
||||
<ActionPillSkeleton bg={bg} width={72} />
|
||||
<ActionPillSkeleton bg={bg} width={72} />
|
||||
<ActionPillSkeleton bg={bg} width={72} />
|
||||
</View>
|
||||
|
||||
{/* 创作者行 + 关注按钮 */}
|
||||
<View style={[styles.creatorRow, { borderTopColor: border, borderBottomColor: border }]}>
|
||||
<SkeletonBlock width={38} height={38} radius={19} bg={bg} />
|
||||
<View style={styles.creatorInfo}>
|
||||
<SkeletonBlock width={120} height={14} bg={bg} />
|
||||
<SkeletonBlock width={160} height={11} bg={bg} style={{ marginTop: 6 }} />
|
||||
</View>
|
||||
<SkeletonBlock width={56} height={28} radius={14} bg={bg} />
|
||||
</View>
|
||||
|
||||
{/* 推荐视频标题 */}
|
||||
@@ -98,21 +111,27 @@ export function VideoDetailSkeleton() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
upRow: {
|
||||
titleBlock: { paddingHorizontal: 14, paddingTop: 12, paddingBottom: 12 },
|
||||
actionRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 10,
|
||||
paddingVertical: 10,
|
||||
gap: 8,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
upInfo: { flex: 1, marginLeft: 10 },
|
||||
titleSection: {
|
||||
padding: 14,
|
||||
creatorRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
creatorInfo: { flex: 1, marginLeft: 10 },
|
||||
relatedHeader: {
|
||||
paddingLeft: 13,
|
||||
paddingTop: 10,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
card: {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { View, StyleSheet, Text, Platform, Modal, StatusBar, useWindowDimensions } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
// expo-screen-orientation requires a dev build; gracefully degrade in Expo Go
|
||||
let ScreenOrientation: typeof import('expo-screen-orientation') | null = null;
|
||||
try { ScreenOrientation = require('expo-screen-orientation'); } catch {}
|
||||
import { NativeVideoPlayer, type NativeVideoPlayerRef } from './NativeVideoPlayer';
|
||||
import type { PlayUrlResponse, DanmakuItem } from '../services/types';
|
||||
import { useTheme } from '../utils/theme';
|
||||
|
||||
interface Props {
|
||||
playData: PlayUrlResponse | null;
|
||||
@@ -16,15 +18,22 @@ interface Props {
|
||||
danmakus?: DanmakuItem[];
|
||||
onTimeUpdate?: (t: number) => void;
|
||||
initialTime?: number;
|
||||
/** 详情页将其挂到弹幕 Sheet 的打开动作。仅小窗口生效,全屏暂不显示。 */
|
||||
onDanmakuListPress?: () => void;
|
||||
/** 播放器左上角返回按钮回调,跟随小窗口控制栏一起显隐。 */
|
||||
onBack?: () => void;
|
||||
/** 视频封面 URL。playData 未到达 / <Video> 首帧未到达时用作 poster,避免黑闪 */
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, bvid, cid, danmakus, onTimeUpdate, initialTime }: Props) {
|
||||
export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, bvid, cid, danmakus, onTimeUpdate, initialTime, onDanmakuListPress, onBack, coverUrl }: Props) {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const { width, height } = useWindowDimensions();
|
||||
const VIDEO_HEIGHT = width * 0.5625;
|
||||
const needsRotation = !ScreenOrientation && fullscreen;
|
||||
const lastTimeRef = useRef(0);
|
||||
const seededRef = useRef(false);
|
||||
const theme = useTheme();
|
||||
// 续播:第一次拿到 initialTime 时塞进 lastTimeRef
|
||||
if (!seededRef.current && typeof initialTime === 'number' && initialTime > 0) {
|
||||
lastTimeRef.current = initialTime;
|
||||
@@ -52,9 +61,13 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
|
||||
}, []);
|
||||
|
||||
if (!playData) {
|
||||
// 没拿到 playData 时背景用主题色(与页面同色,消除"页面→纯黑"撞色),有封面则铺封面
|
||||
return (
|
||||
<View style={[{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }, styles.placeholder]}>
|
||||
<Text style={styles.placeholderText}>视频加载中...</Text>
|
||||
<View style={[{ width, height: VIDEO_HEIGHT, backgroundColor: theme.card }, styles.placeholder]}>
|
||||
{!!coverUrl && (
|
||||
<Image source={{ uri: coverUrl }} style={StyleSheet.absoluteFill} contentFit="cover" />
|
||||
)}
|
||||
<Text style={[styles.placeholderText, { color: coverUrl ? '#fff' : theme.textSub }]}>视频加载中...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +78,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
|
||||
<View style={{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }}>
|
||||
<video
|
||||
src={url}
|
||||
poster={coverUrl}
|
||||
style={{ width: '100%', height: '100%', backgroundColor: '#000' } as any}
|
||||
controls
|
||||
playsInline
|
||||
@@ -89,6 +103,9 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
|
||||
isFullscreen={false}
|
||||
initialTime={lastTimeRef.current}
|
||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||
onDanmakuListPress={onDanmakuListPress}
|
||||
onBack={onBack}
|
||||
coverUrl={coverUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -112,6 +129,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
|
||||
isFullscreen={true}
|
||||
initialTime={lastTimeRef.current}
|
||||
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
|
||||
coverUrl={coverUrl}
|
||||
style={needsRotation ? { width: height, height: width } : { flex: 1 }}
|
||||
/>
|
||||
</View>
|
||||
@@ -124,5 +142,5 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
placeholder: { justifyContent: 'center', alignItems: 'center' },
|
||||
placeholderText: { color: '#fff', fontSize: 14 },
|
||||
placeholderText: { fontSize: 14 },
|
||||
});
|
||||
|
||||
68
hooks/useFollow.ts
Normal file
68
hooks/useFollow.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getRelation, modifyRelation } from "../services/bilibili";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { getSecure } from "../utils/secureStorage";
|
||||
import { toast } from "../utils/toast";
|
||||
|
||||
/**
|
||||
* 关注 / 取消关注 UP 主。
|
||||
* - 未登录:toggle 时 toast 提示,按钮 disabled=false 仍允许点击
|
||||
* - 已登录但无 bili_jct:toggle 时 toast 提示让用户重登
|
||||
* - 正常路径:乐观更新 UI,请求失败回滚
|
||||
*/
|
||||
export function useFollow(mid: number | undefined) {
|
||||
const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
|
||||
const [following, setFollowing] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inflightRef = useRef(false);
|
||||
|
||||
// 首次拉取 / mid 切换时刷新关注状态
|
||||
useEffect(() => {
|
||||
if (!mid || !isLoggedIn) {
|
||||
setFollowing(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
getRelation(mid)
|
||||
.then((r) => {
|
||||
if (!cancelled) setFollowing(r.following);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mid, isLoggedIn]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (!mid) return;
|
||||
if (!isLoggedIn) {
|
||||
toast("请先登录后再关注");
|
||||
return;
|
||||
}
|
||||
const biliJct = await getSecure("bili_jct");
|
||||
if (!biliJct) {
|
||||
toast("请重新登录后再使用关注功能");
|
||||
return;
|
||||
}
|
||||
if (inflightRef.current) return;
|
||||
inflightRef.current = true;
|
||||
setLoading(true);
|
||||
const next = !following;
|
||||
setFollowing(next); // 乐观更新
|
||||
try {
|
||||
await modifyRelation(mid, next ? 1 : 2);
|
||||
} catch (e: any) {
|
||||
setFollowing(!next); // 失败回滚
|
||||
if (e?.message === "NO_CSRF") {
|
||||
toast("请重新登录后再使用关注功能");
|
||||
} else {
|
||||
toast(`操作失败:${e?.message || "未知错误"}`);
|
||||
}
|
||||
} finally {
|
||||
inflightRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [mid, isLoggedIn, following]);
|
||||
|
||||
return { following, loading, toggle };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { getVideoRelated } from '../services/bilibili';
|
||||
import type { VideoItem } from '../services/types';
|
||||
|
||||
@@ -7,6 +7,11 @@ export function useRelatedVideos(bvid: string) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
// 切到不同 bvid 时立刻清空,避免新页面短暂显示上一支视频的推荐流
|
||||
useEffect(() => {
|
||||
setVideos([]);
|
||||
}, [bvid]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
|
||||
@@ -37,6 +37,12 @@ export function useVideoDetail(bvid: string) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// bvid 切换时立刻清空旧数据,防止上一支视频的播放器/简介/清晰度短暂"残影"造成抖动
|
||||
setVideo(null);
|
||||
setPlayData(null);
|
||||
setQualities([]);
|
||||
setCurrentQn(0);
|
||||
cidRef.current = 0;
|
||||
async function fetchData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
@@ -5,7 +5,7 @@ import pako from 'pako';
|
||||
import type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem, LiveRoom, LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo, SearchSuggestItem, HotSearchItem } from './types';
|
||||
import { signWbi } from '../utils/wbi';
|
||||
import { parseDanmakuXml } from '../utils/danmaku';
|
||||
import { getSecure } from '../utils/secureStorage';
|
||||
import { getSecure, setSecure } from '../utils/secureStorage';
|
||||
|
||||
const isWeb = Platform.OS === 'web';
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
@@ -46,22 +46,41 @@ const api = axios.create({
|
||||
});
|
||||
|
||||
api.interceptors.request.use(async (config) => {
|
||||
const [sessdata, buvid3] = await Promise.all([
|
||||
const [sessdata, biliJct, buvid3] = await Promise.all([
|
||||
getSecure('SESSDATA'),
|
||||
getSecure('bili_jct'),
|
||||
getBuvid3(),
|
||||
]);
|
||||
if (isWeb) {
|
||||
// Browsers block Cookie/Referer/Origin headers; relay via custom headers to proxy
|
||||
if (buvid3) config.headers['X-Buvid3'] = buvid3;
|
||||
if (sessdata) config.headers['X-Sessdata'] = sessdata;
|
||||
if (biliJct) config.headers['X-Bili-Jct'] = biliJct;
|
||||
} else {
|
||||
const cookies: string[] = [`buvid3=${buvid3}`];
|
||||
if (sessdata) cookies.push(`SESSDATA=${sessdata}`);
|
||||
if (biliJct) cookies.push(`bili_jct=${biliJct}`);
|
||||
config.headers['Cookie'] = cookies.join('; ');
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 被动累积:任何响应 Set-Cookie 里出现 bili_jct 都顺手存下来。覆盖老用户没有 CSRF 的情况。
|
||||
api.interceptors.response.use(async (response) => {
|
||||
if (isWeb) return response;
|
||||
const setCookie = response.headers?.['set-cookie'];
|
||||
if (Array.isArray(setCookie)) {
|
||||
for (const c of setCookie) {
|
||||
const part = c.split(';').find((p: string) => p.trim().startsWith('bili_jct='));
|
||||
if (part) {
|
||||
const value = part.trim().replace('bili_jct=', '');
|
||||
if (value) await setSecure('bili_jct', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
// ─── Request deduplication ──────────────────────────────────────────────────
|
||||
// Prevents identical concurrent requests (same URL + params) from hitting the network twice.
|
||||
const inflightRequests = new Map<string, Promise<any>>();
|
||||
@@ -296,7 +315,7 @@ export async function generateQRCode(): Promise<QRCodeInfo> {
|
||||
return res.data.data as QRCodeInfo;
|
||||
}
|
||||
|
||||
export async function pollQRCode(qrcode_key: string): Promise<{ code: number; cookie?: string }> {
|
||||
export async function pollQRCode(qrcode_key: string): Promise<{ code: number; cookie?: string; csrf?: string }> {
|
||||
const headers = isWeb
|
||||
? {}
|
||||
: { 'Referer': 'https://www.bilibili.com' };
|
||||
@@ -306,22 +325,58 @@ export async function pollQRCode(qrcode_key: string): Promise<{ code: number; co
|
||||
});
|
||||
const { code } = res.data.data;
|
||||
let cookie: string | undefined;
|
||||
let csrf: string | undefined;
|
||||
if (code === 0) {
|
||||
if (isWeb) {
|
||||
// Proxy relays SESSDATA via custom response header
|
||||
cookie = res.headers['x-sessdata'] as string | undefined;
|
||||
csrf = res.headers['x-bili-jct'] as string | undefined;
|
||||
} else {
|
||||
const setCookie = res.headers['set-cookie'];
|
||||
const match = setCookie?.find((c: string) => c.includes('SESSDATA='));
|
||||
if (match) {
|
||||
const sessPart = match.split(';').find((p: string) => p.trim().startsWith('SESSDATA='));
|
||||
if (sessPart) {
|
||||
cookie = sessPart.trim().replace('SESSDATA=', '');
|
||||
if (Array.isArray(setCookie)) {
|
||||
for (const c of setCookie) {
|
||||
const sessPart = c.split(';').find((p: string) => p.trim().startsWith('SESSDATA='));
|
||||
if (sessPart) cookie = sessPart.trim().replace('SESSDATA=', '');
|
||||
const jctPart = c.split(';').find((p: string) => p.trim().startsWith('bili_jct='));
|
||||
if (jctPart) csrf = jctPart.trim().replace('bili_jct=', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { code, cookie };
|
||||
return { code, cookie, csrf };
|
||||
}
|
||||
|
||||
// 查关注状态(attribute: 0=未关注, 2=关注, 6=互相关注)
|
||||
export async function getRelation(mid: number): Promise<{ following: boolean; mutual: boolean }> {
|
||||
try {
|
||||
const res = await api.get('/x/relation', { params: { fid: mid } });
|
||||
const attribute = res.data?.data?.attribute ?? 0;
|
||||
return {
|
||||
following: attribute === 2 || attribute === 6,
|
||||
mutual: attribute === 6,
|
||||
};
|
||||
} catch {
|
||||
return { following: false, mutual: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** 修改与 UP 主的关系:act=1 关注,act=2 取消关注。失败抛错(消息文本来自 B 站 code/message) */
|
||||
export async function modifyRelation(mid: number, act: 1 | 2): Promise<void> {
|
||||
const biliJct = await getSecure('bili_jct');
|
||||
if (!biliJct) throw new Error('NO_CSRF');
|
||||
// application/x-www-form-urlencoded body
|
||||
const body =
|
||||
`fid=${encodeURIComponent(String(mid))}` +
|
||||
`&act=${act}` +
|
||||
`&re_src=11` +
|
||||
`&csrf=${encodeURIComponent(biliJct)}`;
|
||||
const res = await api.post('/x/relation/modify', body, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
const code = res.data?.code;
|
||||
if (code !== 0) {
|
||||
throw new Error(res.data?.message || `code=${code}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ import { getSecure, setSecure, deleteSecure } from '../utils/secureStorage';
|
||||
|
||||
interface AuthState {
|
||||
sessdata: string | null;
|
||||
/** B 站 CSRF token(bili_jct cookie 值)。写操作(关注/点赞/投币 etc)必填 */
|
||||
biliJct: string | null;
|
||||
uid: string | null;
|
||||
username: string | null;
|
||||
face: string | null;
|
||||
isLoggedIn: boolean;
|
||||
login: (sessdata: string, uid: string, username?: string) => Promise<void>;
|
||||
login: (sessdata: string, uid: string, username?: string, biliJct?: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
restore: () => Promise<void>;
|
||||
setProfile: (face: string, username: string, uid: string) => void;
|
||||
@@ -17,16 +19,24 @@ interface AuthState {
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
sessdata: null,
|
||||
biliJct: null,
|
||||
uid: null,
|
||||
username: null,
|
||||
face: null,
|
||||
isLoggedIn: false,
|
||||
|
||||
login: async (sessdata, uid, username) => {
|
||||
login: async (sessdata, uid, username, biliJct) => {
|
||||
await setSecure('SESSDATA', sessdata);
|
||||
if (biliJct) await setSecure('bili_jct', biliJct);
|
||||
// Migrate: remove SESSDATA from AsyncStorage if it was there before
|
||||
await AsyncStorage.removeItem('SESSDATA').catch(() => { });
|
||||
set({ sessdata, uid: uid || null, username: username || null, isLoggedIn: true });
|
||||
set({
|
||||
sessdata,
|
||||
biliJct: biliJct ?? null,
|
||||
uid: uid || null,
|
||||
username: username || null,
|
||||
isLoggedIn: true,
|
||||
});
|
||||
getUserInfo().then(async (info) => {
|
||||
await AsyncStorage.multiSet([
|
||||
['UID', String(info.mid)],
|
||||
@@ -39,8 +49,9 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
|
||||
logout: async () => {
|
||||
await deleteSecure('SESSDATA');
|
||||
await deleteSecure('bili_jct');
|
||||
await AsyncStorage.multiRemove(['UID', 'USERNAME', 'FACE']);
|
||||
set({ sessdata: null, uid: null, username: null, face: null, isLoggedIn: false });
|
||||
set({ sessdata: null, biliJct: null, uid: null, username: null, face: null, isLoggedIn: false });
|
||||
},
|
||||
|
||||
restore: async () => {
|
||||
@@ -54,8 +65,9 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
await AsyncStorage.removeItem('SESSDATA');
|
||||
}
|
||||
}
|
||||
const biliJct = await getSecure('bili_jct');
|
||||
if (sessdata) {
|
||||
set({ sessdata, isLoggedIn: true });
|
||||
set({ sessdata, biliJct, isLoggedIn: true });
|
||||
try {
|
||||
const info = await getUserInfo();
|
||||
await AsyncStorage.setItem('FACE', info.face);
|
||||
|
||||
9
utils/toast.ts
Normal file
9
utils/toast.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ToastAndroid, Platform, Alert } from "react-native";
|
||||
|
||||
export function toast(msg: string) {
|
||||
if (Platform.OS === "android") {
|
||||
ToastAndroid.show(msg, ToastAndroid.SHORT);
|
||||
} else {
|
||||
Alert.alert("", msg);
|
||||
}
|
||||
}
|
||||
42
utils/useSheetTransition.ts
Normal file
42
utils/useSheetTransition.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Animated } from "react-native";
|
||||
|
||||
/**
|
||||
* 控制底部 Sheet 的"挂载 + 滑入 / 滑出 + 卸载"时序。
|
||||
*
|
||||
* 用法:
|
||||
* const { rendered, slideAnim } = useSheetTransition(visible, sheetH);
|
||||
* if (!rendered) return null;
|
||||
* <Animated.View style={{ transform: [{ translateY: slideAnim }] }}>…</Animated.View>
|
||||
*
|
||||
* 关键行为:
|
||||
* - visible=true 时立刻 rendered=true 并触发"由下往上"滑入
|
||||
* - visible=false 时先反向滑出 240ms,结束才把 rendered 置 false(卸载 Modal)
|
||||
* - 中途反复切换 visible 不会错乱:被打断的 timing.start() 回调拿到的 finished=false,
|
||||
* 所以 rendered 不会在动画未完成时意外清掉。
|
||||
*/
|
||||
export function useSheetTransition(visible: boolean, sheetH: number) {
|
||||
const [rendered, setRendered] = useState(visible);
|
||||
const slideAnim = useRef(new Animated.Value(visible ? 0 : sheetH)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setRendered(true);
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 240,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} else {
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: sheetH,
|
||||
duration: 240,
|
||||
useNativeDriver: true,
|
||||
}).start(({ finished }) => {
|
||||
if (finished) setRendered(false);
|
||||
});
|
||||
}
|
||||
}, [visible, sheetH]);
|
||||
|
||||
return { rendered, slideAnim };
|
||||
}
|
||||
Reference in New Issue
Block a user