From 47d2e8479e94bc65607dcfe7669276f4121736a5 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 16 Mar 2026 14:24:32 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=84=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/video/[bvid].tsx | 310 +++++++++++++++++++++++-------- components/DanmakuList.tsx | 7 +- components/NativeVideoPlayer.tsx | 18 +- ddd.js | 0 hooks/useComments.ts | 37 +++- services/bilibili.ts | 24 ++- services/types.ts | 15 ++ utils/videoRows.ts | 2 +- 8 files changed, 316 insertions(+), 97 deletions(-) create mode 100644 ddd.js diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx index 0a26de7..bd0d4f5 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { View, Text, ScrollView, + FlatList, StyleSheet, TouchableOpacity, Image, @@ -22,7 +23,7 @@ import { useVideoStore } from "../../store/videoStore"; import { formatCount } from "../../utils/format"; import { proxyImageUrl } from "../../utils/imageUrl"; -type Tab = "intro" | "comments"; +type Tab = "intro" | "comments" | "danmaku"; export default function VideoDetailScreen() { const { bvid } = useLocalSearchParams<{ bvid: string }>(); @@ -35,15 +36,17 @@ export default function VideoDetailScreen() { currentQn, changeQuality, } = useVideoDetail(bvid as string); + const [commentSort, setCommentSort] = useState<0 | 2>(2); + const flatListHeightRef = useRef(0); const { comments, loading: cmtLoading, + hasMore: cmtHasMore, load: loadComments, - } = useComments(video?.aid ?? 0); - const [tab, setTab] = useState("comments"); + } = useComments(video?.aid ?? 0, commentSort); + const [tab, setTab] = useState("intro"); const [danmakus, setDanmakus] = useState([]); const [currentTime, setCurrentTime] = useState(0); - const [showDanmakuList, setShowDanmakuList] = useState(true); const { setVideo, clearVideo } = useVideoStore(); useEffect(() => { @@ -52,7 +55,7 @@ export default function VideoDetailScreen() { useEffect(() => { if (video?.aid) loadComments(); - }, [video?.aid]); + }, [video?.aid, commentSort]); useEffect(() => { if (!video?.cid) return; @@ -68,6 +71,7 @@ export default function VideoDetailScreen() { return ( + {/* TopBar */} router.back()} style={styles.backBtn}> @@ -80,6 +84,7 @@ export default function VideoDetailScreen() { + {/* Video player — fixed 16:9 */} - setShowDanmakuList((v) => !v)} - /> - - {videoLoading ? ( - - ) : video ? ( - <> + {/* TabBar — sits directly below player, always visible once video loads */} + {video && ( + + setTab("intro")}> + + 简介 + + {tab === "intro" && } + + setTab("comments")}> + + 评论{video.stat?.reply > 0 ? ` ${formatCount(video.stat.reply)}` : ""} + + {tab === "comments" && } + + setTab("danmaku")}> + + 弹幕{danmakus.length > 0 ? ` ${formatCount(danmakus.length)}` : ""} + + {tab === "danmaku" && } + + + )} + + {/* Tab content */} + {videoLoading ? ( + + ) : video ? ( + tab === "intro" ? ( + // 简介:视频信息 + 合集 + 简介文本 + {video.title} @@ -112,7 +137,6 @@ export default function VideoDetailScreen() { - + 关注 - - - setTab("intro")} - > - - 简介 - - {tab === "intro" && } - - setTab("comments")} - > - - 评论{" "} - {video.stat.reply > 0 ? formatCount(video.stat.reply) : ""} - - {tab === "comments" && } - - - - {tab === "intro" ? ( - - {video.desc || "暂无简介"} - - ) : ( - <> - {comments.map((c) => ( - - ))} - {cmtLoading && ( - - )} - {!cmtLoading && comments.length > 0 && ( - - 加载更多评论 - - )} - {!cmtLoading && comments.length === 0 && !videoLoading && ( - 暂无评论 - )} - + {video.ugc_season && ( + router.replace(`/video/${epBvid}`)} + /> )} - - ) : null} - + + {video.desc || "暂无简介"} + + + ) : tab === "danmaku" ? ( + {}} + style={styles.danmakuTab} + /> + ) : ( + String(c.rpid)} + renderItem={({ item }) => } + onEndReached={() => { if (cmtHasMore && !cmtLoading) loadComments(); }} + onEndReachedThreshold={0.3} + onLayout={({ nativeEvent }) => { flatListHeightRef.current = nativeEvent.layout.height; }} + onContentSizeChange={(_, contentHeight) => { + if (contentHeight < flatListHeightRef.current && cmtHasMore && !cmtLoading) { + loadComments(); + } + }} + showsVerticalScrollIndicator={false} + ListHeaderComponent={ + + 排序 + setCommentSort(2)} + > + 热门 + + setCommentSort(0)} + > + 最新 + + + } + ListFooterComponent={ + cmtLoading ? ( + + ) : !cmtHasMore && comments.length > 0 ? ( + 已加载全部评论 + ) : null + } + ListEmptyComponent={ + !cmtLoading ? 暂无评论 : null + } + /> + ) + ) : null} ); } @@ -194,6 +224,78 @@ function StatBadge({ icon, count }: { icon: string; count: number }) { ); } +function SeasonSection({ + season, + currentBvid, + onEpisodePress, +}: { + season: NonNullable; + currentBvid: string; + onEpisodePress: (bvid: string) => void; +}) { + const episodes = season.sections?.[0]?.episodes ?? []; + const currentIndex = episodes.findIndex((ep) => ep.bvid === currentBvid); + const listRef = useRef(null); + + useEffect(() => { + if (currentIndex <= 0 || episodes.length === 0) return; + // 等布局完成再滚动 + listRef.current?.scrollToIndex({ + index: currentIndex, + viewPosition: 0.5, // 居中 + animated: false, + }); + }, [currentIndex, episodes.length]); + + return ( + + + 合集 · {season.title} + {season.ep_count}个视频 + + + ep.bvid} + contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }} + // 每个卡片宽 120,gap 10,让 FlatList 直接算任意索引的偏移量 + getItemLayout={(_data, index) => ({ + length: 130, + offset: 12 + index * 130, + index, + })} + onScrollToIndexFailed={() => {}} + renderItem={({ item: ep, index }) => { + const isCurrent = ep.bvid === currentBvid; + return ( + !isCurrent && onEpisodePress(ep.bvid)} + activeOpacity={0.8} + > + {ep.arc?.pic && ( + + )} + + 第{index + 1}集 + + + {ep.title} + + + ); + }} + /> + + ); +} + const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: "#fff" }, topBar: { @@ -213,7 +315,6 @@ const styles = StyleSheet.create({ color: "#212121", }, miniBtn: { padding: 4 }, - scroll: { flex: 1 }, loader: { marginVertical: 30 }, titleSection: { padding: 14 }, title: { @@ -243,6 +344,39 @@ const styles = StyleSheet.create({ borderRadius: 14, }, followTxt: { color: "#fff", fontSize: 12, fontWeight: "600" }, + seasonBox: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: "#f0f0f0", + paddingVertical: 10, + }, + seasonHeader: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 14, + paddingBottom: 8, + gap: 4, + }, + seasonTitle: { flex: 1, fontSize: 13, fontWeight: "600", color: "#212121" }, + seasonCount: { fontSize: 12, color: "#999" }, + epCard: { + width: 120, + borderRadius: 6, + overflow: "hidden", + backgroundColor: "#f8f8f8", + borderWidth: 1.5, + borderColor: "transparent", + }, + epCardActive: { borderColor: "#00AEEC" }, + epThumb: { width: 120, height: 68, backgroundColor: "#eee" }, + epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 }, + epNumActive: { color: "#00AEEC", fontWeight: "600" }, + epTitle: { + fontSize: 12, + color: "#333", + paddingHorizontal: 6, + paddingBottom: 6, + lineHeight: 16, + }, tabBar: { flexDirection: "row", borderBottomWidth: StyleSheet.hairlineWidth, @@ -264,9 +398,29 @@ const styles = StyleSheet.create({ backgroundColor: "#00AEEC", borderRadius: 1, }, + tabScroll: { flex: 1 }, descBox: { padding: 16 }, descText: { fontSize: 14, color: "#555", lineHeight: 22 }, - loadMore: { alignItems: "center", padding: 16 }, - loadMoreTxt: { color: "#00AEEC", fontSize: 13 }, + danmakuTab: { flex: 1 }, emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 }, + sortRow: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 14, + paddingVertical: 10, + gap: 8, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#f0f0f0", + }, + sortLabel: { fontSize: 13, color: "#999", marginRight: 4 }, + sortBtn: { + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 12, + borderWidth: 1, + borderColor: "#e0e0e0", + }, + sortBtnActive: { borderColor: "#00AEEC", backgroundColor: "#e8f7fd" }, + sortBtnTxt: { fontSize: 12, color: "#666" }, + sortBtnTxtActive: { color: "#00AEEC", fontWeight: "600" as const }, }); diff --git a/components/DanmakuList.tsx b/components/DanmakuList.tsx index 46ed99c..91b6c34 100644 --- a/components/DanmakuList.tsx +++ b/components/DanmakuList.tsx @@ -18,6 +18,7 @@ interface Props { currentTime: number; visible: boolean; onToggle: () => void; + style?: object; } interface DisplayedDanmaku extends DanmakuItem { @@ -37,7 +38,7 @@ function formatTimestamp(seconds: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } -export default function DanmakuList({ danmakus, currentTime, visible, onToggle }: Props) { +export default function DanmakuList({ danmakus, currentTime, visible, onToggle, style }: Props) { const flatListRef = useRef(null); const [displayedItems, setDisplayedItems] = useState([]); const [unseenCount, setUnseenCount] = useState(0); @@ -179,7 +180,7 @@ export default function DanmakuList({ danmakus, currentTime, visible, onToggle } const keyExtractor = useCallback((item: DisplayedDanmaku) => String(item._key), []); return ( - + > 1; + if (index[mid] <= seekTime) lo = mid; + else hi = mid - 1; + } + return lo; +} + interface Props { playData: PlayUrlResponse | null; @@ -129,7 +140,7 @@ export function NativeVideoPlayer({ if (cancelled) return; if (shotData?.image?.length) { setShots(shotData); - console.log(shotData.index,'缩略图长度') + console.log(shotData.index,shotData.image,'缩略图长度') } }); return () => { @@ -263,14 +274,17 @@ export function NativeVideoPlayer({ const framesPerSheet = img_x_len * img_y_len; const totalFrames = framesPerSheet * image.length; const seekTime = touchRatio * duration; + // index[i] = timestamp of frame i (seconds). Binary search returns position i (= frame number). + // Cap to index.length-1 to avoid black padding frames beyond valid content. const frameIdx = index?.length && duration > 0 - ? clamp(index[clamp(Math.floor(seekTime / duration * index.length), 0, index.length - 1)], 0, totalFrames - 1) + ? clamp(findFrameByTime(index, seekTime), 0, index.length - 1) : clamp(Math.floor(touchRatio * (totalFrames - 1)), 0, totalFrames - 1); const sheetIdx = Math.floor(frameIdx / framesPerSheet); const local = frameIdx % framesPerSheet; const col = local % img_x_len; const row = Math.floor(local / img_x_len); + console.log('[thumb]', { seekTime, duration, indexLen: index?.length, frameIdx, totalFrames, sheetIdx, col, row }); // Scale sprite frame to display size diff --git a/ddd.js b/ddd.js new file mode 100644 index 0000000..e69de29 diff --git a/hooks/useComments.ts b/hooks/useComments.ts index ec152ff..e6288f3 100644 --- a/hooks/useComments.ts +++ b/hooks/useComments.ts @@ -1,30 +1,49 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { getComments } from '../services/bilibili'; import type { Comment } from '../services/types'; -export function useComments(aid: number) { +export function useComments(aid: number, sort: number) { const [comments, setComments] = useState([]); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); - const pageRef = useRef(1); + const loadingRef = useRef(false); + const hasMoreRef = useRef(true); + const sortRef = useRef(sort); + const aidRef = useRef(aid); + const cursorRef = useRef(''); // empty = first page + + aidRef.current = aid; + + useEffect(() => { + if (sortRef.current === sort) return; + sortRef.current = sort; + cursorRef.current = ''; + hasMoreRef.current = true; + setComments([]); + setHasMore(true); + }, [sort]); const load = useCallback(async () => { - if (loadingRef.current || !hasMore || !aid) return; + if (loadingRef.current || !hasMoreRef.current || !aidRef.current) return; loadingRef.current = true; setLoading(true); try { - const data = await getComments(aid, pageRef.current); - if (data.length === 0) { setHasMore(false); return; } - setComments(prev => [...prev, ...data]); - pageRef.current += 1; + const isFirstPage = cursorRef.current === ''; + const { replies, nextCursor, isEnd } = await getComments(aidRef.current, cursorRef.current, sortRef.current); + cursorRef.current = nextCursor; + setComments(prev => isFirstPage ? replies : [...prev, ...replies]); + if (isEnd || replies.length === 0) { + hasMoreRef.current = false; + setHasMore(false); + } } catch (e) { console.error('Failed to load comments', e); } finally { loadingRef.current = false; setLoading(false); } - }, [aid, hasMore]); + }, []); return { comments, loading, hasMore, load }; } diff --git a/services/bilibili.ts b/services/bilibili.ts index 1c5cf9b..fe97be0 100644 --- a/services/bilibili.ts +++ b/services/bilibili.ts @@ -94,6 +94,7 @@ export async function getRecommendFeed(freshIdx = 0): Promise { ); const res = await api.get('/x/web-interface/wbi/index/top/feed/rcmd', { params: signed }); const items: any[] = res.data.data?.item ?? []; + console.log(items,'items') return items .filter(item => item.goto === 'av' && item.bvid && item.title) .map(item => ({ @@ -129,11 +130,26 @@ export async function getUserInfo(): Promise<{ face: string; uname: string; mid: return { face: face ?? '', uname: uname ?? '', mid: mid ?? 0 }; } -export async function getComments(aid: number, pn = 1): Promise { - const res = await api.get('/x/v2/reply', { - params: { oid: aid, type: 1, pn, ps: 20, sort: 2 }, +export async function getComments( + aid: number, + cursor = '', + sort = 2, +): Promise<{ replies: Comment[]; nextCursor: string; isEnd: boolean }> { + const mode = sort === 2 ? 3 : 2; // 3=hot, 2=time + const res = await api.get('/x/v2/reply/main', { + params: { + oid: aid, + type: 1, + mode, + plat: 1, + pagination_str: JSON.stringify({ offset: cursor }), + }, }); - return (res.data.data?.replies ?? []) as Comment[]; + const data = res.data.data; + const replies = (data?.replies ?? []) as Comment[]; + const nextCursor: string = data?.cursor?.next ?? ''; + const isEnd: boolean = data?.cursor?.is_end ?? true; + return { replies, nextCursor, isEnd }; } export async function getVideoShot(bvid: string, cid: number): Promise { diff --git a/services/types.ts b/services/types.ts index 38538be..0266689 100644 --- a/services/types.ts +++ b/services/types.ts @@ -24,6 +24,21 @@ export interface VideoItem { roomid?: number; online?: number; area_name?: string; + ugc_season?: { + id: number; + title: string; + cover: string; + ep_count: number; + sections: Array<{ + episodes: Array<{ + aid: number; + bvid: string; + cid: number; + title: string; + arc?: { pic: string; stat?: { view: number } }; + }>; + }>; + }; } export interface Comment { diff --git a/utils/videoRows.ts b/utils/videoRows.ts index 2d00450..886ca08 100644 --- a/utils/videoRows.ts +++ b/utils/videoRows.ts @@ -53,8 +53,8 @@ export function toListRows(pages: VideoItem[][], liveRooms?: LiveRoom[]): ListRo roomIdx++; } - rows.push(...pairs); rows.push({ type: 'big', item: bigItem }); + rows.push(...pairs); } return rows; }