// components/BigVideoCard.tsx import React, { useEffect, useRef, useState } from 'react'; import { View, Text, Image, TouchableOpacity, StyleSheet, useWindowDimensions, Animated, } from 'react-native'; import Video from 'react-native-video'; import { Ionicons } from '@expo/vector-icons'; import { buildDashMpdUri } from '../utils/dash'; import { getPlayUrl, getVideoDetail } from '../services/bilibili'; import { proxyImageUrl } from '../utils/imageUrl'; 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', }; interface Props { item: VideoItem; isVisible: boolean; onPress: () => void; } export function BigVideoCard({ item, isVisible, onPress }: Props) { const { width: SCREEN_W } = useWindowDimensions(); 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 thumbOpacity = useRef(new Animated.Value(1)).current; // Reset video state when the item changes useEffect(() => { setVideoUrl(undefined); setIsDash(false); setPaused(true); thumbOpacity.setValue(1); }, [item.bvid]); // Fetch play URL when visible for the first time useEffect(() => { if (!isVisible || videoUrl) return; let cancelled = false; (async () => { try { // cid may be missing from feed items; fetch detail if needed let cid = item.cid; if (!cid) { const detail = await getVideoDetail(item.bvid); cid = detail.cid ?? detail.pages?.[0]?.cid; } if (!cid || 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; }; // videoUrl intentionally excluded — re-fetch guard prevents redundant fetches after URL is set }, [isVisible, item.bvid]); // Pause/resume when visibility changes useEffect(() => { if (!videoUrl) return; setPaused(!isVisible); if (!isVisible) { // Restore thumbnail when leaving viewport Animated.timing(thumbOpacity, { toValue: 1, duration: 150, useNativeDriver: true }).start(); } }, [isVisible, videoUrl]); const handleVideoReady = () => { if (!isVisible) return; setPaused(false); Animated.timing(thumbOpacity, { toValue: 0, duration: 300, useNativeDriver: true, }).start(); }; return ( {/* Media area */} {/* Thumbnail */} {/* Duration badge on thumbnail */} {formatDuration(item.duration)} {/* Video player — only mounted when URL is available */} {videoUrl && ( {/* Info */} {item.title} {formatCount(item.stat?.view ?? 0)} {item.owner?.name ?? ''} ); } const styles = StyleSheet.create({ card: { marginHorizontal: 4, marginBottom: 6, backgroundColor: '#fff', borderRadius: 6, overflow: 'hidden', }, durationBadge: { position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1, zIndex: 2, }, durationText: { color: '#fff', fontSize: 10 }, info: { padding: 8 }, title: { fontSize: 14, color: '#212121', lineHeight: 18, marginBottom: 4 }, meta: { flexDirection: 'row', alignItems: 'center', gap: 2 }, metaText: { fontSize: 11, color: '#999' }, owner: { fontSize: 11, color: '#999', marginTop: 2 }, });