diff --git a/dailyjs/active-speaker/components/Room/Room.js b/dailyjs/active-speaker/components/Room/Room.js index 527564c..f088154 100644 --- a/dailyjs/active-speaker/components/Room/Room.js +++ b/dailyjs/active-speaker/components/Room/Room.js @@ -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 = () => ( - - - - - -); +export const Room = () => ; export default Room; diff --git a/dailyjs/active-speaker/components/SpeakerView/SpeakerView.js b/dailyjs/active-speaker/components/SpeakerView/SpeakerView.js index bb69d9d..877b64d 100644 --- a/dailyjs/active-speaker/components/SpeakerView/SpeakerView.js +++ b/dailyjs/active-speaker/components/SpeakerView/SpeakerView.js @@ -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( + () => , + [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 && ( + + )} + ); diff --git a/dailyjs/shared/components/Aside/PeopleAside.js b/dailyjs/shared/components/Aside/PeopleAside.js index 88ee50f..b595bad 100644 --- a/dailyjs/shared/components/Aside/PeopleAside.js +++ b/dailyjs/shared/components/Aside/PeopleAside.js @@ -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 ( ); }; 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 && ( +
+
+ {otherTiles} +
+
+ )} + +
+ ); +}; + +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;