This commit is contained in:
Developer
2026-03-13 00:30:07 +08:00
parent 2e7578afde
commit b447ec0c76
3 changed files with 72 additions and 31 deletions

View File

@@ -37,6 +37,7 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
const [videoUrl, setVideoUrl] = useState<string | undefined>(); const [videoUrl, setVideoUrl] = useState<string | undefined>();
const [isDash, setIsDash] = useState(false); const [isDash, setIsDash] = useState(false);
const [paused, setPaused] = useState(true); const [paused, setPaused] = useState(true);
const [muted, setMuted] = useState(true);
const thumbOpacity = useRef(new Animated.Value(1)).current; const thumbOpacity = useRef(new Animated.Value(1)).current;
// Reset video state when the item changes // Reset video state when the item changes
@@ -44,6 +45,7 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
setVideoUrl(undefined); setVideoUrl(undefined);
setIsDash(false); setIsDash(false);
setPaused(true); setPaused(true);
setMuted(true);
thumbOpacity.setValue(1); thumbOpacity.setValue(1);
}, [item.bvid]); }, [item.bvid]);
@@ -88,6 +90,7 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
if (!videoUrl) return; if (!videoUrl) return;
setPaused(!isVisible); setPaused(!isVisible);
if (!isVisible) { if (!isVisible) {
setMuted(true);
// Restore thumbnail when leaving viewport // Restore thumbnail when leaving viewport
Animated.timing(thumbOpacity, { Animated.timing(thumbOpacity, {
toValue: 1, toValue: 1,
@@ -111,9 +114,28 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.9}> <TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.9}>
{/* Media area */} {/* Media area */}
<View style={[mediaDimensions, { position: "relative" }]}> <View style={[mediaDimensions, { position: "relative" }]}>
{/* Thumbnail */} {/* Video player — rendered first so it sits behind the thumbnail */}
{videoUrl && (
<Video
source={
isDash
? { uri: videoUrl, type: "mpd", headers: HEADERS }
: { uri: videoUrl, headers: HEADERS }
}
style={StyleSheet.absoluteFill}
resizeMode="cover"
muted={muted}
paused={paused}
repeat
controls={false}
onReadyForDisplay={handleVideoReady}
/>
)}
{/* Thumbnail — on top of Video, fades out once video is ready */}
<Animated.View <Animated.View
style={[StyleSheet.absoluteFill, { opacity: thumbOpacity }]} style={[StyleSheet.absoluteFill, { opacity: thumbOpacity }]}
pointerEvents="none"
> >
<Image <Image
source={{ uri: proxyImageUrl(item.pic) }} source={{ uri: proxyImageUrl(item.pic) }}
@@ -136,22 +158,19 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
</Text> </Text>
</View> </View>
{/* Video player — only mounted when URL is available */} {/* Mute toggle — visible only when video is playing */}
{videoUrl && ( {videoUrl && !paused && (
<Video <TouchableOpacity
source={ style={styles.muteBtn}
isDash onPress={() => setMuted((m) => !m)}
? { uri: videoUrl, type: "mpd", headers: HEADERS } activeOpacity={0.7}
: { uri: videoUrl, headers: HEADERS } >
} <Ionicons
style={StyleSheet.absoluteFill} name={muted ? "volume-mute" : "volume-high"}
resizeMode="cover" size={16}
muted color="#fff"
paused={paused} />
repeat </TouchableOpacity>
controls={false}
onReadyForDisplay={handleVideoReady}
/>
)} )}
</View> </View>
@@ -188,6 +207,18 @@ const styles = StyleSheet.create({
zIndex: 2, zIndex: 2,
}, },
durationText: { color: "#fff", fontSize: 10 }, 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 }, info: { padding: 8 },
title: { fontSize: 14, color: "#212121", lineHeight: 18, marginBottom: 4 }, title: { fontSize: 14, color: "#212121", lineHeight: 18, marginBottom: 4 },
meta: { meta: {

View File

@@ -190,6 +190,7 @@ export function NativeVideoPlayer({
const [showQuality, setShowQuality] = useState(false); const [showQuality, setShowQuality] = useState(false);
const [buffered, setBuffered] = useState(0);
const [isSeeking, setIsSeeking] = useState(false); const [isSeeking, setIsSeeking] = useState(false);
const isSeekingRef = useRef(false); const isSeekingRef = useRef(false);
const [touchX, setTouchX] = useState<number | null>(null); const [touchX, setTouchX] = useState<number | null>(null);
@@ -332,6 +333,7 @@ export function NativeVideoPlayer({
const touchRatio = const touchRatio =
touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null; touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0; const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
const THUMB_DISPLAY_W = 120; // scaled display width const THUMB_DISPLAY_W = 120; // scaled display width
@@ -419,9 +421,10 @@ export function NativeVideoPlayer({
resizeMode="contain" resizeMode="contain"
controls={false} controls={false}
paused={paused} paused={paused}
onProgress={({ currentTime: ct, seekableDuration: dur }) => { onProgress={({ currentTime: ct, seekableDuration: dur, playableDuration: buf }) => {
setCurrentTime(ct); setCurrentTime(ct);
if (dur > 0) setDuration(dur); if (dur > 0) setDuration(dur);
setBuffered(buf);
onTimeUpdate?.(ct); onTimeUpdate?.(ct);
}} }}
onLoad={() => { onLoad={() => {
@@ -498,16 +501,18 @@ export function NativeVideoPlayer({
{...panResponder.panHandlers} {...panResponder.panHandlers}
> >
<View style={styles.track}> <View style={styles.track}>
{/* Buffered: lighter white overlay on top of base */}
<View <View
style={[ style={[
styles.seg, styles.trackLayer,
{ flex: 1, backgroundColor: "#00AEEC" }, { width: `${bufferedRatio * 100}%` as any, backgroundColor: "rgba(255,255,255,0.35)" },
]} ]}
/> />
{/* Played: accent color on top */}
<View <View
style={[ style={[
styles.playedOverlay, styles.trackLayer,
{ width: `${progressRatio * 100}%` as any }, { width: `${progressRatio * 100}%` as any, backgroundColor: "#00AEEC" },
]} ]}
/> />
</View> </View>
@@ -672,18 +677,15 @@ const styles = StyleSheet.create({
}, },
track: { track: {
height: BAR_H, height: BAR_H,
flexDirection: "row",
borderRadius: 2, borderRadius: 2,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.3)", backgroundColor: "rgba(255,255,255,0.2)",
}, },
seg: { height: BAR_H }, trackLayer: {
playedOverlay: {
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
height: BAR_H, height: BAR_H,
backgroundColor: "rgba(255,255,255,0.3)",
}, },
ball: { ball: {
position: "absolute", position: "absolute",

View File

@@ -27,11 +27,19 @@ export function toListRows(videos: VideoItem[]): ListRow[] {
const rows: ListRow[] = []; const rows: ListRow[] = [];
for (let start = 0; start < videos.length; start += PAGE) { for (let start = 0; start < videos.length; start += PAGE) {
const chunk = videos.slice(start, start + PAGE); const chunk = videos.slice(start, start + PAGE);
const body = chunk.slice(0, chunk.length - 1); // Pick the video with the highest view count as the BigRow
for (let i = 0; i < body.length; i += 2) { let bigIdx = 0;
rows.push({ type: 'pair', left: body[i], right: body[i + 1] ?? null }); let maxView = chunk[0].stat?.view ?? 0;
for (let i = 1; i < chunk.length; i++) {
const v = chunk[i].stat?.view ?? 0;
if (v > maxView) { maxView = v; bigIdx = i; }
} }
rows.push({ type: 'big', item: chunk[chunk.length - 1] }); const bigItem = chunk[bigIdx];
const rest = chunk.filter((_, i) => i !== bigIdx);
for (let i = 0; i < rest.length; i += 2) {
rows.push({ type: 'pair', left: rest[i], right: rest[i + 1] ?? null });
}
rows.push({ type: 'big', item: bigItem });
} }
return rows; return rows;