expo-video
Version:
A cross-platform, performant video component for React Native and Expo with Web support
179 lines • 7.79 kB
JavaScript
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import { StyleSheet } from 'react-native';
import { getSourceUri } from './VideoPlayer.web';
function createAudioContext() {
return typeof window !== 'undefined' ? new window.AudioContext() : null;
}
function createZeroGainNode(audioContext) {
const zeroGainNode = audioContext?.createGain() ?? null;
if (audioContext && zeroGainNode) {
zeroGainNode.gain.value = 0;
zeroGainNode.connect(audioContext.destination);
}
return zeroGainNode;
}
function mapStyles(style) {
const flattenedStyles = StyleSheet.flatten(style);
// Looking through react-native-web source code they also just pass styles directly without further conversions, so it's just a cast.
return flattenedStyles;
}
export function isPictureInPictureSupported() {
return typeof document === 'object' && typeof document.exitPictureInPicture === 'function';
}
export const VideoView = forwardRef((props, ref) => {
const videoRef = useRef(null);
const mediaNodeRef = useRef(null);
const hasToSetupAudioContext = useRef(false);
const fullscreenChangeListener = useRef(null);
const isWaitingForFirstFrame = useRef(false);
/**
* Audio context is used to mute all but one video when multiple video views are playing from one player simultaneously.
* Using audio context nodes allows muting videos without displaying the mute icon in the video player.
* We have to keep the context that called createMediaElementSource(videoRef), as the method can't be called
* for the second time with another context and there is no way to unbind the video and audio context afterward.
*/
const audioContextRef = useRef(null);
const zeroGainNodeRef = useRef(null);
useImperativeHandle(ref, () => ({
enterFullscreen: async () => {
if (!props.allowsFullscreen) {
return;
}
await videoRef.current?.requestFullscreen();
},
exitFullscreen: async () => {
await document.exitFullscreen();
},
startPictureInPicture: async () => {
await videoRef.current?.requestPictureInPicture();
},
stopPictureInPicture: async () => {
try {
await document.exitPictureInPicture();
}
catch (e) {
if (e instanceof DOMException && e.name === 'InvalidStateError') {
console.warn('The VideoView is not in Picture-in-Picture mode.');
}
else {
throw e;
}
}
},
}));
useEffect(() => {
const onEnter = () => {
props.onPictureInPictureStart?.();
};
const onLeave = () => {
props.onPictureInPictureStop?.();
};
const onLoadStart = () => {
isWaitingForFirstFrame.current = true;
};
const onCanPlay = () => {
if (isWaitingForFirstFrame.current) {
props.onFirstFrameRender?.();
}
isWaitingForFirstFrame.current = false;
};
videoRef.current?.addEventListener('enterpictureinpicture', onEnter);
videoRef.current?.addEventListener('leavepictureinpicture', onLeave);
videoRef.current?.addEventListener('loadstart', onLoadStart);
videoRef.current?.addEventListener('loadeddata', onCanPlay);
return () => {
videoRef.current?.removeEventListener('enterpictureinpicture', onEnter);
videoRef.current?.removeEventListener('leavepictureinpicture', onLeave);
videoRef.current?.removeEventListener('loadstart', onLoadStart);
videoRef.current?.removeEventListener('loadeddata', onCanPlay);
};
}, [videoRef, props.onPictureInPictureStop, props.onPictureInPictureStart]);
// Adds the video view as a candidate for being the audio source for the player (when multiple views play from one
// player only one will emit audio).
function attachAudioNodes() {
const audioContext = audioContextRef.current;
const zeroGainNode = zeroGainNodeRef.current;
const mediaNode = mediaNodeRef.current;
if (audioContext && zeroGainNode && mediaNode) {
props.player.mountAudioNode(audioContext, zeroGainNode, mediaNode);
}
else {
console.warn("Couldn't mount audio node, this might affect the audio playback when using multiple video views with the same player.");
}
}
function detachAudioNodes() {
const audioContext = audioContextRef.current;
const mediaNode = mediaNodeRef.current;
if (audioContext && mediaNode && videoRef.current) {
props.player.unmountAudioNode(videoRef.current, audioContext, mediaNode);
}
}
function maybeSetupAudioContext() {
if (!hasToSetupAudioContext.current ||
!navigator.userActivation.hasBeenActive ||
!videoRef.current) {
return;
}
const audioContext = createAudioContext();
detachAudioNodes();
audioContextRef.current = audioContext;
zeroGainNodeRef.current = createZeroGainNode(audioContextRef.current);
mediaNodeRef.current = audioContext
? audioContext.createMediaElementSource(videoRef.current)
: null;
attachAudioNodes();
hasToSetupAudioContext.current = false;
}
function fullscreenListener() {
if (document.fullscreenElement === videoRef.current) {
props.onFullscreenEnter?.();
}
else {
props.onFullscreenExit?.();
}
}
function setupFullscreenListener() {
fullscreenChangeListener.current = fullscreenListener;
videoRef.current?.addEventListener('fullscreenchange', fullscreenChangeListener.current);
}
function cleanupFullscreenListener() {
if (fullscreenChangeListener.current) {
videoRef.current?.removeEventListener('fullscreenchange', fullscreenChangeListener.current);
fullscreenChangeListener.current = null;
}
}
useEffect(() => {
if (videoRef.current) {
props.player?.mountVideoView(videoRef.current);
}
setupFullscreenListener();
attachAudioNodes();
return () => {
if (videoRef.current) {
props.player?.unmountVideoView(videoRef.current);
}
cleanupFullscreenListener();
detachAudioNodes();
};
}, [props.player]);
return (<video controls={props.nativeControls ?? true} controlsList={props.allowsFullscreen ? undefined : 'nofullscreen'} crossOrigin={props.crossOrigin} style={{
...mapStyles(props.style),
objectFit: props.contentFit,
}} onPlay={() => {
maybeSetupAudioContext();
}}
// The player can autoplay when muted, unmuting by a user should create the audio context
onVolumeChange={() => {
maybeSetupAudioContext();
}} ref={(newRef) => {
// This is called with a null value before `player.unmountVideoView` is called,
// we can't assign null to videoRef if we want to unmount it from the player.
if (newRef && !newRef.isEqualNode(videoRef.current)) {
videoRef.current = newRef;
hasToSetupAudioContext.current = true;
maybeSetupAudioContext();
}
}} disablePictureInPicture={!props.allowsPictureInPicture} playsInline={props.playsInline} src={getSourceUri(props.player?.src) ?? ''}/>);
});
export default VideoView;
//# sourceMappingURL=VideoView.web.js.map