Merge pull request #18 from daily-demos/dailyjs/livestreaming
Live streaming
This commit is contained in:
commit
6164bd86d9
|
|
@ -8,6 +8,10 @@ The basic call demo (derived from our prebuilt UI codebase) demonstrates how to
|
||||||
|
|
||||||
Send messages to other participants using sendAppMessage
|
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
|
## Getting started
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import DeviceSelectModal from '@dailyjs/shared/components/DeviceSelectModal/DeviceSelectModal';
|
import DeviceSelectModal from '@dailyjs/shared/components/DeviceSelectModal/DeviceSelectModal';
|
||||||
|
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||||
|
|
||||||
export const Modals = () => (
|
export const Modals = () => {
|
||||||
<>
|
const { modals } = useUIState();
|
||||||
<DeviceSelectModal />
|
|
||||||
</>
|
return (
|
||||||
);
|
<>
|
||||||
|
<DeviceSelectModal />
|
||||||
|
{modals.map((ModalComponent) => (
|
||||||
|
<ModalComponent key={ModalComponent.name} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Modals;
|
export default Modals;
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,12 @@ export const Intro = ({
|
||||||
onJoin,
|
onJoin,
|
||||||
title,
|
title,
|
||||||
fetching = false,
|
fetching = false,
|
||||||
|
forceFetchToken = false,
|
||||||
|
forceOwner = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [roomName, setRoomName] = useState();
|
const [roomName, setRoomName] = useState();
|
||||||
const [owner, setOwner] = useState(false);
|
const [fetchToken, setFetchToken] = useState(forceFetchToken);
|
||||||
const [fetchToken, setFetchToken] = useState(false);
|
const [owner, setOwner] = useState(forceOwner);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRoomName(room);
|
setRoomName(room);
|
||||||
|
|
@ -51,10 +53,12 @@ export const Intro = ({
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Fetch meeting token">
|
{!forceFetchToken && (
|
||||||
<BooleanInput onChange={(e) => setFetchToken(e.target.checked)} />
|
<Field label="Fetch meeting token">
|
||||||
</Field>
|
<BooleanInput onChange={(e) => setFetchToken(e.target.checked)} />
|
||||||
{fetchToken && (
|
</Field>
|
||||||
|
)}
|
||||||
|
{fetchToken && !forceOwner && (
|
||||||
<Field label="Join as owner">
|
<Field label="Join as owner">
|
||||||
<BooleanInput onChange={(e) => setOwner(e.target.checked)} />
|
<BooleanInput onChange={(e) => setOwner(e.target.checked)} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -79,6 +83,8 @@ Intro.propTypes = {
|
||||||
domain: PropTypes.string.isRequired,
|
domain: PropTypes.string.isRequired,
|
||||||
onJoin: PropTypes.func.isRequired,
|
onJoin: PropTypes.func.isRequired,
|
||||||
fetching: PropTypes.bool,
|
fetching: PropTypes.bool,
|
||||||
|
forceFetchToken: PropTypes.bool,
|
||||||
|
forceOwner: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Intro;
|
export default Intro;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
|
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
|
||||||
|
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||||
|
|
||||||
export const Header = () => {
|
export const Header = () => {
|
||||||
const { participantCount } = useParticipants();
|
const { participantCount } = useParticipants();
|
||||||
|
const { customCapsule } = useUIState();
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
|
|
@ -14,6 +16,12 @@ export const Header = () => {
|
||||||
participantCount > 1 ? 'participants' : 'participant'
|
participantCount > 1 ? 'participants' : 'participant'
|
||||||
}`}
|
}`}
|
||||||
</div>
|
</div>
|
||||||
|
{customCapsule && (
|
||||||
|
<div className={`capsule ${customCapsule.variant}`}>
|
||||||
|
{customCapsule.variant === 'recording' && <span />}
|
||||||
|
{customCapsule.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
.room-header {
|
.room-header {
|
||||||
|
|
@ -31,6 +39,9 @@ export const Header = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.capsule {
|
.capsule {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xxxs);
|
||||||
background-color: var(--blue-dark);
|
background-color: var(--blue-dark);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: var(--spacing-xxs) var(--spacing-xs);
|
padding: var(--spacing-xxs) var(--spacing-xs);
|
||||||
|
|
@ -39,10 +50,35 @@ export const Header = () => {
|
||||||
font-weight: var(--weight-medium);
|
font-weight: var(--weight-medium);
|
||||||
user-select: none;
|
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>
|
`}</style>
|
||||||
</header>
|
</header>
|
||||||
),
|
),
|
||||||
[participantCount]
|
[participantCount, customCapsule]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ function App({ Component, pageProps }) {
|
||||||
<GlobalStyle />
|
<GlobalStyle />
|
||||||
<Component
|
<Component
|
||||||
asides={App.asides}
|
asides={App.asides}
|
||||||
|
modals={App.modals}
|
||||||
customTrayComponent={App.customTrayComponent}
|
customTrayComponent={App.customTrayComponent}
|
||||||
customAppComponent={App.customAppComponent}
|
customAppComponent={App.customAppComponent}
|
||||||
{...pageProps}
|
{...pageProps}
|
||||||
|
|
@ -33,6 +34,7 @@ App.propTypes = {
|
||||||
};
|
};
|
||||||
|
|
||||||
App.asides = [];
|
App.asides = [];
|
||||||
|
App.modals = [];
|
||||||
App.customTrayComponent = null;
|
App.customTrayComponent = null;
|
||||||
App.customAppComponent = null;
|
App.customAppComponent = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ export default function Index({
|
||||||
domain,
|
domain,
|
||||||
isConfigured = false,
|
isConfigured = false,
|
||||||
predefinedRoom = false,
|
predefinedRoom = false,
|
||||||
|
forceFetchToken = false,
|
||||||
|
forceOwner = false,
|
||||||
asides,
|
asides,
|
||||||
|
modals,
|
||||||
customTrayComponent,
|
customTrayComponent,
|
||||||
customAppComponent,
|
customAppComponent,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -73,6 +76,8 @@ export default function Index({
|
||||||
<NotConfigured />
|
<NotConfigured />
|
||||||
) : (
|
) : (
|
||||||
<Intro
|
<Intro
|
||||||
|
forceFetchToken={forceFetchToken}
|
||||||
|
forceOwner={forceOwner}
|
||||||
title={process.env.PROJECT_TITLE}
|
title={process.env.PROJECT_TITLE}
|
||||||
room={roomName}
|
room={roomName}
|
||||||
error={tokenError}
|
error={tokenError}
|
||||||
|
|
@ -99,7 +104,11 @@ export default function Index({
|
||||||
* Main call UI
|
* Main call UI
|
||||||
*/
|
*/
|
||||||
return (
|
return (
|
||||||
<UIStateProvider asides={asides} customTrayComponent={customTrayComponent}>
|
<UIStateProvider
|
||||||
|
asides={asides}
|
||||||
|
modals={modals}
|
||||||
|
customTrayComponent={customTrayComponent}
|
||||||
|
>
|
||||||
<CallProvider domain={domain} room={roomName} token={token}>
|
<CallProvider domain={domain} room={roomName} token={token}>
|
||||||
<ParticipantsProvider>
|
<ParticipantsProvider>
|
||||||
<TracksProvider>
|
<TracksProvider>
|
||||||
|
|
@ -120,8 +129,11 @@ Index.propTypes = {
|
||||||
predefinedRoom: PropTypes.bool,
|
predefinedRoom: PropTypes.bool,
|
||||||
domain: PropTypes.string,
|
domain: PropTypes.string,
|
||||||
asides: PropTypes.arrayOf(PropTypes.func),
|
asides: PropTypes.arrayOf(PropTypes.func),
|
||||||
|
modals: PropTypes.arrayOf(PropTypes.func),
|
||||||
customTrayComponent: PropTypes.node,
|
customTrayComponent: PropTypes.node,
|
||||||
customAppComponent: PropTypes.node,
|
customAppComponent: PropTypes.node,
|
||||||
|
forceFetchToken: PropTypes.bool,
|
||||||
|
forceOwner: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getStaticProps() {
|
export async function getStaticProps() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"presets": ["next/babel"],
|
||||||
|
"plugins": ["inline-react-svg"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Live Streaming
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
[](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)
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { AppWithLiveStreaming as default } from './App';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { LiveStreamingModal as default } from './LiveStreamingModal';
|
||||||
|
export { LiveStreamingModal } from './LiveStreamingModal';
|
||||||
|
export { LIVE_STREAMING_MODAL } from './LiveStreamingModal';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { Tray as default } from './Tray';
|
||||||
|
|
@ -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 |
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
../../basic-call/pages/api
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import Index from '@dailyjs/basic-call/pages';
|
||||||
|
|
||||||
|
export async function getStaticProps() {
|
||||||
|
// Check that both domain and key env vars are set
|
||||||
|
const isConfigured =
|
||||||
|
!!process.env.DAILY_DOMAIN && !!process.env.DAILY_API_KEY;
|
||||||
|
|
||||||
|
// Pass through domain as prop
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
domain: process.env.DAILY_DOMAIN || null,
|
||||||
|
isConfigured,
|
||||||
|
forceFetchToken: true,
|
||||||
|
forceOwner: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Index;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
../basic-call/public
|
||||||
|
|
@ -4,14 +4,16 @@ import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||||
import { Button } from '../Button';
|
import { Button } from '../Button';
|
||||||
import { DeviceSelect } from '../DeviceSelect';
|
import { DeviceSelect } from '../DeviceSelect';
|
||||||
|
|
||||||
|
export const DEVICE_MODAL = 'device';
|
||||||
|
|
||||||
export const DeviceSelectModal = () => {
|
export const DeviceSelectModal = () => {
|
||||||
const { showDeviceModal, setShowDeviceModal } = useUIState();
|
const { currentModals, closeModal } = useUIState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="Select your device"
|
title="Select your device"
|
||||||
isOpen={showDeviceModal}
|
isOpen={currentModals[DEVICE_MODAL]}
|
||||||
onClose={() => setShowDeviceModal(false)}
|
onClose={() => closeModal(DEVICE_MODAL)}
|
||||||
actions={[
|
actions={[
|
||||||
<Button fullWidth variant="outline">
|
<Button fullWidth variant="outline">
|
||||||
Cancel
|
Cancel
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export { DeviceSelectModal as default } from './DeviceSelectModal';
|
export { DeviceSelectModal as default } from './DeviceSelectModal';
|
||||||
export { DeviceSelectModal } from './DeviceSelectModal';
|
export { DeviceSelectModal } from './DeviceSelectModal';
|
||||||
|
export { DEVICE_MODAL } from './DeviceSelectModal';
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export const GlobalStyle = () => (
|
||||||
--primary-dark: ${theme.primary.dark};
|
--primary-dark: ${theme.primary.dark};
|
||||||
--secondary-default: ${theme.secondary.default};
|
--secondary-default: ${theme.secondary.default};
|
||||||
--secondary-dark: ${theme.secondary.dark};
|
--secondary-dark: ${theme.secondary.dark};
|
||||||
|
--secondary-light: ${theme.secondary.light};
|
||||||
--blue-light: ${theme.blue.light};
|
--blue-light: ${theme.blue.light};
|
||||||
--blue-default: ${theme.blue.default};
|
--blue-default: ${theme.blue.default};
|
||||||
--blue-dark: ${theme.blue.dark};
|
--blue-dark: ${theme.blue.dark};
|
||||||
|
|
@ -116,6 +117,12 @@ export const GlobalStyle = () => (
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--gray-light);
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import Button from '@dailyjs/shared/components/Button';
|
import Button from '@dailyjs/shared/components/Button';
|
||||||
|
import { DEVICE_MODAL } from '@dailyjs/shared/components/DeviceSelectModal/DeviceSelectModal';
|
||||||
import { TextInput } from '@dailyjs/shared/components/Input';
|
import { TextInput } from '@dailyjs/shared/components/Input';
|
||||||
import Loader from '@dailyjs/shared/components/Loader';
|
import Loader from '@dailyjs/shared/components/Loader';
|
||||||
import { MuteButton } from '@dailyjs/shared/components/MuteButtons';
|
import { MuteButton } from '@dailyjs/shared/components/MuteButtons';
|
||||||
|
|
@ -33,7 +34,7 @@ export const HairCheck = () => {
|
||||||
const { localParticipant } = useParticipants();
|
const { localParticipant } = useParticipants();
|
||||||
const { deviceState, camError, micError, isCamMuted, isMicMuted } =
|
const { deviceState, camError, micError, isCamMuted, isMicMuted } =
|
||||||
useMediaDevices();
|
useMediaDevices();
|
||||||
const { showDeviceModal, setShowDeviceModal } = useUIState();
|
const { openModal } = useUIState();
|
||||||
const [waiting, setWaiting] = useState(false);
|
const [waiting, setWaiting] = useState(false);
|
||||||
const [joining, setJoining] = useState(false);
|
const [joining, setJoining] = useState(false);
|
||||||
const [denied, setDenied] = useState();
|
const [denied, setDenied] = useState();
|
||||||
|
|
@ -143,7 +144,7 @@ export const HairCheck = () => {
|
||||||
className="device-button"
|
className="device-button"
|
||||||
size="medium-square"
|
size="medium-square"
|
||||||
variant="blur"
|
variant="blur"
|
||||||
onClick={() => setShowDeviceModal(!showDeviceModal)}
|
onClick={() => openModal(DEVICE_MODAL)}
|
||||||
>
|
>
|
||||||
<IconSettings />
|
<IconSettings />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ export const SelectInput = ({
|
||||||
SelectInput.propTypes = {
|
SelectInput.propTypes = {
|
||||||
onChange: PropTypes.func,
|
onChange: PropTypes.func,
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
value: PropTypes.number,
|
value: PropTypes.any,
|
||||||
variant: PropTypes.string,
|
variant: PropTypes.string,
|
||||||
label: PropTypes.string,
|
label: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { PEOPLE_ASIDE } from '@dailyjs/shared/components/Aside/PeopleAside';
|
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 { useCallState } from '@dailyjs/shared/contexts/CallProvider';
|
||||||
import { useMediaDevices } from '@dailyjs/shared/contexts/MediaDeviceProvider';
|
import { useMediaDevices } from '@dailyjs/shared/contexts/MediaDeviceProvider';
|
||||||
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||||
|
|
@ -14,7 +15,7 @@ import { Tray, TrayButton } from './Tray';
|
||||||
|
|
||||||
export const BasicTray = () => {
|
export const BasicTray = () => {
|
||||||
const { callObject, leave } = useCallState();
|
const { callObject, leave } = useCallState();
|
||||||
const { customTrayComponent, setShowDeviceModal, toggleAside } = useUIState();
|
const { customTrayComponent, openModal, toggleAside } = useUIState();
|
||||||
const { isCamMuted, isMicMuted } = useMediaDevices();
|
const { isCamMuted, isMicMuted } = useMediaDevices();
|
||||||
|
|
||||||
const toggleCamera = (newState) => {
|
const toggleCamera = (newState) => {
|
||||||
|
|
@ -43,7 +44,7 @@ export const BasicTray = () => {
|
||||||
>
|
>
|
||||||
{isMicMuted ? <IconMicOff /> : <IconMicOn />}
|
{isMicMuted ? <IconMicOff /> : <IconMicOn />}
|
||||||
</TrayButton>
|
</TrayButton>
|
||||||
<TrayButton label="Settings" onClick={() => setShowDeviceModal(true)}>
|
<TrayButton label="Settings" onClick={() => openModal(DEVICE_MODAL)}>
|
||||||
<IconSettings />
|
<IconSettings />
|
||||||
</TrayButton>
|
</TrayButton>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,34 @@
|
||||||
import React, { useCallback, createContext, useContext, useState } from 'react';
|
import React, { useCallback, createContext, useContext, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { useDeepCompareMemo } from 'use-deep-compare';
|
||||||
|
|
||||||
export const UIStateContext = createContext();
|
export const UIStateContext = createContext();
|
||||||
|
|
||||||
export const UIStateProvider = ({ asides, customTrayComponent, children }) => {
|
export const UIStateProvider = ({
|
||||||
const [showDeviceModal, setShowDeviceModal] = useState(false);
|
asides,
|
||||||
|
modals,
|
||||||
|
customTrayComponent,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
const [showAside, setShowAside] = useState();
|
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) => {
|
const toggleAside = useCallback((newAside) => {
|
||||||
setShowAside((p) => (p === newAside ? null : newAside));
|
setShowAside((p) => (p === newAside ? null : newAside));
|
||||||
|
|
@ -15,12 +38,16 @@ export const UIStateProvider = ({ asides, customTrayComponent, children }) => {
|
||||||
<UIStateContext.Provider
|
<UIStateContext.Provider
|
||||||
value={{
|
value={{
|
||||||
asides,
|
asides,
|
||||||
|
modals,
|
||||||
customTrayComponent,
|
customTrayComponent,
|
||||||
showDeviceModal,
|
openModal,
|
||||||
setShowDeviceModal,
|
closeModal,
|
||||||
|
currentModals,
|
||||||
toggleAside,
|
toggleAside,
|
||||||
showAside,
|
showAside,
|
||||||
setShowAside,
|
setShowAside,
|
||||||
|
customCapsule,
|
||||||
|
setCustomCapsule,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -31,6 +58,7 @@ export const UIStateProvider = ({ asides, customTrayComponent, children }) => {
|
||||||
UIStateProvider.propTypes = {
|
UIStateProvider.propTypes = {
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
asides: PropTypes.arrayOf(PropTypes.func),
|
asides: PropTypes.arrayOf(PropTypes.func),
|
||||||
|
modals: PropTypes.arrayOf(PropTypes.func),
|
||||||
customTrayComponent: PropTypes.node,
|
customTrayComponent: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 952 B After Width: | Height: | Size: 952 B |
|
|
@ -10,6 +10,7 @@ export const defaultTheme = {
|
||||||
secondary: {
|
secondary: {
|
||||||
default: '#FF9254',
|
default: '#FF9254',
|
||||||
dark: '#FB651E',
|
dark: '#FB651E',
|
||||||
|
light: '#FF9254',
|
||||||
},
|
},
|
||||||
|
|
||||||
blue: {
|
blue: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue