refactor(stories): co-locate stories with components (#2078)
* refactor: move ActionButton component * refactor: move BanUserButton component * refactor: move ChatActionMessage component * refactor: move ChatContainer component * refactor: move AuthModal component * refactor: move BrowserNotifyModal component * refactor: move ChatUserMessage component * refactor: move ChatJoinMessage component * refactor: move ChatTextField component * refactor: move ChatUserBadge component * refactor: move FollowerCollection and SingleFollower components * fix: bad import path * refactor: move FollowModal component * refactor: move Modal component * refactor: move ContentHeader component * refactor: move ChatSystemMessage component * refactor: move Header component * refactor: move Footer component * refactor: move StatusBar component * refactor: move OfflineBanner component * refactor: move OwncastPlayer component * refactor: move IndieAuthModal component * refactor: move SocialLinks component * refactor: move VideoPoster component * refactor: move FollowModal component * refactor: move FediAuthModal.tsx component * refactor: move UserDropdown component * refactor: move ChatSocialMessage component * refactor: move Logo component * refactor: move NotifyReminderPopup component * refactor: move NameChangeModal component * refactor: move FatalErrorStateModal component * refactor: move ChatModeratorNotification component * refactor: move ChatModerationActionMenu and ChatModerationDetailsModal components * refactor: move CustomPageContent component * refactor: move storybook Introduction file * refactor: update storybook story import path * refactor: move storybook preview styles * refactor: move storybook doc pages * refactor: move Color and ImageAsset components * fix: bad import path * fix: bad import path in story file
This commit is contained in:
37
web/components/video/OwncastPlayer/OwncastPlayer.stories.tsx
Normal file
37
web/components/video/OwncastPlayer/OwncastPlayer.stories.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import OwncastPlayer from './OwncastPlayer';
|
||||
|
||||
const streams = {
|
||||
DemoServer: `https://watch.owncast.online/hls/stream.m3u8`,
|
||||
RetroStrangeTV: `https://live.retrostrange.com/hls/stream.m3u8`,
|
||||
localhost: `http://localhost:8080/hls/stream.m3u8`,
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'owncast/Player/Player',
|
||||
component: OwncastPlayer,
|
||||
argTypes: {
|
||||
source: {
|
||||
options: Object.keys(streams),
|
||||
mapping: streams,
|
||||
control: {
|
||||
type: 'select',
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {},
|
||||
} as ComponentMeta<typeof OwncastPlayer>;
|
||||
|
||||
const Template: ComponentStory<typeof OwncastPlayer> = args => (
|
||||
<RecoilRoot>
|
||||
<OwncastPlayer {...args} />
|
||||
</RecoilRoot>
|
||||
);
|
||||
|
||||
export const LiveDemo = Template.bind({});
|
||||
LiveDemo.args = {
|
||||
online: true,
|
||||
source: 'https://watch.owncast.online/hls/stream.m3u8',
|
||||
};
|
||||
305
web/components/video/OwncastPlayer/OwncastPlayer.tsx
Normal file
305
web/components/video/OwncastPlayer/OwncastPlayer.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import VideoJS from '../player';
|
||||
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';
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
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 videoJsButtonClass = videojs.getComponent('Button');
|
||||
const ConcreteButtonClass = videojs.extend(videoJsButtonClass, {
|
||||
// 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() {
|
||||
videoJsButtonClass.call(this, player);
|
||||
},
|
||||
|
||||
handleClick() {
|
||||
try {
|
||||
const videoElement = document.getElementsByTagName('video')[0];
|
||||
(videoElement as any).webkitShowPlaybackTargetPicker();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const concreteButtonInstance = player.controlBar.addChild(new ConcreteButtonClass());
|
||||
concreteButtonInstance.addClass('vjs-airplay');
|
||||
}
|
||||
};
|
||||
|
||||
// Register keyboard shortcut for the space bar to toggle playback
|
||||
useHotkeys('space', togglePlayback, {
|
||||
enableOnContentEditable: false,
|
||||
});
|
||||
|
||||
// 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',
|
||||
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', () => {
|
||||
player.log('player is waiting');
|
||||
});
|
||||
|
||||
player.on('dispose', () => {
|
||||
player.log('player will dispose');
|
||||
ping.stop();
|
||||
});
|
||||
|
||||
player.on('playing', () => {
|
||||
player.log('player is playing');
|
||||
ping.start();
|
||||
setVideoPlaying(true);
|
||||
});
|
||||
|
||||
player.on('pause', () => {
|
||||
player.log('player is paused');
|
||||
ping.stop();
|
||||
setVideoPlaying(false);
|
||||
});
|
||||
|
||||
player.on('ended', () => {
|
||||
player.log('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]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid' }}>
|
||||
{online && (
|
||||
<div style={{ gridColumn: 1, gridRow: 1 }}>
|
||||
<VideoJS options={videoJsOptions} onReady={handlePlayerReady} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ gridColumn: 1, gridRow: 1 }}>
|
||||
{!videoPlaying && (
|
||||
<VideoPoster online={online} initialSrc="/thumbnail.jpg" src="/thumbnail.jpg" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user