本项目已收到哔哩哔哩(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

@@ -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 {
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,148 +38,143 @@ 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 }]} />
{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 style={styles.right}>
{!task && (
<TouchableOpacity
style={styles.downloadBtn}
onPress={() =>
startDownload(bvid, cid, q.qn, q.desc, title, cover)
}
>
<Text style={styles.downloadBtnTxt}></Text>
</TouchableOpacity>
)}
{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>
)}
<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, { borderBottomColor: theme.modalBorder }]}
>
<Text style={[styles.qualityLabel, { color: theme.modalText }]}>
{q.desc}
</Text>
<View style={styles.right}>
{!task && (
<TouchableOpacity
style={styles.retryBtn}
style={styles.downloadBtn}
onPress={() =>
startDownload(bvid, cid, q.qn, q.desc, title, cover)
}
>
<Ionicons name="refresh" size={14} color="#f44" />
<Text style={styles.retryTxt}></Text>
<Text style={styles.downloadBtnTxt}></Text>
</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 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 },
});

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) {
clearInterval(pollRef.current!);
try {
await login(result.cookie, "", "");
await login(result.cookie, "", "", result.csrf);
} catch {
if (!cancelled) setStatus("error");
return;

View File

@@ -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}

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 { 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: {

View File

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