├── .nvmrc ├── custom-typings ├── .gitkeep └── atlaskit.d.ts ├── example ├── assets │ └── .gitkeep ├── root_files │ ├── foo │ └── .gitkeep ├── video-renderer-flow.png ├── index.tsx ├── index.html ├── utils.ts ├── timeRange.tsx ├── styled.ts └── app.tsx ├── .prettierrc.json ├── .travis.yml ├── src ├── utils.ts ├── index.ts ├── text.tsx └── video.tsx ├── tsconfig.json ├── LICENSE ├── .gitignore ├── package.json ├── README.md └── __tests__ └── index.tsx /.nvmrc: -------------------------------------------------------------------------------- 1 | 12 2 | -------------------------------------------------------------------------------- /custom-typings/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/root_files/foo: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/root_files/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom-typings/atlaskit.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@atlaskit/*'; -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } -------------------------------------------------------------------------------- /example/video-renderer-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzarcon/react-video-renderer/HEAD/example/video-renderer-flow.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | cache: yarn 5 | script: 6 | - yarn 7 | - yarn test:ci 8 | notifications: 9 | email: false 10 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import App from './app'; 4 | 5 | ReactDOM.render(, document.getElementById('app')); 6 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const requestFullScreen = (element: HTMLVideoElement) => { 2 | const methods = ['requestFullscreen', 'webkitRequestFullscreen', 'mozRequestFullScreen', 'msRequestFullscreen']; 3 | const methodName = (methods as any).find((name: string) => (element as any)[name]); 4 | 5 | (element as any)[methodName](); 6 | } -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | VideoProps, 3 | VideoComponentState, 4 | VideoStatus, 5 | VideoError, 6 | VideoState, 7 | NavigateFunction, 8 | SetVolumeFunction, 9 | SetPlaybackSpeed, 10 | VideoActions, 11 | RenderCallback, 12 | SourceElement, 13 | } from './video'; 14 | export type { VideoTextTracks, VideoTextTracksProps, VideoTextTrack, VideoTextTrackKind } from './text'; 15 | export { Video as default } from './video'; 16 | -------------------------------------------------------------------------------- /example/utils.ts: -------------------------------------------------------------------------------- 1 | import {Component} from 'react'; 2 | 3 | export class ToolboxApp extends Component { 4 | onCheckboxChange = (propName: any) => () => { 5 | const currentValue = (this.state as any)[propName]; 6 | this.setState({ [propName]: !currentValue } as any); 7 | } 8 | 9 | onFieldTextChange = (propName: any) => (e: any) => { 10 | const value = e.target.value; 11 | 12 | (this as any).setState({ 13 | [propName]: value 14 | }); 15 | } 16 | } -------------------------------------------------------------------------------- /src/text.tsx: -------------------------------------------------------------------------------- 1 | export type VideoTextTracks = { 2 | subtitles?: VideoTextTracksProps; 3 | captions?: VideoTextTracksProps; 4 | descriptions?: VideoTextTracksProps; 5 | chapters?: VideoTextTracksProps; 6 | metadata?: VideoTextTracksProps; 7 | }; 8 | 9 | export type VideoTextTracksProps = { 10 | selectedTrackIndex?: number; 11 | tracks: VideoTextTrack[]; 12 | }; 13 | 14 | export type VideoTextTrack = { 15 | src: string; 16 | lang: string; 17 | label: string; 18 | }; 19 | 20 | export type VideoTextTrackKind = keyof VideoTextTracks; 21 | 22 | export const getVideoTextTrackId = (kind: VideoTextTrackKind, lang: string) => `${kind}-${lang}`; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "removeComments": true, 5 | "target": "es5", 6 | "jsx": "react", 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "strictFunctionTypes": true, 11 | "noImplicitThis": true, 12 | "alwaysStrict": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "importHelpers": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "keyofStringsOnly": true, 19 | "lib": [ 20 | "dom", 21 | "es2015", 22 | "es2015.promise", 23 | "es5", 24 | "scripthost", 25 | "es2015.collection", 26 | "es2015.symbol", 27 | "es2015.iterable", 28 | "es2015.promise" 29 | ], 30 | "types": ["react", "jest", "node"] 31 | }, 32 | "files": [ 33 | "./custom-typings/atlaskit.d.ts" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Hector Zarco 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # VSCode 61 | .vscode 62 | 63 | publish_dist 64 | dist -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-video-renderer", 3 | "version": "2.5.1", 4 | "main": "dist/es5/index.js", 5 | "jsnext:main": "dist/es2015/index.js", 6 | "module": "dist/es2015/index.js", 7 | "types": "dist/es5/index.d.ts", 8 | "repository": "git@github.com:zzarcon/react-video-renderer.git", 9 | "author": "Hector Leon Zarco Garcia ", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "@atlaskit/button": "^7.0.2", 13 | "@atlaskit/icon": "^11.0.0", 14 | "@atlaskit/single-select": "^4.0.3", 15 | "@atlaskit/spinner": "^8.0.0", 16 | "react-gh-corner": "^1.1.2", 17 | "ts-react-toolbox": "^1.0.1" 18 | }, 19 | "engines": { 20 | "node": ">=12.0.0" 21 | }, 22 | "scripts": { 23 | "bootstrap": "ts-react-toolbox init", 24 | "dev": "ts-react-toolbox dev", 25 | "test": "ts-react-toolbox test", 26 | "test:ci": "ts-react-toolbox test --runInBand --coverage", 27 | "build": "ts-react-toolbox build", 28 | "release": "ts-react-toolbox release", 29 | "lint": "ts-react-toolbox lint", 30 | "static": "ts-react-toolbox publish", 31 | "format": "ts-react-toolbox format", 32 | "analyze": "ts-react-toolbox analyze" 33 | }, 34 | "peerDependencies": { 35 | "react": "^16.3.0" 36 | }, 37 | "files": [ 38 | "dist" 39 | ], 40 | "keywords": [ 41 | "react", 42 | "video", 43 | "renderer", 44 | "render-props", 45 | "props", 46 | "actions", 47 | "state", 48 | "player", 49 | "video-player" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /example/timeRange.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Component } from 'react'; 3 | import { TimeLineWrapper, TimeLine, CurrentTimeLine, Thumb, BufferedTime } from './styled'; 4 | 5 | export interface TimeRangeProps { 6 | currentTime: number; 7 | bufferedTime: number; 8 | duration: number; 9 | onChange: (newTime: number) => void; 10 | } 11 | 12 | export interface TimeRangeState { 13 | isDragging: boolean; 14 | } 15 | 16 | export class TimeRange extends Component { 17 | state: TimeRangeState = { 18 | isDragging: false, 19 | }; 20 | 21 | componentDidMount() { 22 | document.addEventListener('mousemove', this.onMouseMove); 23 | document.addEventListener('mouseup', this.onMouseUp); 24 | } 25 | 26 | componentWillUnmount() { 27 | document.removeEventListener('mousemove', this.onMouseMove); 28 | document.removeEventListener('mouseup', this.onMouseUp); 29 | } 30 | 31 | onMouseMove = (e: MouseEvent) => { 32 | const { isDragging } = this.state; 33 | if (!isDragging) { 34 | return; 35 | } 36 | 37 | const { currentTime, onChange, duration } = this.props; 38 | const { movementX } = e; 39 | const thumbCorrection = 65; 40 | const movementPercentage = 41 | Math.abs(movementX) * 100 / duration / thumbCorrection; 42 | 43 | onChange( 44 | currentTime + (movementX > 0 ? movementPercentage : -movementPercentage), 45 | ); 46 | }; 47 | 48 | onMouseUp = () => { 49 | this.setState({ 50 | isDragging: false, 51 | }); 52 | }; 53 | 54 | onThumbMouseDown = () => { 55 | this.setState({ 56 | isDragging: true, 57 | }); 58 | }; 59 | 60 | onNavigate = (e: any) => { 61 | const { duration, onChange } = this.props; 62 | const event = e.nativeEvent; 63 | const x = event.x; 64 | const width = e.currentTarget.getBoundingClientRect().width; 65 | const currentTime = x * duration / width; 66 | 67 | onChange(currentTime); 68 | }; 69 | 70 | render() { 71 | const { currentTime, duration, bufferedTime } = this.props; 72 | const currentPosition = currentTime * 100 / duration; 73 | const bufferedTimePercentage = bufferedTime * 100 / duration; 74 | 75 | return ( 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-video-renderer [![Build Status](https://travis-ci.org/zzarcon/react-video-renderer.svg?branch=master)](https://travis-ci.org/zzarcon/react-video-renderer) 2 | 3 | > Build custom video players effortless 4 | 5 | * Render props, get all video state passed down as props. 6 | * Bidirectional flow to render and update the video state in a declarative way. 7 | * No side effects out of the box, you just need to build the UI. 8 | * Actions handling: play, pause, mute, unmute, navigate, etc 9 | * Dependency free, [<2KB size](https://bundlephobia.com/result?p=react-video-renderer) 10 | * Cross-browser support, no more browser hacks. 11 | 12 | ## Demo 🎩 13 | 14 | [https://zzarcon.github.io/react-video-renderer](https://zzarcon.github.io/react-video-renderer) 15 | 16 | ## Installation 🚀 17 | 18 | ```bash 19 | yarn add react-video-renderer 20 | ``` 21 | 22 | ## Usage ⛏ 23 | 24 | > Render video state and communicate user interactions up when volume or time changes. 25 | 26 | ```jsx 27 | import Video from 'react-video-renderer'; 28 | 29 | 42 | ``` 43 | 44 |
45 | Logo 46 |

47 |
48 | 49 | ## Api 💅 50 | 51 | ### Props 52 | 53 | ```typescript 54 | interface Props { 55 | src: string; 56 | children: RenderCallback; 57 | controls?: boolean; 58 | autoPlay?: boolean; 59 | preload?: string; 60 | textTracks?: VideoTextTracks; 61 | } 62 | ``` 63 | 64 | ### Render method 65 | 66 | ```typescript 67 | type RenderCallback = (reactElement: ReactElement, state: VideoState, actions: VideoActions, ref: React.RefObject) => ReactNode; 68 | ``` 69 | 70 | ### State 71 | 72 | ```typescript 73 | interface VideoState { 74 | status: 'playing' | 'paused' | 'errored'; 75 | currentTime: number; 76 | currentActiveCues: (kind: VideoTextTrackKind, lang: string) => TextTrackCueList | null | undefined; 77 | volume: number; 78 | duration: number; 79 | buffered: number; 80 | isMuted: boolean; 81 | isLoading: boolean; 82 | error?: MediaError | null; 83 | } 84 | ``` 85 | 86 | ### Actions 87 | 88 | ```typescript 89 | interface VideoActions { 90 | play: () => void; 91 | pause: () => void; 92 | navigate: (time: number) => void; 93 | setVolume: (volume: number) => void; 94 | requestFullscreen: () => void; 95 | mute: () => void; 96 | unmute: () => void; 97 | toggleMute: () => void; 98 | } 99 | ``` 100 | 101 | ## Error handling 💥 102 | 103 | > this is all you need to detect video errors 104 | 105 | ```jsx 106 | 123 | ``` 124 | 125 | ## Loading state ✨ 126 | 127 | > you can still interact with the player regardless if the video is loading or not 128 | 129 | ```jsx 130 | 144 | ``` 145 | 146 | ## Video text tracks 🚂 147 | 148 | > HTML5 [text tracks](https://html.spec.whatwg.org/multipage/media.html#the-track-element) support for videos. 149 | > 150 | > subtitles can be rendered natively, or they can be rendered using `VideoState.currentActiveCues` property: 151 | 152 | ```jsx 153 | 184 | ``` 185 | 186 | ## Author 🧔 187 | 188 | [@zzarcon](https://twitter.com/zzarcon) 189 | -------------------------------------------------------------------------------- /example/styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { injectGlobal } from 'styled-components'; 2 | 3 | injectGlobal` 4 | * { 5 | padding: 0; 6 | margin: 0; 7 | } 8 | 9 | .cdSVOz, .grZUYY { 10 | color: white !important; 11 | } 12 | `; 13 | 14 | export const AppWrapper = styled.div``; 15 | 16 | export const Timebar = styled.progress``; 17 | 18 | export const TimebarWrapper = styled.div` 19 | position: absolute; 20 | bottom: 0; 21 | left: 0; 22 | width: 100%; 23 | `; 24 | 25 | export interface VolumeWrapperProps { 26 | isMuted: boolean; 27 | } 28 | 29 | export const MutedIndicator = styled.div` 30 | width: 29px; 31 | height: 2px; 32 | position: absolute; 33 | top: 5px; 34 | left: 9px; 35 | background: white; 36 | transform: rotate(32deg) translateY(10px); 37 | opacity: 0; 38 | pointer-events: none; 39 | 40 | ${(props: VolumeWrapperProps) => 41 | props.isMuted 42 | ? ` 43 | opacity: 1; 44 | ` 45 | : ''} 46 | `; 47 | 48 | export const VolumeWrapper = styled.div` 49 | display: flex; 50 | align-items: center; 51 | cursor: pointer; 52 | position: relative; 53 | width: 35px; 54 | overflow: hidden; 55 | transition: width 0.3s ease-out; 56 | transition-delay: 1s; 57 | 58 | input { 59 | margin-left: 20px; 60 | } 61 | 62 | &:hover { 63 | width: 165px; 64 | transition: width 0.3s; 65 | } 66 | &:active { 67 | width: 165px; 68 | transition: width 0.3s; 69 | } 70 | `; 71 | 72 | export const TimeWrapper = styled.div` 73 | width: 700px; 74 | margin-right: 30px; 75 | `; 76 | 77 | export const CurrentTime = styled.div` 78 | width: 60px; 79 | `; 80 | 81 | export const BufferedProgress = styled.progress` 82 | width: 100%; 83 | `; 84 | 85 | export const TimeLine = styled.div` 86 | width: 100%; 87 | height: 3px; 88 | background-color: rgba(255, 255, 255, 0.2); 89 | border-radius: 5px; 90 | position: relative; 91 | `; 92 | 93 | export const CurrentTimeLine = styled.div` 94 | background-color: #f00; 95 | border-radius: inherit; 96 | height: inherit; 97 | position: absolute; 98 | top: 0; 99 | `; 100 | 101 | export const Thumb = styled.div` 102 | width: 13px; 103 | height: 13px; 104 | border-radius: 100%; 105 | background-color: #f00; 106 | position: absolute; 107 | right: 0; 108 | top: 50%; 109 | transform: translateY(-50%); 110 | `; 111 | 112 | export const BufferedTime = styled.div` 113 | background-color: rgba(255, 255, 255, 0.4); 114 | height: inherit; 115 | border-radius: inherit; 116 | `; 117 | 118 | export const TimeRangeWrapper = styled.div``; 119 | 120 | export const ControlsWrapper = styled.div` 121 | display: flex; 122 | align-items: center; 123 | color: #eee; 124 | user-select: none; 125 | font-size: 13px; 126 | justify-content: space-between; 127 | margin: 10px; 128 | `; 129 | 130 | export const TimeLineWrapper = styled.div` 131 | cursor: pointer; 132 | height: 20px; 133 | display: flex; 134 | align-items: center; 135 | 136 | &:hover { 137 | ${TimeLine} { 138 | height: 6px; 139 | } 140 | ${CurrentTimeLine} { 141 | min-width: 13px; 142 | } 143 | } 144 | `; 145 | 146 | export const LeftControls = styled.div` 147 | display: flex; 148 | align-items: center; 149 | `; 150 | 151 | export const RightControls = styled.div` 152 | display: flex; 153 | align-items: center; 154 | `; 155 | 156 | export const VideoRendererWrapper = styled.div` 157 | text-align: center; 158 | position: relative; 159 | top: 100px; 160 | width: 100%; 161 | overflow: hidden; 162 | background: black; 163 | height: calc(100vh - 200px); 164 | `; 165 | 166 | export const PlaybackSpeedWrapper = styled.div` 167 | > div { 168 | width: 135px; 169 | } 170 | 171 | label { 172 | float: left; 173 | margin-right: 10px; 174 | } 175 | `; 176 | 177 | export const VideoWrapper = styled.div` 178 | display: flex; 179 | width: 100%; 180 | height: 100%; 181 | align-items: center; 182 | justify-content: center; 183 | flex-direction: column; 184 | 185 | video { 186 | position: relative; 187 | height: 100%; 188 | } 189 | 190 | video::cue, 191 | video::-webkit-media-text-track-display-backdrop, 192 | video::-webkit-media-text-track-display, 193 | video::-webkit-media-text-track-container { 194 | opacity: 0; 195 | background-color: transparent !important; 196 | } 197 | `; 198 | 199 | export const ErrorWrapper = styled.div` 200 | color: white; 201 | position: absolute; 202 | left: 50%; 203 | top: 50%; 204 | transform: translate(-50%, -50%); 205 | font-size: 25px; 206 | `; 207 | 208 | export const SelectWrapper = styled.div` 209 | position: absolute; 210 | left: 50%; 211 | transform: translateX(-50%); 212 | z-index: 1; 213 | `; 214 | 215 | export const SpinnerWrapper = styled.div` 216 | position: absolute; 217 | top: 0; 218 | left: 0; 219 | width: 100%; 220 | height: 100%; 221 | display: flex; 222 | flex-direction: column; 223 | align-items: center; 224 | justify-content: center; 225 | `; 226 | 227 | export const BuiltWithWrapper = styled.div` 228 | position: absolute; 229 | left: 20px; 230 | top: 20px; 231 | font-size: 20px; 232 | border: 1px solid #ccc; 233 | border-radius: 3px; 234 | padding: 5px; 235 | 236 | a { 237 | color: black; 238 | } 239 | `; 240 | 241 | export const SubtitlesWrapper = styled.div` 242 | position: absolute; 243 | bottom: 120px; 244 | font-size: 1.8rem; 245 | font-weight: 600; 246 | -webkit-text-fill-color: white; 247 | -webkit-text-stroke-width: 1px; 248 | -webkit-text-stroke-color: black; 249 | color: white; 250 | `; 251 | -------------------------------------------------------------------------------- /src/video.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Component, ReactElement, ReactNode, SyntheticEvent, RefObject, MediaHTMLAttributes } from 'react'; 3 | import { VideoTextTracks, VideoTextTrackKind, getVideoTextTrackId } from './text'; 4 | import { requestFullScreen } from './utils'; 5 | 6 | export type VideoStatus = 'playing' | 'paused' | 'errored'; 7 | export type VideoError = MediaError | null; 8 | 9 | export interface VideoState { 10 | status: VideoStatus; 11 | currentTime: number; 12 | currentActiveCues: (kind: VideoTextTrackKind, lang: string) => TextTrackCueList | null | undefined; 13 | volume: number; 14 | duration: number; 15 | buffered: number; 16 | isMuted: boolean; 17 | isLoading: boolean; 18 | error?: VideoError; 19 | } 20 | 21 | export type NavigateFunction = (time: number) => void; 22 | export type SetVolumeFunction = (volume: number) => void; 23 | export type SetPlaybackSpeed = (speed: number) => void; 24 | 25 | export interface VideoActions { 26 | play: () => void; 27 | pause: () => void; 28 | navigate: NavigateFunction; 29 | setVolume: SetVolumeFunction; 30 | setPlaybackSpeed: SetPlaybackSpeed; 31 | requestFullscreen: () => void; 32 | mute: () => void; 33 | unmute: () => void; 34 | toggleMute: () => void; 35 | } 36 | 37 | export type RenderCallback = ( 38 | reactElement: ReactElement, 39 | state: VideoState, 40 | actions: VideoActions, 41 | ref: RefObject 42 | ) => ReactNode; 43 | 44 | export interface VideoProps { 45 | src: string; 46 | children: RenderCallback; 47 | defaultTime: number; 48 | sourceType: 'video' | 'audio'; 49 | controls: boolean; 50 | autoPlay: boolean; 51 | preload: string; 52 | poster?: string; 53 | crossOrigin?: string; 54 | textTracks?: VideoTextTracks; 55 | onCanPlay?: (event: SyntheticEvent) => void; 56 | onError?: (event: SyntheticEvent) => void; 57 | onTimeChange?: (time: number, duration: number) => void; 58 | } 59 | 60 | export interface VideoComponentState { 61 | currentTime: number; 62 | volume: number; 63 | status: VideoStatus; 64 | duration: number; 65 | buffered: number; 66 | isMuted: boolean; 67 | isLoading: boolean; 68 | error?: VideoError; 69 | } 70 | 71 | const getVolumeFromVideo = (video: SourceElement): { volume: number; isMuted: boolean } => { 72 | const volume = video.volume; 73 | const isMuted = volume === 0; 74 | 75 | return { 76 | volume, 77 | isMuted, 78 | }; 79 | }; 80 | 81 | export type SourceElement = HTMLVideoElement | HTMLAudioElement; 82 | const isSafari = typeof navigator !== 'undefined' ? /^((?!chrome|android).)*safari/i.test(navigator.userAgent) : false; 83 | 84 | export class Video extends Component { 85 | previousVolume: number = 1; 86 | previousTime: number = -1; 87 | videoRef: RefObject = React.createRef(); 88 | audioRef: RefObject = React.createRef(); 89 | hasCanPlayTriggered: boolean = false; 90 | 91 | state: VideoComponentState = { 92 | isLoading: true, 93 | buffered: 0, 94 | currentTime: 0, 95 | volume: 1, 96 | status: 'paused', 97 | duration: 0, 98 | isMuted: false, 99 | }; 100 | 101 | static defaultProps = { 102 | defaultTime: 0, 103 | sourceType: 'video', 104 | autoPlay: false, 105 | controls: false, 106 | preload: isSafari ? 'auto' : 'metadata', 107 | }; 108 | 109 | onLoadedData = () => { 110 | const { defaultTime } = this.props; 111 | if (this.currentElement) { 112 | this.currentElement.currentTime = defaultTime; 113 | } 114 | }; 115 | 116 | componentDidUpdate(prevProps: VideoProps) { 117 | const { src } = this.props; 118 | const { currentTime, status } = this.state; 119 | const hasSrcChanged = prevProps.src !== src; 120 | 121 | if (hasSrcChanged) { 122 | this.hasCanPlayTriggered = false; 123 | // TODO: add test to cover this case 124 | if (status === 'playing') { 125 | this.play(); 126 | } 127 | 128 | this.navigate(currentTime); 129 | } 130 | } 131 | 132 | private onVolumeChange = (event: SyntheticEvent) => { 133 | const video = event.target as SourceElement; 134 | const { volume, isMuted } = getVolumeFromVideo(video); 135 | this.setState({ 136 | volume, 137 | isMuted, 138 | }); 139 | }; 140 | 141 | private onTimeUpdate = (event: SyntheticEvent) => { 142 | const video = event.target as SourceElement; 143 | const { onTimeChange } = this.props; 144 | const { duration } = this.state; 145 | 146 | const flooredTime = Math.floor(video.currentTime); 147 | if (onTimeChange && flooredTime !== this.previousTime) { 148 | onTimeChange(flooredTime, duration); 149 | this.previousTime = flooredTime; 150 | } 151 | 152 | this.setState({ 153 | currentTime: video.currentTime, 154 | }); 155 | 156 | if (video.buffered.length) { 157 | const buffered = video.buffered.end(video.buffered.length - 1); 158 | 159 | this.setState({ buffered }); 160 | } 161 | }; 162 | 163 | private onCanPlay = (event: SyntheticEvent) => { 164 | const { onCanPlay } = this.props; 165 | const video = event.target as SourceElement; 166 | const { volume, isMuted } = getVolumeFromVideo(video); 167 | 168 | this.setState({ 169 | volume, 170 | isMuted, 171 | isLoading: false, 172 | currentTime: video.currentTime, 173 | duration: video.duration, 174 | }); 175 | 176 | if (!this.hasCanPlayTriggered) { 177 | // protect against browser firing this event multiple times 178 | this.hasCanPlayTriggered = true; 179 | onCanPlay && onCanPlay(event); 180 | } 181 | }; 182 | 183 | private onPlay = () => { 184 | this.setState({ 185 | status: 'playing', 186 | }); 187 | }; 188 | 189 | private onPause = () => { 190 | this.setState({ 191 | status: 'paused', 192 | }); 193 | }; 194 | 195 | private get videoState(): VideoState { 196 | const { currentTime, volume, status, duration, buffered, isMuted, isLoading, error } = this.state; 197 | 198 | return { 199 | currentTime, 200 | currentActiveCues: (kind: VideoTextTrackKind, lang: string) => 201 | this.videoRef.current?.textTracks.getTrackById(getVideoTextTrackId(kind, lang))?.activeCues, 202 | volume, 203 | status, 204 | duration, 205 | buffered, 206 | isMuted, 207 | isLoading, 208 | error, 209 | }; 210 | } 211 | 212 | private play = () => { 213 | this.currentElement && this.currentElement.play(); 214 | }; 215 | 216 | private pause = () => { 217 | this.currentElement && this.currentElement.pause(); 218 | }; 219 | 220 | private navigate = (time: number) => { 221 | this.setState({ currentTime: time }); 222 | this.currentElement && (this.currentElement.currentTime = time); 223 | }; 224 | 225 | private setVolume = (volume: number) => { 226 | this.setState({ volume }); 227 | this.currentElement && (this.currentElement.volume = volume); 228 | }; 229 | 230 | private setPlaybackSpeed = (playbackSpeed: number) => { 231 | this.currentElement && (this.currentElement.playbackRate = playbackSpeed); 232 | }; 233 | 234 | private get currentElement(): SourceElement | undefined { 235 | const { sourceType } = this.props; 236 | if (sourceType === 'video' && this.videoRef.current) { 237 | return this.videoRef.current; 238 | } else if (sourceType === 'audio' && this.audioRef.current) { 239 | return this.audioRef.current; 240 | } else { 241 | return undefined; 242 | } 243 | } 244 | 245 | private requestFullscreen = () => { 246 | const { sourceType } = this.props; 247 | if (sourceType === 'video') { 248 | requestFullScreen(this.currentElement as HTMLVideoElement); 249 | } 250 | }; 251 | 252 | private mute = () => { 253 | const { volume } = this.state; 254 | 255 | this.previousVolume = volume; 256 | this.setVolume(0); 257 | }; 258 | 259 | private unmute = () => { 260 | this.setVolume(this.previousVolume); 261 | }; 262 | 263 | private toggleMute = () => { 264 | const { volume } = this.videoState; 265 | 266 | if (volume > 0) { 267 | this.mute(); 268 | } else { 269 | this.unmute(); 270 | } 271 | }; 272 | 273 | private get actions(): VideoActions { 274 | const { play, pause, navigate, setVolume, setPlaybackSpeed, requestFullscreen, mute, unmute, toggleMute } = this; 275 | 276 | return { 277 | play, 278 | pause, 279 | navigate, 280 | setVolume, 281 | setPlaybackSpeed, 282 | requestFullscreen, 283 | mute, 284 | unmute, 285 | toggleMute, 286 | }; 287 | } 288 | 289 | private onDurationChange = (event: SyntheticEvent) => { 290 | const video = event.target as SourceElement; 291 | 292 | this.setState({ 293 | duration: video.duration, 294 | }); 295 | }; 296 | 297 | private onError = (event: SyntheticEvent) => { 298 | const { onError } = this.props; 299 | const video = event.target as SourceElement; 300 | 301 | this.setState({ 302 | isLoading: false, 303 | status: 'errored', 304 | error: video.error, 305 | }); 306 | 307 | onError && onError(event); 308 | }; 309 | 310 | private onWaiting = () => { 311 | this.setState({ isLoading: true }); 312 | }; 313 | 314 | private renderTracks = (kind: VideoTextTrackKind) => { 315 | const { textTracks } = this.props; 316 | 317 | if (textTracks && Array.isArray(textTracks[kind]?.tracks)) { 318 | const tracks = textTracks[kind]?.tracks; 319 | const selectedIndex = textTracks[kind]?.selectedTrackIndex; 320 | 321 | return ( 322 | <> 323 | {tracks?.map(({ src, lang, label }, index) => ( 324 | 333 | ))} 334 | 335 | ); 336 | } 337 | 338 | return null; 339 | }; 340 | 341 | render() { 342 | const { videoState, actions } = this; 343 | const { sourceType, poster, src, children, autoPlay, controls, preload, crossOrigin } = this.props; 344 | 345 | const props: Partial> = { 346 | src, 347 | preload, 348 | controls, 349 | autoPlay, 350 | onLoadedData: this.onLoadedData, 351 | onPlay: this.onPlay, 352 | onPause: this.onPause, 353 | onVolumeChange: this.onVolumeChange, 354 | onTimeUpdate: this.onTimeUpdate, 355 | onCanPlay: this.onCanPlay, 356 | onDurationChange: this.onDurationChange, 357 | onError: this.onError, 358 | onWaiting: this.onWaiting, 359 | crossOrigin, 360 | }; 361 | 362 | if (sourceType === 'video') { 363 | return children( 364 | , 371 | videoState, 372 | actions, 373 | this.videoRef 374 | ); 375 | } else { 376 | return children(