likes
comments
collection
share

react native 开发带全屏功能的 Video 组件实践

作者站长头像
站长
· 阅读数 18

最近使用 React native 开发 app,其中有要求视频播放的功能,遂记录下开发过程中的思路以及遇到的难点,方便做个回顾,也希望能帮助需要的朋友 测试视频链接:media.w3.org/2010/05/sin… 注:每个步骤的示例代码都做了简化,只保留了功能和思路相关的,最后我会一次性放出组件的代码

播放视频

播放视频我采用了最主流的 react-native-video。这个框架的使用其实很简单,只需要提供一条可以使用的视频链接(当然也可以是本地视频),设置宽高作为容器,就能使得视频开始播放了

import Video from 'react-native-video';

const SuperVideo = () => {
  return (
    <Video
      source={{uri: 'https://media.w3.org/2010/05/sintel/trailer.mp4'}}
      style={{
        width: 300,
        height: 200,
      }}
      resizeMode="contain"
    />
  );
};

创建工具栏

视频组件的工具栏就三个功能,一个切换暂停和播放的按钮,中间是一条进度条,能调整视频进度和实时显示进度,进度条的左右两边分别是当前的播放时间和视频时长,最右边是切换全屏的按钮

  1. 切换暂停和播放很好做,Video 组件提供了 paused 的 api,只要一个 boolean 变量,就能控制视频的播放和暂停
const [paused, setPaused] = useState(true);

const togglePaused = () => {
  setPaused(!paused);
};

return <Video paused={paused} />;
  1. 进度条采用的是 @react-native-community/slider 的 Slider 组件,用法请参考文档,Video 的 onProgress 提供了当前播放时间和视频总时长,但是都是秒数,显示为分钟和小时还需要写一个函数转换,调整时间使用 Video 的实例提供的 seek 方法去调整
import Slider from '@react-native-community/slider';
import dayjs from 'dayjs';

// 不考虑超过 10 小时的视频(e.g. 85 -> 01:25)
export const convertSeconds = (seconds: number): string => {
  let template = 'mm:ss';
  if (seconds > 36000) {
    template = 'HH:mm:ss';
  } else if (seconds > 3600) {
    template = 'H:mm:ss';
  }
  return dayjs().startOf('day').second(seconds).format(template);
};

const initProgressData: OnProgressData = {
  currentTime: 0,
  playableDuration: 0,
  seekableDuration: 0,
};

const [progressData, setProgressData] =
  useState<OnProgressData>(initProgressData);
const [player, setPlayer] = useState<Video | null>(null);

const onProgress = (data: OnProgressData) => {
  setProgressData(data);
};

return (
  <>
    <Video
      onProgress={onProgress}
      ref={instance => {
        setPlayer(instance);
      }}
    />
    <Slider
      style={{flex: 1}}
      minimumValue={0}
      value={progressData.currentTime}
      maximumValue={progressData.seekableDuration}
      onValueChange={value => {
        if (player) {
          player.seek(value);
          setProgressData({
            ...progressData,
            currentTime: value,
          });
        }
      }}
      minimumTrackTintColor="#fff"
      maximumTrackTintColor="#fff"
    />
  </>
);
  1. 关于全屏,Video 组件提供了 fullscreen 的接口,可以传入一个 boolean 变量,但是实测下来,fullscreen 为 true 时,只有状态栏会改变,具体的实现看下面。

全屏实现

首先创建一个 state 变量,用于全屏的切换。我们先假设所有的视频都是 width > height,那么实现全屏最简单的是强制横屏并且调整整个 View 的尺寸,强制横屏我使用的是 react-native-orientation-lockerreact-native-orientation 作为一个最近提交都是 5 年前的库,在当前 0.71 版本的 RN 会遇到一些构建问题,所以 react-native-orientation-locker 也挺不错。

import Orientation from 'react-native-orientation-locker';

const {width, height} = Dimensions.get('screen');

const toggleFullscreen = () => {
  const newFullscreenState = !fullscreen;
  setFullscreen(newFullscreenState);
  newFullscreenState
    ? Orientation.lockToLandscape()
    : Orientation.lockToPortrait();
};

return (
  <View
    style={[
      styles.wrapper,
      fullscreen
        ? {
            width: height,
            height: width,
            borderRadius: 0,
          }
        : {
            marginTop: 50,
            marginLeft: 20,
            marginRight: 20,
            height: 220,
          },
    ]}>
    <Video fullscreen={fullscreen} resizeMode="contain" />
  </View>
);

全屏实现优化版

上面的全屏实现其实还是有不少的缺陷,比如在一个 ScrollView 中,你会发现所谓的全屏就是一个大点的 View 而已,一滑动就露馅了,而且通常 App 都有 topbar 和 bottombar 这种,视频的层级大概率不比 topbar 和 bottombar 高,因此这两个会覆盖在 Video 上,另外基于我刷 B 站的习惯,总是习惯使用全面屏手势在手机边缘滑动退出全屏,一滑动,就退出当前的页面了,体验很不好。

