最近运用 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>
);

总结

能够看到开发过程中要考虑到的工作仍是许多的,下面供给完好的代码

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 Orientation from 'react-native-orientation-locker';
import dayjs from 'dayjs';
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);
      setProgressData({
        ...progressData,
        currentTime: value,
      });
    }
  };
  const assignInstance = (instance: Video | null) => {
    if (instance) {
      setPlayer(instance);
    }
  };
  const onLoad = ({naturalSize}: OnLoadData) => {
    if (player) {
      player.seek(currentTime);
    }
    setOrientation(naturalSize.orientation);
  };
  const onProgress = (data: OnProgressData) => {
    setProgressData(data);
  };
  const onRequestClose = () => {
    setFullscreen(false);
    setCurrentTime(progressData.currentTime);
    Orientation.lockToPortrait();
  };
  const togglePaused = () => {
    setPaused(!paused);
  };
  const toggleFullscreen = () => {
    const newFullscreenState = !fullscreen;
    setFullscreen(newFullscreenState);
    setCurrentTime(progressData.currentTime);
    orientation === 'landscape'
      ? Orientation.lockToLandscape()
      : 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={toggleFullscreen}
            progressData={progressData}
            onValueChange={onValueChange}
          />
        ) : (
          <>
            <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={onRequestClose}>
        <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={onRequestClose}
              progressData={progressData}
              onValueChange={onValueChange}
            />
          </View>
        </View>
      </Modal>
    </>
  );
}
/**
 * 不处理超越 24 小时的时刻
 * @param seconds
 * @returns
 */
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 playIconXml = `<svg   viewBox="0 0 11 13" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.427 5.875L1.486.907A1 1 0 0 0 0 1.782v9.934a1 1 0 0 0 1.486.874l8.94-4.967a1 1 0 0 0 0-1.748z" fill="#fff"/></svg>`;
const stopIconXml = `<svg   viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="6" y="5"   rx="1" fill="#fff"/><rect x="14" y="5"   rx="1" fill="#fff"/></svg>`;
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   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   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- stroke-linecap="round"/></svg>`;
const closeFullscreenIconXml = `<svg   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- 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;
}