From bd26a0abc1c14e29cc99843dc01726f7fa756d38 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Tue, 6 Aug 2024 14:29:46 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A9(frontend)=20duplicate=20Controls?= =?UTF-8?q?=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same approach as the previous commit. Few elements were not exposed by the LiveKit package, and I had to duplicate them. Let's see if we can asap get rid off all this complexity. --- .../controls/SettingsMenuToggle.tsx | 58 +++++ .../components/controls/StartMediaButton.tsx | 52 ++++ .../rooms/livekit/hooks/useMediaQuery.ts | 46 ++++ .../rooms/livekit/prefabs/ControlBar.tsx | 224 ++++++++++++++++++ .../rooms/livekit/prefabs/VideoConference.tsx | 3 +- src/frontend/src/utils/mergeProps.ts | 95 ++++++++ 6 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/features/rooms/livekit/components/controls/SettingsMenuToggle.tsx create mode 100644 src/frontend/src/features/rooms/livekit/components/controls/StartMediaButton.tsx create mode 100644 src/frontend/src/features/rooms/livekit/hooks/useMediaQuery.ts create mode 100644 src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx create mode 100644 src/frontend/src/utils/mergeProps.ts diff --git a/src/frontend/src/features/rooms/livekit/components/controls/SettingsMenuToggle.tsx b/src/frontend/src/features/rooms/livekit/components/controls/SettingsMenuToggle.tsx new file mode 100644 index 00000000..1cd1fa77 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/SettingsMenuToggle.tsx @@ -0,0 +1,58 @@ +import * as React from 'react' +import { useLayoutContext } from '@livekit/components-react' +import { mergeProps } from '@/utils/mergeProps' + +/** @alpha */ +export interface UseSettingsToggleProps { + props: React.ButtonHTMLAttributes +} + +/** + * The `useSettingsToggle` hook provides state and functions for toggling the settings menu. + * @remarks + * Depends on the `LayoutContext` to work properly. + * @see {@link SettingsMenu} + * @alpha + */ +export function useSettingsToggle({ props }: UseSettingsToggleProps) { + const { dispatch, state } = useLayoutContext().widget + const className = 'lk-button lk-settings-toggle' + + const mergedProps = React.useMemo(() => { + return mergeProps(props, { + className, + onClick: () => { + if (dispatch) dispatch({ msg: 'toggle_settings' }) + }, + 'aria-pressed': state?.showSettings ? 'true' : 'false', + }) + }, [props, className, dispatch, state]) + + return { mergedProps } +} + +/** @alpha */ +export interface SettingsMenuToggleProps + extends React.ButtonHTMLAttributes {} + +/** + * The `SettingsMenuToggle` component is a button that toggles the visibility of the `SettingsMenu` component. + * @remarks + * For the component to have any effect it has to live inside a `LayoutContext` context. + * + * @alpha + */ +export const SettingsMenuToggle: ( + props: SettingsMenuToggleProps & React.RefAttributes +) => React.ReactNode = /* @__PURE__ */ React.forwardRef< + HTMLButtonElement, + SettingsMenuToggleProps +>(function SettingsMenuToggle(props: SettingsMenuToggleProps, ref) { + const { mergedProps } = useSettingsToggle({ props }) + + return ( + + ) +}) diff --git a/src/frontend/src/features/rooms/livekit/components/controls/StartMediaButton.tsx b/src/frontend/src/features/rooms/livekit/components/controls/StartMediaButton.tsx new file mode 100644 index 00000000..9e536e5b --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/StartMediaButton.tsx @@ -0,0 +1,52 @@ +import { + useRoomContext, + useStartAudio, + useStartVideo, +} from '@livekit/components-react' +import React from 'react' + +/** @public */ +export interface AllowMediaPlaybackProps + extends React.ButtonHTMLAttributes { + label?: string +} + +/** + * The `StartMediaButton` component is only visible when the browser blocks media playback. This is due to some browser implemented autoplay policies. + * To start media playback, the user must perform a user-initiated event such as clicking this button. + * As soon as media playback starts, the button hides itself again. + * + * @example + * ```tsx + * + * + * + * ``` + * + * @see Autoplay policy on MDN web docs: {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices#autoplay_policy} + * @public + */ +export const StartMediaButton: ( + props: AllowMediaPlaybackProps & React.RefAttributes +) => React.ReactNode = /* @__PURE__ */ React.forwardRef< + HTMLButtonElement, + AllowMediaPlaybackProps +>(function StartMediaButton({ label, ...props }: AllowMediaPlaybackProps, ref) { + const room = useRoomContext() + const { mergedProps: audioProps, canPlayAudio } = useStartAudio({ + room, + props, + }) + const { mergedProps, canPlayVideo } = useStartVideo({ + room, + props: audioProps, + }) + const { style, ...restProps } = mergedProps + style.display = canPlayAudio && canPlayVideo ? 'none' : 'block' + + return ( + + ) +}) diff --git a/src/frontend/src/features/rooms/livekit/hooks/useMediaQuery.ts b/src/frontend/src/features/rooms/livekit/hooks/useMediaQuery.ts new file mode 100644 index 00000000..0919a2b5 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useMediaQuery.ts @@ -0,0 +1,46 @@ +import * as React from 'react' +/** + * Implementation used from https://github.com/juliencrn/usehooks-ts + * + * @internal + */ +export function useMediaQuery(query: string): boolean { + const getMatches = (query: string): boolean => { + // Prevents SSR issues + if (typeof window !== 'undefined') { + return window.matchMedia(query).matches + } + return false + } + + const [matches, setMatches] = React.useState(getMatches(query)) + + function handleChange() { + setMatches(getMatches(query)) + } + + React.useEffect(() => { + const matchMedia = window.matchMedia(query) + + // Triggered at the first client-side load and if query changes + handleChange() + + // Listen matchMedia + if (matchMedia.addListener) { + matchMedia.addListener(handleChange) + } else { + matchMedia.addEventListener('change', handleChange) + } + + return () => { + if (matchMedia.removeListener) { + matchMedia.removeListener(handleChange) + } else { + matchMedia.removeEventListener('change', handleChange) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query]) + + return matches +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx new file mode 100644 index 00000000..54330999 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx @@ -0,0 +1,224 @@ +import { Track } from 'livekit-client' +import * as React from 'react' + +import { supportsScreenSharing } from '@livekit/components-core' + +import { + ChatIcon, + ChatToggle, + DisconnectButton, + GearIcon, + LeaveIcon, + MediaDeviceMenu, + TrackToggle, + useLocalParticipantPermissions, + useMaybeLayoutContext, + usePersistentUserChoices, +} from '@livekit/components-react' + +import { SettingsMenuToggle } from '../components/controls/SettingsMenuToggle' +import { mergeProps } from '@/utils/mergeProps.ts' +import { StartMediaButton } from '../components/controls/StartMediaButton' +import { useMediaQuery } from '../hooks/useMediaQuery' + +/** @public */ +export type ControlBarControls = { + microphone?: boolean + camera?: boolean + chat?: boolean + screenShare?: boolean + leave?: boolean + settings?: boolean +} + +/** @public */ +export interface ControlBarProps extends React.HTMLAttributes { + onDeviceError?: (error: { source: Track.Source; error: Error }) => void + variation?: 'minimal' | 'verbose' | 'textOnly' + controls?: ControlBarControls + /** + * If `true`, the user's device choices will be persisted. + * This will enables the user to have the same device choices when they rejoin the room. + * @defaultValue true + * @alpha + */ + saveUserChoices?: boolean +} + +/** + * The `ControlBar` prefab gives the user the basic user interface to control their + * media devices (camera, microphone and screen share), open the `Chat` and leave the room. + * + * @remarks + * This component is build with other LiveKit components like `TrackToggle`, + * `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`. + * + * @example + * ```tsx + * + * + * + * ``` + * @public + */ +export function ControlBar({ + variation, + controls, + saveUserChoices = true, + onDeviceError, + ...props +}: ControlBarProps) { + const [isChatOpen, setIsChatOpen] = React.useState(false) + const layoutContext = useMaybeLayoutContext() + React.useEffect(() => { + if (layoutContext?.widget.state?.showChat !== undefined) { + setIsChatOpen(layoutContext?.widget.state?.showChat) + } + }, [layoutContext?.widget.state?.showChat]) + + const isTooLittleSpace = useMediaQuery( + `(max-width: ${isChatOpen ? 1000 : 760}px)` + ) + + const defaultVariation = isTooLittleSpace ? 'minimal' : 'verbose' + variation ??= defaultVariation + + const visibleControls = { leave: true, ...controls } + + const localPermissions = useLocalParticipantPermissions() + + if (!localPermissions) { + visibleControls.camera = false + visibleControls.chat = false + visibleControls.microphone = false + visibleControls.screenShare = false + } else { + visibleControls.camera ??= localPermissions.canPublish + visibleControls.microphone ??= localPermissions.canPublish + visibleControls.screenShare ??= localPermissions.canPublish + visibleControls.chat ??= localPermissions.canPublishData && controls?.chat + } + + const showIcon = React.useMemo( + () => variation === 'minimal' || variation === 'verbose', + [variation] + ) + const showText = React.useMemo( + () => variation === 'textOnly' || variation === 'verbose', + [variation] + ) + + const browserSupportsScreenSharing = supportsScreenSharing() + + const [isScreenShareEnabled, setIsScreenShareEnabled] = React.useState(false) + + const onScreenShareChange = React.useCallback( + (enabled: boolean) => { + setIsScreenShareEnabled(enabled) + }, + [setIsScreenShareEnabled] + ) + + const htmlProps = mergeProps({ className: 'lk-control-bar' }, props) + + const { + saveAudioInputEnabled, + saveVideoInputEnabled, + saveAudioInputDeviceId, + saveVideoInputDeviceId, + } = usePersistentUserChoices({ preventSave: !saveUserChoices }) + + const microphoneOnChange = React.useCallback( + (enabled: boolean, isUserInitiated: boolean) => + isUserInitiated ? saveAudioInputEnabled(enabled) : null, + [saveAudioInputEnabled] + ) + + const cameraOnChange = React.useCallback( + (enabled: boolean, isUserInitiated: boolean) => + isUserInitiated ? saveVideoInputEnabled(enabled) : null, + [saveVideoInputEnabled] + ) + + return ( +
+ {visibleControls.microphone && ( +
+ + onDeviceError?.({ source: Track.Source.Microphone, error }) + } + > + {showText && 'Microphone'} + +
+ + saveAudioInputDeviceId(deviceId ?? '') + } + /> +
+
+ )} + {visibleControls.camera && ( +
+ + onDeviceError?.({ source: Track.Source.Camera, error }) + } + > + {showText && 'Camera'} + +
+ + saveVideoInputDeviceId(deviceId ?? '') + } + /> +
+
+ )} + {visibleControls.screenShare && browserSupportsScreenSharing && ( + + onDeviceError?.({ source: Track.Source.ScreenShare, error }) + } + > + {showText && + (isScreenShareEnabled ? 'Stop screen share' : 'Share screen')} + + )} + {visibleControls.chat && ( + + {showIcon && } + {showText && 'Chat'} + + )} + {visibleControls.settings && ( + + {showIcon && } + {showText && 'Settings'} + + )} + {visibleControls.leave && ( + + {showIcon && } + {showText && 'Leave'} + + )} + +
+ ) +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index a113b506..695bc62a 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -26,10 +26,11 @@ import { usePinnedTracks, useTracks, useCreateLayoutContext, - ControlBar, Chat, } from '@livekit/components-react' +import { ControlBar } from './ControlBar' + /** * @public */ diff --git a/src/frontend/src/utils/mergeProps.ts b/src/frontend/src/utils/mergeProps.ts new file mode 100644 index 00000000..1e77555d --- /dev/null +++ b/src/frontend/src/utils/mergeProps.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import clsx from 'clsx' + +/** + * Calls all functions in the order they were chained with the same arguments. + * @internal + */ +export function chain(...callbacks: any[]): (...args: any[]) => void { + return (...args: any[]) => { + for (const callback of callbacks) { + if (typeof callback === 'function') { + try { + callback(...args) + } catch (e) { + console.error(e) + } + } + } + } +} + +interface Props { + [key: string]: any +} + +// taken from: https://stackoverflow.com/questions/51603250/typescript-3-parameter-list-intersection-type/51604379#51604379 +type TupleTypes = { [P in keyof T]: T[P] } extends { [key: number]: infer V } + ? V + : never +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I +) => void + ? I + : never + +/** + * Merges multiple props objects together. Event handlers are chained, + * classNames are combined, and ids are deduplicated - different ids + * will trigger a side-effect and re-render components hooked up with `useId`. + * For all other props, the last prop object overrides all previous ones. + * @param args - Multiple sets of props to merge together. + * @internal + */ +export function mergeProps( + ...args: T +): UnionToIntersection> { + // Start with a base clone of the first argument. This is a lot faster than starting + // with an empty object and adding properties as we go. + const result: Props = { ...args[0] } + for (let i = 1; i < args.length; i++) { + const props = args[i] + for (const key in props) { + const a = result[key] + const b = props[key] + + // Chain events + if ( + typeof a === 'function' && + typeof b === 'function' && + // This is a lot faster than a regex. + key[0] === 'o' && + key[1] === 'n' && + key.charCodeAt(2) >= /* 'A' */ 65 && + key.charCodeAt(2) <= /* 'Z' */ 90 + ) { + result[key] = chain(a, b) + + // Merge classnames, sometimes classNames are empty string which eval to false, so we just need to do a type check + } else if ( + (key === 'className' || key === 'UNSAFE_className') && + typeof a === 'string' && + typeof b === 'string' + ) { + result[key] = clsx(a, b) + } else { + result[key] = b !== undefined ? b : a + } + } + } + + return result as UnionToIntersection> +}