You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
334 lines
9.0 KiB
334 lines
9.0 KiB
import React, { FC, useContext, useEffect } from 'react'; |
|
import { useRecoilState, useRecoilValue } from 'recoil'; |
|
import { useHotkeys } from 'react-hotkeys-hook'; |
|
import classNames from 'classnames'; |
|
import { ErrorBoundary } from 'react-error-boundary'; |
|
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'; |
|
import { VideoSettingsServiceContext } from '../../../services/video-settings-service'; |
|
import { ComponentError } from '../../ui/ComponentError/ComponentError'; |
|
|
|
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; |
|
title: string; |
|
className?: string; |
|
embedded?: boolean; |
|
}; |
|
|
|
export const OwncastPlayer: FC<OwncastPlayerProps> = ({ |
|
source, |
|
online, |
|
initiallyMuted = false, |
|
title, |
|
className, |
|
embedded = false, |
|
}) => { |
|
const VideoSettingsService = useContext(VideoSettingsServiceContext); |
|
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 = t => { |
|
const item = document.querySelector('.latency-toggle-item > .vjs-menu-item-text'); |
|
if (!item) { |
|
return; |
|
} |
|
|
|
item.innerHTML = t; |
|
}; |
|
|
|
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 VideoSettingsService.getVideoQualities(); |
|
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, |
|
fill: true, |
|
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', |
|
}, |
|
], |
|
}; |
|
|
|
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 ( |
|
<ErrorBoundary |
|
// eslint-disable-next-line react/no-unstable-nested-components |
|
fallbackRender={({ error, resetErrorBoundary }) => ( |
|
<ComponentError |
|
componentName="OwncastPlayer" |
|
message={error.message} |
|
retryFunction={resetErrorBoundary} |
|
/> |
|
)} |
|
> |
|
<div |
|
className={classNames(styles.container, className, embedded && styles.embedded)} |
|
id="player" |
|
> |
|
{online && ( |
|
<div className={styles.player}> |
|
<VideoJS options={videoJsOptions} onReady={handlePlayerReady} aria-label={title} /> |
|
</div> |
|
)} |
|
<div className={styles.poster}> |
|
{!videoPlaying && ( |
|
<VideoPoster online={online} initialSrc="/thumbnail.jpg" src="/thumbnail.jpg" /> |
|
)} |
|
</div> |
|
</div> |
|
</ErrorBoundary> |
|
); |
|
}; |
|
export default OwncastPlayer;
|
|
|