-
-
+
+ {isOwner && (
+
+
+
+
+ )}
+
+ {allParticipants.map((p) => (
+
+ ))}
- )}
-
- {allParticipants.map((p) => (
-
- ))}
-
-
+ .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;
+ }
+ `}
+
+
);
};
diff --git a/dailyjs/shared/components/Button/Button.js b/dailyjs/shared/components/Button/Button.js
index 05c06af..9bca12d 100644
--- a/dailyjs/shared/components/Button/Button.js
+++ b/dailyjs/shared/components/Button/Button.js
@@ -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);
diff --git a/dailyjs/shared/components/ParticipantBar/ParticipantBar.js b/dailyjs/shared/components/ParticipantBar/ParticipantBar.js
new file mode 100644
index 0000000..e0d3665
--- /dev/null
+++ b/dailyjs/shared/components/ParticipantBar/ParticipantBar.js
@@ -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) => (
+
+ )),
+ [aspectRatio, visibleOthers]
+ );
+
+ return (
+
0,
+ })}
+ >
+
+ {fixed.map((item, i) => {
+ // reduce setting up & tearing down tiles as much as possible
+ const key = i;
+ return (
+
+ );
+ })}
+
+ {showParticipantsBar && (
+
+ )}
+
+
+ );
+};
+
+ParticipantBar.propTypes = {
+ aspectRatio: PropTypes.number,
+ fixed: PropTypes.array.isRequired,
+ others: PropTypes.array.isRequired,
+ width: PropTypes.number,
+};
+
+export default ParticipantBar;
diff --git a/dailyjs/shared/components/ParticipantBar/index.js b/dailyjs/shared/components/ParticipantBar/index.js
new file mode 100644
index 0000000..92bde7d
--- /dev/null
+++ b/dailyjs/shared/components/ParticipantBar/index.js
@@ -0,0 +1,2 @@
+export { ParticipantBar as default } from './ParticipantBar';
+export { ParticipantBar } from './ParticipantBar';
diff --git a/dailyjs/shared/components/ParticipantBar/useBlockScrolling.js b/dailyjs/shared/components/ParticipantBar/useBlockScrolling.js
new file mode 100644
index 0000000..5b36749
--- /dev/null
+++ b/dailyjs/shared/components/ParticipantBar/useBlockScrolling.js
@@ -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;
diff --git a/dailyjs/shared/components/Tray/BasicTray.js b/dailyjs/shared/components/Tray/BasicTray.js
index b7f5aaf..162cf89 100644
--- a/dailyjs/shared/components/Tray/BasicTray.js
+++ b/dailyjs/shared/components/Tray/BasicTray.js
@@ -52,6 +52,10 @@ export const BasicTray = () => {
+
callObject.addFakeParticipant()}>
+ +
+
+
{customTrayComponent}
diff --git a/dailyjs/shared/contexts/CallProvider.js b/dailyjs/shared/contexts/CallProvider.js
index 848069b..fd74091 100644
--- a/dailyjs/shared/contexts/CallProvider.js
+++ b/dailyjs/shared/contexts/CallProvider.js
@@ -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,
}}
diff --git a/dailyjs/shared/contexts/TracksProvider.js b/dailyjs/shared/contexts/TracksProvider.js
index d1c137e..d004a6c 100644
--- a/dailyjs/shared/contexts/TracksProvider.js
+++ b/dailyjs/shared/contexts/TracksProvider.js
@@ -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 (
{
+ 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 (
{
+ 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;