diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx index 9e9cbe3..edd602b 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -10,25 +10,24 @@ import { InteractionManager, } from "react-native"; import { Image } from "expo-image"; -import { SafeAreaView } from "react-native-safe-area-context"; +import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"; import { useLocalSearchParams, useRouter } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { VideoPlayer } from "../../components/VideoPlayer"; -import { CommentItem } from "../../components/CommentItem"; import { getDanmaku, getUploaderStat } from "../../services/bilibili"; -import { DanmakuItem } from "../../services/types"; -import DanmakuList from "../../components/DanmakuList"; +import type { DanmakuItem, VideoItem } from "../../services/types"; import { useVideoDetail } from "../../hooks/useVideoDetail"; -import { useComments } from "../../hooks/useComments"; import { useRelatedVideos } from "../../hooks/useRelatedVideos"; -import { formatCount, formatDuration } from "../../utils/format"; +import { formatCount, formatDuration, formatTime } from "../../utils/format"; import { proxyImageUrl } from "../../utils/imageUrl"; import { DownloadSheet } from "../../components/DownloadSheet"; import { VideoDetailSkeleton } from "../../components/VideoDetailSkeleton"; import { useTheme } from "../../utils/theme"; import { useLiveStore } from "../../store/liveStore"; - -type Tab = "intro" | "comments" | "danmaku"; +import { VideoActionRow } from "../../components/VideoActionRow"; +import { DescriptionSheet } from "../../components/DescriptionSheet"; +import { EngagementSheet, type EngagementTab } from "../../components/EngagementSheet"; +import { useFollow } from "../../hooks/useFollow"; export default function VideoDetailScreen() { const { bvid } = useLocalSearchParams<{ bvid: string }>(); @@ -39,13 +38,15 @@ export default function VideoDetailScreen() { useLiveStore.getState().clearLive(); }, []); - // 骨架屏最短展示时长:即便数据秒回,也至少撑够这么久,避免一闪而过 + // 骨架屏最短展示时长:即便数据秒回,也至少撑够这么久,避免一闪而过。 + // 重要:依赖 [bvid] —— 切换合集/推荐时同步复位,让骨架屏盖住老数据残影。 const SKELETON_MIN_MS = 600; const [minSkeletonElapsed, setMinSkeletonElapsed] = useState(false); useEffect(() => { + setMinSkeletonElapsed(false); const t = setTimeout(() => setMinSkeletonElapsed(true), SKELETON_MIN_MS); return () => clearTimeout(t); - }, []); + }, [bvid]); const { video, @@ -56,23 +57,37 @@ export default function VideoDetailScreen() { changeQuality, initialTime, } = useVideoDetail(bvid as string); - const [commentSort, setCommentSort] = useState<0 | 2>(2); - const { - comments, - loading: cmtLoading, - hasMore: cmtHasMore, - load: loadComments, - } = useComments(video?.aid ?? 0, commentSort); - const [tab, setTab] = useState("intro"); + const [danmakus, setDanmakus] = useState([]); const [currentTime, setCurrentTime] = useState(0); const [showDownload, setShowDownload] = useState(false); - const [descExpanded, setDescExpanded] = useState(false); - const [descOverflows, setDescOverflows] = useState(false); + const [showDescSheet, setShowDescSheet] = useState(false); + // 评论/弹幕合并 Sheet:null=关闭,否则记录当前应该打开哪个 Tab + const [engagementTab, setEngagementTab] = useState(null); const [uploaderStat, setUploaderStat] = useState<{ follower: number; archiveCount: number; } | null>(null); + + // 切换视频时把和老视频绑定的瞬态状态全部清掉,避免新页面短暂显示老弹幕/老 UP 主统计/老开着的 Sheet + useEffect(() => { + setDanmakus([]); + setUploaderStat(null); + setCurrentTime(0); + setShowDescSheet(false); + setShowDownload(false); + setEngagementTab(null); + }, [bvid]); + + const { following, loading: followLoading, toggle: toggleFollow } = useFollow( + video?.owner.mid, + ); + + // Sheet 顶部对齐播放器底部:safe-area 顶 inset + 播放器高度(无 TopBar,返回按钮悬浮在播放器上) + const insets = useSafeAreaInsets(); + const { width: SCREEN_W } = useWindowDimensions(); + const sheetTopOffset = insets.top + SCREEN_W * 0.5625; + const { videos: relatedVideos, loading: relatedLoading, @@ -80,20 +95,13 @@ export default function VideoDetailScreen() { } = useRelatedVideos(bvid as string); // 推荐视频不参与首屏,等导航动画结束再拉,避免与详情/播放流抢 JS 线程 + // 依赖 [bvid]:合集/推荐切到新视频时同步重新拉新推荐列表 useEffect(() => { const handle = InteractionManager.runAfterInteractions(() => { loadRelated(); }); return () => handle.cancel(); - }, []); - - useEffect(() => { - if (!video?.aid) return; - const handle = InteractionManager.runAfterInteractions(() => { - loadComments(); - }); - return () => handle.cancel(); - }, [video?.aid, commentSort]); + }, [bvid, loadRelated]); useEffect(() => { if (!video?.cid) return; @@ -111,21 +119,10 @@ export default function VideoDetailScreen() { return () => handle.cancel(); }, [video?.owner?.mid]); + const shareUrl = bvid ? `https://www.bilibili.com/video/${bvid}` : undefined; + return ( - {/* TopBar */} - - router.back()} style={styles.backBtn}> - - - - {video?.title ?? "视频详情"} - - setShowDownload(true)}> - - - - router.back()} + coverUrl={video?.pic ? proxyImageUrl(video.pic) : undefined} /> + + {videoLoading || !video || !minSkeletonElapsed ? ( + + ) : ( + + // bvid 是 key —— 切换视频时 FlatList 整体重挂,滚动位置回到顶,避免老内容残影 + key={bvid} + style={styles.scroll} + data={relatedVideos} + keyExtractor={(item) => item.bvid} + showsVerticalScrollIndicator={false} + ListHeaderComponent={ + <> + {/* 标题 + Meta 行(点击展开简介 Sheet) */} + + + {video.title} + + setShowDescSheet(true)} + > + + {formatCount(video.stat?.view ?? 0)} 次观看 + {video.pubdate ? ` · ${formatTime(video.pubdate)}` : ""} + {video.stat?.like ? ` · ${formatCount(video.stat.like)} 点赞` : ""} + {video.tname ? ` · ${video.tname}` : ""} + + 更多 + + + + + {/* 动作按钮行 */} + + setShowDownload(true)} + onComments={() => setEngagementTab("comments")} + onDanmaku={() => setEngagementTab("danmaku")} + /> + + + {/* UP 主行 + 关注按钮 */} + + router.push(`/creator/${video.owner.mid}` as any)} + > + + + + {video.owner.name} + + {uploaderStat && ( + + {formatCount(uploaderStat.follower)}粉丝 ·{" "} + {formatCount(uploaderStat.archiveCount)}视频 + + )} + + + + + {following ? "已关注" : "关注"} + + + + + {/* 合集 */} + {video.ugc_season && ( + router.replace(`/video/${epBvid}`)} + /> + )} + + {/* 推荐视频小标题 */} + + + 推荐视频 + + + + } + renderItem={({ item }) => ( + router.replace(`/video/${item.bvid}` as any)} + activeOpacity={0.85} + > + + + + + {formatDuration(item.duration)} + + + + + + {item.title} + + + + {item.owner?.name ?? ""} + + + {formatCount(item.stat?.view ?? 0)} 播放 + + + + + )} + ListEmptyComponent={ + relatedLoading ? ( + + ) : null + } + ListFooterComponent={ + relatedLoading ? ( + + ) : null + } + /> + )} + setShowDownload(false)} + topOffset={sheetTopOffset} bvid={bvid as string} cid={video?.cid ?? 0} title={video?.title ?? ""} cover={video?.pic ?? ""} qualities={qualities} /> - - {/* TabBar */} - {video && ( - - {(["intro", "comments", "danmaku"] as Tab[]).map((t) => { - const label = - t === "intro" ? "简介" - : t === "comments" ? `评论${video.stat?.reply ? ` ${formatCount(video.stat.reply)}` : ""}` - : `弹幕${danmakus.length ? ` ${formatCount(danmakus.length)}` : ""}`; - return ( - setTab(t)}> - - {label} - - {tab === t && } - - ); - })} - - )} - - {/* Tab content */} - {videoLoading || !video || !minSkeletonElapsed ? ( - - ) : ( - <> - {tab === "intro" && ( - - style={styles.tabScroll} - data={relatedVideos} - keyExtractor={(item) => item.bvid} - showsVerticalScrollIndicator={false} - ListHeaderComponent={ - <> - router.push(`/creator/${video.owner.mid}` as any)} - > - - - {video.owner.name} - {uploaderStat && ( - - {formatCount(uploaderStat.follower)}粉丝 · {formatCount(uploaderStat.archiveCount)}视频 - - )} - - - - {formatCount(video.stat?.view ?? 0)} - · - - {formatCount(video.stat?.like ?? 0)} - - - - - {video.title} - {!!video.tname && ( - - {video.tname} - - )} - {!!video.desc && ( - setDescOverflows(e.nativeEvent.lines.length > 2)} - > - {video.desc} - - )} - - {video.desc || "暂无简介"} - - {descOverflows && ( - setDescExpanded(e => !e)} - style={styles.expandBtn} - > - - {descExpanded ? "收起" : "展开"} - - )} - - - {video.ugc_season && ( - router.replace(`/video/${epBvid}`)} - /> - )} - - 推荐视频 - - - } - renderItem={({ item }) => ( - router.replace(`/video/${item.bvid}` as any)} - activeOpacity={0.85} - > - - - - {formatDuration(item.duration)} - - - - - {item.title} - - - {item.owner?.name ?? ""} - {formatCount(item.stat?.view ?? 0)} 播放 - - - - )} - ListEmptyComponent={ - relatedLoading ? : null - } - ListFooterComponent={ - relatedLoading ? : null - } - /> - )} - - {tab === "comments" && ( - String(c.rpid)} - renderItem={({ item }) => } - onEndReached={() => { if (cmtHasMore && !cmtLoading) loadComments(); }} - onEndReachedThreshold={0.3} - showsVerticalScrollIndicator={false} - ListHeaderComponent={ - - {([2, 0] as const).map((sort) => ( - setCommentSort(sort)} - > - - {sort === 2 ? "热门" : "最新"} - - - ))} - - } - ListFooterComponent={ - cmtLoading ? ( - - ) : !cmtHasMore && comments.length > 0 ? ( - 已加载全部评论 - ) : null - } - ListEmptyComponent={!cmtLoading ? 暂无评论 : null} - /> - )} - - {/* 弹幕面板:始终挂载,切 tab 时用 display:none 隐藏而不卸载 */} - {}} - hideHeader={true} - style={[styles.danmakuTab, tab !== "danmaku" && { display: "none" }]} - /> - - )} + setShowDescSheet(false)} + topOffset={sheetTopOffset} + video={video ?? null} + /> + setEngagementTab(null)} + topOffset={sheetTopOffset} + initialTab={engagementTab ?? "comments"} + aid={video?.aid ?? 0} + replyCount={video?.stat?.reply} + danmakus={danmakus} + currentTime={currentTime} + /> ); } - function SeasonSection({ season, currentBvid, onEpisodePress, }: { - season: NonNullable; + season: NonNullable; currentBvid: string; onEpisodePress: (bvid: string) => void; }) { @@ -368,14 +376,11 @@ function SeasonSection({ return Math.max(0, itemCenter - screenW / 2); }, [currentIndex, screenW]); - // 内容布局完成时(getItemLayout 同步即知尺寸,content size 一就绪就触发) - // 同步 scrollToOffset 后立刻显示,避免任何中间帧 const handleContentSizeChange = useCallback(() => { if (ready) return; if (currentIndex > 0 && initialOffset > 0) { listRef.current?.scrollToOffset({ offset: initialOffset, animated: false }); } - // 等到下一帧再显示,确保滚动已落地 requestAnimationFrame(() => setReady(true)); }, [ready, currentIndex, initialOffset]); @@ -430,46 +435,49 @@ function SeasonSection({ const styles = StyleSheet.create({ safe: { flex: 1 }, - topBar: { + loader: { marginVertical: 30 }, + scroll: { flex: 1 }, + + // Title + Meta(YT 风格) + titleBlock: { paddingHorizontal: 14, paddingTop: 12, paddingBottom: 8 }, + title: { fontSize: 16, fontWeight: "700", lineHeight: 22 }, + metaRow: { flexDirection: "row", alignItems: "center", - paddingHorizontal: 8, - paddingVertical: 8, + marginTop: 6, + }, + metaText: { flex: 1, fontSize: 12, lineHeight: 18 }, + metaMore: { fontSize: 12, lineHeight: 18, marginLeft: 6, marginRight: 2 }, + + // Action row 外壳 + actionWrap: { borderTopWidth: StyleSheet.hairlineWidth }, + + // UP 主行 + creatorRow: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 14, + paddingVertical: 12, + borderTopWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth, }, - backBtn: { padding: 4 }, - topTitle: { flex: 1, fontSize: 15, fontWeight: "600", marginLeft: 4 }, - miniBtn: { padding: 4 }, - loader: { marginVertical: 30 }, - titleSection: { padding: 14, borderBottomWidth: StyleSheet.hairlineWidth }, - title: { fontSize: 13, fontWeight: "600", lineHeight: 22, marginBottom: 6 }, - tnameBadge: { - alignSelf: "flex-start", - backgroundColor: "rgba(0,174,236,0.12)", - borderRadius: 4, - paddingHorizontal: 6, - paddingVertical: 2, - marginBottom: 8, + creatorLeft: { flex: 1, flexDirection: "row", alignItems: "center" }, + avatar: { width: 38, height: 38, borderRadius: 19, marginRight: 10 }, + creatorInfo: { flex: 1 }, + creatorName: { fontSize: 14, fontWeight: "600", lineHeight: 18 }, + creatorStat: { fontSize: 11, marginTop: 2, lineHeight: 16 }, + subBtn: { + backgroundColor: "#00AEEC", + paddingHorizontal: 14, + paddingVertical: 6, + borderRadius: 16, + marginLeft: 12, }, - tnameText: { fontSize: 11, color: "#00AEEC" }, - subTitle: { fontSize: 12, lineHeight: 18, marginBottom: 4 }, - descMeasure: { position: "absolute", opacity: 0, left: 0, right: 0 }, - expandBtn: { flexDirection: "row", alignItems: "center", gap: 3, marginBottom: 8 }, - expandBtnTxt: { fontSize: 12, color: "#00AEEC" }, - upRow: { - flexDirection: "row", - alignItems: "flex-start", - paddingHorizontal: 12, - paddingTop: 12, - paddingBottom: 10, - }, - avatar: { width: 38, height: 38, borderRadius: 19, marginRight: 10, marginTop: 1 }, - upInfo: { flex: 1 }, - upName: { fontSize: 13, fontWeight: "600", lineHeight: 18 }, - upStat: { fontSize: 11, color: "#999", marginTop: 2, lineHeight: 16 }, - upStats: { flexDirection: "row", alignItems: "center", gap: 3, marginTop: 3 }, - upStatTxt: { fontSize: 11, color: "#999" }, - upStatDot: { fontSize: 11, color: "#ccc" }, + // 已关注:浅灰底 + 灰字(textSub 由 useTheme 提供) + subBtnFollowing: { backgroundColor: "rgba(127,127,127,0.18)" }, + subBtnTxt: { color: "#fff", fontSize: 13, fontWeight: "600" }, + + // 合集 seasonBox: { borderTopWidth: StyleSheet.hairlineWidth, paddingVertical: 10 }, seasonHeader: { flexDirection: "row", @@ -486,24 +494,9 @@ const styles = StyleSheet.create({ epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 }, epNumActive: { color: "#00AEEC", fontWeight: "600" }, epTitle: { fontSize: 12, paddingHorizontal: 6, paddingBottom: 6, lineHeight: 16 }, - tabBar: { flexDirection: "row", borderBottomWidth: StyleSheet.hairlineWidth, paddingLeft: 3 }, - tabItem: { alignItems: "center", paddingVertical: 12, paddingHorizontal: 12, position: "relative" }, - tabLabel: { fontSize: 13 }, - tabActive: { color: "#00AEEC" }, - tabUnderline: { - position: "absolute", - bottom: 0, - width: 24, - height: 2, - backgroundColor: "#00AEEC", - borderRadius: 1, - }, - tabScroll: { flex: 1 }, - descBox: { padding: 16 }, - descText: { fontSize: 14, lineHeight: 22 }, - danmakuTab: { flex: 1 }, - emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 }, - relatedHeader: { paddingLeft: 13, paddingBottom: 8, paddingTop: 8 }, + + // 推荐列表 + relatedHeader: { paddingLeft: 13, paddingBottom: 8, paddingTop: 12 }, relatedHeaderText: { fontSize: 13, fontWeight: "600" as const }, relatedCard: { flexDirection: "row", @@ -533,19 +526,6 @@ const styles = StyleSheet.create({ relatedDurationText: { color: "#fff", fontSize: 10 }, relatedInfo: { flex: 1, justifyContent: "space-between", paddingVertical: 2 }, relatedTitle: { fontSize: 13, lineHeight: 18 }, - relatedOwner: { fontSize: 12, color: "#999" }, - relatedView: { fontSize: 11, color: "#bbb" }, - sortRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "flex-end", - paddingHorizontal: 14, - paddingVertical: 10, - gap: 8, - borderBottomWidth: StyleSheet.hairlineWidth, - }, - sortBtn: { paddingHorizontal: 10, paddingVertical: 2, borderRadius: 16, backgroundColor: "#f0f0f0" }, - sortBtnActive: { backgroundColor: "#00AEEC" }, - sortBtnTxt: { fontSize: 13, color: "#333", fontWeight: "500" }, - sortBtnTxtActive: { color: "#fff", fontWeight: "600" as const }, + relatedOwner: { fontSize: 12 }, + relatedView: { fontSize: 11 }, }); diff --git a/components/DescriptionSheet.tsx b/components/DescriptionSheet.tsx new file mode 100644 index 0000000..51d4c65 --- /dev/null +++ b/components/DescriptionSheet.tsx @@ -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 ( + + + + 简介 + + + + + + {video.title} + + {!!video.tname && ( + + {video.tname} + + )} + + {!!video.pubdate && ( + + 发布于 {formatTime(video.pubdate)} + + )} + + {!!stat && ( + + + + + + + + + )} + + + + + {video.desc?.trim() || "暂无简介"} + + + + + ); +} + +function StatCell({ + label, + value, + theme, +}: { + label: string; + value: number; + theme: ReturnType; +}) { + return ( + + + {formatCount(value ?? 0)} + + {label} + + ); +} + +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 }, +}); diff --git a/components/DownloadSheet.tsx b/components/DownloadSheet.tsx index 558c589..4dbe7e8 100644 --- a/components/DownloadSheet.tsx +++ b/components/DownloadSheet.tsx @@ -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 ( - - + 下载视频 - - + + - - {qualities.map((q) => { - const key = taskKey(bvid, q.qn); - const task = tasks[key]; - return ( - - {q.desc} - - {!task && ( - - startDownload(bvid, cid, q.qn, q.desc, title, cover) - } - > - 下载 - - )} - {task?.status === "downloading" && ( - - - - - - {Math.round(task.progress * 100)}% - - - )} - {task?.status === "done" && ( - - - 已下载 - - )} - {task?.status === "error" && ( - - {!!task.error && ( - - {task.error} - - )} + + {qualities.map((q) => { + const key = taskKey(bvid, q.qn); + const task = tasks[key]; + return ( + + + {q.desc} + + + {!task && ( startDownload(bvid, cid, q.qn, q.desc, title, cover) } > - - 重试 + 下载 - - )} + )} + {task?.status === "downloading" && ( + + + + + + {Math.round(task.progress * 100)}% + + + )} + {task?.status === "done" && ( + + + 已下载 + + )} + {task?.status === "error" && ( + + {!!task.error && ( + + {task.error} + + )} + + startDownload(bvid, cid, q.qn, q.desc, title, cover) + } + > + + 重试 + + + )} + - - ); - })} - + ); + })} + ); } 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 }, }); diff --git a/components/EngagementSheet.tsx b/components/EngagementSheet.tsx new file mode 100644 index 0000000..f559811 --- /dev/null +++ b/components/EngagementSheet.tsx @@ -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(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 ( + + + {/* Tab 栏 + 关闭按钮 */} + + {(["comments", "danmaku"] as EngagementTab[]).map((t) => { + const label = + t === "comments" + ? `评论${replyCount ? ` ${formatCount(replyCount)}` : ""}` + : `弹幕${danmakus.length ? ` ${formatCount(danmakus.length)}` : ""}`; + const active = tab === t; + return ( + setTab(t)} + activeOpacity={0.7} + > + + {label} + + {active && } + + ); + })} + + + + + + + {/* 评论 Tab */} + {tab === "comments" && ( + <> + + {([2, 0] as const).map((sort) => ( + setCommentSort(sort)} + > + + {sort === 2 ? "热门" : "最新"} + + + ))} + + String(c.rpid)} + renderItem={({ item }) => } + onEndReached={() => { + if (hasMore && !loading) load(); + }} + onEndReachedThreshold={0.3} + showsVerticalScrollIndicator={false} + ListFooterComponent={ + loading ? ( + + ) : !hasMore && comments.length > 0 ? ( + + 已加载全部评论 + + ) : null + } + ListEmptyComponent={ + !loading ? ( + + 暂无评论 + + ) : null + } + /> + + )} + + {/* 弹幕 Tab —— hideHeader=true 让弹幕列表填满,header 由本 Sheet 顶部 Tab 提供 */} + {tab === "danmaku" && ( + {}} + hideHeader + style={styles.danmakuList} + /> + )} + + + ); +} + +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 }, +}); diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index e77b1a7..4522178 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -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; diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index 7ed39b8..4cfd20a 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -82,6 +82,12 @@ interface Props { onTimeUpdate?: (t: number) => void; initialTime?: number; forcePaused?: boolean; + /** 点击播放器右上角"弹幕列表"图标,由外层打开 Sheet。仅在小窗口生效。 */ + onDanmakuListPress?: () => void; + /** 点击播放器左上角返回箭头,跟随播放器控制栏一起显隐。仅在小窗口生效。 */ + onBack?: () => void; + /** 视频封面 URL:作为 poster 覆盖在