119
common/resources/client/player/providers/dash-provider.tsx
Executable file
119
common/resources/client/player/providers/dash-provider.tsx
Executable file
@@ -0,0 +1,119 @@
|
||||
import {useCallback, useContext, useEffect, useRef, useState} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {useHtmlMediaInternalState} from '@common/player/providers/html-media/use-html-media-internal-state';
|
||||
import {useHtmlMediaEvents} from '@common/player/providers/html-media/use-html-media-events';
|
||||
import {useHtmlMediaApi} from '@common/player/providers/html-media/use-html-media-api';
|
||||
import {MediaPlayer, MediaPlayerClass, supportsMediaSource} from 'dashjs';
|
||||
|
||||
export default function DashProvider() {
|
||||
const store = useContext(PlayerStoreContext);
|
||||
const cuedMedia = usePlayerStore(s => s.cuedMedia);
|
||||
|
||||
// html medial element state
|
||||
const videoRef = useRef<HTMLVideoElement>(null!);
|
||||
const htmlMediaState = useHtmlMediaInternalState(videoRef);
|
||||
const htmlMediaEvents = useHtmlMediaEvents(htmlMediaState);
|
||||
const htmlMediaApi = useHtmlMediaApi(htmlMediaState);
|
||||
|
||||
const dash = useRef<MediaPlayerClass | undefined>();
|
||||
const [dashReady, setDashReady] = useState(false);
|
||||
|
||||
const destroyDash = useCallback(() => {
|
||||
if (dash.current) {
|
||||
dash.current.destroy();
|
||||
dash.current = undefined;
|
||||
setDashReady(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setupDash = useCallback(() => {
|
||||
if (!supportsMediaSource()) {
|
||||
store.getState().emit('error', {fatal: true});
|
||||
return;
|
||||
}
|
||||
|
||||
const dashInstance = MediaPlayer().create();
|
||||
|
||||
dashInstance.on(MediaPlayer.events.ERROR, (e: any) => {
|
||||
store.getState().emit('error', {sourceEvent: e});
|
||||
});
|
||||
|
||||
dashInstance.on(MediaPlayer.events.PLAYBACK_METADATA_LOADED, () => {
|
||||
const levels = dashInstance.getBitrateInfoListFor('video');
|
||||
if (!levels?.length) return;
|
||||
|
||||
store.getState().emit('playbackQualities', {
|
||||
qualities: ['auto', ...levels.map(levelToPlaybackQuality)],
|
||||
});
|
||||
|
||||
store.getState().emit('playbackQualityChange', {quality: 'auto'});
|
||||
});
|
||||
|
||||
dashInstance.initialize(videoRef.current, undefined, false);
|
||||
|
||||
// set dash instance after attaching to video element, so "attachSource" is called after
|
||||
dash.current = dashInstance;
|
||||
setDashReady(true);
|
||||
}, [store]);
|
||||
|
||||
useEffect(() => {
|
||||
setupDash();
|
||||
return () => {
|
||||
destroyDash();
|
||||
};
|
||||
}, [setupDash, destroyDash]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dash.current && cuedMedia?.src) {
|
||||
dash.current.attachSource(cuedMedia.src);
|
||||
}
|
||||
}, [cuedMedia?.src, dashReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashReady) return;
|
||||
store.setState({
|
||||
providerApi: {
|
||||
...htmlMediaApi,
|
||||
setPlaybackQuality: (quality: string) => {
|
||||
if (!dash.current) return;
|
||||
|
||||
const levels = dash.current.getBitrateInfoListFor('video');
|
||||
const index = levels.findIndex(
|
||||
level => levelToPlaybackQuality(level) === quality
|
||||
);
|
||||
|
||||
dash.current.updateSettings({
|
||||
streaming: {
|
||||
abr: {
|
||||
autoSwitchBitrate: {
|
||||
video: index === -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (index >= 0) {
|
||||
dash.current.setQualityFor('video', index);
|
||||
}
|
||||
|
||||
store.getState().emit('playbackQualityChange', {quality});
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [store, htmlMediaApi, dashReady]);
|
||||
|
||||
return (
|
||||
<video
|
||||
className="h-full w-full"
|
||||
ref={videoRef}
|
||||
playsInline
|
||||
poster={cuedMedia?.poster}
|
||||
{...htmlMediaEvents}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const levelToPlaybackQuality = (level: any) => {
|
||||
return level === -1 ? 'auto' : `${level.height}p`;
|
||||
};
|
||||
167
common/resources/client/player/providers/hls-provider.tsx
Executable file
167
common/resources/client/player/providers/hls-provider.tsx
Executable file
@@ -0,0 +1,167 @@
|
||||
import {useCallback, useContext, useEffect, useRef, useState} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import Hls, {LevelLoadedData} from 'hls.js';
|
||||
import {useHtmlMediaInternalState} from '@common/player/providers/html-media/use-html-media-internal-state';
|
||||
import {useHtmlMediaEvents} from '@common/player/providers/html-media/use-html-media-events';
|
||||
import {useHtmlMediaApi} from '@common/player/providers/html-media/use-html-media-api';
|
||||
import {HlsMediaItem} from '@common/player/media-item';
|
||||
import {AudioTrack} from '@common/player/state/player-state';
|
||||
|
||||
export default function HlsProvider() {
|
||||
const store = useContext(PlayerStoreContext);
|
||||
const cuedMedia = usePlayerStore(s => s.cuedMedia) as HlsMediaItem;
|
||||
|
||||
// html medial element state
|
||||
const videoRef = useRef<HTMLVideoElement>(null!);
|
||||
const htmlMediaState = useHtmlMediaInternalState(videoRef);
|
||||
const htmlMediaEvents = useHtmlMediaEvents(htmlMediaState);
|
||||
const htmlMediaApi = useHtmlMediaApi(htmlMediaState);
|
||||
|
||||
// need both so we can "loadSource" when hls is ready, while keeping other callbacks stable
|
||||
const hls = useRef<Hls | undefined>();
|
||||
const [hlsReady, setHlsReady] = useState(false);
|
||||
|
||||
const destroyHls = useCallback(() => {
|
||||
if (hls) {
|
||||
hls.current?.destroy();
|
||||
hls.current = undefined;
|
||||
setHlsReady(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setupHls = useCallback(() => {
|
||||
if (!Hls.isSupported()) {
|
||||
store.getState().emit('error', {fatal: true});
|
||||
return;
|
||||
}
|
||||
|
||||
const hlsInstance = new Hls({
|
||||
startLevel: -1,
|
||||
});
|
||||
|
||||
hlsInstance.on(Hls.Events.ERROR, (event: any, data: any) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hlsInstance.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
hlsInstance.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
destroyHls();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
store.getState().emit('error', {sourceEvent: data, fatal: data.fatal});
|
||||
});
|
||||
|
||||
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (!hlsInstance.levels?.length) return;
|
||||
|
||||
store.getState().emit('playbackQualities', {
|
||||
qualities: ['auto', ...hlsInstance.levels.map(levelToPlaybackQuality)],
|
||||
});
|
||||
|
||||
store.getState().emit('playbackQualityChange', {quality: 'auto'});
|
||||
});
|
||||
|
||||
hlsInstance.on(Hls.Events.AUDIO_TRACK_SWITCHED, (eventType, data) => {
|
||||
const track = store.getState().audioTracks.find(t => t.id === data.id);
|
||||
if (track) {
|
||||
store.getState().emit('currentAudioTrackChange', {trackId: track.id});
|
||||
}
|
||||
});
|
||||
|
||||
hlsInstance.on(
|
||||
Hls.Events.LEVEL_LOADED,
|
||||
(eventType: string, data: LevelLoadedData) => {
|
||||
if (!store.getState().providerReady) {
|
||||
const {type, live, totalduration: duration} = data.details;
|
||||
const inferredStreamType = live
|
||||
? type === 'EVENT' && Number.isFinite(duration)
|
||||
? 'live:dvr'
|
||||
: 'live'
|
||||
: 'on-demand';
|
||||
store.getState().emit('streamTypeChange', {
|
||||
streamType:
|
||||
(store.getState().cuedMedia as HlsMediaItem)?.streamType ||
|
||||
inferredStreamType,
|
||||
});
|
||||
store.getState().emit('durationChange', {duration});
|
||||
|
||||
const audioTracks: AudioTrack[] = hlsInstance.audioTracks.map(
|
||||
track => ({
|
||||
id: track.id,
|
||||
label: track.name,
|
||||
language: track.lang || '',
|
||||
kind: 'main',
|
||||
})
|
||||
);
|
||||
store.getState().emit('audioTracks', {tracks: audioTracks});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
hlsInstance.attachMedia(videoRef.current);
|
||||
|
||||
hls.current = hlsInstance;
|
||||
// trigger initial load source
|
||||
setHlsReady(true);
|
||||
}, [destroyHls, store]);
|
||||
|
||||
// setup and destroy hls on mount and unmount
|
||||
useEffect(() => {
|
||||
setupHls();
|
||||
return () => {
|
||||
destroyHls();
|
||||
};
|
||||
}, [setupHls, destroyHls]);
|
||||
|
||||
// load source via hls when media src changes and hls is ready
|
||||
useEffect(() => {
|
||||
if (
|
||||
hls.current &&
|
||||
cuedMedia?.src &&
|
||||
(hls.current as any).url !== cuedMedia?.src
|
||||
) {
|
||||
hls.current.loadSource(cuedMedia.src);
|
||||
}
|
||||
}, [cuedMedia?.src, hlsReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hlsReady) return;
|
||||
store.setState({
|
||||
providerApi: {
|
||||
...htmlMediaApi,
|
||||
setCurrentAudioTrack: (trackId: number) => {
|
||||
if (!hls.current) return;
|
||||
hls.current.audioTrack = trackId;
|
||||
},
|
||||
setPlaybackQuality: (quality: string) => {
|
||||
if (!hls.current) return;
|
||||
hls.current.currentLevel = hls.current.levels.findIndex(
|
||||
level => levelToPlaybackQuality(level) === quality
|
||||
);
|
||||
store.getState().emit('playbackQualityChange', {quality});
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [htmlMediaApi, store, hlsReady]);
|
||||
|
||||
return (
|
||||
<video
|
||||
className="h-full w-full"
|
||||
ref={videoRef}
|
||||
playsInline
|
||||
poster={cuedMedia?.poster}
|
||||
{...htmlMediaEvents}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const levelToPlaybackQuality = (level: any) => {
|
||||
return level === -1 ? 'auto' : `${level.height}p`;
|
||||
};
|
||||
41
common/resources/client/player/providers/html-audio-provider.tsx
Executable file
41
common/resources/client/player/providers/html-audio-provider.tsx
Executable file
@@ -0,0 +1,41 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {useContext, useEffect, useRef} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {useHtmlMediaInternalState} from '@common/player/providers/html-media/use-html-media-internal-state';
|
||||
import {useHtmlMediaEvents} from '@common/player/providers/html-media/use-html-media-events';
|
||||
import {useHtmlMediaApi} from '@common/player/providers/html-media/use-html-media-api';
|
||||
|
||||
export function HtmlAudioProvider() {
|
||||
const ref = useRef<HTMLAudioElement>(null);
|
||||
|
||||
const autoPlay = usePlayerStore(s => s.options.autoPlay);
|
||||
const muted = usePlayerStore(s => s.muted);
|
||||
const cuedMedia = usePlayerStore(s => s.cuedMedia);
|
||||
const store = useContext(PlayerStoreContext);
|
||||
|
||||
const state = useHtmlMediaInternalState(ref);
|
||||
const events = useHtmlMediaEvents(state);
|
||||
const providerApi = useHtmlMediaApi(state);
|
||||
|
||||
useEffect(() => {
|
||||
store.setState({
|
||||
providerApi,
|
||||
});
|
||||
}, [store, providerApi]);
|
||||
|
||||
let src = cuedMedia?.src;
|
||||
if (src && cuedMedia?.initialTime) {
|
||||
src = `${src}#t=${cuedMedia.initialTime}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<audio
|
||||
className="w-full h-full"
|
||||
ref={ref}
|
||||
src={src}
|
||||
autoPlay={autoPlay}
|
||||
muted={muted}
|
||||
{...events}
|
||||
/>
|
||||
);
|
||||
}
|
||||
67
common/resources/client/player/providers/html-media/use-html-media-api.ts
Executable file
67
common/resources/client/player/providers/html-media/use-html-media-api.ts
Executable file
@@ -0,0 +1,67 @@
|
||||
import {HtmlMediaInternalStateReturn} from '@common/player/providers/html-media/use-html-media-internal-state';
|
||||
import {useContext, useMemo} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {PlayerProviderApi} from '@common/player/state/player-provider-api';
|
||||
|
||||
export function useHtmlMediaApi({
|
||||
ref,
|
||||
internalState,
|
||||
toggleTextTrackModes,
|
||||
}: HtmlMediaInternalStateReturn): PlayerProviderApi {
|
||||
const store = useContext(PlayerStoreContext);
|
||||
return useMemo(
|
||||
() => ({
|
||||
play: async () => {
|
||||
try {
|
||||
await ref.current?.play();
|
||||
} catch (e) {
|
||||
store.getState().emit('error', {sourceEvent: e});
|
||||
}
|
||||
internalState.current.timeRafLoop.start();
|
||||
},
|
||||
pause: () => {
|
||||
ref.current?.pause();
|
||||
internalState.current.timeRafLoop.stop();
|
||||
},
|
||||
stop: () => {
|
||||
if (ref.current) {
|
||||
ref.current.pause();
|
||||
ref.current.currentTime = 0;
|
||||
}
|
||||
},
|
||||
seek: (time: number) => {
|
||||
if (time !== internalState.current.currentTime && ref.current) {
|
||||
ref.current.currentTime = time;
|
||||
}
|
||||
},
|
||||
setVolume: (volume: number) => {
|
||||
if (ref.current) {
|
||||
ref.current.volume = volume / 100;
|
||||
}
|
||||
},
|
||||
setMuted: (muted: boolean) => {
|
||||
if (ref.current) {
|
||||
ref.current.muted = muted;
|
||||
}
|
||||
},
|
||||
setPlaybackRate: (value: number) => {
|
||||
if (ref.current) {
|
||||
ref.current.playbackRate = value;
|
||||
}
|
||||
},
|
||||
setTextTrackVisibility: isVisible => {
|
||||
toggleTextTrackModes(store.getState().currentTextTrack, isVisible);
|
||||
},
|
||||
setCurrentTextTrack: newTrackId => {
|
||||
toggleTextTrackModes(newTrackId, store.getState().textTrackIsVisible);
|
||||
},
|
||||
getCurrentTime: () => {
|
||||
return internalState.current.currentTime;
|
||||
},
|
||||
getSrc: () => {
|
||||
return ref.current?.src;
|
||||
},
|
||||
}),
|
||||
[ref, store, internalState, toggleTextTrackModes]
|
||||
);
|
||||
}
|
||||
125
common/resources/client/player/providers/html-media/use-html-media-events.ts
Executable file
125
common/resources/client/player/providers/html-media/use-html-media-events.ts
Executable file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
HTMLAttributes,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {HtmlMediaInternalStateReturn} from '@common/player/providers/html-media/use-html-media-internal-state';
|
||||
|
||||
const defaultPlaybackRates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||
|
||||
export function useHtmlMediaEvents({
|
||||
ref,
|
||||
updateCurrentTime,
|
||||
updateBuffered,
|
||||
internalState,
|
||||
}: HtmlMediaInternalStateReturn): HTMLAttributes<HTMLMediaElement> {
|
||||
const store = useContext(PlayerStoreContext);
|
||||
|
||||
const onTextTracksChange = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
const tracks = Array.from(ref.current.textTracks).filter(
|
||||
t => t.label && (t.kind === 'subtitles' || t.kind === 'captions')
|
||||
);
|
||||
|
||||
let trackId = -1;
|
||||
for (let id = 0; id < tracks.length; id += 1) {
|
||||
if (tracks[id].mode === 'hidden') {
|
||||
// Do not break in case there is a following track with showing.
|
||||
trackId = id;
|
||||
} else if (tracks[id].mode === 'showing') {
|
||||
trackId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const isVisible = trackId !== -1 && tracks[trackId].mode === 'showing';
|
||||
store.getState().emit('currentTextTrackChange', {trackId});
|
||||
store.getState().emit('textTrackVisibilityChange', {isVisible});
|
||||
store.getState().emit('textTracks', {tracks});
|
||||
}, [ref, store]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
return () => {
|
||||
el?.textTracks.removeEventListener('change', onTextTracksChange);
|
||||
};
|
||||
}, [ref, onTextTracksChange]);
|
||||
|
||||
return useMemo(() => {
|
||||
const emit = store.getState().emit;
|
||||
return {
|
||||
// set some common props used on audio/video/hls/dash providers
|
||||
autoPlay: false,
|
||||
onContextMenu: e => e.preventDefault(),
|
||||
controlsList: 'nodownload',
|
||||
preload: 'metadata',
|
||||
'x-webkit-airplay': 'allow',
|
||||
onEnded: () => {
|
||||
emit('playbackEnd');
|
||||
updateCurrentTime();
|
||||
internalState.current.timeRafLoop.stop();
|
||||
},
|
||||
onStalled: e => {
|
||||
if (e.currentTarget.readyState < 3) {
|
||||
emit('buffering', {isBuffering: true});
|
||||
}
|
||||
},
|
||||
onWaiting: () => {
|
||||
emit('buffering', {isBuffering: true});
|
||||
},
|
||||
onPlaying: () => {
|
||||
emit('play');
|
||||
emit('buffering', {isBuffering: false});
|
||||
},
|
||||
onPause: e => {
|
||||
emit('pause');
|
||||
emit('buffering', {isBuffering: false});
|
||||
internalState.current.timeRafLoop.stop();
|
||||
},
|
||||
onSuspend: () => {
|
||||
emit('buffering', {isBuffering: false});
|
||||
},
|
||||
onSeeking: () => {
|
||||
updateCurrentTime();
|
||||
},
|
||||
onSeeked: () => {
|
||||
updateCurrentTime();
|
||||
},
|
||||
onTimeUpdate: () => {
|
||||
updateCurrentTime();
|
||||
},
|
||||
onError: e => {
|
||||
emit('error', {sourceEvent: e});
|
||||
},
|
||||
onDurationChange: e => {
|
||||
updateCurrentTime();
|
||||
emit('durationChange', {duration: e.currentTarget.duration});
|
||||
},
|
||||
onRateChange: e => {
|
||||
emit('playbackRateChange', {rate: e.currentTarget.playbackRate});
|
||||
},
|
||||
onLoadedMetadata: e => {
|
||||
if (!internalState.current.playbackReady) {
|
||||
emit('providerReady', {el: e.currentTarget});
|
||||
internalState.current.playbackReady = true;
|
||||
updateBuffered();
|
||||
onTextTracksChange();
|
||||
e.currentTarget.textTracks.addEventListener('change', () => {
|
||||
onTextTracksChange();
|
||||
});
|
||||
}
|
||||
emit('cued');
|
||||
emit('playbackRates', {rates: defaultPlaybackRates});
|
||||
},
|
||||
};
|
||||
}, [
|
||||
internalState,
|
||||
store,
|
||||
updateCurrentTime,
|
||||
onTextTracksChange,
|
||||
updateBuffered,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {createRafLoop} from '@common/utils/dom/create-ref-loop';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
|
||||
export interface HtmlMediaInternalStateReturn {
|
||||
ref: RefObject<HTMLMediaElement>;
|
||||
updateCurrentTime: () => void;
|
||||
updateBuffered: () => void;
|
||||
toggleTextTrackModes: (newTrackId: number, isVisible: boolean) => void;
|
||||
internalState: MutableRefObject<{
|
||||
currentTime: number;
|
||||
playbackReady: boolean;
|
||||
timeRafLoop: ReturnType<typeof createRafLoop>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function useHtmlMediaInternalState(
|
||||
ref: RefObject<HTMLMediaElement>
|
||||
): HtmlMediaInternalStateReturn {
|
||||
const store = useContext(PlayerStoreContext);
|
||||
const cuedMedia = usePlayerStore(s => s.cuedMedia);
|
||||
|
||||
const internalState = useRef({
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
isMediaWaiting: false,
|
||||
playbackReady: false,
|
||||
/**
|
||||
* The `timeupdate` event fires surprisingly infrequently during playback, meaning your progress
|
||||
* bar (or whatever else is synced to the currentTime) moves in a choppy fashion. This helps
|
||||
* resolve that by retrieving time updates in a request animation frame loop.
|
||||
*/
|
||||
timeRafLoop: createRafLoop(() => {
|
||||
updateCurrentTime();
|
||||
updateBuffered();
|
||||
}),
|
||||
});
|
||||
|
||||
const updateBuffered = useCallback(() => {
|
||||
const timeRange = ref.current?.buffered;
|
||||
const seconds =
|
||||
!timeRange || timeRange.length === 0
|
||||
? 0
|
||||
: timeRange.end(timeRange.length - 1);
|
||||
|
||||
if (internalState.current.buffered !== seconds) {
|
||||
store.getState().emit('buffered', {seconds});
|
||||
internalState.current.buffered = seconds;
|
||||
}
|
||||
}, [ref, store]);
|
||||
|
||||
const updateCurrentTime = useCallback(() => {
|
||||
const newTime = ref.current?.currentTime || 0;
|
||||
if (
|
||||
internalState.current.currentTime !== newTime &&
|
||||
!store.getState().isSeeking
|
||||
) {
|
||||
store.getState().emit('progress', {currentTime: newTime});
|
||||
internalState.current.currentTime = newTime;
|
||||
}
|
||||
}, [internalState, store, ref]);
|
||||
|
||||
const toggleTextTrackModes = useCallback(
|
||||
(newTrackId: number, isVisible: boolean) => {
|
||||
if (!ref.current) return;
|
||||
const {textTracks} = ref.current;
|
||||
|
||||
if (newTrackId === -1) {
|
||||
Array.from(textTracks).forEach(track => {
|
||||
track.mode = 'disabled';
|
||||
});
|
||||
} else {
|
||||
const oldTrack = textTracks[store.getState().currentTextTrack];
|
||||
if (oldTrack) oldTrack.mode = 'disabled';
|
||||
}
|
||||
|
||||
const nextTrack = textTracks[newTrackId];
|
||||
|
||||
if (nextTrack) {
|
||||
nextTrack.mode = isVisible ? 'showing' : 'hidden';
|
||||
}
|
||||
|
||||
store.getState().emit('currentTextTrackChange', {
|
||||
trackId: !isVisible ? -1 : newTrackId,
|
||||
});
|
||||
store
|
||||
.getState()
|
||||
.emit('textTrackVisibilityChange', {isVisible: isVisible});
|
||||
},
|
||||
[ref, store]
|
||||
);
|
||||
|
||||
// stop current time loop on unmount
|
||||
useEffect(() => {
|
||||
const timeRafLoop = internalState.current.timeRafLoop;
|
||||
return () => {
|
||||
timeRafLoop.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// reload metadata when new media is cued
|
||||
useEffect(() => {
|
||||
ref.current?.load();
|
||||
}, [cuedMedia?.src, ref]);
|
||||
|
||||
return {
|
||||
ref,
|
||||
internalState,
|
||||
updateCurrentTime,
|
||||
toggleTextTrackModes,
|
||||
updateBuffered,
|
||||
};
|
||||
}
|
||||
54
common/resources/client/player/providers/html-video-provider.tsx
Executable file
54
common/resources/client/player/providers/html-video-provider.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {useContext, useEffect, useRef} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {useHtmlMediaInternalState} from '@common/player/providers/html-media/use-html-media-internal-state';
|
||||
import {useHtmlMediaEvents} from '@common/player/providers/html-media/use-html-media-events';
|
||||
import {useHtmlMediaApi} from '@common/player/providers/html-media/use-html-media-api';
|
||||
|
||||
export function HtmlVideoProvider() {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const autoPlay = usePlayerStore(s => s.options.autoPlay);
|
||||
const muted = usePlayerStore(s => s.muted);
|
||||
const cuedMedia = usePlayerStore(s => s.cuedMedia);
|
||||
const store = useContext(PlayerStoreContext);
|
||||
|
||||
const state = useHtmlMediaInternalState(ref);
|
||||
const events = useHtmlMediaEvents(state);
|
||||
const providerApi = useHtmlMediaApi(state);
|
||||
|
||||
useEffect(() => {
|
||||
store.setState({
|
||||
providerApi,
|
||||
});
|
||||
}, [store, providerApi]);
|
||||
|
||||
let src = cuedMedia?.src;
|
||||
if (src && cuedMedia?.initialTime) {
|
||||
src = `${src}#t=${cuedMedia.initialTime}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
className="w-full h-full"
|
||||
ref={ref}
|
||||
src={src}
|
||||
playsInline
|
||||
poster={cuedMedia?.poster}
|
||||
autoPlay={autoPlay}
|
||||
muted={muted}
|
||||
{...events}
|
||||
>
|
||||
{cuedMedia?.captions?.map((caption, index) => (
|
||||
<track
|
||||
key={caption.id}
|
||||
label={caption.label}
|
||||
kind="subtitles"
|
||||
srcLang={caption.language || 'en'}
|
||||
src={caption.src}
|
||||
default={index === 0}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
YoutubeInternalState,
|
||||
YouTubeMessage,
|
||||
YoutubeMessageInfo,
|
||||
YouTubePlayerState,
|
||||
YoutubeProviderError,
|
||||
} from '@common/player/providers/youtube/youtube-types';
|
||||
import {MutableRefObject, RefObject} from 'react';
|
||||
import {PlayerStoreApi} from '@common/player/state/player-state';
|
||||
import {isNumber} from '@common/utils/number/is-number';
|
||||
import {loadYoutubePoster} from '@common/player/providers/youtube/load-youtube-poster';
|
||||
|
||||
export function handleYoutubeEmbedMessage(
|
||||
e: MessageEvent,
|
||||
internalStateRef: MutableRefObject<YoutubeInternalState>,
|
||||
iframeRef: RefObject<HTMLIFrameElement>,
|
||||
store: PlayerStoreApi
|
||||
) {
|
||||
const data = (
|
||||
typeof e.data === 'string' ? JSON.parse(e.data) : e.data
|
||||
) as YouTubeMessage;
|
||||
const info = data.info;
|
||||
const internalState = internalStateRef.current;
|
||||
const emit = store.getState().emit;
|
||||
if (!info) return;
|
||||
|
||||
if (info.videoData?.video_id) {
|
||||
internalState.videoId = info.videoData.video_id;
|
||||
}
|
||||
|
||||
if (info.videoData?.errorCode) {
|
||||
const event: YoutubeProviderError = {
|
||||
code: info.videoData.errorCode,
|
||||
videoId: internalState.videoId,
|
||||
};
|
||||
emit('error', {sourceEvent: event});
|
||||
}
|
||||
|
||||
if (isNumber(info.duration) && info.duration !== internalState.duration) {
|
||||
internalState.duration = info.duration;
|
||||
emit('durationChange', {duration: internalState.duration});
|
||||
}
|
||||
|
||||
if (
|
||||
isNumber(info.currentTime) &&
|
||||
info.currentTime !== internalState.currentTime
|
||||
) {
|
||||
internalState.currentTime = info.currentTime;
|
||||
// don't fire progress events while seeking via seekbar
|
||||
if (!store.getState().isSeeking) {
|
||||
emit('progress', {currentTime: internalState.currentTime});
|
||||
}
|
||||
}
|
||||
|
||||
if (isNumber(info.currentTimeLastUpdated)) {
|
||||
internalState.lastTimeUpdate = info.currentTimeLastUpdated;
|
||||
}
|
||||
|
||||
if (isNumber(info.playbackRate)) {
|
||||
if (internalState.playbackRate !== info.playbackRate) {
|
||||
emit('playbackRateChange', {rate: info.playbackRate});
|
||||
}
|
||||
internalState.playbackRate = info.playbackRate;
|
||||
}
|
||||
|
||||
if (isNumber(info.videoLoadedFraction)) {
|
||||
const buffered = info.videoLoadedFraction * internalState.duration;
|
||||
if (internalState.buffered !== buffered) {
|
||||
emit('buffered', {
|
||||
seconds: info.videoLoadedFraction * internalState.duration,
|
||||
});
|
||||
}
|
||||
internalState.buffered = buffered;
|
||||
}
|
||||
|
||||
if (Array.isArray(info.availablePlaybackRates)) {
|
||||
emit('playbackRates', {rates: info.availablePlaybackRates});
|
||||
}
|
||||
|
||||
if (isNumber(info.playerState)) {
|
||||
onYoutubeStateChange(info, internalStateRef, iframeRef, store);
|
||||
internalState.state = info.playerState;
|
||||
}
|
||||
}
|
||||
|
||||
function onYoutubeStateChange(
|
||||
info: YoutubeMessageInfo,
|
||||
internalStateRef: MutableRefObject<YoutubeInternalState>,
|
||||
iframeRef: RefObject<HTMLIFrameElement>,
|
||||
store: PlayerStoreApi
|
||||
) {
|
||||
const emit = store.getState().emit;
|
||||
const state = info.playerState!;
|
||||
|
||||
const onCued = async () => {
|
||||
// load poster, if needed
|
||||
if (info.videoData?.video_id && !store.getState().cuedMedia?.poster) {
|
||||
const url = await loadYoutubePoster(info.videoData.video_id);
|
||||
if (url) {
|
||||
store.getState().emit('posterLoaded', {url});
|
||||
}
|
||||
}
|
||||
|
||||
// mark provider as ready
|
||||
if (!internalStateRef.current.playbackReady) {
|
||||
emit('providerReady', {el: iframeRef.current!});
|
||||
internalStateRef.current.playbackReady = true;
|
||||
}
|
||||
emit('cued');
|
||||
};
|
||||
|
||||
emit('youtubeStateChange', {state});
|
||||
emit('buffering', {isBuffering: state === YouTubePlayerState.Buffering});
|
||||
|
||||
if (state !== YouTubePlayerState.Ended) {
|
||||
internalStateRef.current.firedPlaybackEnd = false;
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case YouTubePlayerState.Unstarted:
|
||||
// When using autoplay, but autoplay fails, player will get "unstarted" event
|
||||
onCued();
|
||||
break;
|
||||
case YouTubePlayerState.Ended:
|
||||
// will sometimes fire twice without this, if player starts buffering as a result of seek to the end
|
||||
if (!internalStateRef.current.firedPlaybackEnd) {
|
||||
emit('playbackEnd');
|
||||
internalStateRef.current.firedPlaybackEnd = true;
|
||||
}
|
||||
break;
|
||||
case YouTubePlayerState.Playing:
|
||||
// When using autoplay, "cued" event is never fired, handle "cued" here instead
|
||||
onCued();
|
||||
emit('play');
|
||||
break;
|
||||
case YouTubePlayerState.Paused:
|
||||
emit('pause');
|
||||
break;
|
||||
case YouTubePlayerState.Cued:
|
||||
onCued();
|
||||
break;
|
||||
}
|
||||
}
|
||||
30
common/resources/client/player/providers/youtube/load-youtube-poster.ts
Executable file
30
common/resources/client/player/providers/youtube/load-youtube-poster.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
import {loadImage} from '@common/utils/http/load-image';
|
||||
|
||||
const posterCache = new Map<string, string>();
|
||||
|
||||
export async function loadYoutubePoster(
|
||||
videoId: string
|
||||
): Promise<string | undefined> {
|
||||
if (!videoId) return;
|
||||
if (posterCache.has(videoId)) {
|
||||
return posterCache.get(videoId);
|
||||
}
|
||||
|
||||
const posterURL = (quality: string) =>
|
||||
`https://i.ytimg.com/vi/${videoId}/${quality}.jpg`;
|
||||
|
||||
/**
|
||||
* We are testing that the image has a min-width of 121px because if the thumbnail does not
|
||||
* exist YouTube returns a blank/error image that is 120px wide.
|
||||
*/
|
||||
return loadImage(posterURL('maxresdefault'), 121) // 1080p (no padding)
|
||||
.catch(() => loadImage(posterURL('sddefault'), 121)) // 640p (padded 4:3)
|
||||
.catch(() => loadImage(posterURL('hqdefault'), 121)) // 480p (padded 4:3)
|
||||
.catch(() => {})
|
||||
.then(img => {
|
||||
if (!img) return;
|
||||
const poster = img.src;
|
||||
posterCache.set(videoId, poster);
|
||||
return poster;
|
||||
});
|
||||
}
|
||||
73
common/resources/client/player/providers/youtube/use-youtube-provider-src.ts
Executable file
73
common/resources/client/player/providers/youtube/use-youtube-provider-src.ts
Executable file
@@ -0,0 +1,73 @@
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {YoutubeMediaItem} from '@common/player/media-item';
|
||||
import {usePlayerActions} from '@common/player/hooks/use-player-actions';
|
||||
import {youtubeIdFromSrc} from '@common/player/utils/youtube-id-from-src';
|
||||
|
||||
const queryString =
|
||||
'&controls=0&disablekb=1&enablejsapi=1&iv_load_policy=3&modestbranding=1&playsinline=1&rel=0&showinfo=0';
|
||||
|
||||
export function useYoutubeProviderSrc(
|
||||
loadVideoById: (videoId: string) => void
|
||||
) {
|
||||
const {getState, emit} = usePlayerActions();
|
||||
const options = usePlayerStore(s => s.options);
|
||||
const media = usePlayerStore(s => s.cuedMedia) as
|
||||
| YoutubeMediaItem
|
||||
| undefined;
|
||||
|
||||
const origin = options.youtube?.useCookies
|
||||
? 'https://www.youtube.com'
|
||||
: 'https://www.youtube-nocookie.com';
|
||||
|
||||
const [initialVideoId, setInitialVideoId] = useState(() => {
|
||||
if (media?.src && media.src !== 'resolve') {
|
||||
return youtubeIdFromSrc(media.src);
|
||||
}
|
||||
});
|
||||
|
||||
const updateVideoIds = useCallback(
|
||||
(src: string) => {
|
||||
const videoId = youtubeIdFromSrc(src);
|
||||
if (!videoId) return;
|
||||
|
||||
// use setState callback, so we don't need to use "initialVideoId" in the dependency array
|
||||
setInitialVideoId(prevId => {
|
||||
if (!prevId) {
|
||||
return videoId;
|
||||
} else {
|
||||
// changing src of iframe will cause it to fully reload, use "loadVideoById" api method instead
|
||||
loadVideoById(videoId);
|
||||
return prevId;
|
||||
}
|
||||
});
|
||||
},
|
||||
[loadVideoById]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (media?.src && media.src !== 'resolve') {
|
||||
updateVideoIds(media.src);
|
||||
} else if (media) {
|
||||
emit('buffering', {isBuffering: true});
|
||||
options.youtube?.srcResolver?.(media).then(item => {
|
||||
// check if resolved media matches the one currently in the store to prevent race conditions.
|
||||
// check against current value in store, because this callback will close over old value
|
||||
if (item?.src && getState().cuedMedia?.id === item.id) {
|
||||
updateVideoIds(item.src);
|
||||
}
|
||||
});
|
||||
}
|
||||
// only update when media id changes to prevent infinite loops
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options, updateVideoIds, media?.id]);
|
||||
|
||||
return {
|
||||
initialVideoUrl: initialVideoId
|
||||
? `${origin}/embed/${initialVideoId}?${queryString}&autoplay=${
|
||||
options.autoPlay ? '1' : '0'
|
||||
}&mute=${getState().muted ? '1' : '0'}&start=${media?.initialTime ?? 0}`
|
||||
: undefined,
|
||||
origin,
|
||||
};
|
||||
}
|
||||
144
common/resources/client/player/providers/youtube/youtube-provider.tsx
Executable file
144
common/resources/client/player/providers/youtube/youtube-provider.tsx
Executable file
@@ -0,0 +1,144 @@
|
||||
import {useGlobalListeners} from '@react-aria/utils';
|
||||
import {useCallback, useContext, useEffect, useRef} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {
|
||||
YoutubeCommand,
|
||||
YouTubeCommandArg,
|
||||
YoutubeInternalState,
|
||||
YoutubeProviderInternalApi,
|
||||
} from '@common/player/providers/youtube/youtube-types';
|
||||
import {handleYoutubeEmbedMessage} from '@common/player/providers/youtube/handle-youtube-embed-message';
|
||||
import {useYoutubeProviderSrc} from '@common/player/providers/youtube/use-youtube-provider-src';
|
||||
|
||||
export function YoutubeProvider() {
|
||||
const {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const youtubeApi = useCallback(
|
||||
<T extends keyof YouTubeCommandArg>(
|
||||
command: T,
|
||||
arg?: YouTubeCommandArg[T]
|
||||
) =>
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
JSON.stringify({
|
||||
event: 'command',
|
||||
func: command,
|
||||
args: arg ? [arg] : undefined,
|
||||
}),
|
||||
'*'
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const loadVideoById = useCallback(
|
||||
(videoId: string) => {
|
||||
// using "YoutubeCommand.Cue" does not play video when changing sources,
|
||||
// it requires double click on play button without this
|
||||
youtubeApi(YoutubeCommand.CueAndPlay, videoId);
|
||||
},
|
||||
[youtubeApi]
|
||||
);
|
||||
|
||||
const {initialVideoUrl, origin} = useYoutubeProviderSrc(loadVideoById);
|
||||
const store = useContext(PlayerStoreContext);
|
||||
|
||||
const internalStateRef = useRef<YoutubeInternalState>({
|
||||
duration: 0,
|
||||
currentTime: 0,
|
||||
lastTimeUpdate: 0,
|
||||
playbackRate: 1,
|
||||
state: -1,
|
||||
playbackReady: false,
|
||||
buffered: 0,
|
||||
firedPlaybackEnd: false,
|
||||
});
|
||||
|
||||
const registerApi = useCallback(() => {
|
||||
const internalProviderApi: YoutubeProviderInternalApi = {
|
||||
loadVideoById,
|
||||
};
|
||||
store.setState({
|
||||
providerApi: {
|
||||
play: () => {
|
||||
youtubeApi(YoutubeCommand.Play);
|
||||
},
|
||||
pause: () => {
|
||||
youtubeApi(YoutubeCommand.Pause);
|
||||
},
|
||||
stop: () => {
|
||||
youtubeApi(YoutubeCommand.Stop);
|
||||
},
|
||||
seek: (time: number) => {
|
||||
if (time !== internalStateRef.current.currentTime) {
|
||||
youtubeApi(YoutubeCommand.Seek, time);
|
||||
}
|
||||
},
|
||||
setVolume: (volume: number) => {
|
||||
youtubeApi(YoutubeCommand.SetVolume, volume);
|
||||
},
|
||||
setMuted: (muted: boolean) => {
|
||||
if (muted) {
|
||||
youtubeApi(YoutubeCommand.Mute);
|
||||
} else {
|
||||
youtubeApi(YoutubeCommand.Unmute);
|
||||
}
|
||||
},
|
||||
setPlaybackRate: (value: number) => {
|
||||
youtubeApi(YoutubeCommand.SetPlaybackRate, value);
|
||||
},
|
||||
setPlaybackQuality: (value: string) => {
|
||||
youtubeApi(YoutubeCommand.SetPlaybackQuality, value);
|
||||
},
|
||||
getCurrentTime: () => {
|
||||
return internalStateRef.current.currentTime;
|
||||
},
|
||||
getSrc: () => {
|
||||
return internalStateRef.current.videoId;
|
||||
},
|
||||
internalProviderApi,
|
||||
},
|
||||
});
|
||||
}, [store, loadVideoById, youtubeApi]);
|
||||
|
||||
useEffect(() => {
|
||||
addGlobalListener(window, 'message', event => {
|
||||
const e = event as MessageEvent;
|
||||
if (
|
||||
e.origin === origin &&
|
||||
e.source === iframeRef.current?.contentWindow
|
||||
) {
|
||||
handleYoutubeEmbedMessage(e, internalStateRef, iframeRef, store);
|
||||
}
|
||||
});
|
||||
|
||||
registerApi();
|
||||
|
||||
return () => {
|
||||
removeAllGlobalListeners();
|
||||
};
|
||||
}, [addGlobalListener, removeAllGlobalListeners, store, origin, registerApi]);
|
||||
|
||||
if (!initialVideoUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className="w-full h-full"
|
||||
ref={iframeRef}
|
||||
src={initialVideoUrl}
|
||||
allowFullScreen
|
||||
allow="autoplay; encrypted-media; picture-in-picture;"
|
||||
onLoad={() => {
|
||||
// window does not receive "message" events on safari without waiting a small amount of time for some reason
|
||||
setTimeout(() => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
JSON.stringify({event: 'listening'}),
|
||||
'*'
|
||||
);
|
||||
registerApi();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
102
common/resources/client/player/providers/youtube/youtube-types.ts
Executable file
102
common/resources/client/player/providers/youtube/youtube-types.ts
Executable file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @see https://developers.google.com/youtube/iframe_api_reference#Playback_controls
|
||||
*/
|
||||
export const enum YoutubeCommand {
|
||||
Play = 'playVideo',
|
||||
Pause = 'pauseVideo',
|
||||
Stop = 'stopVideo',
|
||||
Seek = 'seekTo',
|
||||
Cue = 'cueVideoById',
|
||||
CueAndPlay = 'loadVideoById',
|
||||
Mute = 'mute',
|
||||
Unmute = 'unMute',
|
||||
SetVolume = 'setVolume',
|
||||
SetPlaybackRate = 'setPlaybackRate',
|
||||
SetPlaybackQuality = 'setPlaybackQuality',
|
||||
}
|
||||
|
||||
export interface YouTubeCommandArg {
|
||||
[YoutubeCommand.Play]: void;
|
||||
[YoutubeCommand.Pause]: void;
|
||||
[YoutubeCommand.Stop]: void;
|
||||
[YoutubeCommand.Seek]: number;
|
||||
[YoutubeCommand.Cue]: string;
|
||||
[YoutubeCommand.CueAndPlay]: string;
|
||||
[YoutubeCommand.Mute]: void;
|
||||
[YoutubeCommand.Unmute]: void;
|
||||
[YoutubeCommand.SetVolume]: number;
|
||||
[YoutubeCommand.SetPlaybackRate]: number;
|
||||
[YoutubeCommand.SetPlaybackQuality]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://developers.google.com/youtube/iframe_api_reference#onStateChange
|
||||
*/
|
||||
export const enum YouTubePlayerState {
|
||||
Unstarted = -1,
|
||||
Ended = 0,
|
||||
Playing = 1,
|
||||
Paused = 2,
|
||||
Buffering = 3,
|
||||
Cued = 5,
|
||||
}
|
||||
|
||||
export interface YoutubeInternalState {
|
||||
duration: number;
|
||||
currentTime: number;
|
||||
videoId?: string;
|
||||
lastTimeUpdate: number;
|
||||
playbackRate: number;
|
||||
playbackReady: boolean;
|
||||
buffered: number;
|
||||
state: YouTubePlayerState;
|
||||
firedPlaybackEnd: boolean;
|
||||
}
|
||||
|
||||
export const enum YouTubePlaybackQuality {
|
||||
Unknown = 'unknown',
|
||||
Tiny = 'tiny',
|
||||
Small = 'small',
|
||||
Medium = 'medium',
|
||||
Large = 'large',
|
||||
Hd720 = 'hd720',
|
||||
Hd1080 = 'hd1080',
|
||||
Highres = 'highres',
|
||||
Max = 'max',
|
||||
}
|
||||
|
||||
export interface YouTubeMessage {
|
||||
channel: string;
|
||||
event: 'initialDelivery' | 'onReady' | 'infoDelivery' | 'apiInfoDelivery';
|
||||
info?: YoutubeMessageInfo;
|
||||
}
|
||||
|
||||
export interface YoutubeMessageInfo {
|
||||
availablePlaybackRates?: number[];
|
||||
availableQualityLevels?: YouTubePlaybackQuality[];
|
||||
currentTime?: number;
|
||||
currentTimeLastUpdated?: number;
|
||||
videoLoadedFraction?: number;
|
||||
volume?: number;
|
||||
videoUrl?: string;
|
||||
videoData?: {
|
||||
author: string;
|
||||
title: string;
|
||||
video_id: string;
|
||||
errorCode?: string;
|
||||
};
|
||||
duration?: number;
|
||||
muted?: boolean;
|
||||
playbackQuality?: YouTubePlaybackQuality;
|
||||
playbackRate?: number;
|
||||
playerState?: YouTubePlayerState;
|
||||
}
|
||||
|
||||
export interface YoutubeProviderError {
|
||||
code?: string;
|
||||
videoId?: string;
|
||||
}
|
||||
|
||||
export interface YoutubeProviderInternalApi {
|
||||
loadVideoById: (videoId: string) => void;
|
||||
}
|
||||
Reference in New Issue
Block a user