// 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 [muted, setMuted] = useState(true); const thumbOpacity = useRef(new Animated.Value(1)).current; // Reset video state when the item changes useEffect(() => { setVideoUrl(undefined); setIsDash(false); setPaused(true); setMuted(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) { setMuted(true); // 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 */} {/* Video player — rendered first so it sits behind the thumbnail */} {videoUrl && ( {/* Info */} {item.title} {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 }, muteBtn: { position: "absolute", top: 8, right: 8, backgroundColor: "rgba(0,0,0,0.5)", borderRadius: 14, width: 28, height: 28, alignItems: "center", justifyContent: "center", zIndex: 3, }, info: { padding: 8 }, title: { fontSize: 14, color: "#212121", lineHeight: 18, marginBottom: 4 }, meta: { position: "absolute", bottom: 4, left: 4, paddingHorizontal: 4, borderRadius: 5, backgroundColor: "rgba(0,0,0,0.6)", flexDirection: "row", alignItems: "center", paddingVertical: 0, gap: 2, zIndex: 2, }, metaText: { fontSize: 10, color: "#fff" }, owner: { fontSize: 11, color: "#999", marginTop: 2 }, });