ParticipantBar
This commit is contained in:
parent
0123e01487
commit
dc2dc63a98
|
|
@ -1,14 +1,7 @@
|
|||
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 const Room = () => <SpeakerView />;
|
||||
|
||||
export default Room;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,106 @@
|
|||
import React, { useRef } from 'react';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import RoomContainer from '@dailyjs/basic-call/components/Room/RoomContainer';
|
||||
import ParticipantBar from '@dailyjs/shared/components/ParticipantBar/ParticipantBar';
|
||||
import VideoContainer from '@dailyjs/shared/components/VideoContainer/VideoContainer';
|
||||
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
|
||||
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
|
||||
import { useTracks } from '@dailyjs/shared/contexts/TracksProvider';
|
||||
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||
import { isScreenId } from '@dailyjs/shared/contexts/participantsState';
|
||||
import SpeakerTile from './SpeakerTile';
|
||||
|
||||
const SIDEBAR_WIDTH = 186;
|
||||
|
||||
export const SpeakerView = () => {
|
||||
const { currentSpeaker } = useParticipants();
|
||||
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(
|
||||
() => <ScreensAndPins items={screensAndPinned} />,
|
||||
[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 (
|
||||
<div ref={activeRef} className="active">
|
||||
<SpeakerTile participant={currentSpeaker} screenRef={activeRef} />
|
||||
<div className="speaker-view">
|
||||
<RoomContainer>
|
||||
<VideoContainer>
|
||||
<div ref={activeRef} className="active">
|
||||
<SpeakerTile participant={currentSpeaker} screenRef={activeRef} />
|
||||
</div>
|
||||
</VideoContainer>
|
||||
</RoomContainer>
|
||||
{showSidebar && (
|
||||
<ParticipantBar
|
||||
fixed={fixedItems}
|
||||
others={otherItems}
|
||||
width={SIDEBAR_WIDTH}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.speaker-view {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -9,13 +9,11 @@ import { Button } from '@dailyjs/shared/components/Button';
|
|||
import Tile from '@dailyjs/shared/components/Tile';
|
||||
import { DEFAULT_ASPECT_RATIO } from '@dailyjs/shared/constants';
|
||||
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
|
||||
import { useTracks } from '@dailyjs/shared/contexts/TracksProvider';
|
||||
import { useActiveSpeaker } from '@dailyjs/shared/hooks/useActiveSpeaker';
|
||||
import { useCamSubscriptions } from '@dailyjs/shared/hooks/useCamSubscriptions';
|
||||
import usePreferredLayer from '@dailyjs/shared/hooks/usePreferredLayer';
|
||||
import { ReactComponent as IconArrow } from '@dailyjs/shared/icons/raquo-md.svg';
|
||||
import sortByKey from '@dailyjs/shared/lib/sortByKey';
|
||||
import { debounce } from 'debounce';
|
||||
import { useDeepCompareMemo } from 'use-deep-compare';
|
||||
|
||||
// --- Constants
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export const Aside = ({ onClose, children }) => (
|
|||
<div className="close">
|
||||
<Button
|
||||
size="small-square"
|
||||
variant="dark"
|
||||
variant="white"
|
||||
className="closeButton"
|
||||
onClick={onClose}
|
||||
>
|
||||
|
|
@ -42,9 +42,15 @@ export const Aside = ({ onClose, children }) => (
|
|||
.call-aside .close {
|
||||
position: absolute;
|
||||
top: var(--spacing-xxs);
|
||||
left: calc(-48px - var(--spacing-xxs));
|
||||
left: calc(-43px);
|
||||
border-right: 1px solid var(--gray-wash);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.call-aside :global(.closeButton) {
|
||||
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
|
||||
height: 48px;
|
||||
}
|
||||
`}</style>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ const PersonRow = ({ participant, isOwner = false }) => (
|
|||
margin-bottom: var(--spacing-xxxs);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.person-row .name {
|
||||
|
|
@ -104,49 +105,57 @@ export const PeopleAside = () => {
|
|||
|
||||
return (
|
||||
<Aside onClose={() => setShowAside(false)}>
|
||||
{isOwner && (
|
||||
<div className="owner-actions">
|
||||
<Button
|
||||
fullWidth
|
||||
size="tiny"
|
||||
variant="outline-gray"
|
||||
onClick={() =>
|
||||
callObject.updateParticipants({ '*': { setAudio: false } })
|
||||
}
|
||||
>
|
||||
Mute all mics
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size="tiny"
|
||||
variant="outline-gray"
|
||||
onClick={() =>
|
||||
callObject.updateParticipants({ '*': { setVideo: false } })
|
||||
}
|
||||
>
|
||||
Mute all cams
|
||||
</Button>
|
||||
<div className="people-aside">
|
||||
{isOwner && (
|
||||
<div className="owner-actions">
|
||||
<Button
|
||||
fullWidth
|
||||
size="tiny"
|
||||
variant="outline-gray"
|
||||
onClick={() =>
|
||||
callObject.updateParticipants({ '*': { setAudio: false } })
|
||||
}
|
||||
>
|
||||
Mute all mics
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size="tiny"
|
||||
variant="outline-gray"
|
||||
onClick={() =>
|
||||
callObject.updateParticipants({ '*': { setVideo: false } })
|
||||
}
|
||||
>
|
||||
Mute all cams
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="rows">
|
||||
{allParticipants.map((p) => (
|
||||
<PersonRow participant={p} key={p.id} isOwner={isOwner} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="rows">
|
||||
{allParticipants.map((p) => (
|
||||
<PersonRow participant={p} key={p.id} isOwner={isOwner} />
|
||||
))}
|
||||
</div>
|
||||
<style jsx>
|
||||
{`
|
||||
.owner-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xxxs);
|
||||
margin: var(--spacing-xs) var(--spacing-xxs);
|
||||
}
|
||||
<style jsx>
|
||||
{`
|
||||
.people-aside {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: var(--spacing-xxs);
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
.owner-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xxxs);
|
||||
margin: var(--spacing-xs) var(--spacing-xxs);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: var(--spacing-xxs);
|
||||
flex: 1;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
</Aside>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -290,6 +290,24 @@ export const Button = forwardRef(
|
|||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.button.white {
|
||||
background: white;
|
||||
border: 0px;
|
||||
}
|
||||
.button.white:hover,
|
||||
.button.white:focus,
|
||||
.button.white:active {
|
||||
background: var(--gray-wash);
|
||||
border: 0px;
|
||||
color: var(--primary-default);
|
||||
}
|
||||
.button.white:focus {
|
||||
box-shadow: 0 0 0px 3px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.button.white:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.button.outline {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,382 @@
|
|||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Tile } from '@dailyjs/shared/components/Tile';
|
||||
import { DEFAULT_ASPECT_RATIO } from '@dailyjs/shared/constants';
|
||||
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
|
||||
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
|
||||
import { useTracks } from '@dailyjs/shared/contexts/TracksProvider';
|
||||
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
|
||||
import { isLocalId } from '@dailyjs/shared/contexts/participantsState';
|
||||
import { useActiveSpeaker } from '@dailyjs/shared/hooks/useActiveSpeaker';
|
||||
import { useCamSubscriptions } from '@dailyjs/shared/hooks/useCamSubscriptions';
|
||||
import { useResize } from '@dailyjs/shared/hooks/useResize';
|
||||
import { useScrollbarWidth } from '@dailyjs/shared/hooks/useScrollbarWidth';
|
||||
import classnames from 'classnames';
|
||||
import debounce from 'debounce';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useBlockScrolling } from './useBlockScrolling';
|
||||
|
||||
/**
|
||||
* Gap between tiles in pixels.
|
||||
*/
|
||||
const GAP = 1;
|
||||
|
||||
/**
|
||||
* Maximum amount of buffered video tiles, in addition
|
||||
* to the visible on-screen tiles.
|
||||
*/
|
||||
const MAX_SCROLL_BUFFER = 10;
|
||||
|
||||
export const ParticipantBar = ({
|
||||
aspectRatio = DEFAULT_ASPECT_RATIO,
|
||||
fixed = [],
|
||||
others = [],
|
||||
width,
|
||||
}) => {
|
||||
const { networkState } = useCallState();
|
||||
const { currentSpeaker, screens, swapParticipantPosition } =
|
||||
useParticipants();
|
||||
const { maxCamSubscriptions } = useTracks();
|
||||
const { pinnedId, showParticipantsBar } = useUIState();
|
||||
const itemHeight = useMemo(
|
||||
() => width / aspectRatio + GAP,
|
||||
[aspectRatio, width]
|
||||
);
|
||||
const paddingTop = useMemo(
|
||||
() => itemHeight * fixed.length,
|
||||
[fixed, itemHeight]
|
||||
);
|
||||
const scrollTop = useRef(0);
|
||||
const spaceBefore = useRef(null);
|
||||
const spaceAfter = useRef(null);
|
||||
const scrollRef = useRef(null);
|
||||
const othersRef = useRef(null);
|
||||
const [range, setRange] = useState([0, 20]);
|
||||
const [isSidebarScrollable, setIsSidebarScrollable] = useState(false);
|
||||
const blockScrolling = useBlockScrolling(scrollRef);
|
||||
const scrollbarWidth = useScrollbarWidth();
|
||||
const activeSpeakerId = useActiveSpeaker();
|
||||
|
||||
const hasScreenshares = useMemo(() => screens.length > 0, [screens]);
|
||||
const othersCount = useMemo(() => others.length, [others]);
|
||||
const visibleOthers = useMemo(
|
||||
() => others.slice(range[0], range[1]),
|
||||
[others, range]
|
||||
);
|
||||
|
||||
/**
|
||||
* Store other ids as string to reduce amount of running useEffects below.
|
||||
*/
|
||||
const otherIds = useMemo(() => others.map((o) => o?.id), [others]);
|
||||
|
||||
const [camSubscriptions, setCamSubscriptions] = useState({
|
||||
subscribedIds: [],
|
||||
pausedIds: [],
|
||||
});
|
||||
useCamSubscriptions(
|
||||
camSubscriptions?.subscribedIds,
|
||||
camSubscriptions?.pausedIds
|
||||
);
|
||||
|
||||
/**
|
||||
* Determines subscribed and paused participant ids,
|
||||
* based on rendered range, scroll position and viewport.
|
||||
*/
|
||||
const updateCamSubscriptions = useCallback(
|
||||
(r) => {
|
||||
const scrollEl = scrollRef.current;
|
||||
const fixedRemote = fixed.filter((p) => !isLocalId(p.id));
|
||||
|
||||
if (!showParticipantsBar) {
|
||||
setCamSubscriptions({
|
||||
subscribedIds: [
|
||||
currentSpeaker?.id,
|
||||
pinnedId,
|
||||
...fixedRemote.map((p) => p.id),
|
||||
],
|
||||
pausedIds: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!scrollEl) return;
|
||||
|
||||
/**
|
||||
* Make sure we don't accidentally end up with a negative buffer,
|
||||
* in case maxCamSubscriptions is lower than the amount of displayable
|
||||
* participants.
|
||||
*/
|
||||
const buffer =
|
||||
Math.max(0, maxCamSubscriptions - (r[1] - r[0]) - fixedRemote.length) /
|
||||
2;
|
||||
const min = Math.max(0, r[0] - buffer);
|
||||
const max = Math.min(otherIds.length, r[1] + buffer);
|
||||
const ids = otherIds.slice(min, max);
|
||||
if (!ids.includes(currentSpeaker?.id) && !isLocalId(currentSpeaker?.id)) {
|
||||
ids.push(currentSpeaker?.id);
|
||||
}
|
||||
// Calculate paused participant ids by determining their tile position
|
||||
const subscribedIds = [...fixedRemote.map((p) => p.id), ...ids];
|
||||
const pausedIds = otherIds.filter((id, i) => {
|
||||
// ignore unrendered ids, they'll be unsubscribed instead
|
||||
if (!ids.includes(id)) return false;
|
||||
// ignore current speaker, it should never be paused
|
||||
if (id === currentSpeaker?.id) return false;
|
||||
const top = i * itemHeight;
|
||||
const fixedHeight = fixed.length * itemHeight;
|
||||
const visibleScrollHeight = scrollEl.clientHeight - fixedHeight;
|
||||
const paused =
|
||||
// bottom video edge above top viewport edge
|
||||
top + itemHeight < scrollEl.scrollTop ||
|
||||
// top video edge below bottom viewport edge
|
||||
top > scrollEl.scrollTop + visibleScrollHeight;
|
||||
return paused;
|
||||
});
|
||||
setCamSubscriptions({
|
||||
subscribedIds,
|
||||
pausedIds,
|
||||
});
|
||||
},
|
||||
[
|
||||
currentSpeaker?.id,
|
||||
fixed,
|
||||
itemHeight,
|
||||
maxCamSubscriptions,
|
||||
otherIds,
|
||||
pinnedId,
|
||||
showParticipantsBar,
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates
|
||||
* 1. the range of rendered others tiles
|
||||
* 2. the spacing boxes before and after the visible tiles
|
||||
*/
|
||||
const updateVisibleRange = useCallback(
|
||||
(st) => {
|
||||
const visibleHeight = scrollRef.current.clientHeight - paddingTop;
|
||||
const scrollBuffer = Math.min(
|
||||
MAX_SCROLL_BUFFER,
|
||||
(2 * visibleHeight) / itemHeight
|
||||
);
|
||||
const visibleItemCount = Math.ceil(
|
||||
visibleHeight / itemHeight + scrollBuffer
|
||||
);
|
||||
let start = Math.floor(
|
||||
Math.max(0, st - (scrollBuffer / 2) * itemHeight) / itemHeight
|
||||
);
|
||||
const end = Math.min(start + visibleItemCount, othersCount);
|
||||
if (end - visibleItemCount < start) {
|
||||
// Make sure we always render the same amount of tiles and buffered tiles
|
||||
start = Math.max(0, end - visibleItemCount);
|
||||
}
|
||||
/**
|
||||
* updateVisibleRange is called while scrolling for every frame!
|
||||
* We're updating only one state to cause exactly one re-render.
|
||||
* Heights for spacer elements are set directly on the DOM to stay ⚡️ fast.
|
||||
*/
|
||||
setRange([start, end]);
|
||||
if (!spaceBefore.current || !spaceAfter.current) return [start, end];
|
||||
spaceBefore.current.style.height = `${start * itemHeight}px`;
|
||||
spaceAfter.current.style.height = `${(othersCount - end) * itemHeight}px`;
|
||||
return [start, end];
|
||||
},
|
||||
[itemHeight, othersCount, paddingTop, spaceAfter, spaceBefore]
|
||||
);
|
||||
|
||||
useResize(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!showParticipantsBar || !scrollEl) return;
|
||||
setIsSidebarScrollable(scrollEl?.scrollHeight > scrollEl?.clientHeight);
|
||||
const r = updateVisibleRange(scrollEl.scrollTop);
|
||||
updateCamSubscriptions(r);
|
||||
}, [
|
||||
scrollRef,
|
||||
showParticipantsBar,
|
||||
updateCamSubscriptions,
|
||||
updateVisibleRange,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Setup optimized scroll listener.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return false;
|
||||
|
||||
let frame;
|
||||
const handleScroll = () => {
|
||||
scrollTop.current = scrollEl.scrollTop;
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
if (!scrollEl) return;
|
||||
const r = updateVisibleRange(scrollEl.scrollTop);
|
||||
updateCamSubscriptions(r);
|
||||
});
|
||||
};
|
||||
|
||||
scrollEl.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
scrollEl.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [scrollRef, updateCamSubscriptions, updateVisibleRange]);
|
||||
|
||||
/**
|
||||
* Move out-of-view active speakers to position right after presenters.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
// Ignore promoting, when no screens are being shared
|
||||
// because the active participant will be shown in the SpeakerTile anyway
|
||||
if (!hasScreenshares || !scrollEl) return false;
|
||||
|
||||
const maybePromoteActiveSpeaker = () => {
|
||||
const fixedOther = fixed.find((f) => !f.isLocal);
|
||||
// Ignore when speaker is already at first position or component unmounted
|
||||
if (!fixedOther || fixedOther?.id === activeSpeakerId || !scrollEl)
|
||||
return false;
|
||||
|
||||
// Active speaker not rendered at all, promote immediately
|
||||
if (
|
||||
visibleOthers.every((p) => p.id !== activeSpeakerId) &&
|
||||
!isLocalId(activeSpeakerId)
|
||||
) {
|
||||
swapParticipantPosition(fixedOther.id, activeSpeakerId);
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeTile = othersRef.current?.querySelector(
|
||||
`[id="${activeSpeakerId}"]`
|
||||
);
|
||||
// Ignore when active speaker is not within "others"
|
||||
if (!activeTile) return false;
|
||||
|
||||
// Ignore when active speaker is already pinned
|
||||
if (activeSpeakerId === pinnedId) return false;
|
||||
|
||||
const { height: tileHeight } = activeTile.getBoundingClientRect();
|
||||
const othersVisibleHeight =
|
||||
scrollEl?.clientHeight - othersRef.current?.offsetTop;
|
||||
|
||||
const scrolledOffsetTop = activeTile.offsetTop - scrollEl?.scrollTop;
|
||||
|
||||
// Ignore when speaker is already visible (< 50% cut off)
|
||||
if (
|
||||
scrolledOffsetTop + tileHeight / 2 < othersVisibleHeight &&
|
||||
scrolledOffsetTop > -tileHeight / 2
|
||||
)
|
||||
return false;
|
||||
|
||||
return swapParticipantPosition(fixedOther.id, activeSpeakerId);
|
||||
};
|
||||
maybePromoteActiveSpeaker();
|
||||
const throttledHandler = debounce(maybePromoteActiveSpeaker, 100);
|
||||
scrollEl.addEventListener('scroll', throttledHandler);
|
||||
|
||||
return () => {
|
||||
scrollEl?.removeEventListener('scroll', throttledHandler);
|
||||
};
|
||||
}, [
|
||||
activeSpeakerId,
|
||||
fixed,
|
||||
hasScreenshares,
|
||||
pinnedId,
|
||||
swapParticipantPosition,
|
||||
visibleOthers,
|
||||
]);
|
||||
|
||||
const otherTiles = useMemo(
|
||||
() =>
|
||||
visibleOthers.map((callItem) => (
|
||||
<Tile
|
||||
aspectRatio={aspectRatio}
|
||||
key={callItem.id}
|
||||
participant={callItem}
|
||||
/>
|
||||
)),
|
||||
[aspectRatio, visibleOthers]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={classnames('sidebar', {
|
||||
blockScrolling,
|
||||
scrollable: isSidebarScrollable,
|
||||
scrollbarOutside: scrollbarWidth > 0,
|
||||
})}
|
||||
>
|
||||
<div className="fixed">
|
||||
{fixed.map((item, i) => {
|
||||
// reduce setting up & tearing down tiles as much as possible
|
||||
const key = i;
|
||||
return (
|
||||
<Tile
|
||||
key={key}
|
||||
aspectRatio={aspectRatio}
|
||||
participant={item}
|
||||
network={networkState}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showParticipantsBar && (
|
||||
<div ref={othersRef} className="participants">
|
||||
<div ref={spaceBefore} style={{ width }} />
|
||||
{otherTiles}
|
||||
<div ref={spaceAfter} style={{ width }} />
|
||||
</div>
|
||||
)}
|
||||
<style jsx>{`
|
||||
.sidebar {
|
||||
border-left: 1px solid var(--blue-dark);
|
||||
flex: none;
|
||||
margin-left: 1px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar.blockScrolling {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.sidebar.blockScrolling.scrollbarOutside {
|
||||
background-color: red;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.sidebar.scrollable:not(.scrollbarOutside) :global(.tile-actions) {
|
||||
right: 20px;
|
||||
}
|
||||
.sidebar .fixed {
|
||||
background: red;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
}
|
||||
.sidebar .participants {
|
||||
position: relative;
|
||||
}
|
||||
.sidebar :global(.tile) {
|
||||
border-top: ${GAP}px solid var(--blue-dark);
|
||||
width: ${width}px;
|
||||
}
|
||||
.sidebar .fixed :global(.tile:first-child) {
|
||||
border: none;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ParticipantBar.propTypes = {
|
||||
aspectRatio: PropTypes.number,
|
||||
fixed: PropTypes.array.isRequired,
|
||||
others: PropTypes.array.isRequired,
|
||||
width: PropTypes.number,
|
||||
};
|
||||
|
||||
export default ParticipantBar;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { ParticipantBar as default } from './ParticipantBar';
|
||||
export { ParticipantBar } from './ParticipantBar';
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Takes a ref to the scrolling element in ParticipantBar.
|
||||
* Observes DOM changes and returns true, if a TileActions menu is opened.
|
||||
* @returns boolean
|
||||
*/
|
||||
export const useBlockScrolling = (scrollRef) => {
|
||||
const [blockScrolling, setBlockScrolling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl || typeof MutationObserver === 'undefined') return false;
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (!scrollEl) return;
|
||||
mutations.forEach((m) => {
|
||||
const { target } = m;
|
||||
if (
|
||||
m.attributeName === 'class' &&
|
||||
target.classList.contains('tile-actions') &&
|
||||
scrollEl.scrollHeight > scrollEl.clientHeight
|
||||
) {
|
||||
setBlockScrolling(target.classList.contains('showMenu'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(scrollEl, {
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [scrollRef]);
|
||||
|
||||
return blockScrolling;
|
||||
};
|
||||
|
||||
export default useBlockScrolling;
|
||||
|
|
@ -52,6 +52,10 @@ export const BasicTray = () => {
|
|||
<IconPeople />
|
||||
</TrayButton>
|
||||
|
||||
<TrayButton label="Fake" onClick={() => callObject.addFakeParticipant()}>
|
||||
+
|
||||
</TrayButton>
|
||||
|
||||
{customTrayComponent}
|
||||
|
||||
<span className="divider" />
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export const CallProvider = ({
|
|||
subscribeToTracksAutomatically = true,
|
||||
}) => {
|
||||
const [videoQuality, setVideoQuality] = useState(VIDEO_QUALITY_AUTO);
|
||||
const [showLocalVideo, setShowLocalVideo] = useState(true);
|
||||
const [preJoinNonAuthorized, setPreJoinNonAuthorized] = useState(false);
|
||||
const [enableRecording, setEnableRecording] = useState(null);
|
||||
const [startCloudRecording, setStartCloudRecording] = useState(false);
|
||||
|
|
@ -106,12 +107,14 @@ export const CallProvider = ({
|
|||
addFakeParticipant,
|
||||
preJoinNonAuthorized,
|
||||
leave,
|
||||
showLocalVideo,
|
||||
roomExp,
|
||||
videoQuality,
|
||||
enableRecording,
|
||||
setVideoQuality,
|
||||
setBandwidth,
|
||||
setRedirectOnLeave,
|
||||
setShowLocalVideo,
|
||||
startCloudRecording,
|
||||
subscribeToTracksAutomatically,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ const SUBSCRIBE_ALL_VIDEO_THRESHOLD = 9;
|
|||
const TracksContext = createContext(null);
|
||||
|
||||
export const TracksProvider = ({ children }) => {
|
||||
const { callObject } = useCallState();
|
||||
const { callObject, subscribeToTracksAutomatically } = useCallState();
|
||||
const { participants } = useParticipants();
|
||||
const [state, dispatch] = useReducer(tracksReducer, initialTracksState);
|
||||
|
||||
|
|
@ -52,37 +52,42 @@ export const TracksProvider = ({ children }) => {
|
|||
[participants]
|
||||
);
|
||||
|
||||
const pauseVideoTrack = useCallback((id) => {
|
||||
/**
|
||||
* Ignore undefined, local or screenshare.
|
||||
*/
|
||||
if (
|
||||
!id ||
|
||||
isLocalId(id) ||
|
||||
isScreenId(id) ||
|
||||
rtcpeers.getCurrentType() !== 'sfu'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const pauseVideoTrack = useCallback(
|
||||
(id) => {
|
||||
/**
|
||||
* Ignore undefined, local or screenshare.
|
||||
*/
|
||||
if (
|
||||
!id ||
|
||||
subscribeToTracksAutomatically ||
|
||||
isLocalId(id) ||
|
||||
isScreenId(id) ||
|
||||
rtcpeers.getCurrentType() !== 'sfu'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rtcpeers.soup.implementationIsAcceptingCalls) {
|
||||
return;
|
||||
}
|
||||
if (!rtcpeers.soup.implementationIsAcceptingCalls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumer = rtcpeers.soup?.findConsumerForTrack(id, 'cam-video');
|
||||
if (!consumer) {
|
||||
rtcpeers.soup.setResumeOnSubscribeForTrack(id, 'cam-video', false);
|
||||
} else {
|
||||
rtcpeers.soup.pauseConsumer(consumer);
|
||||
}
|
||||
}, []);
|
||||
rtcpeers.soup.pauseTrack(id, 'cam-video');
|
||||
},
|
||||
[subscribeToTracksAutomatically]
|
||||
);
|
||||
|
||||
const resumeVideoTrack = useCallback(
|
||||
(id) => {
|
||||
/**
|
||||
* Ignore undefined, local or screenshare.
|
||||
*/
|
||||
if (!id || isLocalId(id) || isScreenId(id)) return;
|
||||
if (
|
||||
!id ||
|
||||
subscribeToTracksAutomatically ||
|
||||
isLocalId(id) ||
|
||||
isScreenId(id)
|
||||
)
|
||||
return;
|
||||
const videoTrack = callObject.participants()?.[id]?.tracks?.video;
|
||||
|
||||
const subscribe = () => {
|
||||
|
|
@ -98,20 +103,14 @@ export const TracksProvider = ({ children }) => {
|
|||
break;
|
||||
case 'sfu': {
|
||||
if (!rtcpeers.soup.implementationIsAcceptingCalls) return;
|
||||
const consumer = rtcpeers.soup?.findConsumerForTrack(id, 'cam-video');
|
||||
if (!(consumer && consumer.appData)) {
|
||||
rtcpeers.soup.setResumeOnSubscribeForTrack(id, 'cam-video', true);
|
||||
subscribe();
|
||||
} else {
|
||||
rtcpeers.soup.resumeConsumer(consumer);
|
||||
}
|
||||
rtcpeers.soup.resumeTrack(id, 'cam-video');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[callObject]
|
||||
[callObject, subscribeToTracksAutomatically]
|
||||
);
|
||||
|
||||
const remoteParticipantIds = useMemo(
|
||||
|
|
@ -127,7 +126,7 @@ export const TracksProvider = ({ children }) => {
|
|||
*/
|
||||
const updateCamSubscriptions = useCallback(
|
||||
(ids, pausedIds = []) => {
|
||||
if (!callObject) return;
|
||||
if (!callObject || subscribeToTracksAutomatically) return;
|
||||
const subscribedIds =
|
||||
remoteParticipantIds.length <= SUBSCRIBE_ALL_VIDEO_THRESHOLD
|
||||
? [...remoteParticipantIds]
|
||||
|
|
@ -176,6 +175,7 @@ export const TracksProvider = ({ children }) => {
|
|||
},
|
||||
[
|
||||
callObject,
|
||||
subscribeToTracksAutomatically,
|
||||
remoteParticipantIds,
|
||||
recentSpeakerIds,
|
||||
pauseVideoTrack,
|
||||
|
|
@ -255,7 +255,10 @@ export const TracksProvider = ({ children }) => {
|
|||
|
||||
return { [id]: result };
|
||||
}, {});
|
||||
callObject.updateParticipants(updates);
|
||||
|
||||
if (!subscribeToTracksAutomatically) {
|
||||
callObject.updateParticipants(updates);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
callObject.on('track-started', handleTrackStarted);
|
||||
|
|
@ -270,7 +273,7 @@ export const TracksProvider = ({ children }) => {
|
|||
callObject.off('participant-joined', handleParticipantJoined);
|
||||
callObject.off('participant-left', handleParticipantLeft);
|
||||
};
|
||||
}, [callObject, pauseVideoTrack]);
|
||||
}, [callObject, subscribeToTracksAutomatically, pauseVideoTrack]);
|
||||
|
||||
return (
|
||||
<TracksContext.Provider
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ export const UIStateProvider = ({
|
|||
customTrayComponent,
|
||||
children,
|
||||
}) => {
|
||||
const [pinnedId, setPinnedId] = useState(null);
|
||||
const [preferredViewMode, setPreferredViewMode] = useState(VIEW_MODE_SPEAKER);
|
||||
const [viewMode, setViewMode] = useState(preferredViewMode);
|
||||
const [isShowingScreenshare, setIsShowingScreenshare] = useState(false);
|
||||
const [showParticipantsBar, setShowParticipantsBar] = useState(true);
|
||||
const [showAside, setShowAside] = useState();
|
||||
const [activeModals, setActiveModals] = useState({});
|
||||
const [customCapsule, setCustomCapsule] = useState();
|
||||
|
|
@ -56,11 +58,12 @@ export const UIStateProvider = ({
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isShowingScreenshare) {
|
||||
if (pinnedId || isShowingScreenshare) {
|
||||
setViewMode(VIEW_MODE_SPEAKER);
|
||||
} else {
|
||||
setViewMode(preferredViewMode);
|
||||
}
|
||||
setViewMode(preferredViewMode);
|
||||
}, [isShowingScreenshare, preferredViewMode]);
|
||||
}, [isShowingScreenshare, pinnedId, preferredViewMode]);
|
||||
|
||||
return (
|
||||
<UIStateContext.Provider
|
||||
|
|
@ -72,12 +75,16 @@ export const UIStateProvider = ({
|
|||
openModal,
|
||||
closeModal,
|
||||
closeAside,
|
||||
showParticipantsBar,
|
||||
currentModals,
|
||||
toggleAside,
|
||||
pinnedId,
|
||||
showAside,
|
||||
setShowAside,
|
||||
setIsShowingScreenshare,
|
||||
setPreferredViewMode,
|
||||
setPinnedId,
|
||||
setShowParticipantsBar,
|
||||
customCapsule,
|
||||
setCustomCapsule,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import equal from 'fast-deep-equal';
|
||||
|
||||
/**
|
||||
* Call State
|
||||
* ---
|
||||
|
|
@ -17,10 +15,6 @@ export const initialCallState = {
|
|||
fatalError: false,
|
||||
};
|
||||
|
||||
function getId(participant) {
|
||||
return participant.local ? 'local' : participant.user_id;
|
||||
}
|
||||
|
||||
export function isLocal(id) {
|
||||
return id === 'local';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useScrollbarWidth = () => {
|
||||
const [scrollbarWidth, setScrollbarWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Create fake div to determine if scrollbar is rendered inside or outside
|
||||
const div = document.createElement('div');
|
||||
div.style.width = '100px';
|
||||
div.style.height = '1px';
|
||||
div.style.position = 'absolute';
|
||||
div.style.left = '-9999em';
|
||||
div.style.top = '-9999em';
|
||||
div.style.overflow = 'auto';
|
||||
div.setAttribute('aria-hidden', 'true');
|
||||
const child = document.createElement('div');
|
||||
child.textContent = 'This is a test div.';
|
||||
div.appendChild(child);
|
||||
document.body.appendChild(div);
|
||||
const autoWidth = child.clientWidth;
|
||||
div.style.overflow = 'hidden';
|
||||
const hiddenWidth = child.clientWidth;
|
||||
setScrollbarWidth(hiddenWidth - autoWidth);
|
||||
div.remove();
|
||||
}, []);
|
||||
|
||||
return scrollbarWidth;
|
||||
};
|
||||
|
||||
export default useScrollbarWidth;
|
||||
Loading…
Reference in New Issue