mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
优化
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
// components/BigVideoCard.tsx
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -37,10 +43,16 @@ function clamp(v: number, lo: number, hi: number) {
|
||||
interface Props {
|
||||
item: VideoItem;
|
||||
isVisible: boolean;
|
||||
isScrolling?: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
||||
export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
item,
|
||||
isVisible,
|
||||
isScrolling,
|
||||
onPress,
|
||||
}: Props) {
|
||||
const { width: SCREEN_W } = useWindowDimensions();
|
||||
const THUMB_H = SCREEN_W * 0.5625;
|
||||
const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H };
|
||||
@@ -74,9 +86,9 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
||||
thumbOpacity.setValue(1);
|
||||
}, [item.bvid]);
|
||||
|
||||
// Fetch play URL when visible for the first time
|
||||
// Preload: fetch play URL on mount (before card is visible)
|
||||
useEffect(() => {
|
||||
if (!isVisible || videoUrl) return;
|
||||
if (videoUrl) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
@@ -106,24 +118,36 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVisible, item.bvid]);
|
||||
}, [item.bvid]);
|
||||
|
||||
// Pause/resume when visibility changes
|
||||
// Pause/resume based on visibility and scroll state
|
||||
useEffect(() => {
|
||||
if (!videoUrl) return;
|
||||
setPaused(!isVisible);
|
||||
if (!isVisible) {
|
||||
// Off-screen: pause, mute, show thumbnail
|
||||
setPaused(true);
|
||||
setMuted(true);
|
||||
Animated.timing(thumbOpacity, {
|
||||
toValue: 1,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} else if (isScrolling) {
|
||||
// Visible but scrolling: just pause (keep thumbnail hidden, keep mute state)
|
||||
setPaused(true);
|
||||
} else {
|
||||
// Visible and not scrolling: play, fade out thumbnail
|
||||
setPaused(false);
|
||||
Animated.timing(thumbOpacity, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
}, [isVisible, videoUrl]);
|
||||
}, [isVisible, isScrolling, videoUrl]);
|
||||
|
||||
const handleVideoReady = () => {
|
||||
if (!isVisible) return;
|
||||
if (!isVisible || isScrolling) return;
|
||||
setPaused(false);
|
||||
Animated.timing(thumbOpacity, {
|
||||
toValue: 0,
|
||||
@@ -142,44 +166,62 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
||||
|
||||
// Horizontal swipe to seek
|
||||
const swipeStartTime = useRef(0);
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onMoveShouldSetPanResponder: (_, gs) =>
|
||||
Math.abs(gs.dx) > SWIPE_THRESHOLD && Math.abs(gs.dx) > Math.abs(gs.dy),
|
||||
onMoveShouldSetPanResponderCapture: (_, gs) =>
|
||||
Math.abs(gs.dx) > SWIPE_THRESHOLD && Math.abs(gs.dx) > Math.abs(gs.dy),
|
||||
onPanResponderGrant: () => {
|
||||
seekingRef.current = true;
|
||||
swipeStartTime.current = currentTimeRef.current;
|
||||
},
|
||||
onPanResponderMove: (_, gs) => {
|
||||
if (durationRef.current <= 0) return;
|
||||
const delta = (gs.dx / SCREEN_W) * 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 / SCREEN_W) * 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);
|
||||
},
|
||||
}),
|
||||
).current;
|
||||
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 (
|
||||
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.9}>
|
||||
<View style={styles.card}>
|
||||
{/* Media area */}
|
||||
<View style={[mediaDimensions, { position: "relative" }]}>
|
||||
{/* Video player — rendered first so it sits behind the thumbnail */}
|
||||
@@ -194,11 +236,15 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="cover"
|
||||
muted={muted}
|
||||
paused={paused}
|
||||
paused={paused || seekingRef.current}
|
||||
repeat
|
||||
controls={false}
|
||||
onReadyForDisplay={handleVideoReady}
|
||||
onProgress={({ currentTime: ct, seekableDuration: dur, playableDuration: buf }) => {
|
||||
onProgress={({
|
||||
currentTime: ct,
|
||||
seekableDuration: dur,
|
||||
playableDuration: buf,
|
||||
}) => {
|
||||
if (!seekingRef.current) setCurrentTime(ct);
|
||||
if (dur > 0) setDuration(dur);
|
||||
setBuffered(buf);
|
||||
@@ -219,12 +265,17 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
||||
</Animated.View>
|
||||
|
||||
{/* Swipe gesture layer */}
|
||||
<View style={StyleSheet.absoluteFill} {...panResponder.panHandlers} />
|
||||
<View
|
||||
style={[StyleSheet.absoluteFill, { zIndex: 5 }]}
|
||||
{...panResponder.panHandlers}
|
||||
/>
|
||||
|
||||
{/* Seek time label */}
|
||||
{seekLabel && (
|
||||
<View style={styles.seekBadge}>
|
||||
<Text style={styles.seekText}>{seekLabel}</Text>
|
||||
<Text style={styles.seekText}>
|
||||
{seekLabel} / {formatDuration(durationRef.current)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -264,32 +315,39 @@ export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
||||
<View
|
||||
style={[
|
||||
styles.progressLayer,
|
||||
{ width: `${bufferedRatio * 100}%` as any, backgroundColor: "rgba(0,174,236,0.25)" },
|
||||
{
|
||||
width: `${bufferedRatio * 100}%` as any,
|
||||
backgroundColor: "rgba(0,174,236,0.25)",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.progressLayer,
|
||||
{ width: `${progressRatio * 100}%` as any, backgroundColor: "#00AEEC" },
|
||||
{
|
||||
width: `${progressRatio * 100}%` as any,
|
||||
backgroundColor: "#00AEEC",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.85}>
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.owner} numberOfLines={1}>
|
||||
{item.owner?.name ?? ""}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.owner} numberOfLines={1}>
|
||||
{item.owner?.name ?? ""}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
@@ -320,7 +378,7 @@ const styles = StyleSheet.create({
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 3,
|
||||
zIndex: 6,
|
||||
},
|
||||
seekBadge: {
|
||||
position: "absolute",
|
||||
|
||||
@@ -23,7 +23,7 @@ interface Props {
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function LiveCard({
|
||||
export const LiveCard = React.memo(function LiveCard({
|
||||
item,
|
||||
onPress,
|
||||
fullWidth,
|
||||
@@ -73,7 +73,7 @@ export function LiveCard({
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
|
||||
@@ -38,10 +38,15 @@ export function LoginModal({ visible, onClose }: Props) {
|
||||
if (result.code === 86090) setStatus('scanned');
|
||||
if (result.code === 0 && result.cookie) {
|
||||
clearInterval(pollRef.current!);
|
||||
await login(result.cookie, '', '');
|
||||
setStatus('done');
|
||||
// 登录后异步拉取用户头像和昵称
|
||||
getUserInfo().then(info => setProfile(info.face, info.uname, String(info.mid))).catch(() => {});
|
||||
try {
|
||||
await login(result.cookie, '', '');
|
||||
setStatus('done');
|
||||
// 登录后异步拉取用户头像和昵称
|
||||
const info = await getUserInfo();
|
||||
setProfile(info.face, info.uname, String(info.mid));
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
View, Text, Image, StyleSheet, TouchableOpacity,
|
||||
Animated, PanResponder,
|
||||
Animated, PanResponder, Dimensions,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
@@ -9,6 +9,9 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { useVideoStore } from '../store/videoStore';
|
||||
import { proxyImageUrl } from '../utils/imageUrl';
|
||||
|
||||
const MINI_W = 160;
|
||||
const MINI_H = 90;
|
||||
|
||||
export function MiniPlayer() {
|
||||
const { isActive, bvid, title, cover, clearVideo } = useVideoStore();
|
||||
const router = useRouter();
|
||||
@@ -18,14 +21,23 @@ export function MiniPlayer() {
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false }),
|
||||
onPanResponderRelease: () => {
|
||||
pan.flattenOffset();
|
||||
},
|
||||
onPanResponderGrant: () => {
|
||||
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
|
||||
pan.setValue({ x: 0, y: 0 });
|
||||
},
|
||||
onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false }),
|
||||
onPanResponderRelease: () => {
|
||||
pan.flattenOffset();
|
||||
// Clamp to screen bounds
|
||||
const { width: sw, height: sh } = Dimensions.get('window');
|
||||
const curX = (pan.x as any)._value;
|
||||
const curY = (pan.y as any)._value;
|
||||
const clampedX = Math.max(-sw + MINI_W + 12, Math.min(12, curX));
|
||||
const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY));
|
||||
if (curX !== clampedX || curY !== clampedY) {
|
||||
Animated.spring(pan, { toValue: { x: clampedX, y: clampedY }, useNativeDriver: false }).start();
|
||||
}
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
|
||||
@@ -41,94 +41,31 @@ function clamp(v: number, lo: number, hi: number) {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
function decodePvBuffer(buffer: ArrayBuffer): number[] {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
const timestamps: number[] = [];
|
||||
function decodePvBuffer(bytes: Uint8Array): number[] {
|
||||
// B站 pvdata 格式:packed uint16 little-endian,每个值 = 帧时间戳(单位:10ms)
|
||||
const result: number[] = [];
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
|
||||
let i = 0;
|
||||
while (i < bytes.length) {
|
||||
const tag = bytes[i++];
|
||||
const wireType = tag & 0x07;
|
||||
const fieldNum = tag >> 3;
|
||||
|
||||
// 我们主要关心 repeated float32,通常 field 1 或直接数据
|
||||
if (wireType === 5) {
|
||||
// fixed32 / float32
|
||||
if (i + 4 > bytes.length) break;
|
||||
timestamps.push(view.getFloat32(i, true)); // little-endian
|
||||
i += 4;
|
||||
} else if (wireType === 2) {
|
||||
// length-delimited → 进入子消息或 packed repeated
|
||||
let len = 0;
|
||||
let shift = 0;
|
||||
while (true) {
|
||||
if (i >= bytes.length) break;
|
||||
const b = bytes[i++];
|
||||
len |= (b & 0x7f) << shift;
|
||||
shift += 7;
|
||||
if (!(b & 0x80)) break;
|
||||
}
|
||||
const end = i + len;
|
||||
// packed repeated float32 最常见情况:直接连续 float32
|
||||
while (i + 4 <= end) {
|
||||
timestamps.push(view.getFloat32(i, true));
|
||||
i += 4;
|
||||
}
|
||||
// 如果不是 packed,也跳过
|
||||
} else if (wireType === 0) {
|
||||
// varint
|
||||
while (i < bytes.length && bytes[i++] & 0x80);
|
||||
} else if (wireType === 1) {
|
||||
// fixed64
|
||||
i += 8;
|
||||
} else {
|
||||
break; // 未知类型,停止
|
||||
}
|
||||
for (let i = 0; i + 1 < bytes.byteLength; i += 2) {
|
||||
// uint16 值除以 100 转换为秒
|
||||
result.push(view.getUint16(i, true) / 100);
|
||||
}
|
||||
|
||||
// 过滤掉明显异常值(比如负数或极大值)
|
||||
return timestamps.filter((t) => t >= 0 && t < 86400); // 视频不会超过24小时
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadPvData(url: string) {
|
||||
async function loadPvData(url: string): Promise<number[]> {
|
||||
const realUrl = url.startsWith("//") ? `https:${url}` : url;
|
||||
|
||||
try {
|
||||
// 选择缓存目录下的一个子目录(避免污染根缓存)
|
||||
const cacheDir = new Directory(Paths.cache, "bili_pvdata");
|
||||
|
||||
// 如果目录不存在,创建(intermediates: true 自动创建父目录)
|
||||
if (!cacheDir.exists) {
|
||||
await cacheDir.create({ intermediates: true });
|
||||
}
|
||||
|
||||
// 下载文件到这个目录(会自动用远程文件名,或你可以指定 File)
|
||||
// 这里用 Directory 作为 destination,SDK 会从 URL 或 header 推导文件名
|
||||
const downloadedFile: File = await File.downloadFileAsync(
|
||||
realUrl,
|
||||
cacheDir,
|
||||
{
|
||||
headers: HEADERS,
|
||||
idempotent: true, // 如果文件已存在,覆盖(避免重复下载失败)
|
||||
},
|
||||
);
|
||||
console.log("Downloaded to:", downloadedFile.uri);
|
||||
// 读取为 base64(如果你原来的 decodeFloats/decodePvBuffer 用 base64)
|
||||
// const base64 = await downloadedFile.base64();
|
||||
// 更好:直接读 binary 为 Uint8Array,然后转 ArrayBuffer
|
||||
const bytes: Uint8Array = await downloadedFile.bytes();
|
||||
const nums = new Uint16Array(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.byteLength / 2,
|
||||
);
|
||||
|
||||
return nums;
|
||||
} catch (error) {
|
||||
console.error("loadPvData failed:", error);
|
||||
throw error;
|
||||
const cacheDir = new Directory(Paths.cache, "bili_pvdata");
|
||||
if (!cacheDir.exists) {
|
||||
await cacheDir.create({ intermediates: true });
|
||||
}
|
||||
const downloadedFile = await File.downloadFileAsync(realUrl, cacheDir, {
|
||||
headers: HEADERS,
|
||||
idempotent: true,
|
||||
});
|
||||
const bytes: Uint8Array = await downloadedFile.bytes();
|
||||
return decodePvBuffer(bytes);
|
||||
}
|
||||
|
||||
function findFrameIdx(timestamps: number[], seekTime: number): number {
|
||||
@@ -231,13 +168,9 @@ export function NativeVideoPlayer({
|
||||
if (shotData?.image?.length) {
|
||||
setShots(shotData);
|
||||
if (shotData.pvdata) {
|
||||
try {
|
||||
loadPvData(shotData.pvdata).then((r) => {
|
||||
setShotTimestamps(r);
|
||||
});
|
||||
} catch {
|
||||
setShotTimestamps([]);
|
||||
}
|
||||
loadPvData(shotData.pvdata)
|
||||
.then((r) => setShotTimestamps(r))
|
||||
.catch(() => setShotTimestamps([]));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -351,10 +284,11 @@ export function NativeVideoPlayer({
|
||||
|
||||
// Use pvdata timestamps for accurate frame lookup; fall back to linear interpolation
|
||||
const seekTime = touchRatio * duration;
|
||||
const frameIdx =
|
||||
const rawIdx =
|
||||
shotTimestamps.length > 0
|
||||
? findFrameIdx(shotTimestamps, seekTime)
|
||||
: Math.floor(touchRatio * (totalFrames - 1));
|
||||
const frameIdx = clamp(rawIdx, 0, totalFrames - 1);
|
||||
|
||||
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
|
||||
const local = frameIdx % framesPerSheet;
|
||||
@@ -421,7 +355,11 @@ export function NativeVideoPlayer({
|
||||
resizeMode="contain"
|
||||
controls={false}
|
||||
paused={paused}
|
||||
onProgress={({ currentTime: ct, seekableDuration: dur, playableDuration: buf }) => {
|
||||
onProgress={({
|
||||
currentTime: ct,
|
||||
seekableDuration: dur,
|
||||
playableDuration: buf,
|
||||
}) => {
|
||||
setCurrentTime(ct);
|
||||
if (dur > 0) setDuration(dur);
|
||||
setBuffered(buf);
|
||||
@@ -505,14 +443,20 @@ export function NativeVideoPlayer({
|
||||
<View
|
||||
style={[
|
||||
styles.trackLayer,
|
||||
{ width: `${bufferedRatio * 100}%` as any, backgroundColor: "rgba(255,255,255,0.35)" },
|
||||
{
|
||||
width: `${bufferedRatio * 100}%` as any,
|
||||
backgroundColor: "rgba(255,255,255,0.35)",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* Played: accent color on top */}
|
||||
<View
|
||||
style={[
|
||||
styles.trackLayer,
|
||||
{ width: `${progressRatio * 100}%` as any, backgroundColor: "#00AEEC" },
|
||||
{
|
||||
width: `${progressRatio * 100}%` as any,
|
||||
backgroundColor: "#00AEEC",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -20,7 +20,7 @@ interface Props {
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function VideoCard({ item, onPress }: Props) {
|
||||
export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.card}
|
||||
@@ -55,7 +55,7 @@ export function VideoCard({ item, onPress }: Props) {
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
|
||||
Reference in New Issue
Block a user