mirror of
https://github.com/owncast/owncast.git
synced 2024-11-25 06:12:23 +03:00
9a7925444f
* Fix embedded status bar being cut off This should resolve https://github.com/owncast/owncast/issues/3210. As recommended in the discussion, flexbox was the way to solve this issue. The main thing that needed to be addressed though was not applying too many constraints on the OwncastPlayer component just because it was embedded. By removing the embedded constraints, styling appears to be working as expected. I'll attach screenshots to the pull request. * Fix eslint error
316 lines
8.6 KiB
TypeScript
316 lines
8.6 KiB
TypeScript
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;
|
|
};
|
|
|
|
export const OwncastPlayer: FC<OwncastPlayerProps> = ({
|
|
source,
|
|
online,
|
|
initiallyMuted = false,
|
|
title,
|
|
className,
|
|
}) => {
|
|
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 startLatencyCompensator = () => {
|
|
if (latencyCompensator) {
|
|
latencyCompensator.stop();
|
|
}
|
|
|
|
latencyCompensatorEnabled = true;
|
|
|
|
latencyCompensator = new LatencyCompensator(playerRef.current);
|
|
latencyCompensator.setClockSkew(clockSkew);
|
|
latencyCompensator.enable();
|
|
setLocalStorage(LATENCY_COMPENSATION_ENABLED, true);
|
|
};
|
|
|
|
const stopLatencyCompensator = () => {
|
|
if (latencyCompensator) {
|
|
latencyCompensator.disable();
|
|
}
|
|
latencyCompensator = null;
|
|
latencyCompensatorEnabled = false;
|
|
setLocalStorage(LATENCY_COMPENSATION_ENABLED, false);
|
|
};
|
|
|
|
// Toggle minimized latency mode. Return the new state.
|
|
const toggleLatencyCompensator = () => {
|
|
if (latencyCompensatorEnabled) {
|
|
stopLatencyCompensator();
|
|
} else {
|
|
startLatencyCompensator();
|
|
}
|
|
return latencyCompensatorEnabled;
|
|
};
|
|
|
|
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)} 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>
|
|
);
|
|
};
|