在react native中实现短视频平台滑动视频播放组件

短视频软件现在人人都离不开,最大的特点之一就是可以无限连刷,通常是上下滑动切换视频,划入自动播放,整体的功能看起来简单清晰明了,但是不同技术栈实现放肆肯定不同,今天在expo创建的react native环境中实现

开发环境关键依赖版本:

  • expo:54+
  • react-native:0.81+
  • react:19.1+
  • expo-video
  • react-native-reanimated:4.1+
  • @shopify/flash-list:2+

版本依赖有必要简单交代一下,版本不同依赖不匹配会有大问题,本功能片段来自本地视频查看功能,列表加播放,可以控制播放进度,并将选中的视频中的音频提取为独立文件:

核心思路:

  1. 每条视频占据可视区域的绝大部分(满屏);
  2. 上下滑动切换(列表);
  3. 滑动切换视频自动播放,隐藏的视频不再播放(处于可视区域播放,不可视区域终止播放,释放内存)
  4. 播放暂停进度条控制,播放时间等简易功能

用户在查看本地视频时分两步走:先以较小的缩略图查看笼统的视频列表,然后可以点击任意一个视频弹出页面中 上下滑动切换播放视频,因此这是两个列表,在rn中有、FlatList列表组件,如下是图1图2的列表组件:

tsx 复制代码
import { useEffect, useState, useRef, type FC, useCallback, useMemo } from 'react';
import { formatTime } from '@/utils';
import { getAssetsAsync, MediaType, requestPermissionsAsync, type AssetsOptions } from 'expo-media-library';
import { StyleSheet, Text, useWindowDimensions, View, RefreshControl } from 'react-native';
import CustomButton from '@/components/ui/CustomButton';
import Empty from '@/components/ui/Empty';
import type { VideoModalProps } from '@/components/VideoModal';
import GlobalLoading from '@/components/GloablLoading';
import VideoListItem from '@/components/VideoListItem';
import { FlashList } from "@shopify/flash-list";
import type { VideoAsset } from '@/types';
import { useThemeNotification, useTheme } from '@/hooks/useTheme';
import ListFooter from '@/components/ui/ListFooter';
interface VideoListProps {
    onPress?: (video: VideoAsset) => void;
}
/**
 * 视频列表页面组件
 * 该组件用于展示设备中的视频文件列表,并支持分页加载、权限申请等功能。
 * 用户可以选择某个视频进行后续操作。
 *
 * @param onVideoSelect 当用户点击某一个视频项时触发的回调函数,接收选中的 Asset 对象作为参数
 */
const VideoList: FC<VideoListProps> = ({ onPress }) => {
    const [video, setVideo] = useState<VideoAsset | null>(null);
    const [videos, setVideos] = useState<VideoAsset[]>([]);
    const [loading, setLoading] = useState(false);
    const [hasPermission, setHasPermission] = useState<boolean | null>(null);
    const [after, setAfter] = useState<string | undefined>(undefined);
    const [hasMore, setHasMore] = useState(true);
    // 防止 FlatList 在首次挂载时触发 onEndReached 导致意外加载,初次加载判定ref的值设置为false
    const initialEndReachedRef = useRef<boolean>(true);
    // 计算响应式列数 - 强制3列布局
    const targetColumns = 3; // 目标列数
    const padding = 32; // 左右边距总和
    const [modalVisible, setModalVisible] = useState(false);
    const [modalLoading, setModalLoading] = useState(false);
    const [VideoModal, setVideoModal] = useState<FC<VideoModalProps> | null>(null);
    const { width } = useWindowDimensions();
    const theme = useTheme();
    // 强制使用3列布局(除非屏幕太窄)
    const numColumns = width > 320 ? targetColumns : 2;
    // 确定每次加载的视频数量,确保能填充3列
    const first = numColumns === 3 ? 21 : 20;
    // 使用margin控制间距,所以这里不考虑spacing
    const itemWidth = useMemo(() => (width - padding - numColumns * 10) / numColumns, [width, numColumns]); // 10是左右的margin总和
    const showNotification = useThemeNotification();
    /**
     * 请求权限并获取视频列表
     *
     * 此方法会先请求媒体库读取权限,如果授权成功则调用 fetchVideos 方法加载视频数据。
     * 如果未获得权限,则弹出提示框告知用户。
     *
     * @param reset 是否重置当前已有的视频列表,默认为 false
     * @returns Promise<void>
     */
    const requestPermissionAndGetVideos = async (reset = true) => {
        try {
            setLoading(true);
            // 请求媒体库权限
            const { status } = await requestPermissionsAsync();
            if (status !== 'granted') {
                showNotification({ tip: '需要访问媒体库权限才能获取视频文件,请在设置中开启权限。', type: 'warning' });
                setHasPermission(false);
                return;
            }
            setHasPermission(true);
            // 获取视频文件,重新请求权限时默认重置列表
            await fetchVideos(reset);
        } catch (error) {
            showNotification({ tip: '获取视频文件失败,请重试', type: 'error' });
        } finally {
            setLoading(false);
        }
    };
    /**
     * 使用expo-media-library 获取视频文件列表
     * 和expo-document-picker 获取视频文件列表结果不太一样,expo-media-library 会返回更多的视频文件
     * 调用系统 API 获取指定范围内的视频资源,并更新状态以渲染到界面。
     * 支持分页加载与刷新功能。
     *
     * @param reset 是否清空已有数据后重新加载,默认为 false
     * @returns Promise<void>
     */
    const fetchVideos = async (reset = false) => {
        try {
            const options: AssetsOptions = {
                mediaType: MediaType.video, // 只获取视频文件
                first, // 每次加载根据列数动态调整
                after: reset ? undefined : after, // 分页加载
                sortBy: [['modificationTime', false]], // 按修改时间降序排序
            };
            const { assets = [], endCursor, hasNextPage } = await getAssetsAsync(options);
            const videoList = assets.map(asset => ({
                ...asset,
                durationString: formatTime(asset.duration),
            }));
            if (reset) {
                setVideos(videoList);
            } else {
                setVideos(prev => [...prev, ...videoList]);
            }
            setAfter(endCursor);
            setHasMore(hasNextPage);
        } catch (error) {
            console.error('获取视频列表失败:', error);
            throw error;
        }
    };
    const handlePressItem = useCallback((item: VideoAsset) => {
        setVideo(item);
        if (!VideoModal) {
            setModalLoading(true);
            import('@/components/VideoModal').then(module => {
                setVideoModal(() => module.default);
                setModalVisible(true);
            }).finally(() => setModalLoading(false));
        } else {
            setModalVisible(true);
        }
    }, [VideoModal]);
    const handleConvertPress = (video: VideoAsset) => {
        setVideo(video);
        onPress?.(video);
        setModalVisible(false);
    };
    /**
     * 渲染单个视频项组件
     * 展示缩略图、名称及持续时间等信息。点击可触发选择事件。
     * @param param 包含 item 字段的对象,代表当前要渲染的 Asset 数据
     * @returns JSX 元素
     */
    const renderVideoItem = useCallback(({ item }: { item: VideoAsset }) => (
        <VideoListItem
            item={item}
            itemWidth={itemWidth}
            isSelected={item.id === video?.id}
            onPress={handlePressItem}
        />
    ), [itemWidth, video?.id, handlePressItem]);

    // 初始化时请求权限(调用被注释或由上层控制时,FlatList 的 onEndReached 可能在挂载时触发,导致 fetchVideos 被调用)
    useEffect(() => {
        requestPermissionAndGetVideos();
    }, []);

    /**
     * 页面初次加载可能会导致 FlatList 触发 onEndReached 事件,导致意外加载
     * 因此初次执行FlatList 的 onEndReached 时,只标记已触发,修改ref的值为false,
     * 不执行加载,待后续值为false再执行请求
     * 处理列表滚动到底部的事件回调函数
     * 该函数用于实现无限滚动加载功能,在用户滚动到列表底部时自动加载更多数据
     *
     * 暂不触底加载更多视频,点击底部加载更多按钮触发加载,触发不稳定
     * @returns {Promise<void>} 返回一个空的 Promise,表示异步操作完成
     */
    const handleEndReached = async () => {
        if (!hasMore || loading) return;
        // 第一次触发 onEndReached 时只标记已触发,不执行加载(常见于 FlatList 在 mount 时触发)
        if (initialEndReachedRef.current) {
            initialEndReachedRef.current = false;
            return;
        }
        await fetchVideos();
    };
    return (
        <View style={styles.container}>
            {hasPermission === false ? (
                <View style={styles.permissionContainer}>
                    <Text style={styles.permissionText}>需要媒体库权限才能访问视频文件</Text>
                    <CustomButton text="重新请求权限" onPress={() => requestPermissionAndGetVideos()} />
                </View>
            ) : (
                <>
                    <Text style={styles.countText}>
                        {videos.length > 0 ? `共找到 ${videos.length} 个视频文件` : ''}
                    </Text>
                    <FlashList
                        style={{ width }}
                        data={videos}
                        refreshControl={<RefreshControl
                            tintColor="#007aff"
                            refreshing={loading}
                            onRefresh={() => fetchVideos(true)}
                            colors={['red']}
                            progressBackgroundColor="transparent"
                        />}
                        showsVerticalScrollIndicator={false}
                        renderItem={renderVideoItem}
                        keyExtractor={({ id }, index) => `${id}-${index}`}
                        numColumns={numColumns}
                        ListEmptyComponent={!loading ? <Empty
                            tip="没有找到视频文件"
                            style={styles.emptyContainer}
                            onPress={() => requestPermissionAndGetVideos()}
                        /> : null}
                        onEndReached={handleEndReached}
                        onEndReachedThreshold={0.1}
                        ListFooterComponent={!loading ? <ListFooter
                            hasMore={hasMore}
                            onPress={() => fetchVideos()}
                        /> : null}
                        contentContainerStyle={styles.gridListContainer}
                    />
                </>
            )}
            {modalLoading && <GlobalLoading />}
            {
                VideoModal && (<VideoModal
                    visible={modalVisible}
                    onClose={() => setModalVisible(false)}
                    videoList={videos}
                    currentVideo={video}
                    onPress={handleConvertPress}
                />)
            }
        </View>
    );
};

