diff --git a/dailyjs/README.md b/dailyjs/README.md index 885bbc7..7d20ecb 100644 --- a/dailyjs/README.md +++ b/dailyjs/README.md @@ -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 diff --git a/dailyjs/basic-call/components/App/Modals.js b/dailyjs/basic-call/components/App/Modals.js index ced3862..264ddd7 100644 --- a/dailyjs/basic-call/components/App/Modals.js +++ b/dailyjs/basic-call/components/App/Modals.js @@ -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 ( + <> + + {modals.map((ModalComponent) => ( + + ))} + + ); +}; export default Modals; diff --git a/dailyjs/basic-call/components/Intro/Intro.js b/dailyjs/basic-call/components/Intro/Intro.js index 301509e..2fe43bb 100644 --- a/dailyjs/basic-call/components/Intro/Intro.js +++ b/dailyjs/basic-call/components/Intro/Intro.js @@ -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 /> - - setFetchToken(e.target.checked)} /> - - {fetchToken && ( + {!forceFetchToken && ( + + setFetchToken(e.target.checked)} /> + + )} + {fetchToken && !forceOwner && ( setOwner(e.target.checked)} /> @@ -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; diff --git a/dailyjs/basic-call/components/Room/Header.js b/dailyjs/basic-call/components/Room/Header.js index 401d272..a18e5d5 100644 --- a/dailyjs/basic-call/components/Room/Header.js +++ b/dailyjs/basic-call/components/Room/Header.js @@ -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' }`} + {customCapsule && ( +
+ {customCapsule.variant === 'recording' && } + {customCapsule.label} +
+ )} ), - [participantCount] + [participantCount, customCapsule] ); }; diff --git a/dailyjs/basic-call/pages/_app.js b/dailyjs/basic-call/pages/_app.js index e1c376c..790ea7a 100644 --- a/dailyjs/basic-call/pages/_app.js +++ b/dailyjs/basic-call/pages/_app.js @@ -14,6 +14,7 @@ function App({ Component, pageProps }) { ) : ( + @@ -120,8 +129,11 @@ Index.propTypes = { predefinedRoom: PropTypes.bool, 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() { diff --git a/dailyjs/live-streaming/.babelrc b/dailyjs/live-streaming/.babelrc new file mode 100644 index 0000000..a6f4434 --- /dev/null +++ b/dailyjs/live-streaming/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": ["inline-react-svg"] +} diff --git a/dailyjs/live-streaming/README.md b/dailyjs/live-streaming/README.md new file mode 100644 index 0000000..c5e7974 --- /dev/null +++ b/dailyjs/live-streaming/README.md @@ -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) diff --git a/dailyjs/live-streaming/components/App/App.js b/dailyjs/live-streaming/components/App/App.js new file mode 100644 index 0000000..4749551 --- /dev/null +++ b/dailyjs/live-streaming/components/App/App.js @@ -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 = () => ( + + + +); + +export default AppWithLiveStreaming; diff --git a/dailyjs/live-streaming/components/App/index.js b/dailyjs/live-streaming/components/App/index.js new file mode 100644 index 0000000..c46acf2 --- /dev/null +++ b/dailyjs/live-streaming/components/App/index.js @@ -0,0 +1 @@ +export { AppWithLiveStreaming as default } from './App'; diff --git a/dailyjs/live-streaming/components/LiveStreamingModal/LiveStreamingModal.js b/dailyjs/live-streaming/components/LiveStreamingModal/LiveStreamingModal.js new file mode 100644 index 0000000..2cee300 --- /dev/null +++ b/dailyjs/live-streaming/components/LiveStreamingModal/LiveStreamingModal.js @@ -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 ( + closeModal(LIVE_STREAMING_MODAL)} + actions={[ + , + !isStreaming ? ( + + ) : ( + + ), + ]} + > + {streamError && ( + + Unable to start stream. Error message: {streamError} + + )} + + + setLayout(Number(e.target.value))} + value={layout} + > + {LAYOUTS.map((l, i) => ( + + ))} + + + + {layout !== + LAYOUTS.findIndex((l) => l.value === 'single-participant') && ( + + setMaxCams(Number(e.target.value))} + value={maxCams} + > + + + + + + + + + + + + )} + + {layout === + LAYOUTS.findIndex((l) => l.value === 'single-participant') && ( + + setParticipant(e.target.value)} + value={participant} + > + {allParticipants.map((p) => ( + + ))} + + + )} + + + setRtmpUrl(e.target.value)} + /> + + + + ); +}; + +export default LiveStreamingModal; diff --git a/dailyjs/live-streaming/components/LiveStreamingModal/index.js b/dailyjs/live-streaming/components/LiveStreamingModal/index.js new file mode 100644 index 0000000..12ffdf0 --- /dev/null +++ b/dailyjs/live-streaming/components/LiveStreamingModal/index.js @@ -0,0 +1,3 @@ +export { LiveStreamingModal as default } from './LiveStreamingModal'; +export { LiveStreamingModal } from './LiveStreamingModal'; +export { LIVE_STREAMING_MODAL } from './LiveStreamingModal'; diff --git a/dailyjs/live-streaming/components/Tray/Tray.js b/dailyjs/live-streaming/components/Tray/Tray.js new file mode 100644 index 0000000..b185aa3 --- /dev/null +++ b/dailyjs/live-streaming/components/Tray/Tray.js @@ -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 ( + <> + openModal(LIVE_STREAMING_MODAL)} + > + + + + ); +}; + +export default Tray; diff --git a/dailyjs/live-streaming/components/Tray/index.js b/dailyjs/live-streaming/components/Tray/index.js new file mode 100644 index 0000000..100bcc8 --- /dev/null +++ b/dailyjs/live-streaming/components/Tray/index.js @@ -0,0 +1 @@ +export { Tray as default } from './Tray'; diff --git a/dailyjs/live-streaming/contexts/LiveStreamingProvider.js b/dailyjs/live-streaming/contexts/LiveStreamingProvider.js new file mode 100644 index 0000000..f0c4a51 --- /dev/null +++ b/dailyjs/live-streaming/contexts/LiveStreamingProvider.js @@ -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 ( + + {children} + + ); +}; + +LiveStreamingProvider.propTypes = { + children: PropTypes.node, +}; + +export const useLiveStreaming = () => useContext(LiveStreamingContext); diff --git a/dailyjs/live-streaming/image.png b/dailyjs/live-streaming/image.png new file mode 100644 index 0000000..9781261 Binary files /dev/null and b/dailyjs/live-streaming/image.png differ diff --git a/dailyjs/live-streaming/next.config.js b/dailyjs/live-streaming/next.config.js new file mode 100644 index 0000000..9a0a6ee --- /dev/null +++ b/dailyjs/live-streaming/next.config.js @@ -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, + }, +}); diff --git a/dailyjs/live-streaming/package.json b/dailyjs/live-streaming/package.json new file mode 100644 index 0000000..c340eb5 --- /dev/null +++ b/dailyjs/live-streaming/package.json @@ -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" + } +} diff --git a/dailyjs/live-streaming/pages/_app.js b/dailyjs/live-streaming/pages/_app.js new file mode 100644 index 0000000..7a097c4 --- /dev/null +++ b/dailyjs/live-streaming/pages/_app.js @@ -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 = ; +App.customTrayComponent = ; + +export default App; diff --git a/dailyjs/live-streaming/pages/api b/dailyjs/live-streaming/pages/api new file mode 120000 index 0000000..999f604 --- /dev/null +++ b/dailyjs/live-streaming/pages/api @@ -0,0 +1 @@ +../../basic-call/pages/api \ No newline at end of file diff --git a/dailyjs/live-streaming/pages/index.js b/dailyjs/live-streaming/pages/index.js new file mode 100644 index 0000000..b82698b --- /dev/null +++ b/dailyjs/live-streaming/pages/index.js @@ -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; diff --git a/dailyjs/live-streaming/public b/dailyjs/live-streaming/public new file mode 120000 index 0000000..33a6e67 --- /dev/null +++ b/dailyjs/live-streaming/public @@ -0,0 +1 @@ +../basic-call/public \ No newline at end of file diff --git a/dailyjs/shared/components/DeviceSelectModal/DeviceSelectModal.js b/dailyjs/shared/components/DeviceSelectModal/DeviceSelectModal.js index 9f87bc4..926ba85 100644 --- a/dailyjs/shared/components/DeviceSelectModal/DeviceSelectModal.js +++ b/dailyjs/shared/components/DeviceSelectModal/DeviceSelectModal.js @@ -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 ( setShowDeviceModal(false)} + isOpen={currentModals[DEVICE_MODAL]} + onClose={() => closeModal(DEVICE_MODAL)} actions={[ diff --git a/dailyjs/shared/components/Input/Input.js b/dailyjs/shared/components/Input/Input.js index 06a8150..5ca32be 100644 --- a/dailyjs/shared/components/Input/Input.js +++ b/dailyjs/shared/components/Input/Input.js @@ -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, }; diff --git a/dailyjs/shared/components/Tray/BasicTray.js b/dailyjs/shared/components/Tray/BasicTray.js index 45326ae..b7f5aaf 100644 --- a/dailyjs/shared/components/Tray/BasicTray.js +++ b/dailyjs/shared/components/Tray/BasicTray.js @@ -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 ? : } - setShowDeviceModal(true)}> + openModal(DEVICE_MODAL)}> diff --git a/dailyjs/shared/contexts/UIStateProvider.js b/dailyjs/shared/contexts/UIStateProvider.js index a9458cb..2aab79b 100644 --- a/dailyjs/shared/contexts/UIStateProvider.js +++ b/dailyjs/shared/contexts/UIStateProvider.js @@ -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 }) => { {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, }; diff --git a/dailyjs/shared/icons/streamin-md.svg b/dailyjs/shared/icons/streaming-md.svg similarity index 100% rename from dailyjs/shared/icons/streamin-md.svg rename to dailyjs/shared/icons/streaming-md.svg diff --git a/dailyjs/shared/styles/defaultTheme.js b/dailyjs/shared/styles/defaultTheme.js index bce6547..ba37156 100644 --- a/dailyjs/shared/styles/defaultTheme.js +++ b/dailyjs/shared/styles/defaultTheme.js @@ -10,6 +10,7 @@ export const defaultTheme = { secondary: { default: '#FF9254', dark: '#FB651E', + light: '#FF9254', }, blue: {