diff --git a/custom/active-speaker/.babelrc b/custom/active-speaker/.babelrc new file mode 100644 index 0000000..a6f4434 --- /dev/null +++ b/custom/active-speaker/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": ["inline-react-svg"] +} diff --git a/custom/active-speaker/README.md b/custom/active-speaker/README.md new file mode 100644 index 0000000..043789d --- /dev/null +++ b/custom/active-speaker/README.md @@ -0,0 +1,29 @@ +# Active Speaker + +### Live example + +**[See it in action here ➡️](https://custom-active-speaker.vercel.app)** + +--- + +## What does this demo do? + +- Uses an active speaker view mode that shows the currently talking participant (or active screen share) in a larger tile +- Introduces the `ParticipantBar` column that virtually scrolls through all call participants +- Uses manual subscriptions to paginate between tiles that are currently in view. For more information about how this works, please refer to the [pagination demo](../pagination) + +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 @custom/active-speaker dev +``` + +## 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/custom/active-speaker/components/App/App.js b/custom/active-speaker/components/App/App.js new file mode 100644 index 0000000..2c4bc1a --- /dev/null +++ b/custom/active-speaker/components/App/App.js @@ -0,0 +1,15 @@ +import React from 'react'; + +import App from '@custom/basic-call/components/App'; +import { Room } from '../Room'; + +// Extend our basic call app component with our custom Room componenet +export const AppWithSpeakerViewRoom = () => ( + , + }} + /> +); + +export default AppWithSpeakerViewRoom; diff --git a/custom/active-speaker/components/App/index.js b/custom/active-speaker/components/App/index.js new file mode 100644 index 0000000..19427e3 --- /dev/null +++ b/custom/active-speaker/components/App/index.js @@ -0,0 +1 @@ +export { AppWithSpeakerViewRoom as default } from './App'; diff --git a/custom/active-speaker/components/Room/Room.js b/custom/active-speaker/components/Room/Room.js new file mode 100644 index 0000000..f088154 --- /dev/null +++ b/custom/active-speaker/components/Room/Room.js @@ -0,0 +1,7 @@ +import React from 'react'; + +import { SpeakerView } from '../SpeakerView'; + +export const Room = () => ; + +export default Room; diff --git a/custom/active-speaker/components/Room/index.js b/custom/active-speaker/components/Room/index.js new file mode 100644 index 0000000..d26c647 --- /dev/null +++ b/custom/active-speaker/components/Room/index.js @@ -0,0 +1,2 @@ +export { Room as default } from './Room'; +export { Room } from './Room'; diff --git a/custom/active-speaker/components/SpeakerView/SpeakerTile/SpeakerTile.js b/custom/active-speaker/components/SpeakerView/SpeakerTile/SpeakerTile.js new file mode 100644 index 0000000..b651402 --- /dev/null +++ b/custom/active-speaker/components/SpeakerView/SpeakerTile/SpeakerTile.js @@ -0,0 +1,72 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import Tile from '@custom/shared/components/Tile'; +import { DEFAULT_ASPECT_RATIO } from '@custom/shared/constants'; +import { useResize } from '@custom/shared/hooks/useResize'; +import PropTypes from 'prop-types'; + +const MAX_RATIO = DEFAULT_ASPECT_RATIO; +const MIN_RATIO = 4 / 3; + +export const SpeakerTile = ({ participant, screenRef }) => { + const [ratio, setRatio] = useState(MAX_RATIO); + const [nativeAspectRatio, setNativeAspectRatio] = useState(null); + const [screenHeight, setScreenHeight] = useState(1); + + const updateRatio = useCallback(() => { + const rect = screenRef.current?.getBoundingClientRect(); + setRatio(rect.width / rect.height); + setScreenHeight(rect.height); + }, [screenRef]); + + useResize(() => { + updateRatio(); + }, [updateRatio]); + + useEffect(() => updateRatio(), [updateRatio]); + + /** + * Only use the video's native aspect ratio if it's in portrait mode + * (e.g. mobile) to update how we crop videos. Otherwise, use landscape + * defaults. + */ + const handleNativeAspectRatio = (r) => { + const isPortrait = r < 1; + setNativeAspectRatio(isPortrait ? r : null); + }; + + const { height, finalRatio, videoFit } = useMemo( + () => + // Avoid cropping mobile videos, which have the nativeAspectRatio set + ({ + height: (nativeAspectRatio ?? ratio) >= MIN_RATIO ? '100%' : null, + finalRatio: + nativeAspectRatio || (ratio <= MIN_RATIO ? MIN_RATIO : MAX_RATIO), + videoFit: ratio >= MAX_RATIO || nativeAspectRatio ? 'contain' : 'cover', + }), + [nativeAspectRatio, ratio] + ); + + const style = { + height, + maxWidth: screenHeight * finalRatio, + overflow: 'hidden', + }; + + return ( + + ); +}; + +SpeakerTile.propTypes = { + participant: PropTypes.object, + screenRef: PropTypes.object, +}; + +export default SpeakerTile; diff --git a/custom/active-speaker/components/SpeakerView/SpeakerTile/index.js b/custom/active-speaker/components/SpeakerView/SpeakerTile/index.js new file mode 100644 index 0000000..84639df --- /dev/null +++ b/custom/active-speaker/components/SpeakerView/SpeakerTile/index.js @@ -0,0 +1,2 @@ +export { SpeakerTile as default } from './SpeakerTile'; +export { SpeakerTile } from './SpeakerTile'; diff --git a/custom/active-speaker/components/SpeakerView/SpeakerView.js b/custom/active-speaker/components/SpeakerView/SpeakerView.js new file mode 100644 index 0000000..29bfbaf --- /dev/null +++ b/custom/active-speaker/components/SpeakerView/SpeakerView.js @@ -0,0 +1,118 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { Container } from '@custom/basic-call/components/Call/Container'; +import Header from '@custom/basic-call/components/Call/Header'; +import ParticipantBar from '@custom/shared/components/ParticipantBar/ParticipantBar'; +import VideoContainer from '@custom/shared/components/VideoContainer/VideoContainer'; +import { useCallState } from '@custom/shared/contexts/CallProvider'; +import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider'; +import { useTracks } from '@custom/shared/contexts/TracksProvider'; +import { useUIState } from '@custom/shared/contexts/UIStateProvider'; +import { isScreenId } from '@custom/shared/contexts/participantsState'; +import { SpeakerTile } from './SpeakerTile'; + +const SIDEBAR_WIDTH = 186; + +export const SpeakerView = () => { + const { currentSpeaker, localParticipant, participants, screens } = + useParticipants(); + const { updateCamSubscriptions } = useTracks(); + const { showLocalVideo } = useCallState(); + const { pinnedId } = useUIState(); + const activeRef = useRef(); + + const screensAndPinned = useMemo( + () => [...screens, ...participants.filter(({ id }) => id === pinnedId)], + [participants, pinnedId, screens] + ); + + const otherParticipants = useMemo( + () => participants.filter(({ isLocal }) => !isLocal), + [participants] + ); + + const showSidebar = useMemo(() => { + const hasScreenshares = screens.length > 0; + + if (isScreenId(pinnedId)) { + return false; + } + + return participants.length > 1 || hasScreenshares; + }, [participants, pinnedId, screens]); + + /* const screenShareTiles = useMemo( + () => , + [screensAndPinned] + ); */ + + const hasScreenshares = useMemo(() => screens.length > 0, [screens]); + + const fixedItems = useMemo(() => { + const items = []; + if (showLocalVideo) { + items.push(localParticipant); + } + if (hasScreenshares && otherParticipants.length > 0) { + items.push(otherParticipants[0]); + } + return items; + }, [hasScreenshares, localParticipant, otherParticipants, showLocalVideo]); + + const otherItems = useMemo(() => { + if (otherParticipants.length > 1) { + return otherParticipants.slice(hasScreenshares ? 1 : 0); + } + return []; + }, [hasScreenshares, otherParticipants]); + + /** + * Update cam subscriptions, in case ParticipantBar is not shown. + */ + useEffect(() => { + // Sidebar takes care of cam subscriptions for all displayed participants. + if (showSidebar) return; + updateCamSubscriptions([ + currentSpeaker?.id, + ...screensAndPinned.map((p) => p.id), + ]); + }, [currentSpeaker, screensAndPinned, showSidebar, updateCamSubscriptions]); + + return ( +
+ +
+ +
+ +
+
+ + {showSidebar && ( + + )} + + +
+ ); +}; + +export default SpeakerView; diff --git a/custom/active-speaker/components/SpeakerView/index.js b/custom/active-speaker/components/SpeakerView/index.js new file mode 100644 index 0000000..aa06afe --- /dev/null +++ b/custom/active-speaker/components/SpeakerView/index.js @@ -0,0 +1,2 @@ +export { SpeakerView as default } from './SpeakerView'; +export { SpeakerView } from './SpeakerView'; diff --git a/custom/active-speaker/env.example b/custom/active-speaker/env.example new file mode 100644 index 0000000..62eaf89 --- /dev/null +++ b/custom/active-speaker/env.example @@ -0,0 +1,12 @@ +# Domain excluding 'https://' and 'daily.co' e.g. 'somedomain' +DAILY_DOMAIN= + +# Obtained from https://dashboard.daily.co/developers +DAILY_API_KEY= + +# Daily REST API endpoint +DAILY_REST_DOMAIN=https://api.daily.co/v1 + + +# Enable manual track subscriptions +MANUAL_TRACK_SUBS=1 \ No newline at end of file diff --git a/custom/active-speaker/image.png b/custom/active-speaker/image.png new file mode 100644 index 0000000..592ea98 Binary files /dev/null and b/custom/active-speaker/image.png differ diff --git a/custom/active-speaker/index.js b/custom/active-speaker/index.js new file mode 100644 index 0000000..9044efc --- /dev/null +++ b/custom/active-speaker/index.js @@ -0,0 +1 @@ +// Note: I am here because next-transpile-modules requires a mainfile diff --git a/custom/active-speaker/next.config.js b/custom/active-speaker/next.config.js new file mode 100644 index 0000000..9140e28 --- /dev/null +++ b/custom/active-speaker/next.config.js @@ -0,0 +1,13 @@ +const withPlugins = require('next-compose-plugins'); +const withTM = require('next-transpile-modules')([ + '@custom/shared', + '@custom/basic-call', +]); + +const packageJson = require('./package.json'); + +module.exports = withPlugins([withTM], { + env: { + PROJECT_TITLE: packageJson.description, + }, +}); diff --git a/custom/active-speaker/package.json b/custom/active-speaker/package.json new file mode 100644 index 0000000..a7ca2c2 --- /dev/null +++ b/custom/active-speaker/package.json @@ -0,0 +1,25 @@ +{ + "name": "@custom/active-speaker", + "description": "Basic Call + Active Speaker", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@custom/basic-call": "*", + "@custom/shared": "*", + "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/custom/active-speaker/pages/_app.js b/custom/active-speaker/pages/_app.js new file mode 100644 index 0000000..9e427d2 --- /dev/null +++ b/custom/active-speaker/pages/_app.js @@ -0,0 +1,7 @@ +import React from 'react'; +import App from '@custom/basic-call/pages/_app'; +import AppWithSpeakerViewRoom from '../components/App'; + +App.customAppComponent = ; + +export default App; diff --git a/custom/active-speaker/pages/api b/custom/active-speaker/pages/api new file mode 120000 index 0000000..999f604 --- /dev/null +++ b/custom/active-speaker/pages/api @@ -0,0 +1 @@ +../../basic-call/pages/api \ No newline at end of file diff --git a/custom/active-speaker/pages/index.js b/custom/active-speaker/pages/index.js new file mode 100644 index 0000000..84a3f53 --- /dev/null +++ b/custom/active-speaker/pages/index.js @@ -0,0 +1,13 @@ +import Index from '@custom/basic-call/pages'; +import getDemoProps from '@custom/shared/lib/demoProps'; + +export async function getStaticProps() { + const defaultProps = getDemoProps(); + + // Pass through domain as prop + return { + props: defaultProps, + }; +} + +export default Index; diff --git a/custom/active-speaker/public/assets/daily-logo-dark.svg b/custom/active-speaker/public/assets/daily-logo-dark.svg new file mode 100644 index 0000000..ef3a565 --- /dev/null +++ b/custom/active-speaker/public/assets/daily-logo-dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/custom/active-speaker/public/assets/daily-logo.svg b/custom/active-speaker/public/assets/daily-logo.svg new file mode 100644 index 0000000..534a18a --- /dev/null +++ b/custom/active-speaker/public/assets/daily-logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/custom/active-speaker/public/assets/join.mp3 b/custom/active-speaker/public/assets/join.mp3 new file mode 100644 index 0000000..7657915 Binary files /dev/null and b/custom/active-speaker/public/assets/join.mp3 differ diff --git a/custom/active-speaker/public/assets/message.mp3 b/custom/active-speaker/public/assets/message.mp3 new file mode 100644 index 0000000..a067315 Binary files /dev/null and b/custom/active-speaker/public/assets/message.mp3 differ diff --git a/custom/active-speaker/public/assets/pattern-bg.png b/custom/active-speaker/public/assets/pattern-bg.png new file mode 100644 index 0000000..01e0d0d Binary files /dev/null and b/custom/active-speaker/public/assets/pattern-bg.png differ diff --git a/custom/recording/contexts/RecordingProvider.js b/custom/recording/contexts/RecordingProvider.js index c63d0b8..9220425 100644 --- a/custom/recording/contexts/RecordingProvider.js +++ b/custom/recording/contexts/RecordingProvider.js @@ -282,7 +282,7 @@ export const RecordingProvider = ({ children }) => { if (!callObject || !enableRecording) return; setIsRecordingLocally(true); setRecordingState(RECORDING_COUNTDOWN_3); - callObject?.sendAppMessage({ + callObject.sendAppMessage({ event: 'recording-starting', }); }, [callObject, enableRecording]); diff --git a/custom/shared/components/Button/Button.js b/custom/shared/components/Button/Button.js index 393558e..ba87bac 100644 --- a/custom/shared/components/Button/Button.js +++ b/custom/shared/components/Button/Button.js @@ -33,9 +33,9 @@ export const Button = forwardRef( const content = ( <> - {IconBefore && } + {IconBefore && } {children} - {IconAfter && } + {IconAfter && } ); diff --git a/custom/shared/components/HairCheck/HairCheck.js b/custom/shared/components/HairCheck/HairCheck.js index d7456cb..0b1fa0f 100644 --- a/custom/shared/components/HairCheck/HairCheck.js +++ b/custom/shared/components/HairCheck/HairCheck.js @@ -38,7 +38,9 @@ export const HairCheck = () => { const [waiting, setWaiting] = useState(false); const [joining, setJoining] = useState(false); const [denied, setDenied] = useState(); - const [userName, setUserName] = useState(''); + const [userName, setUserName] = useState( + localStorage.getItem('PLUOT_PARTICIPANT_NAME') || '' + ); // Initialise devices (even though we're not yet in a call) useEffect(() => { @@ -72,6 +74,7 @@ export const HairCheck = () => { if (granted) { // Note: we don't have to do any thing here as the call state will mutate console.log('👋 Access granted'); + localStorage.setItem('PLUOT_PARTICIPANT_NAME', userName); } else { console.log('❌ Access denied'); setDenied(true); @@ -187,6 +190,7 @@ export const HairCheck = () => { placeholder="Enter display name" variant="dark" disabled={joining} + value={userName} onChange={(e) => setUserName(e.target.value)} /> + + + + )} + {!responsive.isMobile() ? ( + <> + openModal(DEVICE_MODAL)}> + + + toggleAside(NETWORK_ASIDE)}> + + + toggleAside(PEOPLE_ASIDE)}> + + + + ) : ( + setShowMore(!showMore)}> + + + )} {customTrayComponent} @@ -63,6 +101,20 @@ export const BasicTray = () => { leave()} orange> + ); }; diff --git a/custom/shared/hooks/useResponsive.js b/custom/shared/hooks/useResponsive.js new file mode 100644 index 0000000..7f9742a --- /dev/null +++ b/custom/shared/hooks/useResponsive.js @@ -0,0 +1,38 @@ +import React, { useEffect, useState } from 'react'; + +let responsiveConfig = { + xs: 0, + sm: 576, + md: 768, + lg: 992, + xl: 1200, +}; + +export const useResponsive = () => { + const [windowSize, setWindowSize] = useState(window.innerWidth); + + const handleChangeWindowSize = () => setWindowSize(window.innerWidth); + + const getResponsiveConfig = (size) => { + const responsive = {}; + Object.keys(responsiveConfig).forEach(config => { + responsive[config] = size > responsiveConfig[config]; + }); + return responsive; + }; + + const isMobile = () => { + const config = getResponsiveConfig(windowSize); + return !config.md && !config.lg && !config.xl; + }; + + useEffect(() => { + window.addEventListener('resize', handleChangeWindowSize); + + return () => { + window.removeEventListener('resize', handleChangeWindowSize); + }; + }, []); + + return { config: getResponsiveConfig(windowSize), isMobile }; +}; \ No newline at end of file diff --git a/custom/shared/icons/more-md.svg b/custom/shared/icons/more-md.svg new file mode 100644 index 0000000..c792022 --- /dev/null +++ b/custom/shared/icons/more-md.svg @@ -0,0 +1 @@ + \ No newline at end of file