💩(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:
committed by
aleb_the_flash
parent
abb708aa49
commit
bd26a0abc1
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
224
src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx
Normal file
224
src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -26,10 +26,11 @@ import {
|
||||
usePinnedTracks,
|
||||
useTracks,
|
||||
useCreateLayoutContext,
|
||||
ControlBar,
|
||||
Chat,
|
||||
} from '@livekit/components-react'
|
||||
|
||||
import { ControlBar } from './ControlBar'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
|
||||
95
src/frontend/src/utils/mergeProps.ts
Normal file
95
src/frontend/src/utils/mergeProps.ts
Normal 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>>
|
||||
}
|
||||
Reference in New Issue
Block a user