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
+
+[](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)}
/>