为了解决这些问题,我们可以在全屏的时候打开一个 Modal,Modal 的层级最高,可以把 topbar 和 bottombar 都盖住,Modal 又提供了 onRequestClose 的方法,可以让我们使用全面屏手势在手机边缘滑动关闭全屏,可以说 Modal 完美地解决了上面的痛点

const onRequestClose = () => {
  setFullscreen(false);
  Orientation.lockToPortrait();
};

return (
  <Modal visible={fullscreen} onRequestClose={onRequestClose}>
    <View
      style={{
        width: '100%',
        height: '100%',
        backgroundColor: '#000',
      }}>
      <Video />
    </View>
  </Modal>
);

另外的问题是视频可能是竖屏视频,因此当点击全屏的时候,我们希望手机还保持竖屏状态,所以我们定义一个 state 变量来判断视频的宽高比,在 onLoad 那里对这个变量进行赋值,全屏的时候更加这个变量判断要不要改变方向

const [orientation, setOrientation] = useState<'portrait' | 'landscape'>(
  'landscape',
);
const onLoad = ({naturalSize}: OnLoadData) => {
  setOrientation(naturalSize.orientation);
};

const toggleFullscreen = () => {
  const newFullscreenState = !fullscreen;
  setFullscreen(newFullscreenState);
  if (newFullscreenState) {
    orientation === 'landscape'
      ? Orientation.lockToLandscape()
      : Orientation.lockToPortrait();
  }
};

总结

可以看到开发过程中要考虑到的事情还是很多的,下面提供完整的代码,欢迎评论区交流!

import React, {useState} from 'react';
import {
  StyleSheet,
  TouchableOpacity,
  Modal,
  StyleProp,
  ViewStyle,
  View,
  Text,
  ImageBackground,
  ImageSourcePropType,
} from 'react-native';
import {SvgXml} from 'react-native-svg';
import Video, {OnProgressData, OnLoadData} from 'react-native-video';
import Slider from '@react-native-community/slider';
import {
  convertSeconds,
  initProgressData,
  playIconXml,
  stopIconXml,
} from './common';
import Orientation from 'react-native-orientation-locker';

/**
 * TODO: 性能优化,降低拖选进度条的卡顿以及全屏的卡顿
 * 测试视频链接:https://media.w3.org/2010/05/sintel/trailer.mp4
 * @param props
 * @returns
 */
export default function SuperVideo({
  wrapperStyle,
  imageSource,
  videoSource,
}: SuperVideoProps) {
  const [touched, setTouched] = useState(false);
  const [fullscreen, setFullscreen] = useState(false);
  const [player, setPlayer] = useState<Video | null>(null);
  const [paused, setPaused] = useState(true);
  const [progressData, setProgressData] =
    useState<OnProgressData>(initProgressData);
  const [orientation, setOrientation] = useState<'portrait' | 'landscape'>(
    'landscape',
  );
  const [currentTime, setCurrentTime] = useState(0);

  const startVideo = () => {
    setTouched(true);
    setPaused(false);
  };

  const onValueChange = (value: number) => {
    if (player) {
      player.seek(value, 1000);
      setProgressData({
        ...progressData,
        currentTime: value,
      });
    }
  };

  const assignInstance = (instance: Video | null) => {
    if (instance) {
      setPlayer(instance);
    }
  };

  const onLoad = ({naturalSize}: OnLoadData) => {
    if (player) {
      player.seek(currentTime, 1000);
    }
    setOrientation(naturalSize.orientation);
  };

  const onProgress = (data: OnProgressData) => {
    setProgressData(data);
  };

  const togglePaused = () => {
    setPaused(!paused);
  };

  const openFullscreen = () => {
    setFullscreen(true);
    setCurrentTime(progressData.currentTime);
    orientation === 'landscape'
      ? Orientation.lockToLandscape()
      : Orientation.lockToPortrait();
  };

  const closeFullscreen = () => {
    setFullscreen(false);
    setCurrentTime(progressData.currentTime);
    Orientation.lockToPortrait();
  };

  const onEnd = () => {
    setPaused(true);
    if (player) {
      player.seek(0);
    }
    setProgressData(initProgressData);
  };

  return (
    <>
      <View style={[styles.wrapper, wrapperStyle]}>
        {!fullscreen && (
          <Video
            source={videoSource}
            style={[styles.videoBg, {bottom: 50}, !touched && {opacity: 0}]}
            resizeMode="contain"
            ref={assignInstance}
            onLoad={onLoad}
            paused={paused}
            onEnd={onEnd}
            onProgress={onProgress}
            fullscreen={fullscreen}
          />
        )}
        {touched ? (
          <Toolbar
            paused={paused}
            togglePaused={togglePaused}
            fullscreen={fullscreen}
            toggleFullscreen={openFullscreen}
            progressData={progressData}
            onValueChange={onValueChange}
          />
        ) : (
          <>
            {imageSource && (
              <ImageBackground
                source={imageSource}
                resizeMode="cover"
                style={styles.imageBg}
              />
            )}
            <TouchableOpacity onPress={startVideo} style={styles.playBox}>
              <SvgXml xml={BigPlayIconXml} style={styles.playIcon} />
            </TouchableOpacity>
          </>
        )}
      </View>
      <Modal visible={fullscreen} onRequestClose={closeFullscreen}>
        <View style={styles.modalStyle}>
          <View style={styles.fullscreenWrapper}>
            {fullscreen && (
              <Video
                source={videoSource}
                style={[styles.videoBg, {bottom: 30}]}
                resizeMode="contain"
                ref={assignInstance}
                onLoad={onLoad}
                paused={paused}
                onEnd={onEnd}
                onProgress={onProgress}
                fullscreen={fullscreen}
              />
            )}
            <Toolbar
              paused={paused}
              togglePaused={togglePaused}
              fullscreen={fullscreen}
              toggleFullscreen={closeFullscreen}
              progressData={progressData}
              onValueChange={onValueChange}
            />
          </View>
        </View>
      </Modal>
    </>
  );
}

