// components/BigVideoCard.tsx import React, { useEffect, useRef, useState, useCallback, useMemo, } from "react"; import { View, Text, TouchableOpacity, StyleSheet, useWindowDimensions, Animated, PanResponder, } from "react-native"; import { Image } from "expo-image"; import Video, { VideoRef } from "react-native-video"; import { Ionicons } from "@expo/vector-icons"; import { buildDashMpdUri } from "../utils/dash"; import { getPlayUrl, getVideoDetail } from "../services/bilibili"; import { coverImageUrl } from "../utils/imageUrl"; import { useSettingsStore } from "../store/settingsStore"; import { useTheme } from "../utils/theme"; import { useLiveStore } from "../store/liveStore"; import { formatCount, formatDuration } from "../utils/format"; import type { VideoItem } from "../services/types"; const HEADERS = { Referer: "https://www.bilibili.com", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", }; const BAR_H = 3; // Minimum horizontal distance (px) before treating the gesture as a seek const SWIPE_THRESHOLD = 8; // Full swipe across the screen = seek this many seconds const SWIPE_SECONDS = 90; function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); } interface Props { item: VideoItem; isVisible: boolean; isScrolling?: boolean; onPress: () => void; } export const BigVideoCard = React.memo(function BigVideoCard({ item, isVisible, isScrolling, onPress, }: Props) { const { width: SCREEN_W } = useWindowDimensions(); const trafficSaving = useSettingsStore(s => s.trafficSaving); const liveActive = useLiveStore(s => s.isActive); const theme = useTheme(); const THUMB_H = SCREEN_W * 0.5625; const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H }; const [videoUrl, setVideoUrl] = useState(); const [isDash, setIsDash] = useState(false); const [paused, setPaused] = useState(true); const [muted, setMuted] = useState(true); const thumbOpacity = useRef(new Animated.Value(1)).current; const videoRef = useRef(null); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [buffered, setBuffered] = useState(0); // Refs for PanResponder (avoid stale closures) const currentTimeRef = useRef(0); const durationRef = useRef(0); const seekingRef = useRef(false); const [seekLabel, setSeekLabel] = useState(null); // Reset video state when the item changes useEffect(() => { setVideoUrl(undefined); setIsDash(false); setPaused(true); setMuted(true); setCurrentTime(0); setDuration(0); setBuffered(0); thumbOpacity.setValue(1); }, [item.bvid]); // Preload: fetch play URL on mount (before card is visible) useEffect(() => { if (videoUrl || trafficSaving || liveActive) return; let cancelled = false; (async () => { try { let cid = item.cid; if (!cid) { const detail = await getVideoDetail(item.bvid); cid = detail.cid ?? detail.pages?.[0]?.cid; } if (!cid) { console.warn('BigVideoCard: no cid available for', item.bvid); return; } if (cancelled) return; const playData = await getPlayUrl(item.bvid, cid, 16); if (cancelled) return; if (playData.dash) { if (!cancelled) setIsDash(true); try { const mpdUri = await buildDashMpdUri(playData, 16); if (!cancelled) setVideoUrl(mpdUri); } catch { if (!cancelled) setVideoUrl(playData.dash.video[0]?.baseUrl); } } else { if (!cancelled) setVideoUrl(playData.durl?.[0]?.url); } } catch (e) { console.warn("BigVideoCard: failed to load play URL", e); } })(); return () => { cancelled = true; }; }, [item.bvid]); // Pause/resume based on visibility and scroll state useEffect(() => { if (!videoUrl) return; if (!isVisible || trafficSaving || liveActive) { setPaused(true); setMuted(true); Animated.timing(thumbOpacity, { toValue: 1, duration: 150, useNativeDriver: true, }).start(); } else if (isScrolling) { setPaused(true); } else { setPaused(false); Animated.timing(thumbOpacity, { toValue: 0, duration: 300, useNativeDriver: true, }).start(); } }, [isVisible, isScrolling, videoUrl, trafficSaving, liveActive]); const handleVideoReady = () => { if (!isVisible || isScrolling || trafficSaving || liveActive) return; setPaused(false); Animated.timing(thumbOpacity, { toValue: 0, duration: 300, useNativeDriver: true, }).start(); }; // Keep refs in sync useEffect(() => { currentTimeRef.current = currentTime; }, [currentTime]); useEffect(() => { durationRef.current = duration; }, [duration]); // Horizontal swipe to seek const swipeStartTime = useRef(0); const screenWRef = useRef(SCREEN_W); useEffect(() => { screenWRef.current = SCREEN_W; }, [SCREEN_W]); const panResponder = useMemo( () => PanResponder.create({ onStartShouldSetPanResponderCapture: () => true, onMoveShouldSetPanResponder: (_, gs) => Math.abs(gs.dx) > SWIPE_THRESHOLD && Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5, onPanResponderGrant: () => { seekingRef.current = true; swipeStartTime.current = currentTimeRef.current; }, onMoveShouldSetPanResponderCapture: (_, gs) => Math.abs(gs.dx) > SWIPE_THRESHOLD && Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5, onPanResponderMove: (_, gs) => { if (durationRef.current <= 0) return; const delta = (gs.dx / screenWRef.current) * SWIPE_SECONDS; const target = clamp( swipeStartTime.current + delta, 0, durationRef.current, ); setSeekLabel(formatDuration(Math.floor(target))); }, onPanResponderRelease: (_, gs) => { if (durationRef.current > 0) { const delta = (gs.dx / screenWRef.current) * SWIPE_SECONDS; const target = clamp( swipeStartTime.current + delta, 0, durationRef.current, ); videoRef.current?.seek(target); setCurrentTime(target); } seekingRef.current = false; setSeekLabel(null); }, onPanResponderTerminate: () => { seekingRef.current = false; setSeekLabel(null); }, }), [], ); const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0; const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0; return ( {/* Media area */} {/* Video player — rendered first so it sits behind the thumbnail */} {videoUrl && !liveActive && (