fix: improve basic player

- allow to set multiple video to to play and zap to next channel
- display toast on error
- add resizing test
- add a seek bar
- add text and audio tracks picker
- add loader during buffering
- add repeat mode test
- add toggle fullscreen
This commit is contained in:
olivier bouillet 2022-04-26 22:59:04 +02:00
parent 1e0c99b32c
commit 3d40461a32
6 changed files with 660 additions and 60 deletions

View File

@ -148,3 +148,5 @@ task copyDownloadableDepsToLibs(type: Copy) {
from configurations.compile
into 'libs'
}
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)

View File

@ -10,6 +10,8 @@ import com.facebook.react.ReactPackage;
import com.facebook.react.shell.MainReactPackage;
import com.facebook.soloader.SoLoader;
import com.reactnativecommunity.picker.RNCPickerPackage;
import java.util.Arrays;
import java.util.List;
@ -25,7 +27,8 @@ public class MainApplication extends MultiDexApplication implements ReactApplica
protected List<ReactPackage> getPackages() {
return Arrays.asList(
new MainReactPackage(),
new ReactVideoPackage()
new ReactVideoPackage(),
new RNCPickerPackage()
);
}

View File

@ -1,6 +1,12 @@
rootProject.name = 'VideoPlayer'
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
include ':react-native-video'
project(':react-native-video').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-video/android-exoplayer')
include ':@react-native-picker_picker'
project(':@react-native-picker_picker').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-picker/picker/android')
include ':app'

View File

@ -10,11 +10,12 @@
"lint": "eslint ."
},
"dependencies": {
"@react-native-picker/picker": "^1.9.11",
"babel-plugin-module-resolver": "^4.1.0",
"react": "^16.12.0",
"react-native": "0.61.5",
"react-native-windows": "^0.61.0-0",
"react-native-video": "file:../.."
"react-native-video": "../../",
"react-native-windows": "^0.61.0-0"
},
"devDependencies": {
"@babel/core": "^7.6.0",

View File

@ -9,9 +9,14 @@ import {
Text,
TouchableOpacity,
View,
ActivityIndicator,
PanResponder,
ToastAndroid,
} from 'react-native';
import Video from 'react-native-video';
import { Picker } from '@react-native-picker/picker'
import Video, { TextTrackType } from 'react-native-video';
class VideoPlayer extends Component {
@ -22,23 +27,126 @@ class VideoPlayer extends Component {
resizeMode: 'contain',
duration: 0.0,
currentTime: 0.0,
videoWidth: 0,
videoHeight: 0,
paused: false,
fullscreen: true,
decoration: true,
isLoading: false,
seekerFillWidth: 0,
seekerPosition: 0,
seekerOffset: 0,
seeking: false,
audioTracks: [],
textTracks: [],
selectedAudioTrack: undefined,
selectedTextTrack: undefined,
srcListId: 0,
loop: false,
};
seekerWidth = 0
srcList = [
{
description: 'demo with sintel Subtitles',
uri:
'http://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=51AF5F39AB0CEC3E5497CD9C900EBFEAECCCB5C7.8506521BFC350652163895D4C26DEE124209AA9E&key=ik0',
type: 'mpd',
},
{
description: 'subtitles',
uri: 'https://bitmovin-a.akamaihd.net/content/sintel/sintel.mpd',
},
{ description: 'Stopped playback', uri: undefined },
{
description: 'no View',
noView: true,
},
]
video: Video;
seekPanResponder: PanResponder | undefined;
onLoad = (data: any) => {
this.setState({ duration: data.duration });
this.setState({ duration: data.duration, loading: false, });
this.onAudioTracks(data.audioTracks)
this.onTextTracks(data.textTracks)
};
onProgress = (data: any) => {
this.setState({ currentTime: data.currentTime });
if (!this.state.seeking) {
const position = this.calculateSeekerPosition()
this.setSeekerPosition(position)
}
this.setState({ currentTime: data.currentTime })
};
onEnd = () => {
this.setState({ paused: true })
this.video.seek(0)
};
onVideoLoadStart = () => {
console.log('onVideoLoadStart')
this.setState({ isLoading: true })
}
onAudioTracks = (data: any) => {
const selectedTrack = data.audioTracks?.find((x: any) => {
return x.selected
})
this.setState({
audioTracks: data,
})
if (selectedTrack?.language) {
this.setState({
selectedAudioTrack: {
type: 'language',
value: selectedTrack?.language,
},
})
}
}
onTextTracks = (data: any) => {
const selectedTrack = data.textTracks?.find((x: any) => {
return x.selected
})
this.setState({
textTracks: data,
})
if (selectedTrack?.language) {
this.setState({
textTracks: data,
selectedTextTrack: {
type: 'language',
value: selectedTrack?.language,
},
})
}
}
onAspectRatio = (data: any) => {
console.log('onAspectRadio called ' + JSON.stringify(data))
this.setState({
videoWidth: data.width,
videoHeight: data.height,
})
}
onVideoBuffer = (param: any) => {
console.log('onVideoBuffer')
this.setState({ isLoading: param.isBuffering })
}
onReadyForDisplay = () => {
console.log('onReadyForDisplay')
this.setState({ isLoading: false })
}
onAudioBecomingNoisy = () => {
this.setState({ paused: true })
@ -48,7 +156,7 @@ class VideoPlayer extends Component {
this.setState({ paused: !event.hasAudioFocus })
};
getCurrentTimePercentage() {
getCurrentTimePercentage = () => {
if (this.state.currentTime > 0 && this.state.duration !== 0) {
return this.state.currentTime / this.state.duration;
}
@ -76,7 +184,7 @@ class VideoPlayer extends Component {
{resizeMode}
</Text>
</TouchableOpacity>
)
)
}
renderVolumeControl(volume: number) {
@ -91,36 +199,350 @@ class VideoPlayer extends Component {
)
}
render() {
const flexCompleted = this.getCurrentTimePercentage() * 100;
const flexRemaining = (1 - this.getCurrentTimePercentage()) * 100;
toast = (visible: boolean, message: string) => {
if (visible) {
ToastAndroid.showWithGravityAndOffset(
message,
ToastAndroid.LONG,
ToastAndroid.BOTTOM,
25,
50,
)
return null
}
return null
}
onError = (err: any) => {
console.log(JSON.stringify(err))
this.toast(true, 'error: ' + err?.error?.code)
}
onEnd = () => {
this.channelUp()
};
toggleFullscreen() {
this.setState({ fullscreen: !this.state.fullscreen })
}
toggleDecoration() {
this.setState({ decoration: !this.state.decoration })
if (this.state.decoration) {
this.video.dismissFullscreenPlayer()
} else {
this.video.presentFullscreenPlayer()
}
}
goToChannel(channel: any) {
this.setState({
srcListId: channel,
duration: 0.0,
currentTime: 0.0,
videoWidth: 0,
videoHeight: 0,
isLoading: false,
audioTracks: [],
textTracks: [],
selectedAudioTrack: undefined,
selectedTextTrack: undefined,
})
}
channelUp() {
console.log('channel up')
this.goToChannel((this.state.srcListId + 1) % this.srcList.length)
}
channelDown() {
console.log('channel down')
this.goToChannel((this.state.srcListId + this.srcList.length - 1) % this.srcList.length)
}
componentDidMount() {
this.initSeekPanResponder()
}
renderDecorationsControl() {
return (
<View style={styles.container}>
<TouchableOpacity
onPress={() => {
this.toggleDecoration()
}}
>
<Text style={[styles.controlOption]}>{'decoration'}</Text>
</TouchableOpacity>
)
}
renderFullScreenControl() {
return (
<TouchableOpacity
onPress={() => {
this.toggleFullscreen()
}}
>
<Text style={[styles.controlOption]}>{'fullscreen'}</Text>
</TouchableOpacity>
)
}
renderPause() {
return (
<TouchableOpacity
onPress={() => {
this.setState({ paused: !this.state.paused })
}}
>
<Text style={[styles.controlOption]}>
{this.state.paused ? 'pause' : 'playing'}
</Text>
</TouchableOpacity>
)
}
renderRepeatModeControl() {
return (
<TouchableOpacity
onPress={() => {
this.setState({ loop: !this.state.loop })
}}
>
<Text style={[styles.controlOption]}>
{this.state.loop ? 'loop enable' : 'loop disable'}
</Text>
</TouchableOpacity>
)
}
renderLeftControl() {
return (
<View>
<TouchableOpacity
style={styles.fullScreen}
onPress={() => this.setState({ paused: !this.state.paused })}
onPress={() => {
this.channelDown()
}}
>
<Video
ref={(ref: Video) => { this.video = ref }}
/* For ExoPlayer */
/* source={{ uri: 'http://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=51AF5F39AB0CEC3E5497CD9C900EBFEAECCCB5C7.8506521BFC350652163895D4C26DEE124209AA9E&key=ik0', type: 'mpd' }} */
source={require('./broadchurch.mp4')}
style={styles.fullScreen}
rate={this.state.rate}
paused={this.state.paused}
volume={this.state.volume}
muted={this.state.muted}
resizeMode={this.state.resizeMode}
onLoad={this.onLoad}
onProgress={this.onProgress}
onEnd={this.onEnd}
onAudioBecomingNoisy={this.onAudioBecomingNoisy}
onAudioFocusChanged={this.onAudioFocusChanged}
repeat={false}
/>
<Text style={[styles.leftRightControlOption]}>{'ChDown'}</Text>
</TouchableOpacity>
<View style={styles.controls}>
</View>
// onTimelineUpdated
)
}
renderRightControl() {
return (
<View>
<TouchableOpacity
onPress={() => {
this.channelUp()
}}
>
<Text style={[styles.leftRightControlOption]}>{'ChUp'}</Text>
</TouchableOpacity>
</View>
)
}
/**
* Render the seekbar and attach its handlers
*/
/**
* Constrain the location of the seeker to the
* min/max value based on how big the
* seeker is.
*
* @param {float} val position of seeker handle in px
* @return {float} constrained position of seeker handle in px
*/
constrainToSeekerMinMax(val = 0) {
if (val <= 0) {
return 0
} else if (val >= this.seekerWidth) {
return this.seekerWidth
}
return val
}
/**
* Set the position of the seekbar's components
* (both fill and handle) according to the
* position supplied.
*
* @param {float} position position in px of seeker handle}
*/
setSeekerPosition(position = 0) {
const state = this.state
position = this.constrainToSeekerMinMax(position)
state.seekerFillWidth = position
state.seekerPosition = position
if (!state.seeking) {
state.seekerOffset = position
}
this.setState(state)
}
/**
* Calculate the position that the seeker should be
* at along its track.
*
* @return {float} position of seeker handle in px based on currentTime
*/
calculateSeekerPosition() {
const percent = this.state.currentTime / this.state.duration
return this.seekerWidth * percent
}
/**
* Return the time that the video should be at
* based on where the seeker handle is.
*
* @return {float} time in ms based on seekerPosition.
*/
calculateTimeFromSeekerPosition() {
const percent = this.state.seekerPosition / this.seekerWidth
return this.state.duration * percent
}
/**
* Get our seekbar responder going
*/
initSeekPanResponder() {
this.seekPanResponder = PanResponder.create({
// Ask to be the responder.
onStartShouldSetPanResponder: (evt, gestureState) => true,
onMoveShouldSetPanResponder: (evt, gestureState) => true,
/**
* When we start the pan tell the machine that we're
* seeking. This stops it from updating the seekbar
* position in the onProgress listener.
*/
onPanResponderGrant: (evt, gestureState) => {
const state = this.state
// this.clearControlTimeout()
const position = evt.nativeEvent.locationX
this.setSeekerPosition(position)
state.seeking = true
this.setState(state)
},
/**
* When panning, update the seekbar position, duh.
*/
onPanResponderMove: (evt, gestureState) => {
const position = this.state.seekerOffset + gestureState.dx
this.setSeekerPosition(position)
},
/**
* On release we update the time and seek to it in the video.
* If you seek to the end of the video we fire the
* onEnd callback
*/
onPanResponderRelease: (evt, gestureState) => {
const time = this.calculateTimeFromSeekerPosition()
const state = this.state
if (time >= state.duration && !state.isLoading) {
state.paused = true
this.onEnd()
} else {
this.video?.seek(time)
state.seeking = false
}
this.setState(state)
},
})
}
renderSeekBar() {
if (!this.seekPanResponder) {
return null
}
return (
<View
style={styles.seekbarContainer}
{...this.seekPanResponder.panHandlers}
{...styles.generalControls}
>
<View
style={styles.seekbarTrack}
onLayout={(event) => (this.seekerWidth = event.nativeEvent.layout.width)}
pointerEvents={'none'}
>
<View
style={[
styles.seekbarFill,
{
width:
this.state.seekerFillWidth > 0 ? this.state.seekerFillWidth : 0,
backgroundColor: '#FFF',
},
]}
pointerEvents={'none'}
/>
</View>
<View
style={[
styles.seekbarHandle,
{ left: this.state.seekerPosition > 0 ? this.state.seekerPosition : 0 },
]}
pointerEvents={'none'}
>
<View
style={[
styles.seekbarCircle,
{ backgroundColor: '#FFF' },
]}
pointerEvents={'none'}
/>
</View>
</View>
)
}
IndicatorLoadingView() {
if (this.state.isLoading)
return <ActivityIndicator color="#3235fd" size="large" style={styles.IndicatorStyle} />
else return <View />
}
renderOverlay() {
return (
<>
{this.IndicatorLoadingView()}
<View style={styles.topControls}>
<Text style={[styles.controlOption]}>
{this.srcList[this.state.srcListId]?.description}
</Text>
</View>
<View style={styles.leftControls}>
<View style={styles.resizeModeControl}>{this.renderLeftControl()}</View>
</View>
<View style={styles.rightControls}>
<View style={styles.resizeModeControl}>{this.renderRightControl()}</View>
</View>
<View style={styles.bottomControls}>
<View style={styles.generalControls}>
<View style={styles.resizeModeControl}>{this.renderPause()}</View>
<View style={styles.resizeModeControl}>
{this.renderRepeatModeControl()}
</View>
<View style={styles.resizeModeControl}>
{this.renderFullScreenControl()}
</View>
<View style={styles.resizeModeControl}>
{this.renderDecorationsControl()}
</View>
</View>
<View style={styles.generalControls}>
<View style={styles.rateControl}>
{this.renderRateControl(0.25)}
@ -142,17 +564,114 @@ class VideoPlayer extends Component {
{this.renderResizeModeControl('stretch')}
</View>
</View>
{this.renderSeekBar()}
<View style={styles.generalControls}>
<Text style={styles.controlOption}>AudioTrack</Text>
{this.state.audioTracks?.length <= 0 ? (
<Text style={styles.controlOption}>empty</Text>
) : (
<Picker
style={styles.picker}
selectedValue={this.state.selectedAudioTrack?.value}
onValueChange={(itemValue, itemIndex) => {
console.log('on audio value change ' + itemValue)
this.setState({
selectedAudioTrack: {
type: 'language',
value: itemValue,
},
})
}}
>
{this.state.audioTracks.map((track) => {
return (
<Picker.Item
label={track.language}
value={track.language}
key={track.language}
/>
)
})}
</Picker>
)}
<Text style={styles.controlOption}>TextTrack</Text>
{this.state.textTracks?.length <= 0 ? (
<Text style={styles.controlOption}>empty</Text>
) : (
<Picker
style={styles.picker}
selectedValue={this.state.selectedTextTrack?.value}
onValueChange={(itemValue, itemIndex) => {
console.log('on value change ' + itemValue)
this.setState({
selectedTextTrack: {
type: 'language',
value: itemValue,
},
})
}}
>
<Picker.Item label={'none'} value={'none'} key={'none'} />
<View style={styles.trackingControls}>
<View style={styles.progress}>
<View style={[styles.innerProgressCompleted, { flex: flexCompleted }]} />
<View style={[styles.innerProgressRemaining, { flex: flexRemaining }]} />
</View>
{this.state.textTracks.map((track) => (
<Picker.Item
label={track.language}
value={track.language}
key={track.language}
/>
))}
</Picker>
)}
</View>
</View>
</View>
);
</>
)
}
renderVideoView() {
const viewStyle = this.state.fullscreen ? styles.fullScreen : styles.halfScreen
return (
<TouchableOpacity style={viewStyle}>
<Video
ref={(ref: Video) => {
this.video = ref
}}
source={this.srcList[this.state.srcListId]}
style={viewStyle}
rate={this.state.rate}
paused={this.state.paused}
volume={this.state.volume}
muted={this.state.muted}
resizeMode={this.state.resizeMode}
onLoad={this.onLoad}
onProgress={this.onProgress}
onEnd={this.onEnd}
progressUpdateInterval={1000}
onError={this.onError}
onAudioBecomingNoisy={this.onAudioBecomingNoisy}
onAudioFocusChanged={this.onAudioFocusChanged}
onLoadStart={this.onVideoLoadStart}
onVideoAspectRatio={this.onAspectRatio}
onReadyForDisplay={this.onReadyForDisplay}
onBuffer={this.onVideoBuffer}
repeat={this.state.loop}
selectedTextTrack={this.state.selectedTextTrack}
selectedAudioTrack={this.state.selectedAudioTrack}
/>
</TouchableOpacity>
)
}
render() {
return (
<View style={styles.container}>
{this.srcList[this.state.srcListId]?.noView ? null : this.renderVideoView()}
{this.renderOverlay()}
</View>
)
}
}
@ -163,6 +682,13 @@ const styles = StyleSheet.create({
alignItems: 'center',
backgroundColor: 'black',
},
halfScreen: {
position: 'absolute',
top: 50,
left: 50,
bottom: 100,
right: 100,
},
fullScreen: {
position: 'absolute',
top: 0,
@ -170,7 +696,7 @@ const styles = StyleSheet.create({
bottom: 0,
right: 0,
},
controls: {
bottomControls: {
backgroundColor: 'transparent',
borderRadius: 5,
position: 'absolute',
@ -178,19 +704,33 @@ const styles = StyleSheet.create({
left: 20,
right: 20,
},
progress: {
leftControls: {
backgroundColor: 'transparent',
borderRadius: 5,
position: 'absolute',
top: 20,
bottom: 20,
left: 20,
},
rightControls: {
backgroundColor: 'transparent',
borderRadius: 5,
position: 'absolute',
top: 20,
bottom: 20,
right: 20,
},
topControls: {
backgroundColor: 'transparent',
borderRadius: 4,
position: 'absolute',
top: 20,
left: 20,
right: 20,
flex: 1,
flexDirection: 'row',
borderRadius: 3,
overflow: 'hidden',
},
innerProgressCompleted: {
height: 20,
backgroundColor: '#cccccc',
},
innerProgressRemaining: {
height: 20,
backgroundColor: '#2C2C2C',
paddingBottom: 10,
},
generalControls: {
flex: 1,
@ -215,6 +755,13 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
leftRightControlOption: {
alignSelf: 'center',
fontSize: 11,
color: 'white',
padding: 10,
lineHeight: 12,
},
controlOption: {
alignSelf: 'center',
fontSize: 11,
@ -223,10 +770,46 @@ const styles = StyleSheet.create({
paddingRight: 2,
lineHeight: 12,
},
trackingControls: {
IndicatorStyle: {
flex: 1,
justifyContent: 'center',
},
seekbarContainer: {
flex: 1,
flexDirection: 'row',
borderRadius: 4,
height: 30,
},
seekbarTrack: {
backgroundColor: '#333',
height: 1,
position: 'relative',
top: 14,
width: '100%',
},
seekbarFill: {
backgroundColor: '#FFF',
height: 1,
width: '100%',
},
seekbarHandle: {
position: 'absolute',
marginLeft: -7,
height: 28,
width: 28,
},
seekbarCircle: {
borderRadius: 12,
position: 'relative',
top: 8,
left: 8,
height: 12,
width: 12,
},
picker: {
color: 'white',
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@ -961,6 +961,11 @@
eslint-plugin-react-native "3.6.0"
prettier "1.16.4"
"@react-native-picker/picker@^1.9.11":
version "1.16.8"
resolved "https://registry.yarnpkg.com/@react-native-picker/picker/-/picker-1.16.8.tgz#2126ca54d4a5a3e9ea5e3f39ad1e6643f8e4b3d4"
integrity sha512-pacdQDX6V6EmjF+HoiIh6u++qx4mTK0WnhgUHRc01B+Qt5eoeUwseBqmqfTSXTx/aHDEd6PiIw7UGvKgFoqgFQ==
"@types/babel__core@^7.1.0":
version "7.1.3"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.3.tgz#e441ea7df63cd080dfcd02ab199e6d16a735fc30"
@ -5463,7 +5468,7 @@ react-is@^16.8.4, react-is@^16.8.6:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.9.0.tgz#21ca9561399aad0ff1a7701c01683e8ca981edcb"
integrity sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==
"react-native-video@file:../..":
react-native-video@../../:
version "5.2.0"
dependencies:
keymirror "^0.1.1"