const Toolbar = ({
  paused,
  togglePaused,
  fullscreen,
  toggleFullscreen,
  progressData,
  onValueChange,
}: ToolbarProps) => {
  return (
    <View style={[styles.toolbarStyle, {bottom: fullscreen ? 0 : 20}]}>
      <TouchableOpacity style={styles.iconBox} onPress={togglePaused}>
        <SvgXml xml={paused ? playIconXml : stopIconXml} />
      </TouchableOpacity>
      <Text style={styles.progressText}>
        {convertSeconds(progressData.currentTime)}
      </Text>
      <Slider
        style={styles.sliderStyle}
        minimumValue={0}
        value={progressData.currentTime}
        maximumValue={progressData.seekableDuration}
        onValueChange={onValueChange}
        minimumTrackTintColor="#fff"
        maximumTrackTintColor="#fff"
      />
      <Text style={styles.progressText}>
        {convertSeconds(progressData.seekableDuration)}
      </Text>
      <TouchableOpacity style={styles.iconBox} onPress={toggleFullscreen}>
        <SvgXml
          xml={fullscreen ? closeFullscreenIconXml : openFullscreenIconXml}
          width={20}
          height={20}
        />
      </TouchableOpacity>
    </View>
  );
};

const BigPlayIconXml = `<svg width="21" height="22" viewBox="0 0 21 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19.224 8.902L4.066.481C2.466-.408.5.749.5 2.579V19.42c0 1.83 1.966 2.987 3.566 2.098l15.158-8.421c1.646-.914 1.646-3.282 0-4.196z" fill="#fff"/></svg>`;
const openFullscreenIconXml = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 3H6c-1.414 0-2.121 0-2.56.44C3 3.878 3 4.585 3 6v2M8 21H6c-1.414 0-2.121 0-2.56-.44C3 20.122 3 19.415 3 18v-2M16 3h2c1.414 0 2.121 0 2.56.44C21 3.878 21 4.585 21 6v2M16 21h2c1.414 0 2.121 0 2.56-.44.44-.439.44-1.146.44-2.56v-2" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>`;
const closeFullscreenIconXml = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 3v1c0 1.886 0 2.828-.586 3.414C6.828 8 5.886 8 4 8H3M16 3v1c0 1.886 0 2.828.586 3.414C17.172 8 18.114 8 20 8h1M8 21v-1c0-1.886 0-2.828-.586-3.414C6.828 16 5.886 16 4 16H3M16 21v-1c0-1.886 0-2.828.586-3.414C17.172 16 18.114 16 20 16h1" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></svg>`;

const styles = StyleSheet.create({
  wrapper: {
    position: 'relative',
    backgroundColor: '#000',
    alignItems: 'center',
    justifyContent: 'center',
    overflow: 'hidden',
    borderRadius: 20,
  },
  imageBg: {
    position: 'absolute',
    top: 0,
    left: 0,
    bottom: 0,
    right: 0,
  },
  videoBg: {
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
  },
  playBox: {
    width: 60,
    height: 60,
    borderRadius: 30,
    backgroundColor: '#34657B',
  },
  playIcon: {
    marginTop: 17.5,
    marginLeft: 22.5,
  },
  progressText: {
    marginLeft: 5,
    marginRight: 5,
    color: '#fff',
  },
  modalStyle: {flex: 1, backgroundColor: '#000'},
  fullscreenWrapper: {
    position: 'relative',
    borderRadius: 0,
    width: '100%',
    height: '100%',
  },
  toolbarStyle: {
    position: 'absolute',
    left: 0,
    right: 0,
    flex: 1,
    flexDirection: 'row',
    justifyContent: 'center',
    alignItems: 'center',
    height: 30,
  },
  sliderStyle: {flex: 1},
  iconBox: {
    width: 30,
    height: 30,
    justifyContent: 'center',
    alignItems: 'center',
  },
});

interface SuperVideoProps {
  wrapperStyle?: StyleProp<ViewStyle>;
  imageSource?: ImageSourcePropType;
  videoSource: {
    uri?: string | undefined;
    headers?: {[key: string]: string} | undefined;
    type?: string | undefined;
  };
}

interface ToolbarProps {
  paused: boolean;
  togglePaused: () => void;
  fullscreen: boolean;
  toggleFullscreen: () => void;
  progressData: OnProgressData;
  onValueChange: (value: number) => void;
}