added default props to live streaming example

This commit is contained in:
Jon 2021-07-06 12:06:27 +01:00
commit 05c20dadd9
33 changed files with 515 additions and 27 deletions

View File

@ -8,6 +8,10 @@ The basic call demo (derived from our prebuilt UI codebase) demonstrates how to
Send messages to other participants using sendAppMessage
### [📺 Live streaming](./live-streaming)
Broadcast call to a custom RTMP endpoint using a variety of difference layout modes
---
## Getting started

View File

@ -1,10 +1,18 @@
import React from 'react';
import DeviceSelectModal from '@dailyjs/shared/components/DeviceSelectModal/DeviceSelectModal';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
export const Modals = () => (
export const Modals = () => {
const { modals } = useUIState();
return (
<>
<DeviceSelectModal />
{modals.map((ModalComponent) => (
<ModalComponent key={ModalComponent.name} />
))}
</>
);
};
export default Modals;

View File

@ -23,10 +23,12 @@ export const Intro = ({
onJoin,
title,
fetching = false,
forceFetchToken = false,
forceOwner = false,
}) => {
const [roomName, setRoomName] = useState();
const [owner, setOwner] = useState(false);
const [fetchToken, setFetchToken] = useState(false);
const [fetchToken, setFetchToken] = useState(forceFetchToken);
const [owner, setOwner] = useState(forceOwner);
useEffect(() => {
setRoomName(room);
@ -51,10 +53,12 @@ export const Intro = ({
required
/>
</Field>
{!forceFetchToken && (
<Field label="Fetch meeting token">
<BooleanInput onChange={(e) => setFetchToken(e.target.checked)} />
</Field>
{fetchToken && (
)}
{fetchToken && !forceOwner && (
<Field label="Join as owner">
<BooleanInput onChange={(e) => setOwner(e.target.checked)} />
</Field>
@ -79,6 +83,8 @@ Intro.propTypes = {
domain: PropTypes.string.isRequired,
onJoin: PropTypes.func.isRequired,
fetching: PropTypes.bool,
forceFetchToken: PropTypes.bool,
forceOwner: PropTypes.bool,
};
export default Intro;

View File

@ -1,8 +1,10 @@
import React, { useMemo } from 'react';
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
export const Header = () => {
const { participantCount } = useParticipants();
const { customCapsule } = useUIState();
return useMemo(
() => (
@ -14,6 +16,12 @@ export const Header = () => {
participantCount > 1 ? 'participants' : 'participant'
}`}
</div>
{customCapsule && (
<div className={`capsule ${customCapsule.variant}`}>
{customCapsule.variant === 'recording' && <span />}
{customCapsule.label}
</div>
)}
<style jsx>{`
.room-header {
@ -31,6 +39,9 @@ export const Header = () => {
}
.capsule {
display: flex;
align-items: center;
gap: var(--spacing-xxxs);
background-color: var(--blue-dark);
border-radius: var(--radius-sm);
padding: var(--spacing-xxs) var(--spacing-xs);
@ -39,10 +50,35 @@ export const Header = () => {
font-weight: var(--weight-medium);
user-select: none;
}
.capsule.recording {
background: var(--secondary-default);
}
.capsule.recording span {
display: block;
width: 12px;
height: 12px;
background: white;
border-radius: 12px;
animation: capsulePulse 2s infinite linear;
}
@keyframes capsulePulse {
0% {
opacity: 1;
}
50% {
opacity: 0.25;
}
100% {
opacity: 1;
}
}
`}</style>
</header>
),
[participantCount]
[participantCount, customCapsule]
);
};

View File

@ -6,7 +6,8 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@dailyjs/shared": "*",

View File

@ -14,6 +14,7 @@ function App({ Component, pageProps }) {
<GlobalStyle />
<Component
asides={App.asides}
modals={App.modals}
customTrayComponent={App.customTrayComponent}
customAppComponent={App.customAppComponent}
{...pageProps}
@ -33,6 +34,7 @@ App.propTypes = {
};
App.asides = [];
App.modals = [];
App.customTrayComponent = null;
App.customAppComponent = null;

View File

@ -23,7 +23,10 @@ export default function Index({
domain,
isConfigured = false,
predefinedRoom = '',
forceFetchToken = false,
forceOwner = false,
asides,
modals,
customTrayComponent,
customAppComponent,
}) {
@ -75,6 +78,8 @@ export default function Index({
<NotConfigured />
) : (
<Intro
forceFetchToken={forceFetchToken}
forceOwner={forceOwner}
title={process.env.PROJECT_TITLE}
room={roomName}
error={tokenError}
@ -101,7 +106,11 @@ export default function Index({
* Main call UI
*/
return (
<UIStateProvider asides={asides} customTrayComponent={customTrayComponent}>
<UIStateProvider
asides={asides}
modals={modals}
customTrayComponent={customTrayComponent}
>
<CallProvider domain={domain} room={roomName} token={token}>
<ParticipantsProvider>
<TracksProvider>
@ -122,8 +131,11 @@ Index.propTypes = {
predefinedRoom: PropTypes.string,
domain: PropTypes.string,
asides: PropTypes.arrayOf(PropTypes.func),
modals: PropTypes.arrayOf(PropTypes.func),
customTrayComponent: PropTypes.node,
customAppComponent: PropTypes.node,
forceFetchToken: PropTypes.bool,
forceOwner: PropTypes.bool,
};
export async function getStaticProps() {

View File

@ -0,0 +1,4 @@
{
"presets": ["next/babel"],
"plugins": ["inline-react-svg"]
}

View File

@ -0,0 +1,41 @@
# Live Streaming
![Live Streaming](./image.png)
### Live example
**[See it in action here ➡️](https://dailyjs-live-streaming.vercel.app)**
---
## What does this demo do?
- Use [startLiveStreaming](https://docs.daily.co/reference#%EF%B8%8F-startlivestreaming) to send video and audio to specified RTMP endpoint
- Listen for stream started / stopped / error events
- Allows call owner to specify stream layout (grid, single participant or active speaker) and maximum cams
- Extends the basic call demo with a live streaming provider, tray button and modal
- Show a notification bubble at the top of the screen when live streaming is in progress
Please note: this demo is not currently mobile optimised
### Getting started
```
# set both DAILY_API_KEY and DAILY_DOMAIN
mv env.example .env.local
yarn
yarn workspace @dailyjs/live-streaming dev
```
## How does this example work?
In this example we extend the [basic call demo](../basic-call) with live streaming functionality.
We pass a custom tray object, a custom app object (wrapping the original in a new `LiveStreamingProvider`) and a custom modal. We also symlink both the `public` and `pages/api` folders from the basic call.
Single live streaming is only available to call owners, you must create a token when joining the call (for simplicity, we have disabled the abiltiy to join the call as a guest.)
## Deploy your own on Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/daily-co/clone-flow?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples.git&env=DAILY_DOMAIN%2CDAILY_API_KEY&envDescription=Your%20Daily%20domain%20and%20API%20key%20can%20be%20found%20on%20your%20account%20dashboard&envLink=https%3A%2F%2Fdashboard.daily.co&project-name=daily-examples&repo-name=daily-examples)

View File

@ -0,0 +1,13 @@
import React from 'react';
import App from '@dailyjs/basic-call/components/App';
import { LiveStreamingProvider } from '../../contexts/LiveStreamingProvider';
// Extend our basic call app component with the live streaming context
export const AppWithLiveStreaming = () => (
<LiveStreamingProvider>
<App />
</LiveStreamingProvider>
);
export default AppWithLiveStreaming;

View File

@ -0,0 +1 @@
export { AppWithLiveStreaming as default } from './App';

View File

@ -0,0 +1,148 @@
import React, { useEffect, useState } from 'react';
import { Button } from '@dailyjs/shared/components/Button';
import { CardBody } from '@dailyjs/shared/components/Card';
import Field from '@dailyjs/shared/components/Field';
import { TextInput, SelectInput } from '@dailyjs/shared/components/Input';
import Modal from '@dailyjs/shared/components/Modal';
import { Well } from '@dailyjs/shared/components/Well';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
import { useLiveStreaming } from '../../contexts/LiveStreamingProvider';
export const LIVE_STREAMING_MODAL = 'live-streaming';
const LAYOUTS = [
{ label: 'Grid (default)', value: 'default' },
{ label: 'Single participant', value: 'single-participant' },
{ label: 'Active participant', value: 'active-participant' },
];
export const LiveStreamingModal = () => {
const { callObject } = useCallState();
const { allParticipants } = useParticipants();
const { currentModals, closeModal } = useUIState();
const { isStreaming, streamError } = useLiveStreaming();
const [pending, setPending] = useState(false);
const [rtmpUrl, setRtmpUrl] = useState('');
const [layout, setLayout] = useState(0);
const [maxCams, setMaxCams] = useState(9);
const [participant, setParticipant] = useState(0);
useEffect(() => {
// Reset pending state whenever stream state changes
setPending(false);
}, [isStreaming]);
function startLiveStream() {
setPending(true);
const opts =
layout === 'single-participant'
? { session_id: participant.id }
: { max_cam_streams: maxCams };
callObject.startLiveStreaming({ rtmpUrl, preset: layout, ...opts });
}
function stopLiveStreaming() {
setPending(true);
callObject.stopLiveStreaming();
}
return (
<Modal
title="Live stream"
isOpen={currentModals[LIVE_STREAMING_MODAL]}
onClose={() => closeModal(LIVE_STREAMING_MODAL)}
actions={[
<Button fullWidth variant="outline">
Close
</Button>,
!isStreaming ? (
<Button
fullWidth
disabled={!rtmpUrl || pending}
onClick={() => startLiveStream()}
>
{pending ? 'Starting stream...' : 'Start live streaming'}
</Button>
) : (
<Button
fullWidth
variant="warning"
onClick={() => stopLiveStreaming()}
>
Stop live streaming
</Button>
),
]}
>
{streamError && (
<Well variant="error">
Unable to start stream. Error message: {streamError}
</Well>
)}
<CardBody>
<Field label="Layout">
<SelectInput
onChange={(e) => setLayout(Number(e.target.value))}
value={layout}
>
{LAYOUTS.map((l, i) => (
<option value={i} key={l.value}>
{l.label}
</option>
))}
</SelectInput>
</Field>
{layout !==
LAYOUTS.findIndex((l) => l.value === 'single-participant') && (
<Field label="Additional cameras">
<SelectInput
onChange={(e) => setMaxCams(Number(e.target.value))}
value={maxCams}
>
<option value={9}>9 cameras</option>
<option value={8}>8 cameras</option>
<option value={7}>7 cameras</option>
<option value={6}>6 cameras</option>
<option value={5}>5 cameras</option>
<option value={4}>4 cameras</option>
<option value={3}>3 cameras</option>
<option value={2}>2 cameras</option>
<option value={1}>1 camera</option>
</SelectInput>
</Field>
)}
{layout ===
LAYOUTS.findIndex((l) => l.value === 'single-participant') && (
<Field label="Select participant">
<SelectInput
onChange={(e) => setParticipant(e.target.value)}
value={participant}
>
{allParticipants.map((p) => (
<option value={p.id} key={p.id}>
{p.name}
</option>
))}
</SelectInput>
</Field>
)}
<Field label="Enter RTMP endpoint">
<TextInput
type="text"
placeholder="RTMP URL"
required
onChange={(e) => setRtmpUrl(e.target.value)}
/>
</Field>
</CardBody>
</Modal>
);
};
export default LiveStreamingModal;

View File

@ -0,0 +1,3 @@
export { LiveStreamingModal as default } from './LiveStreamingModal';
export { LiveStreamingModal } from './LiveStreamingModal';
export { LIVE_STREAMING_MODAL } from './LiveStreamingModal';

View File

@ -0,0 +1,27 @@
import React from 'react';
import { TrayButton } from '@dailyjs/shared/components/Tray';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
import { ReactComponent as IconStream } from '@dailyjs/shared/icons/streaming-md.svg';
import { useLiveStreaming } from '../../contexts/LiveStreamingProvider';
import { LIVE_STREAMING_MODAL } from '../LiveStreamingModal';
export const Tray = () => {
const { openModal } = useUIState();
const { isStreaming } = useLiveStreaming();
return (
<>
<TrayButton
label={isStreaming ? 'Live' : 'Stream'}
orange={isStreaming}
onClick={() => openModal(LIVE_STREAMING_MODAL)}
>
<IconStream />
</TrayButton>
</>
);
};
export default Tray;

View File

@ -0,0 +1 @@
export { Tray as default } from './Tray';

View File

@ -0,0 +1,71 @@
import React, {
useState,
createContext,
useContext,
useEffect,
useCallback,
} from 'react';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
import PropTypes from 'prop-types';
export const LiveStreamingContext = createContext();
export const LiveStreamingProvider = ({ children }) => {
const [isStreaming, setIsStreaming] = useState(false);
const [streamError, setStreamError] = useState();
const { setCustomCapsule } = useUIState();
const { callObject } = useCallState();
const handleStreamStarted = useCallback(() => {
console.log('📺 Live stream started');
setIsStreaming(true);
setStreamError(null);
setCustomCapsule({ variant: 'recording', label: 'Live streaming' });
}, [setCustomCapsule]);
const handleStreamStopped = useCallback(() => {
console.log('📺 Live stream stopped');
setIsStreaming(false);
setCustomCapsule(null);
}, [setCustomCapsule]);
const handleStreamError = useCallback(
(e) => {
setIsStreaming(false);
setCustomCapsule(null);
setStreamError(e.errorMsg);
},
[setCustomCapsule]
);
useEffect(() => {
if (!callObject) {
return false;
}
console.log('📺 Live streaming provider listening for stream events');
callObject.on('live-streaming-started', handleStreamStarted);
callObject.on('live-streaming-stopped', handleStreamStopped);
callObject.on('live-streaming-error', handleStreamError);
return () => {
callObject.off('live-streaming-started', handleStreamStarted);
callObject.off('live-streaming-stopped', handleStreamStopped);
callObject.on('live-streaming-error', handleStreamError);
};
}, [callObject, handleStreamStarted, handleStreamStopped, handleStreamError]);
return (
<LiveStreamingContext.Provider value={{ isStreaming, streamError }}>
{children}
</LiveStreamingContext.Provider>
);
};
LiveStreamingProvider.propTypes = {
children: PropTypes.node,
};
export const useLiveStreaming = () => useContext(LiveStreamingContext);

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

View File

@ -0,0 +1,13 @@
const withPlugins = require('next-compose-plugins');
const withTM = require('next-transpile-modules')([
'@dailyjs/shared',
'@dailyjs/basic-call',
]);
const packageJson = require('./package.json');
module.exports = withPlugins([withTM], {
env: {
PROJECT_TITLE: packageJson.description,
},
});

View File

@ -0,0 +1,25 @@
{
"name": "@dailyjs/live-streaming",
"description": "Basic Call + Live Streaming",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@dailyjs/shared": "*",
"@dailyjs/basic-call": "*",
"next": "^11.0.0",
"pluralize": "^8.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"babel-plugin-module-resolver": "^4.1.0",
"next-compose-plugins": "^2.2.1",
"next-transpile-modules": "^8.0.0"
}
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import App from '@dailyjs/basic-call/pages/_app';
import AppWithLiveStreaming from '../components/App';
import { LiveStreamingModal } from '../components/LiveStreamingModal';
import Tray from '../components/Tray';
App.modals = [LiveStreamingModal];
App.customAppComponent = <AppWithLiveStreaming />;
App.customTrayComponent = <Tray />;
export default App;

View File

@ -0,0 +1 @@
../../basic-call/pages/api

View File

@ -0,0 +1,16 @@
import Index from '@dailyjs/basic-call/pages';
import getDemoProps from '@dailyjs/shared/lib/demoProps';
export async function getStaticProps() {
const defaultProps = getDemoProps();
return {
props: {
...defaultProps,
forceFetchToken: true,
forceOwner: true,
},
};
}
export default Index;

View File

@ -0,0 +1 @@
../basic-call/public

View File

@ -4,14 +4,16 @@ import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
import { Button } from '../Button';
import { DeviceSelect } from '../DeviceSelect';
export const DEVICE_MODAL = 'device';
export const DeviceSelectModal = () => {
const { showDeviceModal, setShowDeviceModal } = useUIState();
const { currentModals, closeModal } = useUIState();
return (
<Modal
title="Select your device"
isOpen={showDeviceModal}
onClose={() => setShowDeviceModal(false)}
isOpen={currentModals[DEVICE_MODAL]}
onClose={() => closeModal(DEVICE_MODAL)}
actions={[
<Button fullWidth variant="outline">
Cancel

View File

@ -1,2 +1,3 @@
export { DeviceSelectModal as default } from './DeviceSelectModal';
export { DeviceSelectModal } from './DeviceSelectModal';
export { DEVICE_MODAL } from './DeviceSelectModal';

View File

@ -12,6 +12,7 @@ export const GlobalStyle = () => (
--primary-dark: ${theme.primary.dark};
--secondary-default: ${theme.secondary.default};
--secondary-dark: ${theme.secondary.dark};
--secondary-light: ${theme.secondary.light};
--blue-light: ${theme.blue.light};
--blue-default: ${theme.blue.default};
--blue-dark: ${theme.blue.dark};
@ -116,6 +117,12 @@ export const GlobalStyle = () => (
padding: 2px 6px;
font-size: 0.875rem;
}
hr {
border: 0;
height: 1px;
background: var(--gray-light);
}
`}</style>
);

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react';
import Button from '@dailyjs/shared/components/Button';
import { DEVICE_MODAL } from '@dailyjs/shared/components/DeviceSelectModal/DeviceSelectModal';
import { TextInput } from '@dailyjs/shared/components/Input';
import Loader from '@dailyjs/shared/components/Loader';
import { MuteButton } from '@dailyjs/shared/components/MuteButtons';
@ -33,7 +34,7 @@ export const HairCheck = () => {
const { localParticipant } = useParticipants();
const { deviceState, camError, micError, isCamMuted, isMicMuted } =
useMediaDevices();
const { showDeviceModal, setShowDeviceModal } = useUIState();
const { openModal } = useUIState();
const [waiting, setWaiting] = useState(false);
const [joining, setJoining] = useState(false);
const [denied, setDenied] = useState();
@ -143,7 +144,7 @@ export const HairCheck = () => {
className="device-button"
size="medium-square"
variant="blur"
onClick={() => setShowDeviceModal(!showDeviceModal)}
onClick={() => openModal(DEVICE_MODAL)}
>
<IconSettings />
</Button>

View File

@ -269,7 +269,7 @@ export const SelectInput = ({
SelectInput.propTypes = {
onChange: PropTypes.func,
children: PropTypes.node,
value: PropTypes.number,
value: PropTypes.any,
variant: PropTypes.string,
label: PropTypes.string,
};

View File

@ -1,5 +1,6 @@
import React from 'react';
import { PEOPLE_ASIDE } from '@dailyjs/shared/components/Aside/PeopleAside';
import { DEVICE_MODAL } from '@dailyjs/shared/components/DeviceSelectModal';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { useMediaDevices } from '@dailyjs/shared/contexts/MediaDeviceProvider';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
@ -14,7 +15,7 @@ import { Tray, TrayButton } from './Tray';
export const BasicTray = () => {
const { callObject, leave } = useCallState();
const { customTrayComponent, setShowDeviceModal, toggleAside } = useUIState();
const { customTrayComponent, openModal, toggleAside } = useUIState();
const { isCamMuted, isMicMuted } = useMediaDevices();
const toggleCamera = (newState) => {
@ -43,7 +44,7 @@ export const BasicTray = () => {
>
{isMicMuted ? <IconMicOff /> : <IconMicOn />}
</TrayButton>
<TrayButton label="Settings" onClick={() => setShowDeviceModal(true)}>
<TrayButton label="Settings" onClick={() => openModal(DEVICE_MODAL)}>
<IconSettings />
</TrayButton>

View File

@ -1,11 +1,34 @@
import React, { useCallback, createContext, useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { useDeepCompareMemo } from 'use-deep-compare';
export const UIStateContext = createContext();
export const UIStateProvider = ({ asides, customTrayComponent, children }) => {
const [showDeviceModal, setShowDeviceModal] = useState(false);
export const UIStateProvider = ({
asides,
modals,
customTrayComponent,
children,
}) => {
const [showAside, setShowAside] = useState();
const [activeModals, setActiveModals] = useState({});
const [customCapsule, setCustomCapsule] = useState();
const openModal = useCallback((modalName) => {
setActiveModals((prevState) => ({
...prevState,
[modalName]: true,
}));
}, []);
const closeModal = useCallback((modalName) => {
setActiveModals((prevState) => ({
...prevState,
[modalName]: false,
}));
}, []);
const currentModals = useDeepCompareMemo(() => activeModals, [activeModals]);
const toggleAside = useCallback((newAside) => {
setShowAside((p) => (p === newAside ? null : newAside));
@ -15,12 +38,16 @@ export const UIStateProvider = ({ asides, customTrayComponent, children }) => {
<UIStateContext.Provider
value={{
asides,
modals,
customTrayComponent,
showDeviceModal,
setShowDeviceModal,
openModal,
closeModal,
currentModals,
toggleAside,
showAside,
setShowAside,
customCapsule,
setCustomCapsule,
}}
>
{children}
@ -31,6 +58,7 @@ export const UIStateProvider = ({ asides, customTrayComponent, children }) => {
UIStateProvider.propTypes = {
children: PropTypes.node,
asides: PropTypes.arrayOf(PropTypes.func),
modals: PropTypes.arrayOf(PropTypes.func),
customTrayComponent: PropTypes.node,
};

View File

Before

Width:  |  Height:  |  Size: 952 B

After

Width:  |  Height:  |  Size: 952 B

View File

@ -10,6 +10,7 @@ export const defaultTheme = {
secondary: {
default: '#FF9254',
dark: '#FB651E',
light: '#FF9254',
},
blue: {

View File

@ -6,7 +6,8 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@dailyjs/shared": "*",