Add react-native-web support (#3958)
Co-authored-by: Kamil Moskała <91079590+moskalakamil@users.noreply.github.com>
This commit is contained in:
@@ -45,8 +45,7 @@ import {
|
||||
resolveAssetSourceForVideo,
|
||||
} from './utils';
|
||||
import NativeVideoManager from './specs/NativeVideoManager';
|
||||
import type {VideoSaveData} from './specs/NativeVideoManager';
|
||||
import {CmcdMode, ViewType} from './types';
|
||||
import {ViewType, type VideoSaveData, CmcdMode} from './types';
|
||||
import type {
|
||||
OnLoadData,
|
||||
OnTextTracksData,
|
||||
|
461
src/Video.web.tsx
Normal file
461
src/Video.web.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
type RefObject,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type {VideoRef, ReactVideoProps, VideoMetadata} from './types';
|
||||
|
||||
// stolen from https://stackoverflow.com/a/77278013/21726244
|
||||
const isDeepEqual = <T,>(a: T, b: T): boolean => {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const bothAreObjects =
|
||||
a && b && typeof a === 'object' && typeof b === 'object';
|
||||
|
||||
return Boolean(
|
||||
bothAreObjects &&
|
||||
Object.keys(a).length === Object.keys(b).length &&
|
||||
Object.entries(a).every(([k, v]) => isDeepEqual(v, b[k as keyof T])),
|
||||
);
|
||||
};
|
||||
|
||||
const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
(
|
||||
{
|
||||
source,
|
||||
paused,
|
||||
muted,
|
||||
volume,
|
||||
rate,
|
||||
repeat,
|
||||
controls,
|
||||
showNotificationControls = false,
|
||||
poster,
|
||||
fullscreen,
|
||||
fullscreenAutorotate,
|
||||
fullscreenOrientation,
|
||||
onBuffer,
|
||||
onLoad,
|
||||
onProgress,
|
||||
onPlaybackRateChange,
|
||||
onError,
|
||||
onReadyForDisplay,
|
||||
onSeek,
|
||||
onVolumeChange,
|
||||
onEnd,
|
||||
onPlaybackStateChanged,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const nativeRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const isSeeking = useRef(false);
|
||||
const seek = useCallback(
|
||||
async (time: number, _tolerance?: number) => {
|
||||
if (isNaN(time)) {
|
||||
throw new Error('Specified time is not a number');
|
||||
}
|
||||
if (!nativeRef.current) {
|
||||
console.warn('Video Component is not mounted');
|
||||
return;
|
||||
}
|
||||
time = Math.max(0, Math.min(time, nativeRef.current.duration));
|
||||
nativeRef.current.currentTime = time;
|
||||
onSeek?.({seekTime: time, currentTime: nativeRef.current.currentTime});
|
||||
},
|
||||
[onSeek],
|
||||
);
|
||||
|
||||
const [src, setSource] = useState(source);
|
||||
const currentSourceProp = useRef(source);
|
||||
useEffect(() => {
|
||||
if (isDeepEqual(source, currentSourceProp.current)) {
|
||||
return;
|
||||
}
|
||||
currentSourceProp.current = source;
|
||||
setSource(source);
|
||||
}, [source]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
nativeRef.current.pause();
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
nativeRef.current.play();
|
||||
}, []);
|
||||
|
||||
const setVolume = useCallback((vol: number) => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
nativeRef.current.volume = Math.max(0, Math.min(vol, 100)) / 100;
|
||||
}, []);
|
||||
|
||||
const getCurrentPosition = useCallback(async () => {
|
||||
if (!nativeRef.current) {
|
||||
throw new Error('Video Component is not mounted');
|
||||
}
|
||||
return nativeRef.current.currentTime;
|
||||
}, []);
|
||||
|
||||
const unsupported = useCallback(() => {
|
||||
throw new Error('This is unsupported on the web');
|
||||
}, []);
|
||||
|
||||
// Stock this in a ref to not invalidate memoization when those changes.
|
||||
const fsPrefs = useRef({
|
||||
fullscreenAutorotate,
|
||||
fullscreenOrientation,
|
||||
});
|
||||
fsPrefs.current = {
|
||||
fullscreenOrientation,
|
||||
fullscreenAutorotate,
|
||||
};
|
||||
const setFullScreen = useCallback(
|
||||
async (
|
||||
newVal: boolean,
|
||||
orientation?: ReactVideoProps['fullscreenOrientation'],
|
||||
autorotate?: boolean,
|
||||
) => {
|
||||
orientation ??= fsPrefs.current.fullscreenOrientation;
|
||||
autorotate ??= fsPrefs.current.fullscreenAutorotate;
|
||||
|
||||
try {
|
||||
if (newVal) {
|
||||
await nativeRef.current?.requestFullscreen({
|
||||
navigationUI: 'hide',
|
||||
});
|
||||
if (orientation === 'all' || !orientation || autorotate) {
|
||||
screen.orientation.unlock();
|
||||
} else {
|
||||
await screen.orientation.lock(orientation);
|
||||
}
|
||||
} else {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
screen.orientation.unlock();
|
||||
}
|
||||
} catch (e) {
|
||||
// Changing fullscreen status without a button click is not allowed so it throws.
|
||||
// Some browsers also used to throw when locking screen orientation was not supported.
|
||||
console.error('Could not toggle fullscreen/screen lock status', e);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFullScreen(
|
||||
fullscreen || false,
|
||||
fullscreenOrientation,
|
||||
fullscreenAutorotate,
|
||||
);
|
||||
}, [
|
||||
setFullScreen,
|
||||
fullscreen,
|
||||
fullscreenAutorotate,
|
||||
fullscreenOrientation,
|
||||
]);
|
||||
|
||||
const presentFullscreenPlayer = useCallback(
|
||||
() => setFullScreen(true),
|
||||
[setFullScreen],
|
||||
);
|
||||
const dismissFullscreenPlayer = useCallback(
|
||||
() => setFullScreen(false),
|
||||
[setFullScreen],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
seek,
|
||||
setSource,
|
||||
pause,
|
||||
resume,
|
||||
setVolume,
|
||||
getCurrentPosition,
|
||||
presentFullscreenPlayer,
|
||||
dismissFullscreenPlayer,
|
||||
setFullScreen,
|
||||
save: unsupported,
|
||||
restoreUserInterfaceForPictureInPictureStopCompleted: unsupported,
|
||||
nativeHtmlVideoRef: nativeRef,
|
||||
}),
|
||||
[
|
||||
seek,
|
||||
setSource,
|
||||
pause,
|
||||
resume,
|
||||
unsupported,
|
||||
setVolume,
|
||||
getCurrentPosition,
|
||||
nativeRef,
|
||||
presentFullscreenPlayer,
|
||||
dismissFullscreenPlayer,
|
||||
setFullScreen,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}, [paused, pause, resume]);
|
||||
useEffect(() => {
|
||||
if (volume === undefined || isNaN(volume)) {
|
||||
return;
|
||||
}
|
||||
setVolume(volume);
|
||||
}, [volume, setVolume]);
|
||||
|
||||
// we use a ref to prevent triggerring the useEffect when the component rerender with a non-stable `onPlaybackStateChanged`.
|
||||
const playbackStateRef = useRef(onPlaybackStateChanged);
|
||||
playbackStateRef.current = onPlaybackStateChanged;
|
||||
useEffect(() => {
|
||||
// Not sure about how to do this but we want to wait for nativeRef to be initialized
|
||||
setTimeout(() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set play state to the player's value (if autoplay is denied)
|
||||
// This is useful if our UI is in a play state but autoplay got denied so
|
||||
// the video is actually in a paused state.
|
||||
playbackStateRef.current?.({
|
||||
isPlaying: !nativeRef.current.paused,
|
||||
isSeeking: isSeeking.current,
|
||||
});
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nativeRef.current || rate === undefined) {
|
||||
return;
|
||||
}
|
||||
nativeRef.current.playbackRate = rate;
|
||||
}, [rate]);
|
||||
|
||||
useMediaSession(src?.metadata, nativeRef, showNotificationControls);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={nativeRef}
|
||||
src={src?.uri as string | undefined}
|
||||
muted={muted}
|
||||
autoPlay={!paused}
|
||||
controls={controls}
|
||||
loop={repeat}
|
||||
playsInline
|
||||
poster={
|
||||
typeof poster === 'object'
|
||||
? typeof poster.source === 'object'
|
||||
? poster.source.uri
|
||||
: undefined
|
||||
: poster
|
||||
}
|
||||
onCanPlay={() => onBuffer?.({isBuffering: false})}
|
||||
onWaiting={() => onBuffer?.({isBuffering: true})}
|
||||
onRateChange={() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
onPlaybackRateChange?.({
|
||||
playbackRate: nativeRef.current?.playbackRate,
|
||||
});
|
||||
}}
|
||||
onDurationChange={() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
onLoad?.({
|
||||
currentTime: nativeRef.current.currentTime,
|
||||
duration: nativeRef.current.duration,
|
||||
videoTracks: [],
|
||||
textTracks: [],
|
||||
audioTracks: [],
|
||||
naturalSize: {
|
||||
width: nativeRef.current.videoWidth,
|
||||
height: nativeRef.current.videoHeight,
|
||||
orientation: 'landscape',
|
||||
},
|
||||
});
|
||||
}}
|
||||
onTimeUpdate={() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
onProgress?.({
|
||||
currentTime: nativeRef.current.currentTime,
|
||||
playableDuration: nativeRef.current.buffered.length
|
||||
? nativeRef.current.buffered.end(
|
||||
nativeRef.current.buffered.length - 1,
|
||||
)
|
||||
: 0,
|
||||
seekableDuration: 0,
|
||||
});
|
||||
}}
|
||||
onLoadedData={() => onReadyForDisplay?.()}
|
||||
onError={() => {
|
||||
if (!nativeRef.current?.error) {
|
||||
return;
|
||||
}
|
||||
onError?.({
|
||||
error: {
|
||||
errorString: nativeRef.current.error.message ?? 'Unknown error',
|
||||
code: nativeRef.current.error.code,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onLoadedMetadata={() => {
|
||||
if (src?.startPosition) {
|
||||
seek(src.startPosition / 1000);
|
||||
}
|
||||
}}
|
||||
onPlay={() =>
|
||||
onPlaybackStateChanged?.({
|
||||
isPlaying: true,
|
||||
isSeeking: isSeeking.current,
|
||||
})
|
||||
}
|
||||
onPause={() =>
|
||||
onPlaybackStateChanged?.({
|
||||
isPlaying: false,
|
||||
isSeeking: isSeeking.current,
|
||||
})
|
||||
}
|
||||
onSeeking={() => (isSeeking.current = true)}
|
||||
onSeeked={() => {
|
||||
// only trigger this if it's from UI seek.
|
||||
// if it was triggered via ref.seek(), onSeek has already been called
|
||||
if (isSeeking.current) {
|
||||
isSeeking.current = false;
|
||||
onSeek?.({
|
||||
seekTime: nativeRef.current!.currentTime,
|
||||
currentTime: nativeRef.current!.currentTime,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onVolumeChange={() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
onVolumeChange?.({volume: nativeRef.current.volume});
|
||||
}}
|
||||
onEnded={onEnd}
|
||||
style={videoStyle}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const videoStyle = {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
const useMediaSession = (
|
||||
metadata: VideoMetadata | undefined,
|
||||
nativeRef: RefObject<HTMLVideoElement>,
|
||||
showNotification: boolean,
|
||||
) => {
|
||||
const isPlaying = !nativeRef.current?.paused ?? false;
|
||||
const progress = nativeRef.current?.currentTime ?? 0;
|
||||
const duration = Number.isFinite(nativeRef.current?.duration)
|
||||
? nativeRef.current?.duration
|
||||
: undefined;
|
||||
const playbackRate = nativeRef.current?.playbackRate ?? 1;
|
||||
|
||||
const enabled = 'mediaSession' in navigator && showNotification;
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: metadata?.title,
|
||||
artist: metadata?.artist,
|
||||
artwork: metadata?.imageUri ? [{src: metadata.imageUri}] : undefined,
|
||||
});
|
||||
}
|
||||
}, [enabled, metadata]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seekTo = (time: number) => {
|
||||
if (nativeRef.current) {
|
||||
nativeRef.current.currentTime = time;
|
||||
}
|
||||
};
|
||||
|
||||
const seekRelative = (offset: number) => {
|
||||
if (nativeRef.current) {
|
||||
nativeRef.current.currentTime = nativeRef.current.currentTime + offset;
|
||||
}
|
||||
};
|
||||
|
||||
const mediaActions: [
|
||||
MediaSessionAction,
|
||||
MediaSessionActionHandler | null,
|
||||
][] = [
|
||||
['play', () => nativeRef.current?.play()],
|
||||
['pause', () => nativeRef.current?.pause()],
|
||||
[
|
||||
'seekbackward',
|
||||
(evt: MediaSessionActionDetails) =>
|
||||
seekRelative(evt.seekOffset ? -evt.seekOffset : -10),
|
||||
],
|
||||
[
|
||||
'seekforward',
|
||||
(evt: MediaSessionActionDetails) =>
|
||||
seekRelative(evt.seekOffset ? evt.seekOffset : 10),
|
||||
],
|
||||
['seekto', (evt: MediaSessionActionDetails) => seekTo(evt.seekTime!)],
|
||||
];
|
||||
|
||||
for (const [action, handler] of mediaActions) {
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(action, handler);
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}, [enabled, nativeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
||||
}
|
||||
}, [isPlaying, enabled]);
|
||||
useEffect(() => {
|
||||
if (enabled && duration !== undefined) {
|
||||
navigator.mediaSession.setPositionState({
|
||||
position: Math.min(progress, duration),
|
||||
duration,
|
||||
playbackRate: playbackRate,
|
||||
});
|
||||
}
|
||||
}, [progress, duration, playbackRate, enabled]);
|
||||
};
|
||||
|
||||
Video.displayName = 'Video';
|
||||
export default Video;
|
34
src/VideoDecoderProperties.web.ts
Normal file
34
src/VideoDecoderProperties.web.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/// <reference lib="dom" />
|
||||
import type {VideoDecoderInfoModuleType} from './specs/NativeVideoDecoderInfoModule';
|
||||
|
||||
const canPlay = (codec: string): boolean => {
|
||||
// most chrome based browser (and safari I think) supports matroska but reports they do not.
|
||||
// for those browsers, only check the codecs and not the container.
|
||||
if (navigator.userAgent.search('Firefox') === -1) {
|
||||
codec = codec.replace('video/x-matroska', 'video/mp4');
|
||||
}
|
||||
|
||||
return !!MediaSource.isTypeSupported(codec);
|
||||
};
|
||||
|
||||
export const VideoDecoderProperties = {
|
||||
async getWidevineLevel() {
|
||||
return 0;
|
||||
},
|
||||
|
||||
async isCodecSupported(
|
||||
mimeType: string,
|
||||
_width: number,
|
||||
_height: number,
|
||||
): Promise<'unsupported' | 'hardware' | 'software'> {
|
||||
// TODO: Figure out if we can get hardware support information
|
||||
return canPlay(mimeType) ? 'software' : 'unsupported';
|
||||
},
|
||||
|
||||
async isHEVCSupported(): Promise<'unsupported' | 'hardware' | 'software'> {
|
||||
// Just a dummy vidoe mime type codec with HEVC to check.
|
||||
return canPlay('video/x-matroska; codecs="hvc1.1.4.L96.BO"')
|
||||
? 'software'
|
||||
: 'unsupported';
|
||||
},
|
||||
} satisfies VideoDecoderInfoModuleType;
|
@@ -1,6 +1,5 @@
|
||||
import Video from './Video';
|
||||
export {VideoDecoderProperties} from './VideoDecoderProperties';
|
||||
export * from './types';
|
||||
export type {VideoRef} from './Video';
|
||||
export {Video};
|
||||
export default Video;
|
||||
|
@@ -2,7 +2,7 @@ import {NativeModules} from 'react-native';
|
||||
import type {Int32} from 'react-native/Libraries/Types/CodegenTypes';
|
||||
|
||||
// @TODO rename to "Spec" when applying new arch
|
||||
interface VideoDecoderInfoModuleType {
|
||||
export interface VideoDecoderInfoModuleType {
|
||||
getWidevineLevel: () => Promise<Int32>;
|
||||
isCodecSupported: (
|
||||
mimeType: string,
|
||||
|
@@ -4,10 +4,7 @@ import type {
|
||||
Float,
|
||||
UnsafeObject,
|
||||
} from 'react-native/Libraries/Types/CodegenTypes';
|
||||
|
||||
export type VideoSaveData = {
|
||||
uri: string;
|
||||
};
|
||||
import type {VideoSaveData} from '../types/video-ref';
|
||||
|
||||
// @TODO rename to "Spec" when applying new arch
|
||||
export interface VideoManagerType {
|
||||
|
@@ -285,12 +285,12 @@ type OnReceiveAdEventData = Readonly<{
|
||||
|
||||
export type OnVideoErrorData = Readonly<{
|
||||
error: Readonly<{
|
||||
errorString?: string; // android
|
||||
errorString?: string; // android | web
|
||||
errorException?: string; // android
|
||||
errorStackTrace?: string; // android
|
||||
errorCode?: string; // android
|
||||
error?: string; // ios
|
||||
code?: Int32; // ios
|
||||
code?: Int32; // ios | web
|
||||
localizedDescription?: string; // ios
|
||||
localizedFailureReason?: string; // ios
|
||||
localizedRecoverySuggestion?: string; // ios
|
||||
|
@@ -20,6 +20,8 @@ import type {
|
||||
OnVolumeChangeData,
|
||||
} from '../specs/VideoNativeComponent';
|
||||
|
||||
export type * from '../specs/VideoNativeComponent';
|
||||
|
||||
export type AudioTrack = OnAudioTracksData['audioTracks'][number];
|
||||
export type TextTrack = OnTextTracksData['textTracks'][number];
|
||||
export type VideoTrack = OnVideoTracksData['videoTracks'][number];
|
||||
|
@@ -7,4 +7,4 @@ export {default as ResizeMode} from './ResizeMode';
|
||||
export {default as TextTrackType} from './TextTrackType';
|
||||
export {default as ViewType} from './ViewType';
|
||||
export * from './video';
|
||||
export * from '../specs/VideoNativeComponent';
|
||||
export * from './video-ref';
|
||||
|
23
src/types/video-ref.ts
Normal file
23
src/types/video-ref.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {RefObject} from 'react';
|
||||
import {ReactVideoSource} from './video';
|
||||
|
||||
export type VideoSaveData = {
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export interface VideoRef {
|
||||
seek: (time: number, tolerance?: number) => void;
|
||||
resume: () => void;
|
||||
pause: () => void;
|
||||
presentFullscreenPlayer: () => void;
|
||||
dismissFullscreenPlayer: () => void;
|
||||
restoreUserInterfaceForPictureInPictureStopCompleted: (
|
||||
restore: boolean,
|
||||
) => void;
|
||||
save: (options: object) => Promise<VideoSaveData>;
|
||||
setVolume: (volume: number) => void;
|
||||
getCurrentPosition: () => Promise<number>;
|
||||
setFullScreen: (fullScreen: boolean) => void;
|
||||
setSource: (source?: ReactVideoSource) => void;
|
||||
nativeHtmlVideoRef?: RefObject<HTMLVideoElement>; // web only
|
||||
}
|
Reference in New Issue
Block a user