diff --git a/web/components/video/OwncastPlayer.tsx b/web/components/video/OwncastPlayer.tsx index 85f0ed380..357eb306e 100644 --- a/web/components/video/OwncastPlayer.tsx +++ b/web/components/video/OwncastPlayer.tsx @@ -7,17 +7,30 @@ import VideoPoster from './VideoPoster'; import { getLocalStorage, setLocalStorage } from '../../utils/localStorage'; import { isVideoPlayingAtom, clockSkewAtom } from '../stores/ClientConfigStore'; import PlaybackMetrics from './metrics/playback'; +import createVideoSettingsMenuButton from './settings-menu'; +const VIDEO_CONFIG_URL = '/api/video/variants'; const PLAYER_VOLUME = 'owncast_volume'; const ping = new ViewerPing(); let playbackMetrics = null; - interface Props { source: string; online: 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 default function OwncastPlayer(props: Props) { const playerRef = React.useRef(null); const { source, online } = props; @@ -118,7 +131,6 @@ export default function OwncastPlayer(props: Props) { const handlePlayerReady = (player, videojs) => { playerRef.current = player; - setSavedVolume(); // You can handle player events here, for example: @@ -149,10 +161,26 @@ export default function OwncastPlayer(props: Props) { setVideoPlaying(false); }); + videojs.hookOnce(); + player.on('volumechange', handleVolume); playbackMetrics = new PlaybackMetrics(player, videojs); playbackMetrics.setClockSkew(clockSkew); + + const createSettings = async () => { + const videoQualities = await getVideoSettings(); + const menuButton = createVideoSettingsMenuButton(player, videojs, videoQualities); + player.controlBar.addChild( + menuButton, + {}, + // eslint-disable-next-line no-underscore-dangle + player.controlBar.children_.length - 2, + ); + // this.latencyCompensatorToggleButton = lowLatencyItem; + }; + + createSettings(); }; useEffect(() => { diff --git a/web/components/video/player.scss b/web/components/video/player.scss index ad3e93eb8..2796ea4c9 100644 --- a/web/components/video/player.scss +++ b/web/components/video/player.scss @@ -12,3 +12,16 @@ .vjs-owncast .vjs-control-bar { background-color: var(--theme-background) !important; } + +// .vjs-airplay .vjs-icon-placeholder::before { +// content: url("../img/airplay.png"); +// } + +.vjs-quality-selector .vjs-icon-placeholder { + font-family: VideoJS; + font-weight: 400; + font-style: normal; +} +.vjs-quality-selector .vjs-icon-placeholder::before { + content: "\f110"; +} \ No newline at end of file diff --git a/web/components/video/settings-menu.ts b/web/components/video/settings-menu.ts new file mode 100644 index 000000000..90ec80b9f --- /dev/null +++ b/web/components/video/settings-menu.ts @@ -0,0 +1,108 @@ +export default function createVideoSettingsMenuButton(player, videojs, qualities): any { + // const VjsMenuItem = videojs.getComponent('MenuItem'); + const MenuItem = videojs.getComponent('MenuItem'); + const MenuButtonClass = videojs.getComponent('MenuButton'); + + // class MenuSeparator extends VjsMenuItem { + // // eslint-disable-next-line no-useless-constructor + // constructor(p: any, options: { selectable: boolean }) { + // super(p, options); + // } + + // createEl(tag = 'button', props = {}, attributes = {}) { + // const el = super.createEl(tag, props, attributes); + // el.innerHTML = '
'; + // return el; + // } + // } + + const lowLatencyItem = new MenuItem(player, { + selectable: true, + }); + lowLatencyItem.setAttribute('class', 'latency-toggle-item'); + lowLatencyItem.on('click', () => { + this.toggleLatencyCompensator(); + }); + + // const separator = new MenuSeparator(player, { + // selectable: false, + // }); + + const MenuButton = videojs.extend(MenuButtonClass, { + // The `init()` method will also work for constructor logic here, but it is + // deprecated. If you provide an `init()` method, it will override the + // `constructor()` method! + constructor() { + MenuButtonClass.call(this, player); + }, + + createItems() { + const tech = player.tech({ IWillNotUseThisInPlugins: true }); + + const defaultAutoItem = new MenuItem(player, { + selectable: true, + label: 'Auto', + }); + + const items = qualities.map(item => { + const newMenuItem = new MenuItem(player, { + selectable: true, + label: item.name, + }); + + // Quality selected + newMenuItem.on('click', () => { + // If for some reason tech doesn't exist, then don't do anything + if (!tech) { + console.warn('Invalid attempt to access null player tech'); + return; + } + // Only enable this single, selected representation. + tech.vhs.representations().forEach((rep, index) => { + rep.enabled(index === item.index); + }); + newMenuItem.selected(false); + }); + + return newMenuItem; + }); + + defaultAutoItem.on('click', () => { + // Re-enable all representations. + tech.vhs.representations().forEach(rep => { + rep.enabled(true); + }); + defaultAutoItem.selected(false); + }); + + const supportsLatencyCompensator = false; // !!tech && !!tech.vhs; + + // Only show the quality selector if there is more than one option. + // if (qualities.length < 2 && supportsLatencyCompensator) { + // return [lowLatencyItem]; + // } + + // if (qualities.length > 1 && supportsLatencyCompensator) { + // return [defaultAutoItem, ...items, separator, lowLatencyItem]; + // } + if (!supportsLatencyCompensator && qualities.length === 1) { + return []; + } + + return [defaultAutoItem, ...items]; + }, + }); + + // If none of the settings in this menu are applicable then don't show it. + const tech = player.tech({ IWillNotUseThisInPlugins: true }); + + if (qualities.length < 2 && (!tech || !tech.vhs)) { + return; + } + + const menuButton = new MenuButton(); + menuButton.addClass('vjs-quality-selector'); + + // eslint-disable-next-line consistent-return + return menuButton; +}