From 3d40461a32dcdc9f7b1d9dd44102746f094e9adb Mon Sep 17 00:00:00 2001 From: olivier bouillet Date: Tue, 26 Apr 2022 22:59:04 +0200 Subject: [PATCH] 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 --- examples/basic/android/app/build.gradle | 2 + .../java/com/videoplayer/MainApplication.java | 5 +- examples/basic/android/settings.gradle | 6 + examples/basic/package.json | 7 +- examples/basic/src/VideoPlayer.android.tsx | 693 ++++++++++++++++-- examples/basic/yarn.lock | 7 +- 6 files changed, 660 insertions(+), 60 deletions(-) diff --git a/examples/basic/android/app/build.gradle b/examples/basic/android/app/build.gradle index c117dd5a..aa7ccf61 100644 --- a/examples/basic/android/app/build.gradle +++ b/examples/basic/android/app/build.gradle @@ -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) diff --git a/examples/basic/android/app/src/main/java/com/videoplayer/MainApplication.java b/examples/basic/android/app/src/main/java/com/videoplayer/MainApplication.java index 33c0d7da..c017e91d 100644 --- a/examples/basic/android/app/src/main/java/com/videoplayer/MainApplication.java +++ b/examples/basic/android/app/src/main/java/com/videoplayer/MainApplication.java @@ -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 getPackages() { return Arrays.asList( new MainReactPackage(), - new ReactVideoPackage() + new ReactVideoPackage(), + new RNCPickerPackage() ); } diff --git a/examples/basic/android/settings.gradle b/examples/basic/android/settings.gradle index 5fc7fca4..f367409d 100644 --- a/examples/basic/android/settings.gradle +++ b/examples/basic/android/settings.gradle @@ -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' diff --git a/examples/basic/package.json b/examples/basic/package.json index 6eebfe63..cf71263d 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -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", @@ -28,4 +29,4 @@ "metro-react-native-babel-preset": "^0.56.0", "react-test-renderer": "16.8.6" } -} \ No newline at end of file +} diff --git a/examples/basic/src/VideoPlayer.android.tsx b/examples/basic/src/VideoPlayer.android.tsx index 01757ed8..7065c024 100644 --- a/examples/basic/src/VideoPlayer.android.tsx +++ b/examples/basic/src/VideoPlayer.android.tsx @@ -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} - ) + ) } 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 ( - + { + this.toggleDecoration() + }} + > + {'decoration'} + + ) + } + + renderFullScreenControl() { + return ( + { + this.toggleFullscreen() + }} + > + {'fullscreen'} + + ) + } + + renderPause() { + return ( + { + this.setState({ paused: !this.state.paused }) + }} + > + + {this.state.paused ? 'pause' : 'playing'} + + + ) + } + + renderRepeatModeControl() { + return ( + { + this.setState({ loop: !this.state.loop }) + }} + > + + {this.state.loop ? 'loop enable' : 'loop disable'} + + + ) + } + + renderLeftControl() { + return ( + this.setState({ paused: !this.state.paused })} + onPress={() => { + this.channelDown() + }} > - - + + // onTimelineUpdated + ) + } + + renderRightControl() { + return ( + + { + this.channelUp() + }} + > + {'ChUp'} + + + ) + } + + /** + * 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 ( + + (this.seekerWidth = event.nativeEvent.layout.width)} + pointerEvents={'none'} + > + 0 ? this.state.seekerFillWidth : 0, + backgroundColor: '#FFF', + }, + ]} + pointerEvents={'none'} + /> + + 0 ? this.state.seekerPosition : 0 }, + ]} + pointerEvents={'none'} + > + + + + ) + } + + IndicatorLoadingView() { + if (this.state.isLoading) + return + else return + } + + renderOverlay() { + return ( + <> + {this.IndicatorLoadingView()} + + + {this.srcList[this.state.srcListId]?.description} + + + + {this.renderLeftControl()} + + + {this.renderRightControl()} + + + + {this.renderPause()} + + {this.renderRepeatModeControl()} + + + {this.renderFullScreenControl()} + + + {this.renderDecorationsControl()} + + {this.renderRateControl(0.25)} @@ -142,17 +564,114 @@ class VideoPlayer extends Component { {this.renderResizeModeControl('stretch')} + {this.renderSeekBar()} + + AudioTrack + {this.state.audioTracks?.length <= 0 ? ( + empty + ) : ( + { + console.log('on audio value change ' + itemValue) + this.setState({ + selectedAudioTrack: { + type: 'language', + value: itemValue, + }, + }) + }} + > + {this.state.audioTracks.map((track) => { + return ( + + ) + })} + + )} + TextTrack + {this.state.textTracks?.length <= 0 ? ( + empty + ) : ( + { + console.log('on value change ' + itemValue) + this.setState({ + selectedTextTrack: { + type: 'language', + value: itemValue, + }, + }) + }} + > + - - - - - + {this.state.textTracks.map((track) => ( + + ))} + + )} - - ); + + ) } + + renderVideoView() { + const viewStyle = this.state.fullscreen ? styles.fullScreen : styles.halfScreen + + return ( + + + ) + } + + render() { + return ( + + {this.srcList[this.state.srcListId]?.noView ? null : this.renderVideoView()} + {this.renderOverlay()} + + ) + } + } @@ -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,12 +770,48 @@ 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', }, }); -export default VideoPlayer \ No newline at end of file +export default VideoPlayer diff --git a/examples/basic/yarn.lock b/examples/basic/yarn.lock index e8eef4ca..6b8424bd 100644 --- a/examples/basic/yarn.lock +++ b/examples/basic/yarn.lock @@ -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"