From 53c67079a1133ea405b024c5326258e7cd8036f8 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 12 May 2026 20:27:30 +0800 Subject: [PATCH] =?UTF-8?q?bug=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/_layout.tsx | 4 + app/index.tsx | 152 +++++++------ app/video/[bvid].tsx | 80 +++++-- components/BigVideoCard.tsx | 45 +++- components/DanmakuOverlay.tsx | 12 +- components/LiveMiniPlayer.tsx | 86 ++----- components/MiniPlayer.tsx | 69 +----- components/NativeVideoPlayer.tsx | 354 +++++++++++++++++++++-------- components/VideoDetailSkeleton.tsx | 127 +++++++++++ components/VideoPlayer.tsx | 9 +- hooks/useAutoHideTimer.ts | 46 ++++ hooks/useLiveDanmaku.ts | 8 +- hooks/useMiniDrag.ts | 70 ++++++ hooks/useVideoDetail.ts | 35 ++- hooks/useVideoList.ts | 23 +- services/bilibili.ts | 57 +++-- services/types.ts | 11 + store/miniExclusion.ts | 30 +++ store/playProgressStore.ts | 74 ++++++ store/playUrlCache.ts | 43 ++++ store/visibleBigKeyStore.ts | 11 + utils/dash.ts | 26 ++- utils/videoRows.ts | 41 +--- 23 files changed, 1026 insertions(+), 387 deletions(-) create mode 100644 components/VideoDetailSkeleton.tsx create mode 100644 hooks/useAutoHideTimer.ts create mode 100644 hooks/useMiniDrag.ts create mode 100644 store/miniExclusion.ts create mode 100644 store/playProgressStore.ts create mode 100644 store/playUrlCache.ts create mode 100644 store/visibleBigKeyStore.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index d836add..6f14a70 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -6,6 +6,8 @@ import { useEffect } from 'react'; import { useAuthStore } from '../store/authStore'; import { useDownloadStore } from '../store/downloadStore'; import { useSettingsStore } from '../store/settingsStore'; +import { usePlayProgressStore } from '../store/playProgressStore'; +import { initMiniExclusion } from '../store/miniExclusion'; import { useTheme } from '../utils/theme'; import { MiniPlayer } from '../components/MiniPlayer'; import { LiveMiniPlayer } from '../components/LiveMiniPlayer'; @@ -35,6 +37,8 @@ function RootLayout() { restore(); loadDownloads(); restoreSettings(); + usePlayProgressStore.getState().hydrate(); + initMiniExclusion(); }, []); if (!fontsLoaded) return null; diff --git a/app/index.tsx b/app/index.tsx index 6c50bda..ac9221e 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -36,11 +36,11 @@ import { toListRows, type ListRow, type BigRow, - type LiveRow, } from "../utils/videoRows"; import { BigVideoCard } from "../components/BigVideoCard"; import { FollowedLiveStrip } from "../components/FollowedLiveStrip"; import { useTheme } from "../utils/theme"; +import { useVisibleBigKeyStore } from "../store/visibleBigKeyStore"; import type { LiveRoom } from "../services/types"; const HEADER_H = 44; @@ -69,8 +69,7 @@ const LIVE_AREAS = [ export default function HomeScreen() { const router = useRouter(); - const { pages, liveRooms, loading, refreshing, load, refresh } = - useVideoList(); + const { pages, loading, refreshing, load, refresh } = useVideoList(); const { rooms, loading: liveLoading, @@ -85,8 +84,7 @@ export default function HomeScreen() { const [liveAreaId, setLiveAreaId] = useState(0); const theme = useTheme(); - const [visibleBigKey, setVisibleBigKey] = useState(null); - const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]); + const rows = useMemo(() => toListRows(pages), [pages]); const pagerRef = useRef(null); const hotListRef = useRef(null); @@ -97,57 +95,113 @@ export default function HomeScreen() { const bigRow = viewableItems.find( (v) => v.item && (v.item as ListRow).type === "big", ); - setVisibleBigKey(bigRow ? (bigRow.item as BigRow).item.bvid : null); + useVisibleBigKeyStore.getState().setKey(bigRow ? (bigRow.item as BigRow).item.bvid : null); }, ).current; - const scrollY = useRef(new Animated.Value(0)).current; + // 滚动累计阈值:方向反转后需累计滚动该距离才触发显隐切换 + const SCROLL_THRESHOLD = 40; + // 显隐过渡时长 + const HEADER_ANIM_MS = 100; - const headerTranslate = scrollY.interpolate({ + const headerOffset = useRef(new Animated.Value(0)).current; // 0 = 显示,HEADER_H = 隐藏 + const liveHeaderOffset = useRef(new Animated.Value(0)).current; + + const headerTranslate = headerOffset.interpolate({ inputRange: [0, HEADER_H], outputRange: [0, -HEADER_H], extrapolate: "clamp", }); - - const headerOpacity = scrollY.interpolate({ - inputRange: [0, HEADER_H * 0.2], - outputRange: [1, 0], + const headerOpacity = headerOffset.interpolate({ + inputRange: [0, HEADER_H * 0.6, HEADER_H], + outputRange: [1, 1, 0], extrapolate: "clamp", }); - // 直播列表也共用同一个 scrollY - const liveScrollY = useRef(new Animated.Value(0)).current; - - const liveHeaderTranslate = liveScrollY.interpolate({ + const liveHeaderTranslate = liveHeaderOffset.interpolate({ inputRange: [0, HEADER_H], outputRange: [0, -HEADER_H], extrapolate: "clamp", }); - - const liveHeaderOpacity = liveScrollY.interpolate({ - inputRange: [0, HEADER_H * 0.2], - outputRange: [1, 0], + const liveHeaderOpacity = liveHeaderOffset.interpolate({ + inputRange: [0, HEADER_H * 0.6, HEADER_H], + outputRange: [1, 1, 0], extrapolate: "clamp", }); + const hotScrollState = useRef({ lastY: 0, acc: 0, dir: 0, hidden: false }).current; + const liveScrollState = useRef({ lastY: 0, acc: 0, dir: 0, hidden: false }).current; + + const animateHeader = useCallback( + (offset: Animated.Value, hide: boolean) => { + Animated.timing(offset, { + toValue: hide ? HEADER_H : 0, + duration: HEADER_ANIM_MS, + useNativeDriver: true, + }).start(); + }, + [], + ); + + const updateHeaderForScroll = useCallback( + ( + y: number, + state: { lastY: number; acc: number; dir: number; hidden: boolean }, + offset: Animated.Value, + ) => { + // 顶部强制显示 + if (y <= 0) { + if (state.hidden) { + state.hidden = false; + animateHeader(offset, false); + } + state.lastY = 0; + state.acc = 0; + state.dir = 0; + return; + } + const dy = y - state.lastY; + state.lastY = y; + if (Math.abs(dy) < 1) return; + const dir = dy > 0 ? 1 : -1; // 1=向下滚(隐藏),-1=向上滚(显示) + if (dir !== state.dir) { + state.dir = dir; + state.acc = 0; + } + state.acc += Math.abs(dy); + if (state.acc < SCROLL_THRESHOLD) return; + const shouldHide = dir === 1; + if (shouldHide !== state.hidden) { + state.hidden = shouldHide; + animateHeader(offset, shouldHide); + } + state.acc = 0; + }, + [animateHeader], + ); + useEffect(() => { load(); }, []); - const onScroll = useMemo( - () => - Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { - useNativeDriver: true, - }), - [], + const onScroll = useCallback( + (e: any) => + updateHeaderForScroll( + e.nativeEvent.contentOffset.y, + hotScrollState, + headerOffset, + ), + [updateHeaderForScroll], ); - const onLiveScroll = useMemo( - () => - Animated.event([{ nativeEvent: { contentOffset: { y: liveScrollY } } }], { - useNativeDriver: true, - }), - [], + const onLiveScroll = useCallback( + (e: any) => + updateHeaderForScroll( + e.nativeEvent.contentOffset.y, + liveScrollState, + liveHeaderOffset, + ), + [updateHeaderForScroll], ); const handleTabPress = useCallback( @@ -195,41 +249,15 @@ export default function HomeScreen() { [liveAreaId, liveLoad], ); - const visibleBigKeyRef = useRef(visibleBigKey); - visibleBigKeyRef.current = visibleBigKey; - const renderItem = useCallback(({ item: row }: { item: ListRow }) => { if (row.type === "big") { return ( router.push(`/video/${row.item.bvid}` as any)} /> ); } - if (row.type === "live") { - return ( - - - router.push(`/live/${row.left.roomid}` as any)} - /> - - {row.right && ( - - router.push(`/live/${row.right!.roomid}` as any)} - /> - - )} - - ); - } const right = row.right; return ( @@ -303,12 +331,10 @@ export default function HomeScreen() { ref={hotListRef as any} style={styles.listContainer} data={rows} - keyExtractor={(row: any, index: number) => + keyExtractor={(row: any) => row.type === "big" ? `big-${row.item.bvid}` - : row.type === "live" - ? `live-${index}-${row.left.roomid}-${row.right?.roomid ?? "empty"}` - : `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}` + : `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}` } contentContainerStyle={{ paddingTop: insets.top + NAV_H + 6, @@ -324,7 +350,6 @@ export default function HomeScreen() { } onEndReached={() => load()} onEndReachedThreshold={0.5} - extraData={visibleBigKey} viewabilityConfig={VIEWABILITY_CONFIG} onViewableItemsChanged={onViewableItemsChangedRef} ListFooterComponent={ @@ -603,7 +628,6 @@ const styles = StyleSheet.create({ paddingHorizontal: 10, paddingVertical: 2, borderRadius: 16, - backgroundColor: "#f0f0f0", }, areaTabActive: { backgroundColor: "#00AEEC", diff --git a/app/video/[bvid].tsx b/app/video/[bvid].tsx index 8dcf690..9e9cbe3 100644 --- a/app/video/[bvid].tsx +++ b/app/video/[bvid].tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useLayoutEffect, useRef } from "react"; +import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from "react"; import { View, Text, @@ -6,6 +6,8 @@ import { StyleSheet, TouchableOpacity, ActivityIndicator, + useWindowDimensions, + InteractionManager, } from "react-native"; import { Image } from "expo-image"; import { SafeAreaView } from "react-native-safe-area-context"; @@ -22,6 +24,7 @@ import { useRelatedVideos } from "../../hooks/useRelatedVideos"; import { formatCount, formatDuration } 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"; @@ -36,6 +39,14 @@ export default function VideoDetailScreen() { useLiveStore.getState().clearLive(); }, []); + // 骨架屏最短展示时长:即便数据秒回,也至少撑够这么久,避免一闪而过 + const SKELETON_MIN_MS = 600; + const [minSkeletonElapsed, setMinSkeletonElapsed] = useState(false); + useEffect(() => { + const t = setTimeout(() => setMinSkeletonElapsed(true), SKELETON_MIN_MS); + return () => clearTimeout(t); + }, []); + const { video, playData, @@ -43,6 +54,7 @@ export default function VideoDetailScreen() { qualities, currentQn, changeQuality, + initialTime, } = useVideoDetail(bvid as string); const [commentSort, setCommentSort] = useState<0 | 2>(2); const { @@ -67,20 +79,36 @@ export default function VideoDetailScreen() { load: loadRelated, } = useRelatedVideos(bvid as string); - useEffect(() => { loadRelated(); }, []); + // 推荐视频不参与首屏,等导航动画结束再拉,避免与详情/播放流抢 JS 线程 + useEffect(() => { + const handle = InteractionManager.runAfterInteractions(() => { + loadRelated(); + }); + return () => handle.cancel(); + }, []); useEffect(() => { - if (video?.aid) loadComments(); + if (!video?.aid) return; + const handle = InteractionManager.runAfterInteractions(() => { + loadComments(); + }); + return () => handle.cancel(); }, [video?.aid, commentSort]); useEffect(() => { if (!video?.cid) return; - getDanmaku(video.cid).then(setDanmakus).catch(() => {}); + const handle = InteractionManager.runAfterInteractions(() => { + getDanmaku(video.cid!).then(setDanmakus).catch(() => {}); + }); + return () => handle.cancel(); }, [video?.cid]); useEffect(() => { if (!video?.owner?.mid) return; - getUploaderStat(video.owner.mid).then(setUploaderStat).catch(() => {}); + const handle = InteractionManager.runAfterInteractions(() => { + getUploaderStat(video.owner.mid).then(setUploaderStat).catch(() => {}); + }); + return () => handle.cancel(); }, [video?.owner?.mid]); return ( @@ -107,6 +135,7 @@ export default function VideoDetailScreen() { cid={video?.cid} danmakus={danmakus} onTimeUpdate={setCurrentTime} + initialTime={initialTime} /> - ) : video ? ( + {videoLoading || !video || !minSkeletonElapsed ? ( + + ) : ( <> {tab === "intro" && ( @@ -306,7 +335,7 @@ export default function VideoDetailScreen() { style={[styles.danmakuTab, tab !== "danmaku" && { display: "none" }]} /> - ) : null} + )} ); } @@ -322,17 +351,33 @@ function SeasonSection({ onEpisodePress: (bvid: string) => void; }) { const theme = useTheme(); + const { width: screenW } = useWindowDimensions(); const episodes = season.sections?.[0]?.episodes ?? []; const currentIndex = episodes.findIndex((ep) => ep.bvid === currentBvid); const listRef = useRef(null); + // 初次渲染先隐身,scrollToOffset 完成后再显示,彻底消除"先 0 再跳"的可见闪烁 + const [ready, setReady] = useState(currentIndex <= 0); - useEffect(() => { - if (currentIndex <= 0 || episodes.length === 0) return; - const t = setTimeout(() => { - listRef.current?.scrollToIndex({ index: currentIndex, viewPosition: 0.5, animated: false }); - }, 200); - return () => clearTimeout(t); - }, [currentIndex, episodes.length]); + // 计算让当前集水平居中的初始 contentOffset + const initialOffset = useMemo(() => { + if (currentIndex <= 0) return 0; + const ITEM_WIDTH = 120; + const STEP = 130; // 120 + 10 gap + const PADDING = 12; + const itemCenter = PADDING + currentIndex * STEP + ITEM_WIDTH / 2; + 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]); return ( @@ -349,7 +394,10 @@ function SeasonSection({ keyExtractor={(ep) => ep.bvid} contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }} getItemLayout={(_data, index) => ({ length: 130, offset: 12 + index * 130, index })} + contentOffset={{ x: initialOffset, y: 0 }} + onContentSizeChange={handleContentSizeChange} onScrollToIndexFailed={() => {}} + style={{ opacity: ready ? 1 : 0 }} renderItem={({ item: ep, index }) => { const isCurrent = ep.bvid === currentBvid; return ( diff --git a/components/BigVideoCard.tsx b/components/BigVideoCard.tsx index d7dc0f5..64a68d2 100644 --- a/components/BigVideoCard.tsx +++ b/components/BigVideoCard.tsx @@ -24,7 +24,9 @@ import { coverImageUrl } from "../utils/imageUrl"; import { useSettingsStore } from "../store/settingsStore"; import { useTheme } from "../utils/theme"; import { useLiveStore } from "../store/liveStore"; +import { usePlayUrlCache } from "../store/playUrlCache"; import { formatCount, formatDuration } from "../utils/format"; +import { useVisibleBigKeyStore } from "../store/visibleBigKeyStore"; import type { VideoItem } from "../services/types"; const HEADERS = { @@ -45,17 +47,16 @@ function clamp(v: number, lo: number, hi: number) { interface Props { item: VideoItem; - isVisible: boolean; isScrolling?: boolean; onPress: () => void; } export const BigVideoCard = React.memo(function BigVideoCard({ item, - isVisible, isScrolling, onPress, }: Props) { + const isVisible = useVisibleBigKeyStore((s) => s.key) === item.bvid; const { width: SCREEN_W } = useWindowDimensions(); const trafficSaving = useSettingsStore(s => s.trafficSaving); const liveActive = useLiveStore(s => s.isActive); @@ -79,6 +80,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({ const durationRef = useRef(0); const seekingRef = useRef(false); const [seekLabel, setSeekLabel] = useState(null); + const lastProgressUpdate = useRef(0); // Reset video state when the item changes useEffect(() => { @@ -98,6 +100,18 @@ export const BigVideoCard = React.memo(function BigVideoCard({ let cancelled = false; (async () => { try { + // 命中缓存(5min TTL)直接复用 + const cached = usePlayUrlCache.getState().get(item.bvid, 16); + if (cached) { + if (cancelled) return; + if (cached.playData.dash) { + setIsDash(true); + setVideoUrl(cached.mpdUri ?? cached.playData.dash.video[0]?.baseUrl); + } else { + setVideoUrl(cached.playData.durl?.[0]?.url); + } + return; + } let cid = item.cid; if (!cid) { const detail = await getVideoDetail(item.bvid); @@ -113,13 +127,22 @@ export const BigVideoCard = React.memo(function BigVideoCard({ if (playData.dash) { if (!cancelled) setIsDash(true); try { - const mpdUri = await buildDashMpdUri(playData, 16); - if (!cancelled) setVideoUrl(mpdUri); + const mpdUri = await buildDashMpdUri(playData, 16, item.bvid); + if (!cancelled) { + setVideoUrl(mpdUri); + usePlayUrlCache.getState().set(item.bvid, 16, { playData, mpdUri }); + } } catch { - if (!cancelled) setVideoUrl(playData.dash.video[0]?.baseUrl); + if (!cancelled) { + setVideoUrl(playData.dash.video[0]?.baseUrl); + usePlayUrlCache.getState().set(item.bvid, 16, { playData }); + } } } else { - if (!cancelled) setVideoUrl(playData.durl?.[0]?.url); + if (!cancelled) { + setVideoUrl(playData.durl?.[0]?.url); + usePlayUrlCache.getState().set(item.bvid, 16, { playData }); + } } } catch (e) { console.warn("BigVideoCard: failed to load play URL", e); @@ -252,8 +275,14 @@ export const BigVideoCard = React.memo(function BigVideoCard({ seekableDuration: dur, playableDuration: buf, }) => { - if (!seekingRef.current) setCurrentTime(ct); - if (dur > 0) setDuration(dur); + currentTimeRef.current = ct; + if (dur > 0) durationRef.current = dur; + if (seekingRef.current) return; + const now = Date.now(); + if (now - lastProgressUpdate.current < 450) return; + lastProgressUpdate.current = now; + setCurrentTime(ct); + if (dur > 0 && Math.abs(dur - duration) > 1) setDuration(dur); setBuffered(buf); }} /> diff --git a/components/DanmakuOverlay.tsx b/components/DanmakuOverlay.tsx index a36f875..2297597 100644 --- a/components/DanmakuOverlay.tsx +++ b/components/DanmakuOverlay.tsx @@ -13,6 +13,10 @@ interface Props { const LANE_COUNT = 5; const LANE_H = 28; +// 同屏弹幕上限(原 30,提至 60) +const MAX_ACTIVE = 60; +// activated 集合阈值,触达后整体清零防止内存膨胀 +const ACTIVATED_LIMIT = 1000; interface ActiveDanmaku { id: string; @@ -73,7 +77,10 @@ export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, scr const newItems: ActiveDanmaku[] = []; - if (activated.current.size > 200) activated.current.clear(); // prevent memory leak + if (activated.current.size > ACTIVATED_LIMIT) { + activated.current.clear(); // prevent memory leak + idCounter.current = 0; + } for (const item of candidates) { const key = `${item.time}_${item.text}`; activated.current.add(key); @@ -124,8 +131,7 @@ export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, scr if (newItems.length > 0) { setActiveDanmakus(prev => { const combined = [...prev, ...newItems]; - // Cap at 30 simultaneous danmakus - return combined.slice(Math.max(0, combined.length - 30)); + return combined.slice(Math.max(0, combined.length - MAX_ACTIVE)); }); } }, [currentTime, visible, danmakus, pickLane, screenWidth]); diff --git a/components/LiveMiniPlayer.tsx b/components/LiveMiniPlayer.tsx index debc842..a2d90b4 100644 --- a/components/LiveMiniPlayer.tsx +++ b/components/LiveMiniPlayer.tsx @@ -1,12 +1,10 @@ -import React, { useRef } from 'react'; +import React, { useState, useEffect } from 'react'; import { View, Text, Image, StyleSheet, Animated, - PanResponder, - Dimensions, Platform, } from 'react-native'; import { useRouter } from 'expo-router'; @@ -15,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons'; import { useLiveStore } from '../store/liveStore'; import { useVideoStore } from '../store/videoStore'; import { proxyImageUrl } from '../utils/imageUrl'; +import { useMiniDrag } from '../hooks/useMiniDrag'; const MINI_W = 160; const MINI_H = 90; @@ -25,70 +24,29 @@ const LIVE_HEADERS = { 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', }; -function snapRelease( - pan: Animated.ValueXY, - curX: number, - curY: number, - sw: number, - sh: number, -) { - const snapRight = 0; - const snapLeft = -(sw - MINI_W - 24); - const snapX = curX < snapLeft / 2 ? snapLeft : snapRight; - const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY)); - Animated.spring(pan, { - toValue: { x: snapX, y: clampedY }, - useNativeDriver: false, - tension: 120, - friction: 10, - }).start(); -} - export function LiveMiniPlayer() { const { isActive, roomId, title, cover, hlsUrl, clearLive } = useLiveStore(); const videoMiniActive = useVideoStore(s => s.isActive); const router = useRouter(); const insets = useSafeAreaInsets(); - const pan = useRef(new Animated.ValueXY()).current; - const isDragging = useRef(false); + // 关闭时先把 Video 暂停一帧,让 native 释放连接,再 unmount + const [paused, setPaused] = useState(false); - // 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值 - const storeRef = useRef({ roomId, clearLive, router }); - storeRef.current = { roomId, clearLive, router }; + // 切换到不同直播间时重置 paused + useEffect(() => { + if (isActive) setPaused(false); + }, [hlsUrl, isActive]); - const panResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onPanResponderGrant: () => { - isDragging.current = false; - pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value }); - pan.setValue({ x: 0, y: 0 }); - }, - onPanResponderMove: (_, gs) => { - if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) { - isDragging.current = true; - } - pan.x.setValue(gs.dx); - pan.y.setValue(gs.dy); - }, - onPanResponderRelease: (evt) => { - pan.flattenOffset(); - if (!isDragging.current) { - const { locationX, locationY } = evt.nativeEvent; - const { roomId: rid, clearLive: clear, router: r } = storeRef.current; - if (locationX > MINI_W - 28 && locationY < 28) { - clear(); - } else { - r.push(`/live/${rid}` as any); - } - return; - } - const { width: sw, height: sh } = Dimensions.get('window'); - snapRelease(pan, (pan.x as any)._value, (pan.y as any)._value, sw, sh); - }, - onPanResponderTerminate: () => { pan.flattenOffset(); }, - }), - ).current; + const { pan, panHandlers } = useMiniDrag({ + width: MINI_W, + height: MINI_H, + hitClose: (x, y) => x > MINI_W - 28 && y < 28, + onTap: () => router.push(`/live/${roomId}` as any), + onClose: () => { + setPaused(true); + requestAnimationFrame(() => clearLive()); + }, + }); if (!isActive) return null; @@ -99,7 +57,7 @@ export function LiveMiniPlayer() { return ( @@ -120,7 +78,7 @@ export function LiveMiniPlayer() { return ( {/* pointerEvents="none" 防止 Video 原生层吞噬触摸事件 */} @@ -131,7 +89,7 @@ export function LiveMiniPlayer() { resizeMode="cover" controls={false} muted={false} - paused={false} + paused={paused} repeat={false} onError={clearLive} /> @@ -141,7 +99,7 @@ export function LiveMiniPlayer() { LIVE {title} - {/* 关闭按钮视觉层,点击逻辑由 onPanResponderRelease 坐标判断 */} + {/* 关闭按钮视觉层,点击逻辑由 hitClose 坐标判断 */} diff --git a/components/MiniPlayer.tsx b/components/MiniPlayer.tsx index f445b6f..0736297 100644 --- a/components/MiniPlayer.tsx +++ b/components/MiniPlayer.tsx @@ -1,13 +1,11 @@ -import React, { useRef } from 'react'; -import { - View, Text, Image, StyleSheet, - Animated, PanResponder, Dimensions, -} from 'react-native'; +import React from 'react'; +import { View, Text, Image, StyleSheet, Animated } from 'react-native'; import { useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useVideoStore } from '../store/videoStore'; import { proxyImageUrl } from '../utils/imageUrl'; +import { useMiniDrag } from '../hooks/useMiniDrag'; const MINI_W = 160; const MINI_H = 90; @@ -16,57 +14,14 @@ export function MiniPlayer() { const { isActive, bvid, title, cover, clearVideo } = useVideoStore(); const router = useRouter(); const insets = useSafeAreaInsets(); - const pan = useRef(new Animated.ValueXY()).current; - const isDragging = useRef(false); - // 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值 - const storeRef = useRef({ bvid, clearVideo, router }); - storeRef.current = { bvid, clearVideo, router }; - - const panResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onPanResponderGrant: () => { - isDragging.current = false; - pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value }); - pan.setValue({ x: 0, y: 0 }); - }, - onPanResponderMove: (_, gs) => { - if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) { - isDragging.current = true; - } - pan.x.setValue(gs.dx); - pan.y.setValue(gs.dy); - }, - onPanResponderRelease: (evt) => { - pan.flattenOffset(); - if (!isDragging.current) { - const { locationX, locationY } = evt.nativeEvent; - const { bvid: vid, clearVideo: clear, router: r } = storeRef.current; - if (locationX > MINI_W - 28 && locationY < 28) { - clear(); - } else { - r.push(`/video/${vid}` as any); - } - return; - } - const { width: sw, height: sh } = Dimensions.get('window'); - const curX = (pan.x as any)._value; - const curY = (pan.y as any)._value; - const snapRight = 0; - const snapLeft = -(sw - MINI_W - 24); - const snapX = curX < snapLeft / 2 ? snapLeft : snapRight; - const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY)); - Animated.spring(pan, { - toValue: { x: snapX, y: clampedY }, - useNativeDriver: false, - tension: 120, - friction: 10, - }).start(); - }, - onPanResponderTerminate: () => { pan.flattenOffset(); }, - }) - ).current; + const { pan, panHandlers } = useMiniDrag({ + width: MINI_W, + height: MINI_H, + hitClose: (x, y) => x > MINI_W - 28 && y < 28, + onTap: () => router.push(`/video/${bvid}` as any), + onClose: clearVideo, + }); if (!isActive) return null; @@ -75,11 +30,11 @@ export function MiniPlayer() { return ( {title} - {/* 关闭按钮仅作视觉展示,点击逻辑由 onPanResponderRelease 坐标判断处理 */} + {/* 关闭按钮仅作视觉展示,点击逻辑由 hitClose 坐标判断处理 */} diff --git a/components/NativeVideoPlayer.tsx b/components/NativeVideoPlayer.tsx index 174a163..7ed39b8 100644 --- a/components/NativeVideoPlayer.tsx +++ b/components/NativeVideoPlayer.tsx @@ -16,6 +16,8 @@ import { Modal, Image, PanResponder, + ActivityIndicator, + Animated, useWindowDimensions, } from "react-native"; import Video, { VideoRef } from "react-native-video"; @@ -25,11 +27,13 @@ import type { PlayUrlResponse, VideoShotData, DanmakuItem, + IVideoPlayer, } from "../services/types"; import { buildDashMpdUri } from "../utils/dash"; import { getVideoShot } from "../services/bilibili"; import DanmakuOverlay from "./DanmakuOverlay"; import { useTheme } from "../utils/theme"; +import { usePlayProgressStore } from "../store/playProgressStore"; const BAR_H = 3; // 进度球尺寸 @@ -59,8 +63,8 @@ function findFrameByTime(index: number[], seekTime: number): number { return lo; } -export interface NativeVideoPlayerRef { - seek: (t: number) => void; +export interface NativeVideoPlayerRef extends IVideoPlayer { + /** @deprecated 用 pause()/resume() 代替 */ setPaused: (v: boolean) => void; } @@ -110,6 +114,9 @@ export const NativeVideoPlayer = forwardRef( const hideTimer = useRef | null>(null); const [paused, setPaused] = useState(false); + // seek 后强制触发 react-native-video 重新评估 paused prop 的 hack 用的瞬时叠加态 + // 单独存储以避免污染 paused(用于图标显示):seek 完成的一瞬间不让"播放/暂停"图标闪 + const [seekHackPaused, setSeekHackPaused] = useState(false); const [currentTime, setCurrentTime] = useState(0); const currentTimeRef = useRef(0); const [duration, setDuration] = useState(0); @@ -118,15 +125,61 @@ export const NativeVideoPlayer = forwardRef( const [showQuality, setShowQuality] = useState(false); + // 倍速 + const RATE_OPTIONS = [0.75, 1, 1.25, 1.5, 2]; + const [rate, setRate] = useState(1); + const [showRate, setShowRate] = useState(false); + + // 清晰度切换:保留进度 + loading 遮罩 + const [switching, setSwitching] = useState(false); + const pendingSeekRef = useRef(null); + const prevQnRef = useRef(currentQn); + const switchTimeoutRef = useRef | null>(null); + + // 续播:每 5s 持久化一次 + const lastSaveRef = useRef(0); + + useEffect(() => { + // 排除初始挂载(prevQn 或 currentQn === 0) + if ( + prevQnRef.current !== 0 && + currentQn !== 0 && + prevQnRef.current !== currentQn + ) { + pendingSeekRef.current = currentTimeRef.current; + setSwitching(true); + // 兜底:8s 内 onLoad 没触发就强制收起遮罩 + if (switchTimeoutRef.current) clearTimeout(switchTimeoutRef.current); + switchTimeoutRef.current = setTimeout(() => setSwitching(false), 8000); + } + prevQnRef.current = currentQn; + }, [currentQn]); + + useEffect(() => { + return () => { + if (switchTimeoutRef.current) clearTimeout(switchTimeoutRef.current); + }; + }, []); + const [buffered, setBuffered] = useState(0); const [isSeeking, setIsSeeking] = useState(false); const isSeekingRef = useRef(false); - const [touchX, setTouchX] = useState(null); - const touchXRef = useRef(null); - const rafRef = useRef(null); + // 拖动球位置用 Animated.Value 驱动:setValue 不触发 React 重渲染, + // 原生层直接更新坐标,跟手 60fps。消除老方案 setState+60ms 节流导致的"段落感"。 + const touchAnimX = useRef(new Animated.Value(0)).current; + // 缩略图换帧仍走 state(精灵图位移涉及 RN 视图属性变化),50ms 节流足够 + const [thumbFrame, setThumbFrame] = useState<{ + sheetIdx: number; + col: number; + row: number; + seekTime: number; + } | null>(null); + const thumbThrottleRef = useRef(0); const barOffsetX = useRef(0); const barWidthRef = useRef(300); const trackRef = useRef(null); + // 让稳定的 PanResponder 闭包能读到最新 shots + const shotsRef = useRef(null); const [shots, setShots] = useState(null); const [showDanmaku, setShowDanmaku] = useState(true); @@ -137,6 +190,9 @@ export const NativeVideoPlayer = forwardRef( seek: (t: number) => { videoRef.current?.seek(t); }, + pause: () => setPaused(true), + resume: () => setPaused(false), + getCurrentTime: () => currentTimeRef.current, setPaused: (v: boolean) => { setPaused(v); }, @@ -153,7 +209,7 @@ export const NativeVideoPlayer = forwardRef( return; } if (isDash) { - buildDashMpdUri(playData, currentQn) + buildDashMpdUri(playData, currentQn, bvid) .then(setResolvedUrl) .catch(() => setResolvedUrl(playData.dash!.video[0]?.baseUrl)); } else { @@ -179,6 +235,25 @@ export const NativeVideoPlayer = forwardRef( durationRef.current = duration; }, [duration]); + useEffect(() => { + shotsRef.current = shots; + }, [shots]); + + // 非拖动时,球/进度填充随 currentTime 同步(onProgress 驱动) + useEffect(() => { + if (isSeekingRef.current) return; + if (durationRef.current <= 0 || barWidthRef.current <= 0) { + touchAnimX.setValue(0); + return; + } + const x = clamp( + (currentTime / durationRef.current) * barWidthRef.current, + 0, + barWidthRef.current, + ); + touchAnimX.setValue(x); + }, [currentTime, duration]); + // 控制栏自动隐藏逻辑:每次用户交互后重置计时器,3秒无交互则隐藏。使用 useRef 存储计时器 ID 和拖动状态,避免闭包问题导致的计时器失效或误触发。 const resetHideTimer = useCallback(() => { if (hideTimer.current) clearTimeout(hideTimer.current); @@ -223,7 +298,29 @@ export const NativeVideoPlayer = forwardRef( } }); }, []); - // 使用 PanResponder 实现进度条拖动,支持在拖动过程中显示预览图。通过 touchXRef 和 rafRef 优化拖动性能,避免频繁更新状态导致的卡顿。用户松开拖动时,根据最终位置计算对应的时间点并跳转,同时清理状态和隐藏预览图。 + // 拖动中按 50ms 节流计算精灵图帧索引;球位置不走这里,由 Animated.setValue 直接更新 + const updateThumbFrame = useCallback((x: number) => { + const shotsData = shotsRef.current; + if (!shotsData || durationRef.current <= 0 || barWidthRef.current <= 0) return; + const ratio = clamp(x / barWidthRef.current, 0, 1); + const seekTime = ratio * durationRef.current; + const { img_x_len, img_y_len, image, index } = shotsData; + const framesPerSheet = img_x_len * img_y_len; + const totalFrames = framesPerSheet * image.length; + const frameIdx = index?.length + ? clamp(findFrameByTime(index, seekTime), 0, index.length - 1) + : clamp(Math.floor(ratio * (totalFrames - 1)), 0, totalFrames - 1); + const local = frameIdx % framesPerSheet; + setThumbFrame({ + sheetIdx: Math.floor(frameIdx / framesPerSheet), + col: local % img_x_len, + row: Math.floor(local / img_x_len), + seekTime, + }); + }, []); + + // PanResponder 进度条拖动:球/进度填充走 Animated.setValue(无 React 渲染), + // 缩略图换帧 50ms 节流;松手时 seek 到目标时间并恢复自动同步。 const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => true, @@ -234,40 +331,39 @@ export const NativeVideoPlayer = forwardRef( setShowControls(true); if (hideTimer.current) clearTimeout(hideTimer.current); const x = clamp(gs.x0 - barOffsetX.current, 0, barWidthRef.current); - touchXRef.current = x; - setTouchX(x); + touchAnimX.setValue(x); + thumbThrottleRef.current = 0; + updateThumbFrame(x); }, onPanResponderMove: (_, gs) => { - touchXRef.current = clamp( + const x = clamp( gs.moveX - barOffsetX.current, 0, barWidthRef.current, ); - if (!rafRef.current) { - rafRef.current = requestAnimationFrame(() => { - setTouchX(touchXRef.current); - rafRef.current = null; - }); + // 关键:setValue 不触发 React 渲染,进度球/进度填充由原生层直接刷新 + touchAnimX.setValue(x); + const now = Date.now(); + if (now - thumbThrottleRef.current >= 50) { + thumbThrottleRef.current = now; + updateThumbFrame(x); } }, - // 用户松开拖动,或拖动被中断(如来电),都视为结束拖动,需要清理状态和隐藏预览 + // 用户松开拖动,或拖动被中断(如来电),都视为结束拖动 onPanResponderRelease: (_, gs) => { - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } - const ratio = clamp( - (gs.moveX - barOffsetX.current) / barWidthRef.current, + const x = clamp( + gs.moveX - barOffsetX.current, 0, - 1, + barWidthRef.current, ); + const ratio = barWidthRef.current > 0 ? x / barWidthRef.current : 0; const t = ratio * durationRef.current; + touchAnimX.setValue(x); videoRef.current?.seek(t); setCurrentTime(t); - touchXRef.current = null; - setTouchX(null); isSeekingRef.current = false; setIsSeeking(false); + setThumbFrame(null); if (hideTimer.current) clearTimeout(hideTimer.current); hideTimer.current = setTimeout( () => setShowControls(false), @@ -275,71 +371,46 @@ export const NativeVideoPlayer = forwardRef( ); }, onPanResponderTerminate: () => { - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } - touchXRef.current = null; - setTouchX(null); isSeekingRef.current = false; setIsSeeking(false); + setThumbFrame(null); }, }), ).current; - // 进度条上触摸位置对应的时间点比例,0-1。非拖动状态为 null - const touchRatio = - touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null; - const progressRatio = - duration > 0 ? clamp(currentTime / duration, 0, 1) : 0; const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0; - const THUMB_DISPLAY_W = 120; // scaled display width + // 缩略图尺寸:竖屏播放器较窄给 160,全屏给 220 提升可读性 + const THUMB_DISPLAY_W = isFullscreen ? 220 : 160; + + // 进度球水平偏移:active 态球更大,translate 偏移需对齐圆心 + const ballTranslate = React.useMemo( + () => Animated.subtract(touchAnimX, (isSeeking ? BALL_ACTIVE : BALL) / 2), + [isSeeking, touchAnimX], + ); const renderThumbnail = () => { - if (touchRatio === null || !shots || !isSeeking) return null; + if (!thumbFrame || !shots || !isSeeking) return null; const { img_x_size: TW, img_y_size: TH, img_x_len, img_y_len, image, - index, } = shots; - const framesPerSheet = img_x_len * img_y_len; - const totalFrames = framesPerSheet * image.length; - const seekTime = touchRatio * duration; - // 通过时间戳索引找到最接近的帧,若无索引则均匀映射到总帧数上 - const frameIdx = - index?.length && duration > 0 - ? 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); - // 根据单帧图尺寸和预设的显示宽度计算缩放后的显示尺寸,保持宽高比 + const { sheetIdx, col, row, seekTime } = thumbFrame; + // 根据单帧图尺寸和预设的显示宽度计算缩放后的显示尺寸,保持宽高比 const scale = THUMB_DISPLAY_W / TW; const DW = THUMB_DISPLAY_W; const DH = Math.round(TH * scale); - - const trackLeft = barOffsetX.current; - const absLeft = clamp( - trackLeft + (touchX ?? 0) - DW / 2, - 0, - SCREEN_W - DW, - ); + // 缩略图固定到播放器水平中点,不跟随手指 / 进度球移动 + const fixedLeft = Math.round(SCREEN_W / 2 - DW / 2); + const raw = image[sheetIdx]; + if (!raw) return null; // 兼容处理图床地址,确保以 http(s) 协议开头 - const sheetUrl = image[sheetIdx].startsWith("//") - ? `https:${image[sheetIdx]}` - : image[sheetIdx]; + const sheetUrl = raw.startsWith("//") ? `https:${raw}` : raw; return ( ( width: DW, height: DH, overflow: "hidden", - borderRadius: 4, + borderRadius: 6, }} > ( style={StyleSheet.absoluteFill} resizeMode="contain" controls={false} - paused={!!(forcePaused || paused)} + paused={!!(forcePaused || paused || seekHackPaused)} + rate={rate} progressUpdateInterval={500} onProgress={({ currentTime: ct, @@ -398,6 +470,14 @@ export const NativeVideoPlayer = forwardRef( }) => { currentTimeRef.current = ct; onTimeUpdate?.(ct); + // 续播持久化(5s 节流) + if (bvid && dur > 0) { + const nowSave = Date.now(); + if (nowSave - lastSaveRef.current > 5000) { + lastSaveRef.current = nowSave; + usePlayProgressStore.getState().save(bvid, ct, dur); + } + } // 拖动进度条时跳过 UI 更新,避免与用户拖动冲突 if (isSeekingRef.current) return; const now = Date.now(); @@ -408,20 +488,44 @@ export const NativeVideoPlayer = forwardRef( setBuffered(buf); }} onLoad={() => { - if (initialTime && initialTime > 0) { + // 切清晰度后跳回原进度 + const pending = pendingSeekRef.current; + let didSeek = false; + if (pending !== null && pending > 0) { + videoRef.current?.seek(pending); + pendingSeekRef.current = null; + didSeek = true; + } else if (initialTime && initialTime > 0) { videoRef.current?.seek(initialTime); + didSeek = true; } - // seek 后部分播放器不恢复播放,先暂停再恢复,强制触发 prop 变化 - if (!forcePaused) { - setPaused(true); - requestAnimationFrame(() => setPaused(false)); + if (switching) { + setSwitching(false); + if (switchTimeoutRef.current) { + clearTimeout(switchTimeoutRef.current); + switchTimeoutRef.current = null; + } + } + // seek 后部分播放器不自动恢复播放,需短暂 paused→false 触发 prop 变化 + // 仅在确实 seek 时执行;走 seekHackPaused(不污染图标显示态),避免播放/暂停图标闪烁 + if (didSeek && !forcePaused && !paused) { + setSeekHackPaused(true); + requestAnimationFrame(() => setSeekHackPaused(false)); } }} onError={(e) => { - // 杜比视界播放失败时自动降级到 1080P - if (currentQn === 126) { - onQualityChange(80); - return; + // 按降级链找下一档可用清晰度(从当前档位的下一档起,跳过不在 qualities 列表里的) + const FALLBACK_CHAIN = [126, 112, 80, 64, 32, 16]; + const idx = FALLBACK_CHAIN.indexOf(currentQn); + if (idx >= 0) { + const acceptable = new Set(qualities.map(q => q.qn)); + for (let i = idx + 1; i < FALLBACK_CHAIN.length; i++) { + const next = FALLBACK_CHAIN[i]; + if (acceptable.has(next)) { + onQualityChange(next); + return; + } + } } console.warn("Video playback error:", e); }} @@ -430,6 +534,13 @@ export const NativeVideoPlayer = forwardRef( )} + {switching && ( + + + 切换到 {currentDesc}… + + )} + {isFullscreen && !!danmakus?.length && ( ( }, ]} /> - - {isSeeking && touchX !== null ? ( - - ) : ( - - )} + {/* Controls */} @@ -538,6 +643,12 @@ export const NativeVideoPlayer = forwardRef( {formatDuration(duration)} + setShowRate(true)} + > + {rate === 1 ? "倍速" : `${rate}x`} + setShowQuality(true)} @@ -601,6 +712,42 @@ export const NativeVideoPlayer = forwardRef( + + {/* 选倍速 */} + + setShowRate(false)} + > + + 选择倍速 + {RATE_OPTIONS.map((r) => ( + { + setRate(r); + setShowRate(false); + showAndReset(); + }} + > + + {r === 1 ? "正常" : `${r}x`} + + {r === rate && ( + + )} + + ))} + + + ); }, @@ -610,6 +757,19 @@ const styles = StyleSheet.create({ container: { backgroundColor: "#000" }, fsContainer: { flex: 1, backgroundColor: "#000" }, placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: "#000" }, + switchOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0,0,0,0.55)", + alignItems: "center", + justifyContent: "center", + flexDirection: "row", + gap: 10, + }, + switchText: { + color: "#fff", + fontSize: 13, + fontWeight: "500", + }, topBar: { position: "absolute", top: 0, diff --git a/components/VideoDetailSkeleton.tsx b/components/VideoDetailSkeleton.tsx new file mode 100644 index 0000000..1e395d4 --- /dev/null +++ b/components/VideoDetailSkeleton.tsx @@ -0,0 +1,127 @@ +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。 + +interface BlockProps { + width: number | `${number}%`; + height: number; + radius?: number; + bg: string; + style?: any; +} + +const SkeletonBlock = memo(function SkeletonBlock({ width, height, radius = 4, bg, style }: BlockProps) { + return ( + + ); +}); + +const RelatedCardSkeleton = memo(function RelatedCardSkeleton({ bg, border }: { bg: string; border: string }) { + return ( + + + + + + + + + + + + + + ); +}); + +const CARD_COUNT = 4; + +export function VideoDetailSkeleton() { + const theme = useTheme(); + const opacity = useRef(new Animated.Value(0.6)).current; + + useEffect(() => { + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(opacity, { toValue: 1, duration: 800, useNativeDriver: true }), + Animated.timing(opacity, { toValue: 0.6, duration: 800, useNativeDriver: true }), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [opacity]); + + const bg = theme.placeholder; + const border = theme.border; + + return ( + + {/* UP 主行 */} + + + + + + + + + {/* 标题 + 简介 */} + + + + + + + + + {/* 推荐视频标题 */} + + + + + {/* 推荐卡片占位(4 张足够撑满首屏,少绘制开销) */} + {Array.from({ length: CARD_COUNT }).map((_, i) => ( + + ))} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1 }, + upRow: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 12, + paddingTop: 12, + paddingBottom: 10, + }, + upInfo: { flex: 1, marginLeft: 10 }, + titleSection: { + padding: 14, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + relatedHeader: { + paddingLeft: 13, + paddingTop: 10, + paddingBottom: 8, + }, + card: { + flexDirection: "row", + paddingHorizontal: 12, + paddingVertical: 8, + borderBottomWidth: StyleSheet.hairlineWidth, + gap: 10, + }, + cardInfo: { flex: 1, justifyContent: "space-between", paddingVertical: 2 }, + cardMeta: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, +}); diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index 593de57..420c2d7 100644 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -15,14 +15,21 @@ interface Props { cid?: number; danmakus?: DanmakuItem[]; onTimeUpdate?: (t: number) => void; + initialTime?: number; } -export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, bvid, cid, danmakus, onTimeUpdate }: Props) { +export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, bvid, cid, danmakus, onTimeUpdate, initialTime }: 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); + // 续播:第一次拿到 initialTime 时塞进 lastTimeRef + if (!seededRef.current && typeof initialTime === 'number' && initialTime > 0) { + lastTimeRef.current = initialTime; + seededRef.current = true; + } const portraitRef = useRef(null); const handleEnterFullscreen = async () => { diff --git a/hooks/useAutoHideTimer.ts b/hooks/useAutoHideTimer.ts new file mode 100644 index 0000000..5c981d7 --- /dev/null +++ b/hooks/useAutoHideTimer.ts @@ -0,0 +1,46 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; + +/** + * 控制栏自动隐藏 hook:show() 后 delayMs 内无交互即隐藏。 + * keep() 用于"按住进度条不让消失"等场景:返回 true 时计时器会被推迟。 + */ +export function useAutoHideTimer(delayMs = 3000, keep?: () => boolean) { + const [visible, setVisible] = useState(true); + const timerRef = useRef | null>(null); + + const reset = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current); + if (keep?.()) return; + timerRef.current = setTimeout(() => setVisible(false), delayMs); + }, [delayMs, keep]); + + const show = useCallback(() => { + setVisible(true); + reset(); + }, [reset]); + + const hide = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current); + setVisible(false); + }, []); + + const toggle = useCallback(() => { + setVisible(prev => { + if (!prev) { + reset(); + return true; + } + if (timerRef.current) clearTimeout(timerRef.current); + return false; + }); + }, [reset]); + + useEffect(() => { + reset(); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + return { visible, show, hide, toggle, reset }; +} diff --git a/hooks/useLiveDanmaku.ts b/hooks/useLiveDanmaku.ts index 4e6d271..7dd7481 100644 --- a/hooks/useLiveDanmaku.ts +++ b/hooks/useLiveDanmaku.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { getLiveDanmakuHistory } from '../services/bilibili'; import type { DanmakuItem } from '../services/types'; -const POLL_INTERVAL = 3000; +const POLL_INTERVAL = 1500; // 匹配 admin 消息中的礼物信息,如 "xxx 赠送了 辣条 x5" 或 "xxx 投喂 小心心 ×1" const GIFT_PATTERN = /(?:赠送|投喂)\s*(?:了\s*)?(.+?)\s*[xX×]\s*(\d+)/; @@ -36,9 +36,10 @@ export function useLiveDanmaku(roomId: number): { const { danmakus: items, adminMsgs } = await getLiveDanmakuHistory(roomId); if (cancelled) return; - // 去重弹幕 + // 去重弹幕:以 uname + text + timeline 为 key + // 用 timeline 区分"同一人重复发同句"的合法重复(gethistory 多次轮询会返回相同尾部数据,靠 timeline 过滤掉) const newItems = items.filter(item => { - const key = `${item.uname ?? ''}:${item.text}`; + const key = `${item.uname ?? ''}:${item.text}:${item.timeline ?? ''}`; if (seenTextsRef.current.has(key)) return false; seenTextsRef.current.add(key); return true; @@ -50,7 +51,6 @@ export function useLiveDanmaku(roomId: number): { // 解析 admin 消息中的礼物 const newGifts: Record = {}; for (const msg of adminMsgs) { - console.log(msg,123) // 去重 admin 消息 if (seenAdminRef.current.has(msg)) continue; seenAdminRef.current.add(msg); diff --git a/hooks/useMiniDrag.ts b/hooks/useMiniDrag.ts new file mode 100644 index 0000000..3a5414d --- /dev/null +++ b/hooks/useMiniDrag.ts @@ -0,0 +1,70 @@ +import { useRef } from 'react'; +import { Animated, Dimensions, PanResponder } from 'react-native'; + +interface Options { + width: number; + height: number; + /** 命中"关闭按钮"区域的判定(locationX, locationY)→ true 时调 onClose,否则调 onTap */ + hitClose?: (locationX: number, locationY: number) => boolean; + onTap: () => void; + onClose?: () => void; +} + +/** + * 全局浮动小窗的拖拽 + 吸边 + 点击/关闭分发。 + * 返回 panHandlers 直接展开到容器,transform 应用 pan.getTranslateTransform()。 + */ +export function useMiniDrag({ width, height, hitClose, onTap, onClose }: Options) { + const pan = useRef(new Animated.ValueXY()).current; + const isDragging = useRef(false); + // 用 ref 保持最新回调,避免 PanResponder 闭包过期 + const cbRef = useRef({ onTap, onClose, hitClose }); + cbRef.current = { onTap, onClose, hitClose }; + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onPanResponderGrant: () => { + isDragging.current = false; + pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value }); + pan.setValue({ x: 0, y: 0 }); + }, + onPanResponderMove: (_, gs) => { + if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) { + isDragging.current = true; + } + pan.x.setValue(gs.dx); + pan.y.setValue(gs.dy); + }, + onPanResponderRelease: (evt) => { + pan.flattenOffset(); + if (!isDragging.current) { + const { locationX, locationY } = evt.nativeEvent; + const cb = cbRef.current; + if (cb.hitClose?.(locationX, locationY) && cb.onClose) { + cb.onClose(); + } else { + cb.onTap(); + } + return; + } + const { width: sw, height: sh } = Dimensions.get('window'); + const curX = (pan.x as any)._value; + const curY = (pan.y as any)._value; + const snapRight = 0; + const snapLeft = -(sw - width - 24); + const snapX = curX < snapLeft / 2 ? snapLeft : snapRight; + const clampedY = Math.max(-sh + height + 60, Math.min(60, curY)); + Animated.spring(pan, { + toValue: { x: snapX, y: clampedY }, + useNativeDriver: false, + tension: 120, + friction: 10, + }).start(); + }, + onPanResponderTerminate: () => { pan.flattenOffset(); }, + }), + ).current; + + return { pan, panHandlers: panResponder.panHandlers }; +} diff --git a/hooks/useVideoDetail.ts b/hooks/useVideoDetail.ts index e0075b9..8177d9d 100644 --- a/hooks/useVideoDetail.ts +++ b/hooks/useVideoDetail.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { getVideoDetail, getPlayUrl } from '../services/bilibili'; import { useAuthStore } from '../store/authStore'; import { useSettingsStore } from '../store/settingsStore'; +import { usePlayProgressStore } from '../store/playProgressStore'; import type { VideoItem, PlayUrlResponse } from '../services/types'; export function useVideoDetail(bvid: string) { @@ -11,6 +12,7 @@ export function useVideoDetail(bvid: string) { const [currentQn, setCurrentQn] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [initialTime, setInitialTime] = useState(0); const cidRef = useRef(0); const isLoggedIn = useAuthStore(s => s.isLoggedIn); const trafficSaving = useSettingsStore(s => s.trafficSaving); @@ -38,6 +40,8 @@ export function useVideoDetail(bvid: string) { async function fetchData() { try { setLoading(true); + // 读取续播位置 + setInitialTime(usePlayProgressStore.getState().get(bvid)); const detail = await getVideoDetail(bvid); setVideo(detail); const cid = detail.pages?.[0]?.cid ?? detail.cid as number; @@ -53,13 +57,32 @@ export function useVideoDetail(bvid: string) { }, [bvid]); // 登录状态变化时重新拉取清晰度列表(登录后可能获得更高画质) + // cancelled flag 防止旧响应(切换登录态后)覆盖新响应 useEffect(() => { - if (cidRef.current) { - fetchPlayData(cidRef.current, defaultQn, true).catch((e) => { - console.warn('Failed to refresh quality list after login change:', e); - }); - } + if (!cidRef.current) return; + let cancelled = false; + (async () => { + try { + const data = await getPlayUrl(bvid, cidRef.current, defaultQn); + if (cancelled) return; + setPlayData(data); + setCurrentQn(data.quality); + if (data.accept_quality?.length) { + setQualities( + data.accept_quality.map((q, i) => ({ + qn: q, + desc: data.accept_description?.[i] ?? String(q), + })), + ); + } + } catch (e) { + if (!cancelled) console.warn('Failed to refresh quality list after login change:', e); + } + })(); + return () => { + cancelled = true; + }; }, [isLoggedIn]); - return { video, playData, loading, error, qualities, currentQn, changeQuality }; + return { video, playData, loading, error, qualities, currentQn, changeQuality, initialTime }; } diff --git a/hooks/useVideoList.ts b/hooks/useVideoList.ts index 16c558a..e2fee5e 100644 --- a/hooks/useVideoList.ts +++ b/hooks/useVideoList.ts @@ -1,14 +1,12 @@ import { useState, useCallback, useRef, useMemo } from 'react'; -import { getRecommendFeed, getLiveList } from '../services/bilibili'; -import type { VideoItem, LiveRoom } from '../services/types'; +import { getRecommendFeed } from '../services/bilibili'; +import type { VideoItem } from '../services/types'; export function useVideoList() { const [pages, setPages] = useState([]); - const [liveRooms, setLiveRooms] = useState([]); const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); - // Use refs to avoid stale closures — load() has stable identity const loadingRef = useRef(false); const freshIdxRef = useRef(0); @@ -21,19 +19,8 @@ export function useVideoList() { const idx = freshIdxRef.current; setLoading(true); try { - const promises: [Promise, Promise] = [ - getRecommendFeed(idx), - (reset || idx === 0) - ? getLiveList(1, 0).catch(() => [] as LiveRoom[]) - : Promise.resolve([] as LiveRoom[]), - ]; - const [data, live] = await Promise.all(promises); + const data = await getRecommendFeed(idx); setPages(prev => reset ? [data] : [...prev, data]); - if (reset || idx === 0) { - // Take top 2 by online count - const sorted = [...live].sort((a, b) => b.online - a.online).slice(0, 10); - setLiveRooms(sorted); - } freshIdxRef.current = idx + 1; } catch (e) { console.error('Failed to load videos', e); @@ -42,7 +29,7 @@ export function useVideoList() { setLoading(false); setRefreshing(false); } - }, []); // stable — no stale closure risk + }, []); const refresh = useCallback(() => { console.log('Refreshing video list'); @@ -51,5 +38,5 @@ export function useVideoList() { }, [load]); const videos = useMemo(() => pages.flat(), [pages]); - return { videos, pages, liveRooms, loading, refreshing, load, refresh }; + return { videos, pages, loading, refreshing, load, refresh }; } diff --git a/services/bilibili.ts b/services/bilibili.ts index c5a74f6..15b081f 100644 --- a/services/bilibili.ts +++ b/services/bilibili.ts @@ -79,6 +79,32 @@ function dedupe(key: string, fn: () => Promise): Promise { return promise; } +/** + * 指数退避重试包装。仅对网络层面错误(无 response、5xx、超时)重试; + * 业务错误(如 401/403/-352)直接抛出,不重试。 + */ +async function withRetry( + fn: () => Promise, + retries = 2, + backoff = [500, 1500], +): Promise { + let lastErr: any; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await fn(); + } catch (e: any) { + lastErr = e; + const status = e?.response?.status; + const isNetwork = !e?.response || e?.code === 'ECONNABORTED'; + const is5xx = typeof status === 'number' && status >= 500; + if (!isNetwork && !is5xx) throw e; + if (attempt === retries) throw e; + await new Promise((r) => setTimeout(r, backoff[attempt] ?? backoff[backoff.length - 1])); + } + } + throw lastErr; +} + // WBI key cache (rotates ~daily) let wbiKeys: { imgKey: string; subKey: string } | null = null; let wbiKeysTimestamp = 0; @@ -128,10 +154,12 @@ export async function getPopularVideos(pn = 1): Promise { } export function getVideoDetail(bvid: string): Promise { - return dedupe(dedupeKey('/x/web-interface/view', { bvid }), async () => { - const res = await api.get('/x/web-interface/view', { params: { bvid } }); - return res.data.data as VideoItem; - }); + return dedupe(dedupeKey('/x/web-interface/view', { bvid }), () => + withRetry(async () => { + const res = await api.get('/x/web-interface/view', { params: { bvid } }); + return res.data.data as VideoItem; + }), + ); } export async function getVideoRelated(bvid: string): Promise { @@ -147,10 +175,12 @@ export function getPlayUrl(bvid: string, cid: number, qn = 64): Promise { - const res = await api.get('/x/player/playurl', { params }); - return res.data.data as PlayUrlResponse; - }); + return dedupe(dedupeKey('/x/player/playurl', params), () => + withRetry(async () => { + const res = await api.get('/x/player/playurl', { params }); + return res.data.data as PlayUrlResponse; + }), + ); } export async function getPlayUrlForDownload( @@ -443,8 +473,9 @@ export async function getLiveDanmakuHistory(roomId: number): Promise<{ danmakus: DanmakuItem[]; adminMsgs: string[]; }> { + // gethistory 默认返回 ~10 条;尝试带 count 参数拉更多,B 站不支持时会自动忽略 const res = await api.get(`${LIVE_BASE}/xlive/web-room/v1/dM/gethistory`, { - params: { roomid: roomId }, + params: { roomid: roomId, room_id: roomId, count: 30 }, }); const room: any[] = res.data?.data?.room ?? []; @@ -467,7 +498,7 @@ export async function getLiveDanmakuHistory(roomId: number): Promise<{ } export async function getDanmaku(cid: number): Promise { - try { + return withRetry(async () => { if (isWeb) { // web 走代理,代理已解压,直接拿文本 const res = await axios.get(`${COMMENT_BASE}/${cid}.xml`, { @@ -501,10 +532,10 @@ export async function getDanmaku(cid: number): Promise { } return parseDanmakuXml(xmlText); - } catch (e) { + }).catch((e) => { console.warn('getDanmaku failed:', e); - return []; - } + return [] as DanmakuItem[]; + }); } export async function getFollowedLiveRooms(): Promise { diff --git a/services/types.ts b/services/types.ts index 77aa95b..6bcdea0 100644 --- a/services/types.ts +++ b/services/types.ts @@ -1,3 +1,14 @@ +/** + * 三种播放器(视频详情、首页大卡、直播)暴露的统一 ref 接口。 + * 不一定每个实现都全部支持,未实现的方法可空操作。 + */ +export interface IVideoPlayer { + seek: (timeSec: number) => void; + pause: () => void; + resume: () => void; + getCurrentTime: () => number; +} + export interface VideoItem { bvid: string; aid: number; diff --git a/store/miniExclusion.ts b/store/miniExclusion.ts new file mode 100644 index 0000000..53e2322 --- /dev/null +++ b/store/miniExclusion.ts @@ -0,0 +1,30 @@ +/** + * MiniPlayer ↔ LiveMiniPlayer 互斥协调。 + * 通过 zustand subscribe 在外部串联两个 store,避免它们直接互相 import + * 造成循环依赖(曾导致 Metro / TS 编译时内存暴涨)。 + * + * 仅需在 app 启动时 import 一次(_layout.tsx)即可生效。 + */ +import { useVideoStore } from './videoStore'; +import { useLiveStore } from './liveStore'; + +let initialized = false; + +export function initMiniExclusion() { + if (initialized) return; + initialized = true; + + // 视频 mini 激活 → 清掉直播 mini + useVideoStore.subscribe((state, prev) => { + if (state.isActive && !prev.isActive && useLiveStore.getState().isActive) { + useLiveStore.getState().clearLive(); + } + }); + + // 直播 mini 激活 → 清掉视频 mini + useLiveStore.subscribe((state, prev) => { + if (state.isActive && !prev.isActive && useVideoStore.getState().isActive) { + useVideoStore.getState().clearVideo(); + } + }); +} diff --git a/store/playProgressStore.ts b/store/playProgressStore.ts new file mode 100644 index 0000000..f9e5700 --- /dev/null +++ b/store/playProgressStore.ts @@ -0,0 +1,74 @@ +import { create } from 'zustand'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const STORAGE_KEY = 'play_progress'; +// 持久化最多 200 条记录 +const MAX_ENTRIES = 200; +// 视频接近结尾(>= 95%)视为看完,清掉记录 +const FINISH_RATIO = 0.95; + +interface ProgressEntry { + bvid: string; + time: number; // 秒 + duration: number; // 秒 + ts: number; // 写入时间戳 +} + +interface PlayProgressStore { + records: Record; + hydrated: boolean; + hydrate: () => Promise; + save: (bvid: string, time: number, duration: number) => void; + get: (bvid: string) => number; // 返回应跳转的秒数(无记录返回 0) + clear: (bvid: string) => void; +} + +const persist = (records: Record) => { + // 控制总量:超量删最早 + const entries = Object.values(records).sort((a, b) => b.ts - a.ts); + const trimmed = entries.slice(0, MAX_ENTRIES); + const trimmedMap: Record = {}; + trimmed.forEach(e => { trimmedMap[e.bvid] = e; }); + AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(trimmedMap)).catch(() => {}); + return trimmedMap; +}; + +export const usePlayProgressStore = create((set, get) => ({ + records: {}, + hydrated: false, + hydrate: async () => { + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY); + if (raw) { + const records = JSON.parse(raw); + set({ records, hydrated: true }); + return; + } + } catch {} + set({ hydrated: true }); + }, + save: (bvid, time, duration) => { + if (!bvid || !duration || duration <= 0) return; + // 视频接近结尾,清掉记录 + if (time / duration >= FINISH_RATIO) { + get().clear(bvid); + return; + } + // 起始 5 秒不必记录 + if (time < 5) return; + const records = { ...get().records }; + records[bvid] = { bvid, time, duration, ts: Date.now() }; + set({ records: persist(records) }); + }, + get: (bvid) => { + const r = get().records[bvid]; + return r?.time ?? 0; + }, + clear: (bvid) => { + const records = { ...get().records }; + if (records[bvid]) { + delete records[bvid]; + set({ records: persist(records) }); + } + }, +})); diff --git a/store/playUrlCache.ts b/store/playUrlCache.ts new file mode 100644 index 0000000..b32a9ab --- /dev/null +++ b/store/playUrlCache.ts @@ -0,0 +1,43 @@ +import { create } from 'zustand'; +import type { PlayUrlResponse } from '../services/types'; + +interface CacheEntry { + playData: PlayUrlResponse; + mpdUri?: string; + ts: number; +} + +interface PlayUrlCacheStore { + // 不通过 set 触发渲染(只是用 store 持有 Map) + cache: Map; + get: (bvid: string, qn: number) => CacheEntry | undefined; + set: (bvid: string, qn: number, entry: Omit) => void; +} + +const TTL_MS = 5 * 60 * 1000; +const MAX_ENTRIES = 30; + +const makeKey = (bvid: string, qn: number) => `${bvid}_${qn}`; + +export const usePlayUrlCache = create((_set, getState) => ({ + cache: new Map(), + get: (bvid, qn) => { + const map = getState().cache; + const entry = map.get(makeKey(bvid, qn)); + if (!entry) return undefined; + if (Date.now() - entry.ts > TTL_MS) { + map.delete(makeKey(bvid, qn)); + return undefined; + } + return entry; + }, + set: (bvid, qn, entry) => { + const map = getState().cache; + map.set(makeKey(bvid, qn), { ...entry, ts: Date.now() }); + // 简单 LRU:超过容量删最早 + if (map.size > MAX_ENTRIES) { + const oldestKey = map.keys().next().value; + if (oldestKey) map.delete(oldestKey); + } + }, +})); diff --git a/store/visibleBigKeyStore.ts b/store/visibleBigKeyStore.ts new file mode 100644 index 0000000..45da6ef --- /dev/null +++ b/store/visibleBigKeyStore.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; + +interface VisibleBigKeyState { + key: string | null; + setKey: (key: string | null) => void; +} + +export const useVisibleBigKeyStore = create((set) => ({ + key: null, + setKey: (key) => set({ key }), +})); diff --git a/utils/dash.ts b/utils/dash.ts index a5243f8..f2d0736 100644 --- a/utils/dash.ts +++ b/utils/dash.ts @@ -5,13 +5,35 @@ import type { PlayUrlResponse, DashAudioItem } from '../services/types'; * 将 Bilibili DASH 响应写成 MPD 文件,返回 file:// URI 供 ExoPlayer 播放。 * 选取 id === qn 的视频流(找不到则取第一条),带宽最高的音频流。 */ -export async function buildDashMpdUri(playData: PlayUrlResponse, qn: number): Promise { +export async function buildDashMpdUri( + playData: PlayUrlResponse, + qn: number, + bvid?: string, +): Promise { const xml = buildMpdXml(playData, qn); - const path = `${FileSystem.cacheDirectory}bili_dash_${qn}.mpd`; + // 带 bvid 区分,避免不同视频在同一 qn 上复用同一文件名导致命中过期 MPD + const suffix = bvid ? `${bvid}_${qn}` : String(qn); + const path = `${FileSystem.cacheDirectory}bili_dash_${suffix}.mpd`; await FileSystem.writeAsStringAsync(path, xml, { encoding: FileSystem.EncodingType.UTF8 }); return path; } +/** 删除指定视频的所有清晰度 MPD 缓存(详情页卸载时调用) */ +export async function cleanupDashMpd(bvid: string): Promise { + if (!bvid || !FileSystem.cacheDirectory) return; + try { + const files = await FileSystem.readDirectoryAsync(FileSystem.cacheDirectory); + const targets = files.filter(f => f.startsWith(`bili_dash_${bvid}_`) && f.endsWith('.mpd')); + await Promise.all( + targets.map(f => + FileSystem.deleteAsync(`${FileSystem.cacheDirectory}${f}`, { idempotent: true }), + ), + ); + } catch { + // 忽略,缓存清理失败不影响功能 + } +} + function isDolbyVision(codecs: string): boolean { return /^(dvhe|dvh1)/.test(codecs); } diff --git a/utils/videoRows.ts b/utils/videoRows.ts index 8a1429f..82553cb 100644 --- a/utils/videoRows.ts +++ b/utils/videoRows.ts @@ -1,4 +1,4 @@ -import type { VideoItem, LiveRoom } from '../services/types'; +import type { VideoItem } from '../services/types'; export interface NormalRow { type: 'pair'; @@ -11,22 +11,14 @@ export interface BigRow { item: VideoItem; } -export interface LiveRow { - type: 'live'; - left: LiveRoom; - right?: LiveRoom; -} +export type ListRow = NormalRow | BigRow; -export type ListRow = NormalRow | BigRow | LiveRow; - -export function toListRows(pages: VideoItem[][], liveRooms?: LiveRoom[]): ListRow[] { +export function toListRows(pages: VideoItem[][]): ListRow[] { const rows: ListRow[] = []; - let roomIdx = 0; for (const chunk of pages) { if (chunk.length === 0) continue; - // Highest view count becomes BigRow let bigIdx = 0; let maxView = chunk[0].stat?.view ?? 0; for (let i = 1; i < chunk.length; i++) { @@ -37,36 +29,17 @@ export function toListRows(pages: VideoItem[][], liveRooms?: LiveRoom[]): ListRo const bigItem = chunk[bigIdx]; const rest = chunk.filter((_, i) => i !== bigIdx); - const pairs: (NormalRow | LiveRow)[] = []; + const pairs: NormalRow[] = []; for (let i = 0; i < rest.length; i += 2) { if (rest[i + 1]) { pairs.push({ type: 'pair', left: rest[i], right: rest[i + 1] ?? null }); } } - - - if (liveRooms && liveRooms.length >= 2) { - const a = liveRooms[roomIdx % liveRooms.length]; - const b = liveRooms[(roomIdx + 1) % liveRooms.length]; - roomIdx += 2; - - if (rows.length < 20) { - rows.push({ type: 'big', item: bigItem }); - rows.push({ type: 'live', left: a, right: b }); - rows.push(...pairs); - } else { - rows.push(...pairs); - rows.push({ type: 'big', item: bigItem }); - rows.push({ type: 'live', left: a, right: b }); - } + if (rows.length < 20) { + rows.push({ type: 'big', item: bigItem }, ...pairs); } else { - // No live data, fall back to original logic - if (rows.length < 20) { - rows.push({ type: 'big', item: bigItem }, ...pairs); - } else { - rows.push(...pairs, { type: 'big', item: bigItem }); - } + rows.push(...pairs, { type: 'big', item: bigItem }); } } return rows;