const styles = StyleSheet.create({
    container: {
        flex: 1,
    },
    gridListContainer: {
        paddingHorizontal: 16,
    },
    emptyContainer: {
        marginTop: 50,
    },
    permissionContainer: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
        paddingHorizontal: 32,
    },
    permissionText: {
        fontSize: 16,
        color: '#333333',
        textAlign: 'center',
        marginBottom: 20,
    },
    countText: {
        fontSize: 14,
        color: '#666666',
        textAlign: 'center',
        paddingVertical: 5,
    },
});

以上是三列列表,元素使用ImageBackground渲染,此时还不具备播放功能,当点击任意一项时将打开VideoModal组件,它是用rn的Modal编写的组件,在其中有一个垂直一列的列表,每一项都会渲染expo-video的VideoView组件:

tsx 复制代码
import { useRef, useState, useCallback, useEffect, type FC } from 'react';
import { View, Modal, StyleSheet, Dimensions, TouchableOpacity, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import VideoItem from './VideoItem';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { FlashList, type FlashListRef } from "@shopify/flash-list";
import type { VideoAsset } from '@/types';
import { Image } from 'expo-image';
export type VideoModalProps = {
    videoList: Array<VideoAsset>;
    visible: boolean;
    onClose: () => void;
    currentVideo: VideoAsset | null;
    onPress?: (video: VideoAsset) => void;
};
const list = [
    {
        id: '0',
        icon: 'heart',
        count: '1.1万',
        color: '#fff',
    },
    {
        id: '1',
        icon: 'chatbubble-ellipses',
        count: '123',
        color: '#fff',
    },
    {
        id: '2',
        icon: 'star',
        count: '123',
        color: '#fff',
    },
    {
        id: '3',
        icon: 'arrow-redo',
        count: '123',
        color: '#fff',
    },
    {
        id: '4',
        icon: 'build',
        count: '转音频',
        color: 'orangered',
    },
]
const VideoModal: FC<VideoModalProps> = ({ visible, onClose, onPress, videoList = [], currentVideo = null }) => {
    const [activeVideo, setActiveVideo] = useState<VideoAsset | null>(currentVideo);
    const flatListRef = useRef<FlashListRef<VideoAsset>>(null);
    const { height } = Dimensions.get('screen');
    const { bottom, top } = useSafeAreaInsets();
    const viewabilityConfig = useRef({
        itemVisiblePercentThreshold: 80,
        minimumViewTime: 100,
    }).current;

    // 处理可见性变化
    const onViewableItemsChanged = useCallback(({ viewableItems }: any) => {
        // 获取所有可见的项目
        if (viewableItems.length === 1) {
            // 使用第一个可见项目(FlatList确保只有一个项目是主要可见的)
            setActiveVideo(viewableItems[0].item);
        }
    }, []);
    // 获取初始滚动索引
    const getInitialIndex = useCallback(() => {
        if (!currentVideo) return 0;
        const index = videoList.findIndex(v => v.id === currentVideo.id);
        return index >= 0 ? index : 0;
    }, [currentVideo, videoList]);

    // 当modal打开或视频列表变化时,初始化activeVideoId
    useEffect(() => {
        if (visible && currentVideo) {
            setActiveVideo(currentVideo);
        }
    }, [visible, currentVideo]);
    const handlePress = (id: string) => {
        if (id === '4') {
            if (activeVideo) {
                onPress?.(activeVideo);
            }
        }
    };
    return (
        <Modal
            visible={visible}
            transparent
            animationType="slide"
            onRequestClose={onClose}
        >
            <View style={styles.container}>
                <TouchableOpacity
                    style={[styles.closeButton, { top: top + 10 }]}
                    onPress={onClose}
                >
                    <Ionicons name="close" size={30} color="white" />
                </TouchableOpacity>
                <FlashList
                    data={videoList}
                    keyExtractor={({ id }) => id}
                    ref={flatListRef}
                    showsVerticalScrollIndicator={false}
                    pagingEnabled
                    decelerationRate="fast"
                    initialScrollIndex={getInitialIndex()}
                    renderItem={({ item }) => (
                        <VideoItem
                            top={top}
                            bottom={bottom}
                            modalVisible={visible}
                            video={item}
                            isActive={item.id === activeVideo?.id}
                            height={height}
                        />
                    )}
                    onViewableItemsChanged={onViewableItemsChanged}
                    viewabilityConfig={viewabilityConfig}
                    removeClippedSubviews={false}
                />
                <View style={[styles.sideList, { bottom: bottom + 70 }]}>
                    <TouchableOpacity
                        style={[styles.sideItem, { marginBottom: 10 }]}
                    >
                        <Image
                            source={require('@/assets/images/singer/singer2.png')}
                            style={styles.photo}
                        />
                        <Text style={[styles.ItemText, styles.plus]}>+</Text>
                    </TouchableOpacity>
                    {
                        list.map(({ id, color, icon, count }) => (
                            <TouchableOpacity
                                key={id}
                                style={styles.sideItem}
                                onPress={() => handlePress(id)}
                            >
                                <Ionicons
                                    name={icon as any}
                                    size={32}
                                    color={color}
                                />
                                <Text style={[styles.ItemText, { color }]}>{count}</Text>
                            </TouchableOpacity>
                        ))
                    }
                </View>
            </View>
        </Modal>
    );
};

const styles = StyleSheet.create({
    container: {
        flex: 1,
    },
    photo: {
        width: 42,
        height: 42,
        borderRadius: 20,
    },
    plus: {
        width: 34,
        height: 15,
        borderRadius: 20,
        position: 'absolute',
        bottom: -10,
        textAlign: 'center',
        backgroundColor: '#ccc',
        fontWeight: 700,
        lineHeight: 15,
        left: '50%',
        transform: [{ translateX: -17 }],
    },
    closeButton: {
        position: 'absolute',
        right: 20,
        zIndex: 10,
    },
    sideList: {
        alignItems: 'center',
        gap: 10,
        position: 'absolute',
        right: 20,
    },
    sideItem: {
        alignItems: 'center',
        gap: 5,
    },
    ItemText: {
        color: '#fff',
        fontSize: 12,
    },
});

如上,可以上下滑动的视频播放列表核心代码在于列表项的每一项应该考虑高度应该是视口高度,列表每次滑动距离应该是视口高度,因此需要获取视口高度和上下的安全距离,将这些传递给视频列表项,如下是列表项组件

tsx 复制代码
import { useEffect, useState, useRef, type FC, act } from 'react';
import { View, StyleSheet, Text, Pressable, TouchableOpacity } from 'react-native';
import { VideoView } from 'expo-video';
import type { VideoAsset } from '@/types';
import { useVideoPlayer } from '@/hooks/useVideoPlayer';
import Slider from '@react-native-community/slider';
import { Ionicons } from '@expo/vector-icons';
import { formatTime } from '@/utils';
import Animated, { useSharedValue, withTiming, useAnimatedStyle } from 'react-native-reanimated';
type Props = {
    video: VideoAsset;
    isActive: boolean;
    height: number;
    modalVisible: boolean;
    top?: number;
    bottom?: number;
};

/**
 * 视频列表子项多,如果每个item都useVideoPler(uri)会在初始化时创建播放器导致崩溃闪退
 * expo-video的videoView必须接收一个player对象且必须存在,否则报错
 * 因此,使用createVideoPlayer创建一个播放器对象,并保存在组件内部,避免 hooks不能在useEffect中创建播放器对象
 * 1.监听isActive变化,当isActive为true时,创建播放器并播放视频,当isActive为false时,暂停并释放播放器
 * 视频列表子项组件
 * FlatList渲染的视频子项
 * @param video 当前组件应该播放的视频
 * @param isActive 当前视频是否处于屏幕上,是否应该播放
 * @param height 视频组件高度
 * @returns JSX 元素
 */
const VideoItem: FC<Props> = ({ video, isActive, height, modalVisible, top, bottom }) => {
    const { player, isPlayerReady, isPlaying, play, pause } = useVideoPlayer({
        uri: video.uri,
        isActive,
        modalVisible
    });
    const [currentTime, setCurrentTime] = useState(0);
    const [visible, setVisible] = useState(true);
    const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
    const topTranslateY = useSharedValue(-50);
    const bottomTranslateY = useSharedValue(0);
    const centerOpacity = useSharedValue(0);
    const centerDisplay = useSharedValue<'flex' | 'none'>('flex');
    const topStyle = useAnimatedStyle(() => ({
        transform: [{ translateY: topTranslateY.value }],
    }));
    const centerStye = useAnimatedStyle(() => ({
        opacity: centerOpacity.value,
        display: centerDisplay.value,
    }));
    const bottomStyle = useAnimatedStyle(() => ({
        transform: [{ translateY: bottomTranslateY.value }],
    }));
    const handlleControl = () => {
        if (isPlaying) {
            pause();
        } else {
            play();
        }
    };
    // 监听播放进度变化
    useEffect(() => {
        if (!player || !isPlayerReady) {
            return;
        }
        // 定时更新播放进度
        let progressInterval: any;
        if (isPlaying) {
            progressInterval = setInterval(() => {
                if (player && player.currentTime) {
                    setCurrentTime(player.currentTime);
                }
            }, 1000); // 每100ms更新一次
        }
        return () => {
            if (progressInterval) {
                clearInterval(progressInterval);
            }
        };
    }, [player, isPlayerReady, isPlaying]);
    const clearTimer = () => {
        if (timerRef.current) {
            clearTimeout(timerRef.current);
            timerRef.current = null;
        }
    };
    useEffect(() => {
        if (visible) {
            clearTimer();
            topTranslateY.value = withTiming(0, { duration: 300 });
            centerOpacity.value = withTiming(1, { duration: 300 });
            bottomTranslateY.value = withTiming(0, { duration: 300 });
            centerDisplay.value = 'flex';
            timerRef.current = setTimeout(() => {
                topTranslateY.value = withTiming(-50, { duration: 300 });
                centerOpacity.value = withTiming(0, { duration: 300 });
                bottomTranslateY.value = withTiming(70, { duration: 300 });
                centerDisplay.value = 'none';
                setVisible(false)
            }, 3000);
        };
        return () => {
            clearTimer();
        };
    }, [visible])
    const handlePressBg = () => {
        setVisible(!visible);
    }

    return (
        <Pressable
            style={[styles.container, { height, paddingTop: top, paddingBottom: bottom }]}
            onPress={handlePressBg}
        >
            {/* 只有当播放器存在且准备就绪时才渲染 VideoView */}
            {player && isPlayerReady && player.status !== 'idle' && (
                <VideoView
                    style={[StyleSheet.absoluteFill, { marginBottom: bottom, marginTop: top }]}
                    player={player}
                    nativeControls={false}
                    fullscreenOptions={{
                        enable: true, //能否全屏
                        orientation: 'landscape', //全屏时屏幕方向
                        autoExitOnRotate: false, //屏幕旋转时是否退出全屏
                    }}
                />
            )}
            <View style={[styles.overlay, { top }]}>
                <Animated.Text
                    style={[styles.videoTitle, topStyle]}
                    numberOfLines={1}
                >
                    {video.filename ?? '未知视频'}
                </Animated.Text>
                <Animated.View
                    style={[styles.center, centerStye]}
                >
                    <TouchableOpacity onPress={handlleControl}>
                        <Ionicons name={isPlaying ? "pause" : "play"} size={50} color="white" />
                    </TouchableOpacity>
                </Animated.View>
                <Animated.View style={[styles.bottom, bottomStyle]}>
                    <Slider
                        style={styles.slider}
                        minimumValue={0}
                        maximumValue={video.duration || 0}
                        value={currentTime}
                        onSlidingComplete={(value) => {
                            if (player) {
                                player.currentTime = value; // expo-video 使用秒为单位
                            }
                        }}
                        minimumTrackTintColor="#fff" // 滑块左侧轨道颜色
                        maximumTrackTintColor="#d3d3d3" // 滑块右侧轨道颜色
                        thumbTintColor="#fff" // 滑块颜色
                    />
                    <View style={styles.time}>
                        <Text
                            numberOfLines={1}
                            style={styles.timeText}
                        >{formatTime(currentTime)}</Text>
                        <Text style={styles.timeText}>/</Text>
                        <Text style={styles.timeText} numberOfLines={1}>{video.durationString}</Text>
                    </View>
                </Animated.View>
            </View>
        </Pressable>
    );
};

const styles = StyleSheet.create({
    container: {
        backgroundColor: '#000',
    },
    time: {
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center',
        gap: 5,
    },
    timeText: {
        color: '#fff',
        fontSize: 14,
    },
    slider: {
        height: 20,
        width: '70%',
    },
    center: {
        height: 70,
        justifyContent: 'center',
        alignItems: 'center',
    },
    overlay: {
        width: '100%',
        height: '100%',
        paddingHorizontal: 20,
        position: 'absolute',
        justifyContent: 'center',
        overflow: 'hidden',
    },
    bottom: {
        height: 70,
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'space-between',
        marginTop: 10,
        width: '100%',
        position: 'absolute',
        bottom: 0,
        left: 20,
    },
    videoTitle: {
        fontSize: 16,
        fontWeight: 'bold',
        width: 200,
        color: '#fff',
        height: 50,
        lineHeight: 50,
        position: 'absolute',
        left: 20,
        top: 0,
    },
    duration: {
        fontSize: 14,
        marginTop: 4,
        color: '#fff',
    },
});

视频组件使用的依赖库是expo-video,这个依赖库中的VideoView组件是视频组件,必须接收VideoPlayer类型的player属性,而这么多视频我们不可能都创建出这个player,创建player的方式有两种,

  1. 用该依赖库提供的useAudioPlayer hooks创建,不用手动处理卸载加载 这意味着视频列表项目很多,而hooks又不能在条件语句中执行,这将会导致视频列表模态框加载即崩溃, 上面的item项目每一个都初始化加载视频,又无法控制列表项出现在视口时创建player加载视频,所以在这个场景下肯定不行的,因此上面引入的useAudioPlayer组件不是依赖库提供的,而是基于下面的api按需封装的

  2. createAudioPlayer,,这个api可以控制创建player时机,以及释放销毁的时机,同样,VideoView仍然需要player,因此只有当播放器存在且准备就绪时才渲染 VideoView

  3. 正确更新当前播放时间,使用了上面的api就意味着不能再使用expo提供的useEvent和useEventListener监听player的播放情况,同样是因为hooks必须在组件顶层使用,

基于以上问题,播放进度更新,采用监听是否在播放,player是否存在,然后setInterval获取player.currentTime属性值,更新state

视频的播放卸载,按需编写一个hooks:

tsx 复制代码
import { useRef, useState, useCallback, useEffect } from 'react';
import { createVideoPlayer, type VideoPlayer } from 'expo-video';

interface UseVideoPlayerOptions {
    uri: string;
    isActive?: boolean;
    modalVisible?: boolean;
}

interface UseVideoPlayerReturn {
    player: VideoPlayer | null;
    isPlayerReady: boolean;
    isPlaying: boolean;
    play: () => void;
    pause: () => void;
    release: () => void;
}

export const useVideoPlayer = ({ uri, isActive = false, modalVisible = false }: UseVideoPlayerOptions): UseVideoPlayerReturn => {
    const playerRef = useRef<VideoPlayer | null>(null);
    const [player, setPlayer] = useState<VideoPlayer | null>(null);
    const [isPlayerReady, setIsPlayerReady] = useState(false);
    const [isPlaying, setIsPlaying] = useState(false);

    const initializePlayer = useCallback(async () => {
        try {
            // 清理之前的播放器
            if (playerRef.current && playerRef.current.status !== 'idle') {
                playerRef.current.pause();
                playerRef.current.release();
                playerRef.current = null;
            }

            setPlayer(null);
            setIsPlayerReady(false);
            setIsPlaying(false);

            // 只有在活跃和模态框可见时才创建新播放器
            if (modalVisible && isActive && uri) {
                const newPlayer = createVideoPlayer(uri);
                playerRef.current = newPlayer;

                // 监听播放状态
                const playingListener = newPlayer.addListener('playingChange', (event) => {
                    setIsPlaying(event.isPlaying);
                });

                // 监听播放器状态变化
                const statusListener = newPlayer.addListener('statusChange', (event) => {
                    if (event.status === 'readyToPlay') {
                        setPlayer(newPlayer);
                        setIsPlayerReady(true);
                        // 自动开始播放
                        newPlayer.play();
                    } else if (event.status === 'error') {
                        setPlayer(null);
                        setIsPlayerReady(false);
                    }
                });

                // 保存监听器引用以便清理
                (newPlayer as any).playingListener = playingListener;
                (newPlayer as any).statusListener = statusListener;
            }
        } catch (error) {
            setPlayer(null);
            setIsPlayerReady(false);
            setIsPlaying(false);
        }
    }, [modalVisible, isActive, uri]);

    const play = useCallback(() => {
        if (playerRef.current && playerRef.current.status !== 'idle') {
            playerRef.current.play();
        }
    }, []);

    const pause = useCallback(() => {
        if (playerRef.current && playerRef.current.status !== 'idle') {
            playerRef.current.pause();
        }
    }, []);

    const release = useCallback(() => {
        if (playerRef.current && playerRef.current.status !== 'idle') {
            // 清理监听器
            const playingListener = (playerRef.current as any).playingListener;
            const statusListener = (playerRef.current as any).statusListener;

            if (playingListener) {
                playingListener.remove();
            }
            if (statusListener) {
                statusListener.remove();
            }

            playerRef.current.pause();
            playerRef.current.release();
            playerRef.current = null;
        }
        setPlayer(null);
        setIsPlayerReady(false);
        setIsPlaying(false);
    }, []);

    useEffect(() => {
        initializePlayer()

        return () => {
            // 组件卸载时清理
            release();
        };
    }, [initializePlayer, release]);

    return {
        player,
        isPlayerReady,
        isPlaying,
        play,
        pause,
        release
    };
}

看似不复杂的需求(上面的右侧边栏的消息,评论都没有功能,主要是用于演示),就已经编写了这么多代码,最大的坑来自于expo-audio,也没有去找其他视频相关的组件,大家可以作为参考,可能其他视频依赖库更简单,后续可能会更新一篇 在rn中使用ffmpeg-kit实现,提取视频文件的音频部分为独立文件的功能篇,这个功能已经实现,

有任何疑问见解欢迎评论区交流

相关推荐
前端程序猿之路2 小时前
模型应用开发的基础工具与原理之Web 框架
前端·python·语言模型·学习方法·web·ai编程·改行学it
名字被你们想完了2 小时前
Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(八)
前端·flutter
听风说图2 小时前
Figma画布协议揭秘:组件系统的设计哲学
前端
weibkreuz2 小时前
React开发者工具的下载及安装@4
前端·javascript·react
代码猎人2 小时前
link和@import有什么区别
前端
万少2 小时前
HarmonyOS6 接入快手 SDK 指南
前端·harmonyos
小肥宅仙女2 小时前
React + ECharts 多图表联动实战:从零实现 Tooltip 同步与锁定功能
前端·react.js·echarts
如果你好2 小时前
一文了解 Cookie、localStorage、sessionStorage的区别与实战案例
前端·javascript
鹏北海2 小时前
Vue3 + Axios 企业级请求封装实战:从零搭建完整的 HTTP 请求层
前端·vue.js·axios