added active speaker view
This commit is contained in:
parent
236e91d302
commit
0123e01487
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"presets": ["next/babel"],
|
||||
"plugins": ["inline-react-svg"]
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# Active Speaker
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||
[](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,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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { AppWithSpeakerViewRoom as default } from './App';
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { Room as default } from './Room';
|
||||
export { Room } from './Room';
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { SpeakerTile as default } from './SpeakerTile';
|
||||
export { SpeakerTile } from './SpeakerTile';
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { SpeakerView as default } from './SpeakerView';
|
||||
export { SpeakerView } from './SpeakerView';
|
||||
|
|
@ -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 |
|
|
@ -0,0 +1 @@
|
|||
// Note: I am here because next-transpile-modules requires a mainfile
|
||||
|
|
@ -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,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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
../../basic-call/pages/api
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
../basic-call/public
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -1 +0,0 @@
|
|||
../../shared/assets
|
||||
|
|
@ -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%;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -68,6 +68,7 @@ export const UIStateProvider = ({
|
|||
asides,
|
||||
modals,
|
||||
customTrayComponent,
|
||||
viewMode,
|
||||
openModal,
|
||||
closeModal,
|
||||
closeAside,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue