added active speaker view

This commit is contained in:
Jon 2021-07-23 12:44:14 +01:00
parent 236e91d302
commit 0123e01487
32 changed files with 363 additions and 34 deletions

View File

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

View File

@ -0,0 +1,31 @@
# Active Speaker
![Active speaker](./image.png)
### Live example
**[See it in action here ➡️](https://dailyjs-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 @dailyjs/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)

View File

@ -0,0 +1,15 @@
import React from 'react';
import App from '@dailyjs/basic-call/components/App';
import Room from '../Room';
// Extend our basic call app component with our custom Room componenet
export const AppWithSpeakerViewRoom = () => (
<App
customComponentForState={{
room: () => <Room />,
}}
/>
);
export default AppWithSpeakerViewRoom;

View File

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

View File

@ -0,0 +1,14 @@
import React from 'react';
import { RoomContainer } from '@dailyjs/basic-call/components/Room';
import VideoContainer from '@dailyjs/shared/components/VideoContainer/VideoContainer';
import { SpeakerView } from '../SpeakerView';
export const Room = () => (
<RoomContainer>
<VideoContainer>
<SpeakerView />
</VideoContainer>
</RoomContainer>
);
export default Room;

View File

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

View File

@ -0,0 +1,72 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Tile } from '@dailyjs/shared/components/Tile';
import { DEFAULT_ASPECT_RATIO } from '@dailyjs/shared/constants';
import { useResize } from '@dailyjs/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 (
<Tile
aspectRatio={finalRatio}
participant={participant}
style={style}
videoFit={videoFit}
onVideoResize={handleNativeAspectRatio}
/>
);
};
SpeakerTile.propTypes = {
participant: PropTypes.object,
screenRef: PropTypes.object,
};
export default SpeakerTile;

View File

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

View File

@ -0,0 +1,25 @@
import React, { useRef } from 'react';
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
import SpeakerTile from './SpeakerTile';
export const SpeakerView = () => {
const { currentSpeaker } = useParticipants();
const activeRef = useRef();
return (
<div ref={activeRef} className="active">
<SpeakerTile participant={currentSpeaker} screenRef={activeRef} />
<style jsx>{`
.active {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
`}</style>
</div>
);
};
export default SpeakerView;

View File

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

View File

@ -0,0 +1,8 @@
# 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

View File

@ -0,0 +1 @@
// Note: I am here because next-transpile-modules requires a mainfile

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/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": {
"@dailyjs/basic-call": "*",
"@dailyjs/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"
}
}

View File

@ -0,0 +1,7 @@
import React from 'react';
import App from '@dailyjs/basic-call/pages/_app';
import AppWithSpeakerViewRoom from '../components/App';
App.customAppComponent = <AppWithSpeakerViewRoom />;
export default App;

View File

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

View File

@ -0,0 +1,13 @@
import Index from '@dailyjs/basic-call/pages';
import getDemoProps from '@dailyjs/shared/lib/demoProps';
export async function getStaticProps() {
const defaultProps = getDemoProps();
// Pass through domain as prop
return {
props: defaultProps,
};
}
export default Index;

View File

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

View File

@ -1,4 +1,5 @@
import React from 'react';
import VideoContainer from '@dailyjs/shared/components/VideoContainer/VideoContainer';
import { VideoGrid } from '../VideoGrid';
import { Header } from './Header';
@ -7,9 +8,9 @@ import { RoomContainer } from './RoomContainer';
export const Room = () => (
<RoomContainer>
<Header />
<main>
<VideoContainer>
<VideoGrid />
</main>
</VideoContainer>
</RoomContainer>
);

View File

