💩(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,
|
usePinnedTracks,
|
||||||
useTracks,
|
useTracks,
|
||||||
useCreateLayoutContext,
|
useCreateLayoutContext,
|
||||||
ControlBar,
|
|
||||||
Chat,
|
Chat,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
|
|
||||||
|
import { ControlBar } from './ControlBar'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @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