Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for "picture in picture" #80

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 28 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

> Build custom video players effortless

* Render props, get all video state passed down as props.
* Bidirectional flow to render and update the video state in a declarative way.
* No side effects out of the box, you just need to build the UI.
* Actions handling: play, pause, mute, unmute, navigate, etc
* Dependency free, [<2KB size](https://bundlephobia.com/result?p=react-video-renderer)
* Cross-browser support, no more browser hacks.
- Render props, get all video state passed down as props.
- Bidirectional flow to render and update the video state in a declarative way.
- No side effects out of the box, you just need to build the UI.
- Actions handling: play, pause, mute, unmute, navigate, etc
- Dependency free, [<2KB size](https://bundlephobia.com/result?p=react-video-renderer)
- Cross-browser support, no more browser hacks.

## Demo 🎩

Expand All @@ -30,15 +30,18 @@ import Video from 'react-video-renderer';
{(video, state, actions) => (
<div>
{video}
<div>{state.currentTime} / {state.duration} / {state.buffered}</div>
<div>
{state.currentTime} / {state.duration} / {state.buffered}
</div>
<progress value={state.currentTime} max={state.duration} onChange={actions.navigate} />
<progress value={state.volume} max={1} onChange={actions.setVolume} />
<button onClick={actions.play}>Play</button>
<button onClick={actions.pause}>Pause</button>
<button onClick={actions.requestFullScreen}>Fullscreen</button>
<button onClick={actions.togglePictureInPicture}>Picture In Picture</button>
</div>
)}
</Video>
</Video>;
```

<div align="center">
Expand All @@ -64,7 +67,12 @@ interface Props {
### Render method

```typescript
type RenderCallback = (reactElement: ReactElement<HTMLMediaElement>, state: VideoState, actions: VideoActions, ref: React.RefObject<HTMLMediaElement>) => ReactNode;
type RenderCallback = (
reactElement: ReactElement<HTMLMediaElement>,
state: VideoState,
actions: VideoActions,
ref: React.RefObject<HTMLMediaElement>
) => ReactNode;
```

### State
Expand Down Expand Up @@ -106,18 +114,10 @@ interface VideoActions {
<Video src="some-error-video.mov">
{(video, state) => {
if (state.status === 'errored') {
return (
<ErrorWrapper>
Error
</ErrorWrapper>
);
return <ErrorWrapper>Error</ErrorWrapper>;
}

return (
<div>
{video}
</div>
)
return <div>{video}</div>;
}}
</Video>
```
Expand All @@ -138,7 +138,7 @@ interface VideoActions {
<button onClick={actions.play}>Play</button>
<button onClick={actions.pause}>Pause</button>
</div>
)
);
}}
</Video>
```
Expand All @@ -150,24 +150,26 @@ interface VideoActions {
> subtitles can be rendered natively, or they can be rendered using `VideoState.currentActiveCues` property:

```jsx
<Video
<Video
src="my-video.mp4"
textTracks={{
'subtitles': {
subtitles: {
selectedTrackIndex: 0,
tracks: [
{ src: 'subtitles-en.vtt', lang: 'en', label: 'Subtitles (english)' },
{ src: 'subtitles-es.vtt', lang: 'es', label: 'Subtitles (spanish)' },
]
}
],
},
}}
>
{(video, state, actions) => {
const cues = state.currentActiveCues('subtitles', 'en');
const subtitles =
cue && cue.length > 0 ? (
<div>
{Array.prototype.map.call(cues, (cue, i) => <span key={i}>{cue.text}</span>)}
{Array.prototype.map.call(cues, (cue, i) => (
<span key={i}>{cue.text}</span>
))}
</div>
) : undefined;

Expand All @@ -178,7 +180,7 @@ interface VideoActions {
<button onClick={actions.play}>Play</button>
<button onClick={actions.pause}>Pause</button>
</div>
)
);
}}
</Video>
```
Expand Down
8 changes: 8 additions & 0 deletions example/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Component } from 'react';
import VidPlayIcon from '@atlaskit/icon/glyph/vid-play';
import VidPauseIcon from '@atlaskit/icon/glyph/vid-pause';
import VidFullScreenOnIcon from '@atlaskit/icon/glyph/vid-full-screen-on';
import EditorTableDisplayOptionsIcon from '@atlaskit/icon/glyph/editor/table-display-options';
import VolumeIcon from '@atlaskit/icon/glyph/hipchat/outgoing-sound';
import Button from '@atlaskit/button';
import Select from '@atlaskit/single-select';
Expand Down Expand Up @@ -245,6 +246,12 @@ export default class App extends Component<{}, AppState> {
const fullScreenButton = sourceType === 'video' && (
<Button iconBefore={<VidFullScreenOnIcon label="fullscreen" />} onClick={actions.requestFullscreen} />
);
const pictureInPictureButton = videoState.isPictureInPictureEnabled && sourceType === 'video' && (
<Button
iconBefore={<EditorTableDisplayOptionsIcon label="pictureinPicture" />}
onClick={actions.togglePictureInPicture}
/>
);
const hdButton = sourceType === 'video' && <Button onClick={this.toggleHD}>HD</Button>;

const playbackSpeedSelect = (
Expand Down Expand Up @@ -306,6 +313,7 @@ export default class App extends Component<{}, AppState> {
{playbackSpeedSelect}
{hdButton}
{fullScreenButton}
{pictureInPictureButton}
</RightControls>
</ControlsWrapper>
</TimebarWrapper>
Expand Down
6 changes: 4 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export const requestFullScreen = (element: HTMLVideoElement) => {
const methods = ['requestFullscreen', 'webkitRequestFullscreen', 'mozRequestFullScreen', 'msRequestFullscreen'];
const methodName = (methods as any).find((name: string) => (element as any)[name]);

(element as any)[methodName]();
}
};

export const getDocument = () => ('document' in window ? document : undefined);
60 changes: 58 additions & 2 deletions src/video.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react';
import { Component, ReactElement, ReactNode, SyntheticEvent, RefObject, MediaHTMLAttributes } from 'react';
import { VideoTextTracks, VideoTextTrackKind, getVideoTextTrackId } from './text';
import { requestFullScreen } from './utils';
import { getDocument, requestFullScreen } from './utils';

export type VideoStatus = 'playing' | 'paused' | 'errored';
export type VideoError = MediaError | null;
Expand All @@ -16,6 +16,8 @@ export interface VideoState {
isMuted: boolean;
isLoading: boolean;
error?: VideoError;
isPictureInPictureActive: boolean;
isPictureInPictureEnabled: boolean;
}

export type NavigateFunction = (time: number) => void;
Expand All @@ -32,6 +34,9 @@ export interface VideoActions {
mute: () => void;
unmute: () => void;
toggleMute: () => void;
requestPictureInPicture: () => void;
exitPictureInPicture: () => void;
togglePictureInPicture: () => void;
}

export type RenderCallback = (
Expand Down Expand Up @@ -195,6 +200,8 @@ export class Video extends Component<VideoProps, VideoComponentState> {
private get videoState(): VideoState {
const { currentTime, volume, status, duration, buffered, isMuted, isLoading, error } = this.state;

const { isPictureInPictureEnabled, isPictureInPictureActive } = this;

return {
currentTime,
currentActiveCues: (kind: VideoTextTrackKind, lang: string) =>
Expand All @@ -206,6 +213,8 @@ export class Video extends Component<VideoProps, VideoComponentState> {
isMuted,
isLoading,
error,
isPictureInPictureActive,
isPictureInPictureEnabled,
};
}

Expand Down Expand Up @@ -249,6 +258,37 @@ export class Video extends Component<VideoProps, VideoComponentState> {
}
};

private get isPictureInPictureActive(): boolean {
return !!getDocument()?.pictureInPictureElement;
}

private get isPictureInPictureEnabled(): boolean {
const { sourceType } = this.props;
return !!getDocument()?.pictureInPictureEnabled && sourceType === 'video';
}

private exitPictureInPicture = () => {
this.isPictureInPictureActive && getDocument()?.exitPictureInPicture();
};

private requestPictureInPicture = async () => {
if (!this.isPictureInPictureEnabled) {
return;
}
try {
// If the binary hasn't been loaded yet, this can throw an error
await this.videoRef.current?.requestPictureInPicture();
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth having a retry mechanism? aka, try again when the video finish loading

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, not binary fully loaded, but the first bytes (i.e. no internet connection or similar).
The component should fall into an error state in these cases. Therefore, no need to auto retry here, I believe

} catch (e) {}
};

private togglePictureInPicture = () => {
if (this.isPictureInPictureActive) {
this.exitPictureInPicture();
} else {
this.requestPictureInPicture();
}
};

private mute = () => {
const { volume } = this.state;

Expand All @@ -271,7 +311,20 @@ export class Video extends Component<VideoProps, VideoComponentState> {
};

private get actions(): VideoActions {
const { play, pause, navigate, setVolume, setPlaybackSpeed, requestFullscreen, mute, unmute, toggleMute } = this;
const {
play,
pause,
navigate,
setVolume,
setPlaybackSpeed,
requestFullscreen,
mute,
unmute,
toggleMute,
requestPictureInPicture,
exitPictureInPicture,
togglePictureInPicture,
} = this;

return {
play,
Expand All @@ -283,6 +336,9 @@ export class Video extends Component<VideoProps, VideoComponentState> {
mute,
unmute,
toggleMute,
requestPictureInPicture,
exitPictureInPicture,
togglePictureInPicture,
};
}

Expand Down