mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
460 lines
14 KiB
TypeScript
460 lines
14 KiB
TypeScript
// 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 },
|
||
});
|