2f2300db8d
* Add query param to initially mute embed player * Add stories for embed player * Improve VideoJS typing
324 lines
8.4 KiB
TypeScript
324 lines
8.4 KiB
TypeScript
import React, { FC, useEffect } from 'react';
|
|
import { useRecoilState, useRecoilValue } from 'recoil';
|
|
import { useHotkeys } from 'react-hotkeys-hook';
|
|
import { VideoJsPlayerOptions } from 'video.js';
|
|
import { VideoJS } from '../VideoJS/VideoJS';
|
|
import ViewerPing from '../viewer-ping';
|
|
import { VideoPoster } from '../VideoPoster/VideoPoster';
|
|
import { getLocalStorage, setLocalStorage } from '../../../utils/localStorage';
|
|
import { isVideoPlayingAtom, clockSkewAtom } from '../../stores/ClientConfigStore';
|
|
import PlaybackMetrics from '../metrics/playback';
|
|
import createVideoSettingsMenuButton from '../settings-menu';
|
|
import LatencyCompensator from '../latencyCompensator';
|
|
|
|
import styles from './OwncastPlayer.module.scss';
|
|
|
|
const VIDEO_CONFIG_URL = '/api/video/variants';
|
|
const PLAYER_VOLUME = 'owncast_volume';
|
|
const LATENCY_COMPENSATION_ENABLED = 'latencyCompensatorEnabled';
|
|
|
|
const ping = new ViewerPing();
|
|
let playbackMetrics = null;
|
|
let latencyCompensator = null;
|
|
let latencyCompensatorEnabled = false;
|
|
|
|
export type OwncastPlayerProps = {
|
|
source: string;
|
|
online: boolean;
|
|
initiallyMuted?: boolean;
|
|
};
|
|
|
|
async function getVideoSettings() {
|
|
let qualities = [];
|
|
|
|
try {
|
|
const response = await fetch(VIDEO_CONFIG_URL);
|
|
qualities = await response.json();
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
return qualities;
|
|
}
|
|
|
|
export const OwncastPlayer: FC<OwncastPlayerProps> = ({
|
|
source,
|
|
online,
|
|
initiallyMuted = false,
|
|
}) => {
|
|
const playerRef = React.useRef(null);
|
|
const [videoPlaying, setVideoPlaying] = useRecoilState<boolean>(isVideoPlayingAtom);
|
|
const clockSkew = useRecoilValue<Number>(clockSkewAtom);
|
|
|
|
const setSavedVolume = () => {
|
|
try {
|
|
playerRef.current.volume(getLocalStorage(PLAYER_VOLUME) || 1);
|
|
} catch (err) {
|
|
console.warn(err);
|
|
}
|
|
};
|
|
|
|
const handleVolume = () => {
|
|
setLocalStorage(PLAYER_VOLUME, playerRef.current.muted() ? 0 : playerRef.current.volume());
|
|
};
|
|
|
|
const togglePlayback = () => {
|
|
if (playerRef.current.paused()) {
|
|
playerRef.current.play();
|
|
} else {
|
|
playerRef.current.pause();
|
|
}
|
|
};
|
|
|
|
const toggleMute = () => {
|
|
if (playerRef.current.muted() || playerRef.current.volume() === 0) {
|
|
playerRef.current.volume(0.7);
|
|
} else {
|
|
playerRef.current.volume(0);
|
|
}
|
|
};
|
|
|
|
const toggleFullScreen = () => {
|
|
if (playerRef.current.isFullscreen()) {
|
|
playerRef.current.exitFullscreen();
|
|
} else {
|
|
playerRef.current.requestFullscreen();
|
|
}
|
|
};
|
|
|
|
const setLatencyCompensatorItemTitle = title => {
|
|
const item = document.querySelector('.latency-toggle-item > .vjs-menu-item-text');
|
|
if (!item) {
|
|
return;
|
|
}
|
|
|
|
item.innerHTML = title;
|
|
};
|
|
|
|
const startLatencyCompensator = () => {
|
|
if (latencyCompensator) {
|
|
latencyCompensator.stop();
|
|
}
|
|
|
|
latencyCompensatorEnabled = true;
|
|
|
|
latencyCompensator = new LatencyCompensator(playerRef.current);
|
|
latencyCompensator.setClockSkew(clockSkew);
|
|
latencyCompensator.enable();
|
|
setLocalStorage(LATENCY_COMPENSATION_ENABLED, true);
|
|
|
|
setLatencyCompensatorItemTitle('disable minimized latency');
|
|
};
|
|
|
|
const stopLatencyCompensator = () => {
|
|
if (latencyCompensator) {
|
|
latencyCompensator.disable();
|
|
}
|
|
latencyCompensator = null;
|
|
latencyCompensatorEnabled = false;
|
|
setLocalStorage(LATENCY_COMPENSATION_ENABLED, false);
|
|
setLatencyCompensatorItemTitle(
|
|
'<span style="font-size: 0.8em">enable minimized latency (experimental)</span>',
|
|
);
|
|
};
|
|
|
|
const toggleLatencyCompensator = () => {
|
|
if (latencyCompensatorEnabled) {
|
|
stopLatencyCompensator();
|
|
} else {
|
|
startLatencyCompensator();
|
|
}
|
|
};
|
|
|
|
const setupLatencyCompensator = player => {
|
|
const tech = player.tech({ IWillNotUseThisInPlugins: true });
|
|
|
|
// VHS is required.
|
|
if (!tech || !tech.vhs) {
|
|
return;
|
|
}
|
|
|
|
const latencyCompensatorEnabledSaved = getLocalStorage(LATENCY_COMPENSATION_ENABLED);
|
|
|
|
if (latencyCompensatorEnabledSaved === 'true' && tech && tech.vhs) {
|
|
startLatencyCompensator();
|
|
} else {
|
|
stopLatencyCompensator();
|
|
}
|
|
};
|
|
|
|
const createSettings = async (player, videojs) => {
|
|
const videoQualities = await getVideoSettings();
|
|
const menuButton = createVideoSettingsMenuButton(
|
|
player,
|
|
videojs,
|
|
videoQualities,
|
|
toggleLatencyCompensator,
|
|
);
|
|
player.controlBar.addChild(
|
|
menuButton,
|
|
{},
|
|
// eslint-disable-next-line no-underscore-dangle
|
|
player.controlBar.children_.length - 2,
|
|
);
|
|
setupLatencyCompensator(player);
|
|
};
|
|
|
|
const setupAirplay = (player, videojs) => {
|
|
// eslint-disable-next-line no-prototype-builtins
|
|
if (window.hasOwnProperty('WebKitPlaybackTargetAvailabilityEvent')) {
|
|
const VJSButtonClass = videojs.getComponent('Button');
|
|
|
|
class ConcreteButtonClass extends VJSButtonClass {
|
|
constructor() {
|
|
super(player);
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
handleClick() {
|
|
try {
|
|
const videoElement = document.getElementsByTagName('video')[0];
|
|
(videoElement as any).webkitShowPlaybackTargetPicker();
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
const ccbc = new ConcreteButtonClass();
|
|
const concreteButtonInstance = player.controlBar.addChild(ccbc);
|
|
concreteButtonInstance.addClass('vjs-airplay');
|
|
}
|
|
};
|
|
|
|
// Register keyboard shortcut for the space bar to toggle playback
|
|
useHotkeys('space', e => {
|
|
e.preventDefault();
|
|
togglePlayback();
|
|
});
|
|
|
|
// Register keyboard shortcut for f to toggle full screen
|
|
useHotkeys('f', toggleFullScreen, {
|
|
enableOnContentEditable: false,
|
|
});
|
|
|
|
// Register keyboard shortcut for the "m" key to toggle mute
|
|
useHotkeys('m', toggleMute, {
|
|
enableOnContentEditable: false,
|
|
});
|
|
|
|
useHotkeys('0', () => playerRef.current.volume(playerRef.current.volume() + 0.1), {
|
|
enableOnContentEditable: false,
|
|
});
|
|
useHotkeys('9', () => playerRef.current.volume(playerRef.current.volume() - 0.1), {
|
|
enableOnContentEditable: false,
|
|
});
|
|
|
|
const videoJsOptions = {
|
|
autoplay: false,
|
|
controls: true,
|
|
responsive: true,
|
|
fluid: false,
|
|
playsinline: true,
|
|
liveui: true,
|
|
preload: 'auto',
|
|
muted: initiallyMuted,
|
|
controlBar: {
|
|
progressControl: {
|
|
seekBar: false,
|
|
},
|
|
},
|
|
html5: {
|
|
vhs: {
|
|
// used to select the lowest bitrate playlist initially. This helps to decrease playback start time. This setting is false by default.
|
|
enableLowInitialPlaylist: true,
|
|
experimentalBufferBasedABR: true,
|
|
useNetworkInformationApi: true,
|
|
maxPlaylistRetries: 30,
|
|
},
|
|
},
|
|
liveTracker: {
|
|
trackingThreshold: 0,
|
|
liveTolerance: 15,
|
|
},
|
|
sources: [
|
|
{
|
|
src: source,
|
|
type: 'application/x-mpegURL',
|
|
},
|
|
],
|
|
} satisfies VideoJsPlayerOptions;
|
|
|
|
const handlePlayerReady = (player, videojs) => {
|
|
playerRef.current = player;
|
|
setSavedVolume();
|
|
setupAirplay(player, videojs);
|
|
|
|
// You can handle player events here, for example:
|
|
player.on('waiting', () => {
|
|
console.debug('player is waiting');
|
|
});
|
|
|
|
player.on('dispose', () => {
|
|
console.debug('player will dispose');
|
|
ping.stop();
|
|
});
|
|
|
|
player.on('playing', () => {
|
|
console.debug('player is playing');
|
|
ping.start();
|
|
setVideoPlaying(true);
|
|
});
|
|
|
|
player.on('pause', () => {
|
|
console.debug('player is paused');
|
|
ping.stop();
|
|
setVideoPlaying(false);
|
|
});
|
|
|
|
player.on('ended', () => {
|
|
console.debug('player is ended');
|
|
ping.stop();
|
|
setVideoPlaying(false);
|
|
});
|
|
|
|
videojs.hookOnce();
|
|
|
|
player.on('volumechange', handleVolume);
|
|
|
|
playbackMetrics = new PlaybackMetrics(player, videojs);
|
|
playbackMetrics.setClockSkew(clockSkew);
|
|
|
|
createSettings(player, videojs);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (playbackMetrics) {
|
|
playbackMetrics.setClockSkew(clockSkew);
|
|
}
|
|
}, [clockSkew]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
stopLatencyCompensator();
|
|
playbackMetrics?.stop();
|
|
},
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
{online && (
|
|
<div className={styles.player}>
|
|
<VideoJS options={videoJsOptions} onReady={handlePlayerReady} />
|
|
</div>
|
|
)}
|
|
<div className={styles.poster}>
|
|
{!videoPlaying && (
|
|
<VideoPoster online={online} initialSrc="/thumbnail.jpg" src="/thumbnail.jpg" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
export default OwncastPlayer;
|