@ -37,16 +37,6 @@ export const RoomContainer = ({ children }) => {
height: 100%;
display: flex;
}
.room > :global(main) {
flex: 1 1 auto;
position: relative;
overflow: hidden;
min-height: 0px;
height: 100%;
padding: var(--spacing-xxxs);
box-sizing: border-box;
}
`}</style>
</div>
);

View File

@ -1,12 +1,13 @@
import React from 'react';
import { RoomContainer } from '@dailyjs/basic-call/components/Room/RoomContainer';
import { VideoContainer } from '@dailyjs/shared/components/VideoContainer';
import { Header } from './Header';
export const Room = () => (
<RoomContainer>
<Header />
<main>Hello</main>
<VideoContainer>Hello</VideoContainer>
</RoomContainer>
);

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

@ -1 +0,0 @@
../../shared/assets

View File

@ -208,7 +208,7 @@ export const HairCheck = () => {
justify-content: center;
height: 100%;
width: 100%;
background: url('/images/pattern-bg.png') center center no-repeat;
background: url('/assets/pattern-bg.png') center center no-repeat;
background-size: 100%;
}

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import useVideoTrack from '@dailyjs/shared/hooks/useVideoTrack';
import { ReactComponent as IconMicMute } from '@dailyjs/shared/icons/mic-off-sm.svg';
import classNames from 'classnames';
@ -14,12 +14,45 @@ export const Tile = React.memo(
showName = true,
showAvatar = true,
aspectRatio = DEFAULT_ASPECT_RATIO,
onVideoResize,
videoFit = 'contain',
...props
}) => {
const videoTrack = useVideoTrack(participant);
const videoEl = useRef(null);
const [tileAspectRatio, setTileAspectRatio] = useState(aspectRatio);
const cx = classNames('tile', {
/**
* Add optional event listener for resize event so the parent component
* can know the video's native aspect ratio.
*/
useEffect(() => {
const video = videoEl.current;
if (!onVideoResize || !video) return false;
const handleResize = () => {
if (!video) return;
const width = video?.videoWidth;
const height = video?.videoHeight;
if (width && height) {
// Return the video's aspect ratio to the parent's handler
onVideoResize(width / height);
}
};
handleResize();
video?.addEventListener('resize', handleResize);
return () => video?.removeEventListener('resize', handleResize);
}, [onVideoResize, videoEl, participant]);
useEffect(() => {
if (aspectRatio === tileAspectRatio) return;
setTileAspectRatio(aspectRatio);
}, [aspectRatio, tileAspectRatio]);
const cx = classNames('tile', videoFit, {
mirrored,
avatar: showAvatar && !videoTrack,
active: participant.isActiveSpeaker,
@ -46,19 +79,14 @@ export const Tile = React.memo(
</div>
<style jsx>{`
.tile .content {
padding-bottom: ${100 / aspectRatio}%;
}
@supports (aspect-ratio: 1 / 1) {
.tile .content {
aspect-ratio: ${aspectRatio};
padding-bottom: 0;
}
padding-bottom: ${100 / tileAspectRatio}%;
}
`}</style>
<style jsx>{`
.tile {
background: var(--blue-dark);
min-width: 1px;
overflow: hidden;
position: relative;
width: 100%;
box-sizing: border-box;
@ -68,10 +96,6 @@ export const Tile = React.memo(
border: 2px solid var(--primary-default);
}
.tile.mirrored :global(video) {
transform: scale(-1, 1);
}
.tile .name {
position: absolute;
bottom: 0px;
@ -92,16 +116,27 @@ export const Tile = React.memo(
}
.tile :global(video) {
height: calc(100% + 4px);
left: -2px;
object-position: center;
object-fit: cover;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
top: -2px;
width: calc(100% + 4px);
z-index: 1;
}
.tile.contain :global(video) {
object-fit: contain;
}
.tile.cover :global(video) {
object-fit: cover;
}
.tile.mirrored :global(video) {
transform: scale(-1, 1);
}
.tile .avatar {
position: absolute;
width: 100%;
@ -122,6 +157,8 @@ Tile.propTypes = {
showName: PropTypes.bool,
showAvatar: PropTypes.bool,
aspectRatio: PropTypes.number,
onVideoResize: PropTypes.func,
videoFit: PropTypes.string,
};
export default Tile;

View File

@ -72,7 +72,8 @@ export const Tray = ({ children }) => (
<style jsx>{`
footer {
flex: 0 0 auto;
padding: var(--spacing-xs);
padding: var(--spacing-xxs) var(--spacing-xs) var(--spacing-xs)
var(--spacing-xs);
box-sizing: border-box;
width: 100%;
display: flex;

View File

@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
export const VideoContainer = ({ children }) => (
<main>
{children}
<style jsx>{`
main {
flex: 1 1 auto;
position: relative;
overflow: hidden;
min-height: 0px;
height: 100%;
width: 100%;
padding: var(--spacing-xxxs);
box-sizing: border-box;
}
`}</style>
</main>
);
VideoContainer.propTypes = {
children: PropTypes.node,
};
export default VideoContainer;

View File

@ -68,6 +68,7 @@ export const UIStateProvider = ({
asides,
modals,
customTrayComponent,
viewMode,
openModal,
closeModal,
closeAside,

View File

@ -0,0 +1,23 @@
import { useCallback, useEffect } from 'react';
export const useResize = (callback, deps = []) => {
let timeout;
const handleResize = useCallback(() => {
if (timeout) cancelAnimationFrame(timeout);
timeout = requestAnimationFrame(() => callback());
}, [callback]);
useEffect(() => {
window.addEventListener('resize', handleResize, { passive: true });
window.addEventListener('orientationchange', handleResize, {
passive: true,
});
callback();
return () => {
if (timeout) cancelAnimationFrame(timeout);
window.removeEventListener('resize', handleResize);
window.removeEventListener('orientationchange', handleResize);
};
}, deps);
};
export default useResize;