Files
JKVideo/components/BigVideoCard.tsx
Developer 53c67079a1 bug修改
2026-05-12 20:27:30 +08:00

460 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { usePlayUrlCache } from "../store/playUrlCache";
import { formatCount, formatDuration } from "../utils/format";
import { useVisibleBigKeyStore } from "../store/visibleBigKeyStore";
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;
isScrolling?: boolean;
onPress: () => void;
}
export const BigVideoCard = React.memo(function BigVideoCard({
item,
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);
const theme = useTheme();
const THUMB_H = SCREEN_W * 0.5625;
const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H };
const [videoUrl, setVideoUrl] = useState<string | undefined>();
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<VideoRef>(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<string | null>(null);
const lastProgressUpdate = useRef(0);
// 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 {
// 命中缓存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);
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, item.bvid);
if (!cancelled) {
setVideoUrl(mpdUri);
usePlayUrlCache.getState().set(item.bvid, 16, { playData, mpdUri });
}
} catch {
if (!cancelled) {
setVideoUrl(playData.dash.video[0]?.baseUrl);
usePlayUrlCache.getState().set(item.bvid, 16, { playData });
}
}
} else {
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);
}
})();
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 (
<View style={[styles.card, { backgroundColor: theme.card }]}>
{/* Media area */}
<View style={[mediaDimensions, { position: "relative" }]}>
{/* Video player — rendered first so it sits behind the thumbnail */}
{videoUrl && !liveActive && (
<Video
ref={videoRef}
source={
isDash
? { uri: videoUrl, type: "mpd", headers: HEADERS }
: { uri: videoUrl, headers: HEADERS }
}
style={StyleSheet.absoluteFill}
resizeMode="cover"
muted={muted}
paused={paused || seekingRef.current}
repeat
controls={false}
onReadyForDisplay={handleVideoReady}
onProgress={({
currentTime: ct,
seekableDuration: dur,
playableDuration: buf,
}) => {
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);
}}
/>
)}
{/* Thumbnail — on top of Video, fades out once video is ready */}
<Animated.View
style={[StyleSheet.absoluteFill, { opacity: thumbOpacity }]}
pointerEvents="none"
>
<Image
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
style={mediaDimensions}
contentFit="cover"
recyclingKey={item.bvid}
/>
</Animated.View>
{/* Swipe gesture layer */}
<View
style={[StyleSheet.absoluteFill, { zIndex: 5 }]}
{...panResponder.panHandlers}
/>
{/* Seek time label */}
{seekLabel && (
<View style={styles.seekBadge}>
<Text style={styles.seekText}>
{seekLabel} / {formatDuration(durationRef.current)}
</Text>
</View>
)}
<View style={styles.meta}>
<Ionicons name="play" size={11} color="#fff" />
<Text style={styles.metaText}>
{formatCount(item.stat?.view ?? 0)}
</Text>
</View>
<View style={styles.durationBadge}>
<Text style={styles.durationText}>
{formatDuration(item.duration)}
</Text>
</View>
{/* Mute toggle — visible only when video is playing */}
{videoUrl && !paused && (
<TouchableOpacity
style={styles.muteBtn}
onPress={() => setMuted((m) => !m)}
activeOpacity={0.7}
>
<Ionicons
name={muted ? "volume-mute" : "volume-high"}
size={16}
color="#fff"
/>
</TouchableOpacity>
)}
</View>
{/* Progress bar between video and info — always rendered to avoid height jump */}
<View style={styles.progressTrack}>
{videoUrl && duration > 0 && (
<>
<View
style={[
styles.progressLayer,
{
width: `${bufferedRatio * 100}%` as any,
backgroundColor: "rgba(0,174,236,0.25)",
},
]}
/>
<View
style={[
styles.progressLayer,
{
width: `${progressRatio * 100}%` as any,
backgroundColor: "#00AEEC",
},
]}
/>
</>
)}
</View>
{/* Info */}
<TouchableOpacity onPress={onPress} activeOpacity={0.85}>
<View style={styles.info}>
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
{item.title}
</Text>
<Text style={[styles.owner, { color: theme.textSub }]} numberOfLines={1}>
{item.owner?.name ?? ""}
</Text>
</View>
</TouchableOpacity>
</View>
);
});
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: 6,
},
seekBadge: {
position: "absolute",
top: "40%",
alignSelf: "center",
backgroundColor: "rgba(0,0,0,0.6)",
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 6,
zIndex: 4,
},
seekText: { color: "#fff", fontSize: 16, fontWeight: "600" },
progressTrack: {
height: BAR_H,
backgroundColor: "rgba(0,0,0,0.08)",
position: "relative",
},
progressLayer: {
position: "absolute",
top: 0,
left: 0,
height: BAR_H,
},
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 },
});