Last active
April 16, 2019 16:05
-
-
Save jpescada/36090ee4af2bc92bba01579bd43751d0 to your computer and use it in GitHub Desktop.
Memory efficient video carousel for mobile as a React component
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // This component was coded for an interactive book for mobile devices | |
| // (ie: for touch interactions) where each page was a 1080p video with the | |
| // dominant colour in the video set as it’s background colour. | |
| // All pages are part of a slider (Slick carousel). | |
| // Each video has a poster image with the first frame of the video as well | |
| // as another poster image with the last frame of the video. When the video | |
| // ends and / or the page is turned, the poster for the ended video is swapped | |
| // for the end frame version and the video sources are removed and the video | |
| // reloaded to force the the device to flush the video from memory. | |
| import React, { Component } from 'react'; | |
| import Slider from 'react-slick'; | |
| import './styles.scss'; | |
| class Book extends Component { | |
| constructor(props) { | |
| super(props); | |
| this.state = { | |
| book: (window.APP && window.APP.book) || {timeLeft: 0}, | |
| currentPage: 0, | |
| bookPages: [ | |
| { video: 'book_1_0', colour: '#d0e5ea' }, | |
| { video: 'book_1_1', colour: '#dceaf0' }, | |
| { video: 'book_1_2', colour: '#e7d9c5' }, | |
| { video: 'book_1_3', colour: '#fcf4d4' }, | |
| { video: 'book_1_4', colour: '#d7e9ea' }, | |
| { video: 'book_2_1', colour: '#f9e6ca' }, | |
| { video: 'book_2_2', colour: '#c7dee7' }, | |
| { video: 'book_2_3', colour: '#fbc9b9' }, | |
| { video: 'book_2_4', colour: '#d9f0eb' }, | |
| { video: 'book_3_1', colour: '#c8ebee' }, | |
| { video: 'book_3_2', colour: '#d9f3eb' }, | |
| { video: 'book_3_3', colour: '#fdddd9' }, | |
| { video: 'book_3_4', colour: '#fef7eb' }, | |
| { video: 'book_4_1', colour: '#b5dedd' }, | |
| { video: 'book_4_2', colour: '#eafff9' }, | |
| { video: 'book_4_3', colour: '#c0e8d7' }, | |
| { video: 'book_4_4', colour: '#c6e4cd' }, | |
| { video: 'book_5_1', colour: '#eff9f5' }, | |
| { video: 'book_5_2', colour: '#e4ecff' }, | |
| { video: 'book_5_3', colour: '#e8e6fa' }, | |
| { video: 'book_5_4', colour: '#dff5e0' }, | |
| { video: 'book_6_1', colour: '#c5dbc4' }, | |
| { video: 'book_6_2', colour: '#d5f5f5' }, | |
| { video: 'book_6_3', colour: '#fef4c6' }, | |
| { video: 'book_6_4', colour: '#e3ece9' }, | |
| { video: 'book_6_5', colour: '#ecf3f4' } | |
| ] | |
| }; | |
| this.videoPlayers = []; | |
| this.bookAudio = new Audio(process.env.PUBLIC_URL +'/static/sounds/book_loop.mp3'); | |
| this.bookAudio.preload = true; | |
| this.bookAudio.loop = true; | |
| this.onVideoEnd = this.onVideoEnd.bind(this); | |
| this.onPagePress = this.onPagePress.bind(this); | |
| this.onPageRelease = this.onPageRelease.bind(this); | |
| this.preventDefaultEvent = this.preventDefaultEvent.bind(this); | |
| this.onVisibilityChange = this.onVisibilityChange.bind(this); | |
| this.onResize = this.onResize.bind(this); | |
| } | |
| componentDidMount() { | |
| document.addEventListener('visibilitychange', this.onVisibilityChange, false); | |
| window.addEventListener('resize', this.onResize, false); | |
| this.resizeVideos(); | |
| this.playCurrentVideo(); | |
| } | |
| componentWillUnmount() { | |
| this.pauseAudio(); | |
| clearTimeout(this.teaseTimeout); | |
| this.bookRef.current.removeEventListener('contextmenu', this.preventDefaultEvent, false); | |
| document.removeEventListener('visibilitychange', this.onVisibilityChange, false); | |
| window.removeEventListener('resize', this.onResize, false); | |
| } | |
| preventDefaultEvent(event) { | |
| event.preventDefault(); | |
| event.stopImmediatePropagation(); | |
| } | |
| onVisibilityChange(event) { | |
| this.pauseAudio(); | |
| this.pauseCurrentVideo(); | |
| } | |
| onResize(event) { | |
| this.resizeVideos(); | |
| } | |
| playAudio() { | |
| // Ignore if audio is already playing | |
| if (this.bookAudio && !this.bookAudio.paused && this.bookAudio.currentTime > 0) { | |
| return; | |
| } | |
| // Try to play audio | |
| const playPromise = this.bookAudio.play(); | |
| // Check if playback was allowed | |
| if (playPromise) { | |
| playPromise.then(_ => { | |
| // console.log('-- audio is playing.'); | |
| }).catch(error => { | |
| // console.log('-- audio failed to play.', error); | |
| }); | |
| } | |
| } | |
| pauseAudio() { | |
| if (this.bookAudio) { | |
| this.bookAudio.pause(); | |
| } | |
| } | |
| restartCurrentVideo() { | |
| this.playCurrentVideo(this.state.currentPage, 0); | |
| } | |
| playCurrentVideo(index, startFrom) { | |
| const videoIndex = Number.isInteger(index) ? index : this.state.currentPage; | |
| const videoPlayer = this.videoPlayers[videoIndex]; | |
| this.playVideo(videoPlayer, startFrom); | |
| this.playAudio(); | |
| } | |
| playVideo(videoPlayer, startFrom) { | |
| if (videoPlayer && videoPlayer.firstChild) { | |
| // Start video from a specific second | |
| if (Number.isInteger(startFrom)) { | |
| videoPlayer.currentTime = startFrom; | |
| } | |
| // Ignore if video already reached the end | |
| if (videoPlayer.duration === videoPlayer.currentTime) { | |
| return; | |
| } | |
| // Try to play video | |
| const playPromise = videoPlayer.play(); | |
| // Check if playback was allowed | |
| if (playPromise) { | |
| playPromise.then(_ => { | |
| // console.log('-- video is playing.'); | |
| }).catch(error => { | |
| console.log('-- video failed to play.', error); | |
| // TODO: Display play button? | |
| }); | |
| } | |
| } | |
| } | |
| pauseCurrentVideo(index) { | |
| const videoIndex = Number.isInteger(index) ? index : this.state.currentPage; | |
| const videoPlayer = this.videoPlayers[videoIndex]; | |
| this.pauseVideo(videoPlayer); | |
| } | |
| pauseVideo(videoPlayer) { | |
| if (videoPlayer) { | |
| videoPlayer.pause(); | |
| } | |
| } | |
| createVideoPlayerRef(index, elem) { | |
| const videoPlayers = [...this.videoPlayers]; | |
| videoPlayers[index] = elem; | |
| this.videoPlayers = videoPlayers; | |
| } | |
| resizeVideos(){ | |
| if (this.videoPlayers) { | |
| this.videoPlayers.forEach( (video) => { | |
| if (video) { | |
| video.width = window.innerWidth; | |
| video.height = window.innerHeight; | |
| } | |
| }); | |
| } | |
| } | |
| swapVideoPoster(video){ | |
| if (video && video.getAttribute('data-end-poster')) { | |
| // Swap poster image for an end frame image | |
| video.setAttribute('poster', video.getAttribute('data-end-poster')); | |
| video.removeAttribute('data-end-poster'); | |
| video = null; | |
| } | |
| } | |
| flushVideo(video) { | |
| if (video && video.firstChild) { | |
| // Pause video before flushing from memory | |
| video.pause(); | |
| this.swapVideoPoster(video); | |
| // Reset video.source.src and remove source nodes | |
| while (video.firstChild) { | |
| video.firstChild.removeAttribute('src'); | |
| video.removeChild(video.firstChild); | |
| } | |
| // Reload video player without a source | |
| video.load(); | |
| // Remove video from videoPlayers | |
| let videoIndex = parseInt( video.getAttribute('data-index') , 10); | |
| this.videoPlayers[ videoIndex ] = null; | |
| // Reset videoPlayers | |
| if (this.videoPlayers.length - 1 === videoIndex) { | |
| this.videoPlayers = []; | |
| } | |
| videoIndex = null; | |
| video = null; | |
| } | |
| } | |
| onVideoEnd(event) { | |
| if (this.state.currentPage === 0) { | |
| this.restartCurrentVideo(); | |
| this.teaseWithNextVideo(); | |
| } else { | |
| // Flush video if video ended after page was flipped | |
| if (this.state.currentPage !== parseInt(event.target.getAttribute('data-index'), 10)) { | |
| this.flushVideo(event.target); | |
| } | |
| } | |
| } | |
| onPagePress(event) { | |
| this.pauseCurrentVideo(); | |
| } | |
| onPageRelease(event) { | |
| this.playCurrentVideo(); | |
| this.playAudio(); | |
| } | |
| renderBookPage(page, index) { | |
| // Compose video path | |
| const videoPath = `${process.env.PUBLIC_URL}/static/videos/${page.video}`; | |
| // Preload end frame image | |
| const endFrameImage = document.createElement('link'); | |
| endFrameImage.setAttribute('rel', 'prefetch'); | |
| endFrameImage.setAttribute('href', `${videoPath}_end.jpg`); | |
| endFrameImage.setAttribute('as', 'image'); | |
| document.head.appendChild(endFrameImage); | |
| return ( | |
| <div | |
| key={index} | |
| className={`Book__page ${ | |
| this.state.currentPage === index ? 'Book__page--active' : '' | |
| }`} | |
| onTouchStart={this.onPagePress} | |
| onTouchEnd={this.onPageRelease}> | |
| <video | |
| ref={elem => this.createVideoPlayerRef(index, elem)} | |
| data-index={index} | |
| width="540" height="960" | |
| className="Book__video" | |
| style={{backgroundColor: page.colour}} | |
| muted playsInline preload={index === 0 ? 'auto' : 'none'} | |
| poster={`${videoPath}.jpg`} | |
| data-end-poster={`${videoPath}_end.jpg`} | |
| onEnded={this.onVideoEnd}> | |
| <source src={`${videoPath}.webm`} type="video/webm" /> | |
| <source src={`${videoPath}.mp4`} type="video/mp4" /> | |
| </video> | |
| </div> | |
| ); | |
| } | |
| render() { | |
| const sliderSettings = { | |
| dots: false, | |
| arrows: false, | |
| infinite: false, | |
| initialSlide: 0, | |
| slidesToShow: 1, | |
| slidesToScroll: 1, | |
| onReInit: () => { | |
| // Run only once | |
| if (!this.state.isSliderInited) { | |
| this.setState({ isSliderInited: true }); | |
| } | |
| }, | |
| beforeChange: (previous) => { | |
| // Wait to allow next slide to enter | |
| setTimeout((index) => { | |
| this.flushVideo(this.videoPlayers[index]); | |
| }, 375, previous); | |
| }, | |
| afterChange: current => { | |
| this.setState({ currentPage: current }); | |
| this.playCurrentVideo(current); | |
| } | |
| }; | |
| return( | |
| <div className="Book"> | |
| {this.state.book && this.state.book.timeLeft === 0 && | |
| <React.Fragment> | |
| <Slider className="Book__pages" | |
| {...sliderSettings}> | |
| {this.state.bookPages.map( | |
| (page, index) => this.renderBookPage(page, index) | |
| )} | |
| </Slider> | |
| <div className={`Book__howto ${ | |
| this.state.isHowToVisible ? 'Book__howto--visible' : '' | |
| }`}> | |
| <SwipeIcon /> | |
| <p>Swipe to<br/>turn pages</p> | |
| </div> | |
| </React.Fragment> | |
| } | |
| {(!this.state.book || this.state.book.timeLeft !== 0) && | |
| <div className="Book__coming-soon">Coming in {this.state.book.timeLeft}...</div> | |
| } | |
| </div> | |
| ); | |
| } | |
| } | |
| export default Book; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment