💩(frontend) duplicate Controls bar

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.
This commit is contained in:
lebaudantoine
2024-08-06 14:29:46 +02:00
committed by aleb_the_flash
parent abb708aa49
commit bd26a0abc1
6 changed files with 477 additions and 1 deletions

View File

@@ -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<HTMLButtonElement>
}
/**
* 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<HTMLButtonElement> {}
/**
* 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<HTMLButtonElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLButtonElement,
SettingsMenuToggleProps
>(function SettingsMenuToggle(props: SettingsMenuToggleProps, ref) {
const { mergedProps } = useSettingsToggle({ props })
return (
<button ref={ref} {...mergedProps}>
{props.children}
</button>
)
})

View File

@@ -0,0 +1,52 @@
import {
useRoomContext,
useStartAudio,
useStartVideo,
} from '@livekit/components-react'
import React from 'react'
/** @public */
export interface AllowMediaPlaybackProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
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
* <LiveKitRoom>
* <StartMediaButton label="Click to allow media playback" />
* </LiveKitRoom>
* ```
*
* @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<HTMLButtonElement>
) => 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 (
<button ref={ref} style={style} {...restProps}>
{label ?? `Start ${!canPlayAudio ? 'Audio' : 'Video'}`}
</button>
)
})

View File

@@ -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<boolean>(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
}

View File

@@ -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<HTMLDivElement> {
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
* <LiveKitRoom>
* <ControlBar />
* </LiveKitRoom>
* ```
* @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 (
<div {...htmlProps}>
{visibleControls.microphone && (
<div className="lk-button-group">
<TrackToggle
source={Track.Source.Microphone}
showIcon={showIcon}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
>
{showText && 'Microphone'}
</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu
kind="audioinput"
onActiveDeviceChange={(_kind, deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
/>
</div>
</div>
)}
{visibleControls.camera && (
<div className="lk-button-group">
<TrackToggle
source={Track.Source.Camera}
showIcon={showIcon}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
>
{showText && 'Camera'}
</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu
kind="videoinput"
onActiveDeviceChange={(_kind, deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
/>
</div>
</div>
)}
{visibleControls.screenShare && browserSupportsScreenSharing && (
<TrackToggle
source={Track.Source.ScreenShare}
captureOptions={{ audio: true, selfBrowserSurface: 'include' }}
showIcon={showIcon}
onChange={onScreenShareChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
>
{showText &&
(isScreenShareEnabled ? 'Stop screen share' : 'Share screen')}
</TrackToggle>
)}
{visibleControls.chat && (
<ChatToggle>
{showIcon && <ChatIcon />}
{showText && 'Chat'}
</ChatToggle>
)}
{visibleControls.settings && (
<SettingsMenuToggle>
{showIcon && <GearIcon />}
{showText && 'Settings'}
</SettingsMenuToggle>
)}
{visibleControls.leave && (
<DisconnectButton>
{showIcon && <LeaveIcon />}
{showText && 'Leave'}
</DisconnectButton>
)}
<StartMediaButton />
</div>
)
}

View File

@@ -26,10 +26,11 @@ import {
usePinnedTracks,
useTracks,
useCreateLayoutContext,
ControlBar,
Chat,
} from '@livekit/components-react'
import { ControlBar } from './ControlBar'
/**
* @public
*/

View File

@@ -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<T> = { [P in keyof T]: T[P] } extends { [key: number]: infer V }
? V
: never
type UnionToIntersection<U> = (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<T extends Props[]>(
...args: T
): UnionToIntersection<TupleTypes<T>> {
// 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<TupleTypes<T>>
}