最近运用 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"
/>
);
};
创立工具栏
视频组件的工具栏就三个功用,一个切换暂停和播映的按钮,中间是一条进度条,能调整视频进度和实时显现进度,进度条的左右两边分别是当时的播映时刻和视频时长,最右边是切换全屏的按钮
- 切换暂停和播映很好做,Video 组件供给了
paused
的 api,只要一个 boolean 变量,就能操控视频的播映和暂停
const [paused, setPaused] = useState(true);
const togglePaused = () => {
setPaused(!paused);
};
return <Video paused={paused} />;
- 进度条采用的是
@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"
/>
</>
);
- 关于全屏,Video 组件供给了 fullscreen 的接口,能够传入一个 boolean 变量,但是实测下来,fullscreen 为 true 时,只要状态栏会改变,详细的完成看下面。
全屏完成
首先创立一个 state 变量,用于全屏的切换。我们先假设一切的视频都是 width > height,那么完成全屏最简略的是强制横屏而且调整整个 View 的尺度,强制横屏我运用的是 react-native-orientation-locker
,react-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;
}