├── .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 [](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 |

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(, videoState, actions, this.audioRef);
377 | }
378 | }
379 | }
380 |
--------------------------------------------------------------------------------
/example/app.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Component } from 'react';
3 | import VidPlayIcon from '@atlaskit/icon/glyph/vid-play';
4 | import VidPauseIcon from '@atlaskit/icon/glyph/vid-pause';
5 | import VidFullScreenOnIcon from '@atlaskit/icon/glyph/vid-full-screen-on';
6 | import VolumeIcon from '@atlaskit/icon/glyph/hipchat/outgoing-sound';
7 | import Button from '@atlaskit/button';
8 | import Select from '@atlaskit/single-select';
9 | import Spinner from '@atlaskit/spinner';
10 | import Corner from 'react-gh-corner';
11 | import Video, { SetPlaybackSpeed, VideoTextTracks } from '../src';
12 | import {
13 | VideoRendererWrapper,
14 | SelectWrapper,
15 | ErrorWrapper,
16 | VideoWrapper,
17 | MutedIndicator,
18 | LeftControls,
19 | RightControls,
20 | ControlsWrapper,
21 | TimeRangeWrapper,
22 | CurrentTime,
23 | AppWrapper,
24 | TimebarWrapper,
25 | VolumeWrapper,
26 | SpinnerWrapper,
27 | BuiltWithWrapper,
28 | PlaybackSpeedWrapper,
29 | SubtitlesWrapper,
30 | } from './styled';
31 | import { TimeRange } from './timeRange';
32 |
33 | export interface ContentSource {
34 | content: string;
35 | value: string;
36 | textTracks?: VideoTextTracks;
37 | }
38 |
39 | export interface PlaybackSpeedSource {
40 | content: string;
41 | value: number;
42 | }
43 |
44 | export interface AppState {
45 | currentSource: ContentSource;
46 | sourceType: ContentType;
47 | playbackSpeed: number;
48 | }
49 |
50 | type ContentType = 'video' | 'audio';
51 | type ContentSourceSelect = Array<{ items: ContentSource[] }>;
52 | type PlaybackSpeedSourceSelect = Array<{ items: PlaybackSpeedSource[] }>;
53 |
54 | const audioSrc = 'https://upload.wikimedia.org/wikipedia/en/8/80/The_Amen_Break%2C_in_context.ogg';
55 | const audioSrcError = 'https://upload.wikimedia.org/';
56 | const hdVideoSrc = 'http://vjs.zencdn.net/v/oceans.mp4';
57 | const sdVideoSrc = 'http://vjs.zencdn.net/v/oceans.webm';
58 | const sdVideoSrc2 = 'http://www.onirikal.com/videos/mp4/battle_games.mp4';
59 | const vttSrc =
60 | 'http://gist.githubusercontent.com/samdutton/ca37f3adaf4e23679957b8083e061177/raw/e19399fbccbc069a2af4266e5120ae6bad62699a/sample.vtt';
61 | const errorVideoSrc = 'http://zzarcon';
62 | const chooseContent: ContentSourceSelect = [
63 | {
64 | items: [
65 | {
66 | value: 'video',
67 | content: 'video',
68 | },
69 | {
70 | value: 'audio',
71 | content: 'audio',
72 | },
73 | ],
74 | },
75 | ];
76 | const audioSources: ContentSourceSelect = [
77 | {
78 | items: [
79 | { value: audioSrc, content: 'OGG' },
80 | { value: audioSrcError, content: 'Errored' },
81 | ],
82 | },
83 | ];
84 | const videoSources: ContentSourceSelect = [
85 | {
86 | items: [
87 | {
88 | value: hdVideoSrc,
89 | content: 'HD',
90 | textTracks: {
91 | subtitles: { selectedTrackIndex: 0, tracks: [{ src: vttSrc, lang: 'en', label: 'Subtitles (english)' }] },
92 | },
93 | },
94 | {
95 | value: sdVideoSrc,
96 | content: 'SD',
97 | textTracks: {
98 | subtitles: { selectedTrackIndex: 0, tracks: [{ src: vttSrc, lang: 'en', label: 'Subtitles (english)' }] },
99 | },
100 | },
101 | { value: sdVideoSrc2, content: 'SD - 2' },
102 | { value: errorVideoSrc, content: 'Errored' },
103 | ],
104 | },
105 | ];
106 | const playbackSpeeds: PlaybackSpeedSourceSelect = [
107 | {
108 | items: [
109 | { value: 0.5, content: '0.5' },
110 | { value: 0.75, content: '0.75' },
111 | { value: 1, content: 'normal' },
112 | { value: 1.25, content: '1.25' },
113 | { value: 1.5, content: '1.5' },
114 | { value: 1.75, content: '1.75' },
115 | { value: 2, content: '2' },
116 | ],
117 | },
118 | ];
119 |
120 | const selectDefaultSource = (type: ContentType) =>
121 | type === 'audio' ? audioSources[0].items[0] : videoSources[0].items[0];
122 |
123 | const selectDefaultPlaybackSpeed = (playbackSpeed: number) =>
124 | playbackSpeeds[0].items.find((item) => item.value === playbackSpeed);
125 |
126 | export default class App extends Component<{}, AppState> {
127 | subtitlesTrackRef: React.RefObject | undefined;
128 |
129 | state: AppState = {
130 | currentSource: selectDefaultSource('video'),
131 | sourceType: 'video',
132 | playbackSpeed: 1,
133 | };
134 |
135 | onNavigate = (navigate: Function) => (value: number) => {
136 | navigate(value);
137 | };
138 |
139 | onVolumeChange = (setVolume: Function) => (e: any) => {
140 | const value = e.target.value;
141 | setVolume(value);
142 | };
143 |
144 | toggleHD = () => {
145 | const { currentSource } = this.state;
146 |
147 | this.setState({
148 | currentSource: currentSource.value === 'HD' ? videoSources[0].items[1] : videoSources[0].items[0],
149 | });
150 | };
151 |
152 | onContentSelected = (e: { item: ContentSource }) => {
153 | this.setState({
154 | currentSource: e.item,
155 | });
156 | };
157 |
158 | renderSpinner = () => {
159 | return (
160 |
161 |
162 |
163 | );
164 | };
165 |
166 | switchContent = (e: { item: { value: ContentType; content: ContentType } }) => {
167 | this.setState({ sourceType: e.item.value, currentSource: selectDefaultSource(e.item.value) });
168 | };
169 |
170 | private changePlaybackSpeed = (setPlaybackSpeed: SetPlaybackSpeed) => (playbackSpeed: number) => {
171 | setPlaybackSpeed(playbackSpeed);
172 | this.setState({ playbackSpeed });
173 | };
174 |
175 | private getDefaultTimeLocalStorageKey() {
176 | const { currentSource } = this.state;
177 | return `react-video-render-default-time-${currentSource.value}`;
178 | }
179 |
180 | private get defaultTime(): number {
181 | const savedTime = localStorage.getItem(this.getDefaultTimeLocalStorageKey());
182 |
183 | if (savedTime) {
184 | return JSON.parse(savedTime);
185 | } else {
186 | return 0;
187 | }
188 | }
189 |
190 | private onTimeChange = (currentTime: number) => {
191 | localStorage.setItem(this.getDefaultTimeLocalStorageKey(), JSON.stringify(currentTime));
192 | };
193 |
194 | render() {
195 | const { currentSource, sourceType, playbackSpeed } = this.state;
196 |
197 | return (
198 |
199 |
200 | Built with{' '}
201 |
202 | react-video-renderer
203 | {' '}
204 | 🎥
205 |
206 |
207 |
208 |
217 |
223 |
224 |
225 |
317 |
318 |
319 | );
320 | }
321 | }
322 |
--------------------------------------------------------------------------------
/__tests__/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { mount, ReactWrapper } from 'enzyme';
3 | import Video, { VideoProps, RenderCallback, VideoState, VideoComponentState, SourceElement } from '../src';
4 |
5 | describe('VideoRenderer', () => {
6 | const setup = (props?: Partial) => {
7 | const src = 'video-url';
8 | const children = jest.fn().mockImplementation((video) => {
9 | return video;
10 | }) as jest.Mock, Parameters>;
11 |
12 | const component = mount