本项目已收到哔哩哔哩(bilibili)律师函,要求停止对 B 站 API 的调用及相关仿制行为。

为尊重知识产权及相关法律法规,本仓库即日起停止后续维护与更新,不再接受新的 Issue 和 Pull Request。

现有代码仅作学习参考保留,请勿将本项目用于任何商业或违法用途。

感谢所有支持过本项目的朋友。
This commit is contained in:
Developer
2026-05-12 21:29:45 +08:00
parent 53c67079a1
commit 6e0dc2729c
16 changed files with 1304 additions and 453 deletions

View File

@@ -10,25 +10,24 @@ import {
InteractionManager, InteractionManager,
} from "react-native"; } from "react-native";
import { Image } from "expo-image"; 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 { useLocalSearchParams, useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { VideoPlayer } from "../../components/VideoPlayer"; import { VideoPlayer } from "../../components/VideoPlayer";
import { CommentItem } from "../../components/CommentItem";
import { getDanmaku, getUploaderStat } from "../../services/bilibili"; import { getDanmaku, getUploaderStat } from "../../services/bilibili";
import { DanmakuItem } from "../../services/types"; import type { DanmakuItem, VideoItem } from "../../services/types";
import DanmakuList from "../../components/DanmakuList";
import { useVideoDetail } from "../../hooks/useVideoDetail"; import { useVideoDetail } from "../../hooks/useVideoDetail";
import { useComments } from "../../hooks/useComments";
import { useRelatedVideos } from "../../hooks/useRelatedVideos"; import { useRelatedVideos } from "../../hooks/useRelatedVideos";
import { formatCount, formatDuration } from "../../utils/format"; import { formatCount, formatDuration, formatTime } from "../../utils/format";
import { proxyImageUrl } from "../../utils/imageUrl"; import { proxyImageUrl } from "../../utils/imageUrl";
import { DownloadSheet } from "../../components/DownloadSheet"; import { DownloadSheet } from "../../components/DownloadSheet";
import { VideoDetailSkeleton } from "../../components/VideoDetailSkeleton"; import { VideoDetailSkeleton } from "../../components/VideoDetailSkeleton";
import { useTheme } from "../../utils/theme"; import { useTheme } from "../../utils/theme";
import { useLiveStore } from "../../store/liveStore"; import { useLiveStore } from "../../store/liveStore";
import { VideoActionRow } from "../../components/VideoActionRow";
type Tab = "intro" | "comments" | "danmaku"; import { DescriptionSheet } from "../../components/DescriptionSheet";
import { EngagementSheet, type EngagementTab } from "../../components/EngagementSheet";
import { useFollow } from "../../hooks/useFollow";
export default function VideoDetailScreen() { export default function VideoDetailScreen() {
const { bvid } = useLocalSearchParams<{ bvid: string }>(); const { bvid } = useLocalSearchParams<{ bvid: string }>();
@@ -39,13 +38,15 @@ export default function VideoDetailScreen() {
useLiveStore.getState().clearLive(); useLiveStore.getState().clearLive();
}, []); }, []);
// 骨架屏最短展示时长:即便数据秒回,也至少撑够这么久,避免一闪而过 // 骨架屏最短展示时长:即便数据秒回,也至少撑够这么久,避免一闪而过
// 重要:依赖 [bvid] —— 切换合集/推荐时同步复位,让骨架屏盖住老数据残影。
const SKELETON_MIN_MS = 600; const SKELETON_MIN_MS = 600;
const [minSkeletonElapsed, setMinSkeletonElapsed] = useState(false); const [minSkeletonElapsed, setMinSkeletonElapsed] = useState(false);
useEffect(() => { useEffect(() => {
setMinSkeletonElapsed(false);
const t = setTimeout(() => setMinSkeletonElapsed(true), SKELETON_MIN_MS); const t = setTimeout(() => setMinSkeletonElapsed(true), SKELETON_MIN_MS);
return () => clearTimeout(t); return () => clearTimeout(t);
}, []); }, [bvid]);
const { const {
video, video,
@@ -56,23 +57,37 @@ export default function VideoDetailScreen() {
changeQuality, changeQuality,
initialTime, initialTime,
} = useVideoDetail(bvid as string); } = 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 [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [showDownload, setShowDownload] = useState(false); const [showDownload, setShowDownload] = useState(false);
const [descExpanded, setDescExpanded] = useState(false); const [showDescSheet, setShowDescSheet] = useState(false);
const [descOverflows, setDescOverflows] = useState(false); // 评论/弹幕合并 Sheetnull=关闭,否则记录当前应该打开哪个 Tab
const [engagementTab, setEngagementTab] = useState<EngagementTab | null>(null);
const [uploaderStat, setUploaderStat] = useState<{ const [uploaderStat, setUploaderStat] = useState<{
follower: number; follower: number;
archiveCount: number; archiveCount: number;
} | null>(null); } | 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 { const {
videos: relatedVideos, videos: relatedVideos,
loading: relatedLoading, loading: relatedLoading,
@@ -80,20 +95,13 @@ export default function VideoDetailScreen() {
} = useRelatedVideos(bvid as string); } = useRelatedVideos(bvid as string);
// 推荐视频不参与首屏,等导航动画结束再拉,避免与详情/播放流抢 JS 线程 // 推荐视频不参与首屏,等导航动画结束再拉,避免与详情/播放流抢 JS 线程
// 依赖 [bvid]:合集/推荐切到新视频时同步重新拉新推荐列表
useEffect(() => { useEffect(() => {
const handle = InteractionManager.runAfterInteractions(() => { const handle = InteractionManager.runAfterInteractions(() => {
loadRelated(); loadRelated();
}); });
return () => handle.cancel(); return () => handle.cancel();
}, []); }, [bvid, loadRelated]);
useEffect(() => {
if (!video?.aid) return;
const handle = InteractionManager.runAfterInteractions(() => {
loadComments();
});
return () => handle.cancel();
}, [video?.aid, commentSort]);
useEffect(() => { useEffect(() => {
if (!video?.cid) return; if (!video?.cid) return;
@@ -111,21 +119,10 @@ export default function VideoDetailScreen() {
return () => handle.cancel(); return () => handle.cancel();
}, [video?.owner?.mid]); }, [video?.owner?.mid]);
const shareUrl = bvid ? `https://www.bilibili.com/video/${bvid}` : undefined;
return ( return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}> <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 <VideoPlayer
playData={playData} playData={playData}
qualities={qualities} qualities={qualities}
@@ -136,217 +133,228 @@ export default function VideoDetailScreen() {
danmakus={danmakus} danmakus={danmakus}
onTimeUpdate={setCurrentTime} onTimeUpdate={setCurrentTime}
initialTime={initialTime} 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 <DownloadSheet
visible={showDownload} visible={showDownload}
onClose={() => setShowDownload(false)} onClose={() => setShowDownload(false)}
topOffset={sheetTopOffset}
bvid={bvid as string} bvid={bvid as string}
cid={video?.cid ?? 0} cid={video?.cid ?? 0}
title={video?.title ?? ""} title={video?.title ?? ""}
cover={video?.pic ?? ""} cover={video?.pic ?? ""}
qualities={qualities} qualities={qualities}
/> />
<DescriptionSheet
{/* TabBar */} visible={showDescSheet}
{video && ( onClose={() => setShowDescSheet(false)}
<View style={[styles.tabBar, { borderBottomColor: theme.border }]}> topOffset={sheetTopOffset}
{(["intro", "comments", "danmaku"] as Tab[]).map((t) => { video={video ?? null}
const label = />
t === "intro" ? "简介" <EngagementSheet
: t === "comments" ? `评论${video.stat?.reply ? ` ${formatCount(video.stat.reply)}` : ""}` visible={engagementTab !== null}
: `弹幕${danmakus.length ? ` ${formatCount(danmakus.length)}` : ""}`; onClose={() => setEngagementTab(null)}
return ( topOffset={sheetTopOffset}
<TouchableOpacity key={t} style={styles.tabItem} onPress={() => setTab(t)}> initialTab={engagementTab ?? "comments"}
<Text style={[styles.tabLabel, { color: theme.textSub }, tab === t && styles.tabActive]}> aid={video?.aid ?? 0}
{label} replyCount={video?.stat?.reply}
</Text> danmakus={danmakus}
{tab === t && <View style={styles.tabUnderline} />} currentTime={currentTime}
</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" }]}
/>
</>
)}
</SafeAreaView> </SafeAreaView>
); );
} }
function SeasonSection({ function SeasonSection({
season, season,
currentBvid, currentBvid,
onEpisodePress, onEpisodePress,
}: { }: {
season: NonNullable<import("../../services/types").VideoItem["ugc_season"]>; season: NonNullable<VideoItem["ugc_season"]>;
currentBvid: string; currentBvid: string;
onEpisodePress: (bvid: string) => void; onEpisodePress: (bvid: string) => void;
}) { }) {
@@ -368,14 +376,11 @@ function SeasonSection({
return Math.max(0, itemCenter - screenW / 2); return Math.max(0, itemCenter - screenW / 2);
}, [currentIndex, screenW]); }, [currentIndex, screenW]);
// 内容布局完成时getItemLayout 同步即知尺寸content size 一就绪就触发)
// 同步 scrollToOffset 后立刻显示,避免任何中间帧
const handleContentSizeChange = useCallback(() => { const handleContentSizeChange = useCallback(() => {
if (ready) return; if (ready) return;
if (currentIndex > 0 && initialOffset > 0) { if (currentIndex > 0 && initialOffset > 0) {
listRef.current?.scrollToOffset({ offset: initialOffset, animated: false }); listRef.current?.scrollToOffset({ offset: initialOffset, animated: false });
} }
// 等到下一帧再显示,确保滚动已落地
requestAnimationFrame(() => setReady(true)); requestAnimationFrame(() => setReady(true));
}, [ready, currentIndex, initialOffset]); }, [ready, currentIndex, initialOffset]);
@@ -430,46 +435,49 @@ function SeasonSection({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
safe: { flex: 1 }, safe: { flex: 1 },
topBar: { loader: { marginVertical: 30 },
scroll: { flex: 1 },
// Title + MetaYT 风格)
titleBlock: { paddingHorizontal: 14, paddingTop: 12, paddingBottom: 8 },
title: { fontSize: 16, fontWeight: "700", lineHeight: 22 },
metaRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
paddingHorizontal: 8, marginTop: 6,
paddingVertical: 8, },
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, borderBottomWidth: StyleSheet.hairlineWidth,
}, },
backBtn: { padding: 4 }, creatorLeft: { flex: 1, flexDirection: "row", alignItems: "center" },
topTitle: { flex: 1, fontSize: 15, fontWeight: "600", marginLeft: 4 }, avatar: { width: 38, height: 38, borderRadius: 19, marginRight: 10 },
miniBtn: { padding: 4 }, creatorInfo: { flex: 1 },
loader: { marginVertical: 30 }, creatorName: { fontSize: 14, fontWeight: "600", lineHeight: 18 },
titleSection: { padding: 14, borderBottomWidth: StyleSheet.hairlineWidth }, creatorStat: { fontSize: 11, marginTop: 2, lineHeight: 16 },
title: { fontSize: 13, fontWeight: "600", lineHeight: 22, marginBottom: 6 }, subBtn: {
tnameBadge: { backgroundColor: "#00AEEC",
alignSelf: "flex-start", paddingHorizontal: 14,
backgroundColor: "rgba(0,174,236,0.12)", paddingVertical: 6,
borderRadius: 4, borderRadius: 16,
paddingHorizontal: 6, marginLeft: 12,
paddingVertical: 2,
marginBottom: 8,
}, },
tnameText: { fontSize: 11, color: "#00AEEC" }, // 已关注:浅灰底 + 灰字textSub 由 useTheme 提供)
subTitle: { fontSize: 12, lineHeight: 18, marginBottom: 4 }, subBtnFollowing: { backgroundColor: "rgba(127,127,127,0.18)" },
descMeasure: { position: "absolute", opacity: 0, left: 0, right: 0 }, subBtnTxt: { color: "#fff", fontSize: 13, fontWeight: "600" },
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" },
seasonBox: { borderTopWidth: StyleSheet.hairlineWidth, paddingVertical: 10 }, seasonBox: { borderTopWidth: StyleSheet.hairlineWidth, paddingVertical: 10 },
seasonHeader: { seasonHeader: {
flexDirection: "row", flexDirection: "row",
@@ -486,24 +494,9 @@ const styles = StyleSheet.create({
epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 }, epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 },
epNumActive: { color: "#00AEEC", fontWeight: "600" }, epNumActive: { color: "#00AEEC", fontWeight: "600" },
epTitle: { fontSize: 12, paddingHorizontal: 6, paddingBottom: 6, lineHeight: 16 }, 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 }, relatedHeader: { paddingLeft: 13, paddingBottom: 8, paddingTop: 12 },
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 },
relatedHeaderText: { fontSize: 13, fontWeight: "600" as const }, relatedHeaderText: { fontSize: 13, fontWeight: "600" as const },
relatedCard: { relatedCard: {
flexDirection: "row", flexDirection: "row",
@@ -533,19 +526,6 @@ const styles = StyleSheet.create({
relatedDurationText: { color: "#fff", fontSize: 10 }, relatedDurationText: { color: "#fff", fontSize: 10 },
relatedInfo: { flex: 1, justifyContent: "space-between", paddingVertical: 2 }, relatedInfo: { flex: 1, justifyContent: "space-between", paddingVertical: 2 },
relatedTitle: { fontSize: 13, lineHeight: 18 }, relatedTitle: { fontSize: 13, lineHeight: 18 },
relatedOwner: { fontSize: 12, color: "#999" }, relatedOwner: { fontSize: 12 },
relatedView: { fontSize: 11, color: "#bbb" }, relatedView: { fontSize: 11 },
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 },
}); });

View 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 },
});

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from "react"; import React from "react";
import { import {
View, View,
Text, Text,
@@ -6,14 +6,19 @@ import {
TouchableOpacity, TouchableOpacity,
Animated, Animated,
StyleSheet, StyleSheet,
ScrollView,
useWindowDimensions,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useDownload } from "../hooks/useDownload"; import { useDownload } from "../hooks/useDownload";
import { useTheme } from "../utils/theme"; import { useTheme } from "../utils/theme";
import { useSheetTransition } from "../utils/useSheetTransition";
interface Props { interface Props {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
/** Sheet 顶部对齐到屏幕的绝对 Y一般是播放器底部 */
topOffset: number;
bvid: string; bvid: string;
cid: number; cid: number;
title: string; title: string;
@@ -24,6 +29,7 @@ interface Props {
export function DownloadSheet({ export function DownloadSheet({
visible, visible,
onClose, onClose,
topOffset,
bvid, bvid,
cid, cid,
title, title,
@@ -32,148 +38,143 @@ export function DownloadSheet({
}: Props) { }: Props) {
const { tasks, startDownload, taskKey } = useDownload(); const { tasks, startDownload, taskKey } = useDownload();
const theme = useTheme(); 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(() => { if (!rendered || qualities.length === 0) return null;
Animated.timing(slideAnim, {
toValue: visible ? 0 : 300,
duration: 260,
useNativeDriver: true,
}).start();
}, [visible]);
if (qualities.length === 0) return null;
return ( return (
<Modal <Modal
visible={visible} visible
transparent transparent
animationType="none" animationType="none"
statusBarTranslucent
onRequestClose={onClose} onRequestClose={onClose}
> >
<TouchableOpacity
style={styles.overlay}
activeOpacity={1}
onPress={onClose}
/>
<Animated.View <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> <Text style={[styles.headerTitle, { color: theme.modalText }]}></Text>
<TouchableOpacity onPress={onClose} style={styles.closeBtn}> <TouchableOpacity onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<Ionicons name="close" size={20} color={theme.modalTextSub} /> <Ionicons name="close" size={22} color={theme.modalTextSub} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={[styles.divider, { backgroundColor: theme.modalBorder }]} /> <ScrollView
{qualities.map((q) => { contentContainerStyle={styles.body}
const key = taskKey(bvid, q.qn); showsVerticalScrollIndicator={false}
const task = tasks[key]; >
return ( {qualities.map((q) => {
<View key={q.qn} style={styles.row}> const key = taskKey(bvid, q.qn);
<Text style={[styles.qualityLabel, { color: theme.modalText }]}>{q.desc}</Text> const task = tasks[key];
<View style={styles.right}> return (
{!task && ( <View
<TouchableOpacity key={q.qn}
style={styles.downloadBtn} style={[styles.row, { borderBottomColor: theme.modalBorder }]}
onPress={() => >
startDownload(bvid, cid, q.qn, q.desc, title, cover) <Text style={[styles.qualityLabel, { color: theme.modalText }]}>
} {q.desc}
> </Text>
<Text style={styles.downloadBtnTxt}></Text> <View style={styles.right}>
</TouchableOpacity> {!task && (
)}
{task?.status === "downloading" && (
<View style={styles.progressWrap}>
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{
width: `${Math.round(task.progress * 100)}%` as any,
},
]}
/>
</View>
<Text style={styles.progressTxt}>
{Math.round(task.progress * 100)}%
</Text>
</View>
)}
{task?.status === "done" && (
<View style={styles.doneRow}>
<Ionicons
name="checkmark-circle"
size={16}
color="#00AEEC"
/>
<Text style={styles.doneTxt}></Text>
</View>
)}
{task?.status === "error" && (
<View style={styles.errorWrap}>
{!!task.error && (
<Text style={styles.errorMsg} numberOfLines={2}>
{task.error}
</Text>
)}
<TouchableOpacity <TouchableOpacity
style={styles.retryBtn} style={styles.downloadBtn}
onPress={() => onPress={() =>
startDownload(bvid, cid, q.qn, q.desc, title, cover) startDownload(bvid, cid, q.qn, q.desc, title, cover)
} }
> >
<Ionicons name="refresh" size={14} color="#f44" /> <Text style={styles.downloadBtnTxt}></Text>
<Text style={styles.retryTxt}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> )}
)} {task?.status === "downloading" && (
<View style={styles.progressWrap}>
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{
width: `${Math.round(task.progress * 100)}%` as any,
},
]}
/>
</View>
<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" />
<Text style={styles.doneTxt}></Text>
</View>
)}
{task?.status === "error" && (
<View style={styles.errorWrap}>
{!!task.error && (
<Text style={styles.errorMsg} numberOfLines={2}>
{task.error}
</Text>
)}
<TouchableOpacity
style={styles.retryBtn}
onPress={() =>
startDownload(bvid, cid, q.qn, q.desc, title, cover)
}
>
<Ionicons name="refresh" size={14} color="#f44" />
<Text style={styles.retryTxt}></Text>
</TouchableOpacity>
</View>
)}
</View>
</View> </View>
</View> );
); })}
})} </ScrollView>
<View style={styles.footer} />
</Animated.View> </Animated.View>
</Modal> </Modal>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.4)",
},
sheet: { sheet: {
position: "absolute", position: "absolute",
bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
backgroundColor: "#fff", bottom: 0,
borderTopLeftRadius: 16, borderTopLeftRadius: 12,
borderTopRightRadius: 16, borderTopRightRadius: 12,
paddingHorizontal: 20, overflow: "hidden",
paddingTop: 16,
}, },
header: { header: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", 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 }, closeBtn: { padding: 4 },
divider: { body: { paddingHorizontal: 20, paddingBottom: 40 },
height: StyleSheet.hairlineWidth,
backgroundColor: "#eee",
marginBottom: 4,
},
row: { row: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
paddingVertical: 14, paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
}, },
qualityLabel: { fontSize: 15, color: "#212121" }, qualityLabel: { fontSize: 15 },
right: { flexDirection: "row", alignItems: "center" }, right: { flexDirection: "row", alignItems: "center" },
downloadBtn: { downloadBtn: {
backgroundColor: "#00AEEC", backgroundColor: "#00AEEC",
@@ -191,12 +192,11 @@ const styles = StyleSheet.create({
overflow: "hidden", overflow: "hidden",
}, },
progressFill: { height: 4, backgroundColor: "#00AEEC", borderRadius: 2 }, 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 }, doneRow: { flexDirection: "row", alignItems: "center", gap: 4 },
doneTxt: { fontSize: 13, color: "#00AEEC" }, doneTxt: { fontSize: 13, color: "#00AEEC" },
errorWrap: { alignItems: "flex-end", gap: 2 }, errorWrap: { alignItems: "flex-end", gap: 2 },
errorMsg: { fontSize: 11, color: "#f44", maxWidth: 160, textAlign: "right" }, errorMsg: { fontSize: 11, color: "#f44", maxWidth: 160, textAlign: "right" },
retryBtn: { flexDirection: "row", alignItems: "center", gap: 4 }, retryBtn: { flexDirection: "row", alignItems: "center", gap: 4 },
retryTxt: { fontSize: 13, color: "#f44" }, retryTxt: { fontSize: 13, color: "#f44" },
footer: { height: 24 },
}); });

View 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 },
});

View File

@@ -87,7 +87,7 @@ export function LoginModal({ visible, onClose }: Props) {
if (result.code === 0 && result.cookie) { if (result.code === 0 && result.cookie) {
clearInterval(pollRef.current!); clearInterval(pollRef.current!);
try { try {
await login(result.cookie, "", ""); await login(result.cookie, "", "", result.csrf);
} catch { } catch {
if (!cancelled) setStatus("error"); if (!cancelled) setStatus("error");
return; return;

View File

@@ -82,6 +82,12 @@ interface Props {
onTimeUpdate?: (t: number) => void; onTimeUpdate?: (t: number) => void;
initialTime?: number; initialTime?: number;
forcePaused?: boolean; forcePaused?: boolean;
/** 点击播放器右上角"弹幕列表"图标,由外层打开 Sheet。仅在小窗口生效。 */
onDanmakuListPress?: () => void;
/** 点击播放器左上角返回箭头,跟随播放器控制栏一起显隐。仅在小窗口生效。 */
onBack?: () => void;
/** 视频封面 URL作为 poster 覆盖在 <Video> 上,首帧出来前用来盖住黑屏 */
coverUrl?: string;
} }
export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>( export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
@@ -100,6 +106,9 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
onTimeUpdate, onTimeUpdate,
initialTime, initialTime,
forcePaused, forcePaused,
onDanmakuListPress,
onBack,
coverUrl,
}: Props, }: Props,
ref, ref,
) { ) {
@@ -109,6 +118,12 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>(); const [resolvedUrl, setResolvedUrl] = useState<string | undefined>();
const isDash = !!playData?.dash; const isDash = !!playData?.dash;
// 封面挡片:用于盖住 <Video> 从挂载到 onLoad 出首帧之间的黑屏
// resolvedUrl 变化(新视频 / 换清晰度)时重新置 true等下一次 onLoad 再撤
const [coverVisible, setCoverVisible] = useState(true);
useEffect(() => {
setCoverVisible(true);
}, [resolvedUrl]);
const [showControls, setShowControls] = useState(true); const [showControls, setShowControls] = useState(true);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -488,6 +503,8 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
setBuffered(buf); setBuffered(buf);
}} }}
onLoad={() => { onLoad={() => {
// 首帧已就绪,撤掉 poster 挡片,让 <Video> 透出
setCoverVisible(false);
// 切清晰度后跳回原进度 // 切清晰度后跳回原进度
const pending = pendingSeekRef.current; const pending = pendingSeekRef.current;
let didSeek = false; 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 && ( {switching && (
@@ -562,7 +598,32 @@ export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
colors={["rgba(0,0,0,0.55)", "transparent"]} colors={["rgba(0,0,0,0.55)", "transparent"]}
style={styles.topBar} style={styles.topBar}
pointerEvents="box-none" 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 <TouchableOpacity
style={styles.centerBtn} style={styles.centerBtn}

View 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" },
});

View File

@@ -2,9 +2,9 @@ import React, { useEffect, useRef, memo } from "react";
import { View, StyleSheet, Animated } from "react-native"; import { View, StyleSheet, Animated } from "react-native";
import { useTheme } from "../utils/theme"; import { useTheme } from "../utils/theme";
// 注意:原版本每个 SkeletonBlock 都调 useTheme导致 40+ store 订阅 // 性能优化:颜色由顶层 useTheme 一次拿到,所有 block 走 inline style避免 40+ store 订阅
// + 整体 6 张卡片 + opacity loop进入页面瞬间产生明显抖动。 // 布局:与详情页 ListHeaderComponent 结构同步 ——
// 现优化:颜色由顶层 useTheme 一次拿到,所有 block 走 inline style。 // 标题块 → Meta 行 → 动作按钮行 → 创作者行 → 推荐视频标题 → 推荐卡片
interface BlockProps { interface BlockProps {
width: number | `${number}%`; 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; const CARD_COUNT = 4;
export function VideoDetailSkeleton() { export function VideoDetailSkeleton() {
@@ -65,22 +70,30 @@ export function VideoDetailSkeleton() {
return ( return (
<Animated.View style={[styles.container, { backgroundColor: theme.card, opacity }]}> <Animated.View style={[styles.container, { backgroundColor: theme.card, opacity }]}>
{/* UP 主行 */} {/* 标题块(两行) + Meta 行 */}
<View style={styles.upRow}> <View style={styles.titleBlock}>
<SkeletonBlock width={38} height={38} radius={19} bg={bg} /> <SkeletonBlock width={"92%"} height={16} bg={bg} />
<View style={styles.upInfo}> <SkeletonBlock width={"60%"} height={16} bg={bg} style={{ marginTop: 8 }} />
<SkeletonBlock width={120} height={13} bg={bg} /> <SkeletonBlock width={"75%"} height={12} bg={bg} style={{ marginTop: 10 }} />
<SkeletonBlock width={160} height={11} bg={bg} style={{ marginTop: 6 }} />
</View>
</View> </View>
{/* 标题 + 简介 */} {/* 动作按钮行 */}
<View style={[styles.titleSection, { borderBottomColor: border }]}> <View style={[styles.actionRow, { borderTopColor: border }]}>
<SkeletonBlock width={"95%"} height={14} bg={bg} /> <ActionPillSkeleton bg={bg} width={72} />
<SkeletonBlock width={"60%"} height={14} bg={bg} style={{ marginTop: 8 }} /> <ActionPillSkeleton bg={bg} width={72} />
<SkeletonBlock width={48} height={16} radius={4} bg={bg} style={{ marginTop: 12 }} /> <ActionPillSkeleton bg={bg} width={72} />
<SkeletonBlock width={"100%"} height={12} bg={bg} style={{ marginTop: 12 }} /> <ActionPillSkeleton bg={bg} width={72} />
<SkeletonBlock width={"80%"} height={12} bg={bg} style={{ marginTop: 6 }} /> <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> </View>
{/* 推荐视频标题 */} {/* 推荐视频标题 */}
@@ -98,21 +111,27 @@ export function VideoDetailSkeleton() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1 }, container: { flex: 1 },
upRow: { titleBlock: { paddingHorizontal: 14, paddingTop: 12, paddingBottom: 12 },
actionRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
paddingHorizontal: 12, paddingHorizontal: 12,
paddingTop: 12, paddingVertical: 10,
paddingBottom: 10, gap: 8,
borderTopWidth: StyleSheet.hairlineWidth,
}, },
upInfo: { flex: 1, marginLeft: 10 }, creatorRow: {
titleSection: { flexDirection: "row",
padding: 14, alignItems: "center",
paddingHorizontal: 14,
paddingVertical: 12,
borderTopWidth: StyleSheet.hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
}, },
creatorInfo: { flex: 1, marginLeft: 10 },
relatedHeader: { relatedHeader: {
paddingLeft: 13, paddingLeft: 13,
paddingTop: 10, paddingTop: 12,
paddingBottom: 8, paddingBottom: 8,
}, },
card: { card: {

View File

@@ -1,10 +1,12 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { View, StyleSheet, Text, Platform, Modal, StatusBar, useWindowDimensions } from 'react-native'; 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 // expo-screen-orientation requires a dev build; gracefully degrade in Expo Go
let ScreenOrientation: typeof import('expo-screen-orientation') | null = null; let ScreenOrientation: typeof import('expo-screen-orientation') | null = null;
try { ScreenOrientation = require('expo-screen-orientation'); } catch {} try { ScreenOrientation = require('expo-screen-orientation'); } catch {}
import { NativeVideoPlayer, type NativeVideoPlayerRef } from './NativeVideoPlayer'; import { NativeVideoPlayer, type NativeVideoPlayerRef } from './NativeVideoPlayer';
import type { PlayUrlResponse, DanmakuItem } from '../services/types'; import type { PlayUrlResponse, DanmakuItem } from '../services/types';
import { useTheme } from '../utils/theme';
interface Props { interface Props {
playData: PlayUrlResponse | null; playData: PlayUrlResponse | null;
@@ -16,15 +18,22 @@ interface Props {
danmakus?: DanmakuItem[]; danmakus?: DanmakuItem[];
onTimeUpdate?: (t: number) => void; onTimeUpdate?: (t: number) => void;
initialTime?: number; 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 [fullscreen, setFullscreen] = useState(false);
const { width, height } = useWindowDimensions(); const { width, height } = useWindowDimensions();
const VIDEO_HEIGHT = width * 0.5625; const VIDEO_HEIGHT = width * 0.5625;
const needsRotation = !ScreenOrientation && fullscreen; const needsRotation = !ScreenOrientation && fullscreen;
const lastTimeRef = useRef(0); const lastTimeRef = useRef(0);
const seededRef = useRef(false); const seededRef = useRef(false);
const theme = useTheme();
// 续播:第一次拿到 initialTime 时塞进 lastTimeRef // 续播:第一次拿到 initialTime 时塞进 lastTimeRef
if (!seededRef.current && typeof initialTime === 'number' && initialTime > 0) { if (!seededRef.current && typeof initialTime === 'number' && initialTime > 0) {
lastTimeRef.current = initialTime; lastTimeRef.current = initialTime;
@@ -52,9 +61,13 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
}, []); }, []);
if (!playData) { if (!playData) {
// 没拿到 playData 时背景用主题色(与页面同色,消除"页面→纯黑"撞色),有封面则铺封面
return ( return (
<View style={[{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }, styles.placeholder]}> <View style={[{ width, height: VIDEO_HEIGHT, backgroundColor: theme.card }, styles.placeholder]}>
<Text style={styles.placeholderText}>...</Text> {!!coverUrl && (
<Image source={{ uri: coverUrl }} style={StyleSheet.absoluteFill} contentFit="cover" />
)}
<Text style={[styles.placeholderText, { color: coverUrl ? '#fff' : theme.textSub }]}>...</Text>
</View> </View>
); );
} }
@@ -65,6 +78,7 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
<View style={{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }}> <View style={{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }}>
<video <video
src={url} src={url}
poster={coverUrl}
style={{ width: '100%', height: '100%', backgroundColor: '#000' } as any} style={{ width: '100%', height: '100%', backgroundColor: '#000' } as any}
controls controls
playsInline playsInline
@@ -89,6 +103,9 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
isFullscreen={false} isFullscreen={false}
initialTime={lastTimeRef.current} initialTime={lastTimeRef.current}
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }} 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} isFullscreen={true}
initialTime={lastTimeRef.current} initialTime={lastTimeRef.current}
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }} onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
coverUrl={coverUrl}
style={needsRotation ? { width: height, height: width } : { flex: 1 }} style={needsRotation ? { width: height, height: width } : { flex: 1 }}
/> />
</View> </View>
@@ -124,5 +142,5 @@ export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, b
const styles = StyleSheet.create({ const styles = StyleSheet.create({
placeholder: { justifyContent: 'center', alignItems: 'center' }, placeholder: { justifyContent: 'center', alignItems: 'center' },
placeholderText: { color: '#fff', fontSize: 14 }, placeholderText: { fontSize: 14 },
}); });

68
hooks/useFollow.ts Normal file
View 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_jcttoggle 时 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 };
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import { getVideoRelated } from '../services/bilibili'; import { getVideoRelated } from '../services/bilibili';
import type { VideoItem } from '../services/types'; import type { VideoItem } from '../services/types';
@@ -7,6 +7,11 @@ export function useRelatedVideos(bvid: string) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const loadingRef = useRef(false); const loadingRef = useRef(false);
// 切到不同 bvid 时立刻清空,避免新页面短暂显示上一支视频的推荐流
useEffect(() => {
setVideos([]);
}, [bvid]);
const load = useCallback(async () => { const load = useCallback(async () => {
if (loadingRef.current) return; if (loadingRef.current) return;
loadingRef.current = true; loadingRef.current = true;

View File

@@ -37,6 +37,12 @@ export function useVideoDetail(bvid: string) {
} }
useEffect(() => { useEffect(() => {
// bvid 切换时立刻清空旧数据,防止上一支视频的播放器/简介/清晰度短暂"残影"造成抖动
setVideo(null);
setPlayData(null);
setQualities([]);
setCurrentQn(0);
cidRef.current = 0;
async function fetchData() { async function fetchData() {
try { try {
setLoading(true); setLoading(true);

View File

@@ -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 type { VideoItem, Comment, PlayUrlResponse, QRCodeInfo, VideoShotData, DanmakuItem, LiveRoom, LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo, SearchSuggestItem, HotSearchItem } from './types';
import { signWbi } from '../utils/wbi'; import { signWbi } from '../utils/wbi';
import { parseDanmakuXml } from '../utils/danmaku'; import { parseDanmakuXml } from '../utils/danmaku';
import { getSecure } from '../utils/secureStorage'; import { getSecure, setSecure } from '../utils/secureStorage';
const isWeb = Platform.OS === 'web'; 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'; 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) => { api.interceptors.request.use(async (config) => {
const [sessdata, buvid3] = await Promise.all([ const [sessdata, biliJct, buvid3] = await Promise.all([
getSecure('SESSDATA'), getSecure('SESSDATA'),
getSecure('bili_jct'),
getBuvid3(), getBuvid3(),
]); ]);
if (isWeb) { if (isWeb) {
// Browsers block Cookie/Referer/Origin headers; relay via custom headers to proxy // Browsers block Cookie/Referer/Origin headers; relay via custom headers to proxy
if (buvid3) config.headers['X-Buvid3'] = buvid3; if (buvid3) config.headers['X-Buvid3'] = buvid3;
if (sessdata) config.headers['X-Sessdata'] = sessdata; if (sessdata) config.headers['X-Sessdata'] = sessdata;
if (biliJct) config.headers['X-Bili-Jct'] = biliJct;
} else { } else {
const cookies: string[] = [`buvid3=${buvid3}`]; const cookies: string[] = [`buvid3=${buvid3}`];
if (sessdata) cookies.push(`SESSDATA=${sessdata}`); if (sessdata) cookies.push(`SESSDATA=${sessdata}`);
if (biliJct) cookies.push(`bili_jct=${biliJct}`);
config.headers['Cookie'] = cookies.join('; '); config.headers['Cookie'] = cookies.join('; ');
} }
return config; 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 ────────────────────────────────────────────────── // ─── Request deduplication ──────────────────────────────────────────────────
// Prevents identical concurrent requests (same URL + params) from hitting the network twice. // Prevents identical concurrent requests (same URL + params) from hitting the network twice.
const inflightRequests = new Map<string, Promise<any>>(); const inflightRequests = new Map<string, Promise<any>>();
@@ -296,7 +315,7 @@ export async function generateQRCode(): Promise<QRCodeInfo> {
return res.data.data as 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 const headers = isWeb
? {} ? {}
: { 'Referer': 'https://www.bilibili.com' }; : { '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; const { code } = res.data.data;
let cookie: string | undefined; let cookie: string | undefined;
let csrf: string | undefined;
if (code === 0) { if (code === 0) {
if (isWeb) { if (isWeb) {
// Proxy relays SESSDATA via custom response header // Proxy relays SESSDATA via custom response header
cookie = res.headers['x-sessdata'] as string | undefined; cookie = res.headers['x-sessdata'] as string | undefined;
csrf = res.headers['x-bili-jct'] as string | undefined;
} else { } else {
const setCookie = res.headers['set-cookie']; const setCookie = res.headers['set-cookie'];
const match = setCookie?.find((c: string) => c.includes('SESSDATA=')); if (Array.isArray(setCookie)) {
if (match) { for (const c of setCookie) {
const sessPart = match.split(';').find((p: string) => p.trim().startsWith('SESSDATA=')); const sessPart = c.split(';').find((p: string) => p.trim().startsWith('SESSDATA='));
if (sessPart) { if (sessPart) cookie = sessPart.trim().replace('SESSDATA=', '');
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}`);
}
} }

View File

@@ -5,11 +5,13 @@ import { getSecure, setSecure, deleteSecure } from '../utils/secureStorage';
interface AuthState { interface AuthState {
sessdata: string | null; sessdata: string | null;
/** B 站 CSRF tokenbili_jct cookie 值)。写操作(关注/点赞/投币 etc必填 */
biliJct: string | null;
uid: string | null; uid: string | null;
username: string | null; username: string | null;
face: string | null; face: string | null;
isLoggedIn: boolean; 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>; logout: () => Promise<void>;
restore: () => Promise<void>; restore: () => Promise<void>;
setProfile: (face: string, username: string, uid: string) => void; setProfile: (face: string, username: string, uid: string) => void;
@@ -17,16 +19,24 @@ interface AuthState {
export const useAuthStore = create<AuthState>((set) => ({ export const useAuthStore = create<AuthState>((set) => ({
sessdata: null, sessdata: null,
biliJct: null,
uid: null, uid: null,
username: null, username: null,
face: null, face: null,
isLoggedIn: false, isLoggedIn: false,
login: async (sessdata, uid, username) => { login: async (sessdata, uid, username, biliJct) => {
await setSecure('SESSDATA', sessdata); await setSecure('SESSDATA', sessdata);
if (biliJct) await setSecure('bili_jct', biliJct);
// Migrate: remove SESSDATA from AsyncStorage if it was there before // Migrate: remove SESSDATA from AsyncStorage if it was there before
await AsyncStorage.removeItem('SESSDATA').catch(() => { }); 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) => { getUserInfo().then(async (info) => {
await AsyncStorage.multiSet([ await AsyncStorage.multiSet([
['UID', String(info.mid)], ['UID', String(info.mid)],
@@ -39,8 +49,9 @@ export const useAuthStore = create<AuthState>((set) => ({
logout: async () => { logout: async () => {
await deleteSecure('SESSDATA'); await deleteSecure('SESSDATA');
await deleteSecure('bili_jct');
await AsyncStorage.multiRemove(['UID', 'USERNAME', 'FACE']); 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 () => { restore: async () => {
@@ -54,8 +65,9 @@ export const useAuthStore = create<AuthState>((set) => ({
await AsyncStorage.removeItem('SESSDATA'); await AsyncStorage.removeItem('SESSDATA');
} }
} }
const biliJct = await getSecure('bili_jct');
if (sessdata) { if (sessdata) {
set({ sessdata, isLoggedIn: true }); set({ sessdata, biliJct, isLoggedIn: true });
try { try {
const info = await getUserInfo(); const info = await getUserInfo();
await AsyncStorage.setItem('FACE', info.face); await AsyncStorage.setItem('FACE', info.face);

9
utils/toast.ts Normal file
View 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);
}
}

View 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 };
}