From 8eca54a77b50970c1e1ab353975643fccce44218 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Mon, 24 Jul 2023 23:20:04 +0800 Subject: [PATCH] feat: rewrite all videoconference components. support useravatar and i18n --- client/shared/utils/request.ts | 3 +- client/web/src/plugin/common/index.ts | 1 + .../src/components/ActiveRoom.tsx | 2 +- .../src/components/LivekitContainer.tsx | 4 + .../src/components/LivekitView.tsx | 57 ++++ .../src/components/lib/CarouselLayout.tsx | 91 +++++++ .../src/components/lib/Chat.tsx | 124 +++++++++ .../src/components/lib/ControlBar.tsx | 153 +++++++++++ .../src/components/lib/ParticipantTile.tsx | 244 ++++++++++++++++++ .../src/components/{ => lib}/PreJoinView.tsx | 9 +- .../src/components/lib/VideoConference.tsx | 162 ++++++++++++ .../src/components/lib/icons/ChatIcon.tsx | 25 ++ .../src/components/lib/icons/LeaveIcon.tsx | 25 ++ .../components/lib/icons/ScreenShareIcon.tsx | 24 ++ .../src/group/LivekitPanel.tsx | 64 +---- .../com.msgbyte.livekit/src/translate.ts | 32 +++ .../com.msgbyte.livekit/src/utils/common.ts | 16 ++ .../src/utils/mergeProps.ts | 75 ++++++ .../src/utils/useMediaQuery.ts | 45 ++++ .../src/utils/useObservableState.ts | 20 ++ .../src/utils/useResizeObserver.ts | 121 +++++++++ 21 files changed, 1231 insertions(+), 66 deletions(-) create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/LivekitView.tsx create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/CarouselLayout.tsx create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/Chat.tsx create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/ControlBar.tsx create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/ParticipantTile.tsx rename server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/{ => lib}/PreJoinView.tsx (98%) create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/VideoConference.tsx create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/icons/ChatIcon.tsx create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/icons/LeaveIcon.tsx create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/icons/ScreenShareIcon.tsx create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/common.ts create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/mergeProps.ts create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/useMediaQuery.ts create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/useObservableState.ts create mode 100644 server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/useResizeObserver.ts diff --git a/client/shared/utils/request.ts b/client/shared/utils/request.ts index 8e2e9271..8c72d801 100644 --- a/client/shared/utils/request.ts +++ b/client/shared/utils/request.ts @@ -24,10 +24,9 @@ export function createAutoMergedRequest( timer = null; // 清空计时器以接受后续请求 const _queue = [...queue]; queue = []; // 清空队列 - const ret = fn(_queue.map((q) => q.params)); try { - const list = await ret; + const list = await fn(_queue.map((q) => q.params)); _queue.forEach((q1, i) => { q1.resolve(list[i]); }); diff --git a/client/web/src/plugin/common/index.ts b/client/web/src/plugin/common/index.ts index 99593f28..3164d14a 100644 --- a/client/web/src/plugin/common/index.ts +++ b/client/web/src/plugin/common/index.ts @@ -71,6 +71,7 @@ export { loginWithToken, useWatch, parseUrlStr, + useUpdateRef, } from 'tailchat-shared'; export { navigate } from '@/components/AppRouterApi'; diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/ActiveRoom.tsx b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/ActiveRoom.tsx index 6953ddad..a24951e3 100644 --- a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/ActiveRoom.tsx +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/ActiveRoom.tsx @@ -3,12 +3,12 @@ import { formatChatMessageLinks, LiveKitRoom, LocalUserChoices, - VideoConference, } from '@livekit/components-react'; import { RoomOptions, VideoPresets } from 'livekit-client'; import React, { useMemo } from 'react'; import { useServerUrl } from '../utils/useServerUrl'; import { useToken } from '../utils/useToken'; +import { VideoConference } from './lib/VideoConference'; type ActiveRoomProps = { userChoices: LocalUserChoices; diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/LivekitContainer.tsx b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/LivekitContainer.tsx index 9b2948d8..5f0c33c4 100644 --- a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/LivekitContainer.tsx +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/LivekitContainer.tsx @@ -6,4 +6,8 @@ export const LivekitContainer = styled.div.attrs({ })` height: 100%; background-color: var(--lk-bg); + + .lk-message-body { + user-select: text; + } `; diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/LivekitView.tsx b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/LivekitView.tsx new file mode 100644 index 00000000..1f0e635a --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/LivekitView.tsx @@ -0,0 +1,57 @@ +import { showErrorToasts, useEvent } from '@capital/common'; +import React, { useState } from 'react'; +import type { LocalUserChoices } from '@livekit/components-react'; +import { PreJoinView } from './lib/PreJoinView'; +import { LivekitContainer } from './LivekitContainer'; +import { ActiveRoom } from './ActiveRoom'; +import { useLivekitState } from '../store/useLivekitState'; + +interface LivekitViewProps { + roomName: string; + url: string; +} +export const LivekitView: React.FC = React.memo((props) => { + const [preJoinChoices, setPreJoinChoices] = useState< + LocalUserChoices | undefined + >(undefined); + const { setActive, setDeactive } = useLivekitState(); + + const handleError = useEvent((err: Error) => { + showErrorToasts('error while setting up prejoin'); + console.log('error while setting up prejoin', err); + }); + + const handleJoin = useEvent((userChoices: LocalUserChoices) => { + setPreJoinChoices(userChoices); + setActive(props.url); + }); + + const handleLeave = useEvent(() => { + setPreJoinChoices(undefined); + setDeactive(); + }); + + return ( + + {props.roomName && preJoinChoices ? ( + + ) : ( +
+ +
+ )} +
+ ); +}); +LivekitView.displayName = 'LivekitView'; diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/CarouselLayout.tsx b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/CarouselLayout.tsx new file mode 100644 index 00000000..17ef1cb7 --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/CarouselLayout.tsx @@ -0,0 +1,91 @@ +import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'; +import { getScrollBarWidth } from '@livekit/components-core'; +import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'; +import * as React from 'react'; +import { useSize } from '../../utils/useResizeObserver'; + +const MIN_HEIGHT = 130; +const MIN_WIDTH = 140; +const MIN_VISIBLE_TILES = 1; +const ASPECT_RATIO = 16 / 10; +const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1; + +/** @public */ +export interface CarouselLayoutProps + extends React.HTMLAttributes { + tracks: TrackReferenceOrPlaceholder[]; + children: React.ReactNode; + /** Place the tiles vertically or horizontally next to each other. + * If undefined orientation is guessed by the dimensions of the container. */ + orientation?: 'vertical' | 'horizontal'; +} + +/** + * The `CarouselLayout` displays a list of tracks horizontally or vertically. + * Depending on the size of the container, the carousel will display as many tiles as possible and overflows the rest. + * The CarouselLayout uses the `useVisualStableUpdate` hook to ensure that tile reordering due to track updates + * is visually stable but still moves the important tiles (speaking participants) to the front. + * + * @example + * ```tsx + * const tracks = useTracks(); + * + * + * + * ``` + * @public + */ +export const CarouselLayout: React.FC = React.memo( + ({ tracks, orientation, ...props }) => { + const asideEl = React.useRef(null); + const [prevTiles, setPrevTiles] = React.useState(0); + const { width, height } = useSize(asideEl); + const carouselOrientation = orientation + ? orientation + : height >= width + ? 'vertical' + : 'horizontal'; + + const tileSpan = + carouselOrientation === 'vertical' + ? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT) + : Math.max(height * ASPECT_RATIO, MIN_WIDTH); + const scrollBarWidth = getScrollBarWidth(); + + const tilesThatFit = + carouselOrientation === 'vertical' + ? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES) + : Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES); + + let maxVisibleTiles = Math.round(tilesThatFit); + if (Math.abs(tilesThatFit - prevTiles) < 0.5) { + maxVisibleTiles = Math.round(prevTiles); + } else if (prevTiles !== tilesThatFit) { + setPrevTiles(tilesThatFit); + } + + const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles); + + React.useLayoutEffect(() => { + if (asideEl.current) { + asideEl.current.dataset.lkOrientation = carouselOrientation; + asideEl.current.style.setProperty( + '--lk-max-visible-tiles', + maxVisibleTiles.toString() + ); + } + }, [maxVisibleTiles, carouselOrientation]); + + return ( + + ); + } +); +CarouselLayout.displayName = 'CarouselLayout'; diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/Chat.tsx b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/Chat.tsx new file mode 100644 index 00000000..948005a4 --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/Chat.tsx @@ -0,0 +1,124 @@ +import type { + ChatMessage, + ReceivedChatMessage, +} from '@livekit/components-core'; +import { setupChat } from '@livekit/components-core'; +import { + ChatEntry, + MessageFormatter, + useRoomContext, +} from '@livekit/components-react'; +import * as React from 'react'; +import { Translate } from '../../translate'; +import { cloneSingleChild } from '../../utils/common'; +import { useObservableState } from '../../utils/useObservableState'; +// import { useRoomContext } from '../context'; +// import { useObservableState } from '../hooks/internal/useObservableState'; +// import { cloneSingleChild } from '../utils'; +// import type { MessageFormatter } from '../components/ChatEntry'; +// import { ChatEntry } from '../components/ChatEntry'; + +export type { ChatMessage, ReceivedChatMessage }; + +/** @public */ +export interface ChatProps extends React.HTMLAttributes { + messageFormatter?: MessageFormatter; +} + +/** @public */ +export function useChat() { + const room = useRoomContext(); + const [setup, setSetup] = React.useState>(); + const isSending = useObservableState(setup?.isSendingObservable, false); + const chatMessages = useObservableState(setup?.messageObservable, []); + + React.useEffect(() => { + const setupChatReturn = setupChat(room); + setSetup(setupChatReturn); + return setupChatReturn.destroy; + }, [room]); + + return { send: setup?.send, chatMessages, isSending }; +} + +/** + * The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants + * in the room. Only users who are in the room at the time of dispatch will receive the message. + * + * @example + * ```tsx + * + * + * + * ``` + * @public + */ +export function Chat({ messageFormatter, ...props }: ChatProps) { + const inputRef = React.useRef(null); + const ulRef = React.useRef(null); + const { send, chatMessages, isSending } = useChat(); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (inputRef.current && inputRef.current.value.trim() !== '') { + if (send) { + await send(inputRef.current.value); + inputRef.current.value = ''; + inputRef.current.focus(); + } + } + } + + React.useEffect(() => { + if (ulRef) { + ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight }); + } + }, [ulRef, chatMessages]); + + return ( +
+
    + {props.children + ? chatMessages.map((msg, idx) => + cloneSingleChild(props.children, { + entry: msg, + key: idx, + messageFormatter, + }) + ) + : chatMessages.map((msg, idx, allMsg) => { + const hideName = idx >= 1 && allMsg[idx - 1].from === msg.from; + // If the time delta between two messages is bigger than 60s show timestamp. + const hideTimestamp = + idx >= 1 && msg.timestamp - allMsg[idx - 1].timestamp < 60_000; + + return ( + + ); + })} +
+
+ + +
+
+ ); +} diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/ControlBar.tsx b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/ControlBar.tsx new file mode 100644 index 00000000..cb1f8722 --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/ControlBar.tsx @@ -0,0 +1,153 @@ +import { Track } from 'livekit-client'; +import * as React from 'react'; + +import { supportsScreenSharing } from '@livekit/components-core'; +import { + ChatToggle, + DisconnectButton, + MediaDeviceMenu, + StartAudio, + TrackToggle, + useLocalParticipantPermissions, + useMaybeLayoutContext, +} from '@livekit/components-react'; +import { Translate } from '../../translate'; +import { useMediaQuery } from '../../utils/useMediaQuery'; +import { ChatIcon } from './icons/ChatIcon'; +import { LeaveIcon } from './icons/LeaveIcon'; + +/** @public */ +export type ControlBarControls = { + microphone?: boolean; + camera?: boolean; + chat?: boolean; + screenShare?: boolean; + leave?: boolean; +}; + +/** @public */ +export type ControlBarProps = React.HTMLAttributes & { + variation?: 'minimal' | 'verbose' | 'textOnly'; + controls?: ControlBarControls; +}; + +/** + * The ControlBar prefab component gives the user the basic user interface + * to control their media devices 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, ...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 = (enabled: boolean) => { + setIsScreenShareEnabled(enabled); + }; + + return ( +
+ {visibleControls.microphone && ( +
+ + {showText && Translate.micLabel} + +
+ +
+
+ )} + + {visibleControls.camera && ( +
+ + {showText && Translate.camLabel} + +
+ +
+
+ )} + + {visibleControls.screenShare && browserSupportsScreenSharing && ( + + {showText && + (isScreenShareEnabled + ? Translate.stopScreenShare + : Translate.startScreenShare)} + + )} + + {visibleControls.chat && ( + + {showIcon && } + {showText && Translate.chat} + + )} + + {visibleControls.leave && ( + + {showIcon && } + {showText && Translate.leave} + + )} + + +
+ ); +} diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/ParticipantTile.tsx b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/ParticipantTile.tsx new file mode 100644 index 00000000..e617691e --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/ParticipantTile.tsx @@ -0,0 +1,244 @@ +/** + * Fork from "@livekit/components-react" + */ + +import * as React from 'react'; +import type { Participant, TrackPublication } from 'livekit-client'; +import { Track } from 'livekit-client'; +import type { + ParticipantClickEvent, + TrackReferenceOrPlaceholder, +} from '@livekit/components-core'; +import { + isParticipantSourcePinned, + setupParticipantTile, +} from '@livekit/components-core'; +import { + AudioTrack, + ConnectionQualityIndicator, + FocusToggle, + ParticipantContext, + ParticipantName, + TrackMutedIndicator, + useEnsureParticipant, + useFacingMode, + useIsMuted, + useIsSpeaking, + useMaybeLayoutContext, + useMaybeParticipantContext, + useMaybeTrackContext, + VideoTrack, +} from '@livekit/components-react'; +import { UserAvatar } from '@capital/component'; +import { mergeProps } from '../../utils/mergeProps'; +import { ScreenShareIcon } from './icons/ScreenShareIcon'; +import { useSize } from '../../utils/useResizeObserver'; +import { Translate } from '../../translate'; +import { useMemo } from 'react'; + +/** @public */ +export type ParticipantTileProps = React.HTMLAttributes & { + disableSpeakingIndicator?: boolean; + participant?: Participant; + source?: Track.Source; + publication?: TrackPublication; + onParticipantClick?: (event: ParticipantClickEvent) => void; +}; + +/** @public */ +export type UseParticipantTileProps = + TrackReferenceOrPlaceholder & { + disableSpeakingIndicator?: boolean; + publication?: TrackPublication; + onParticipantClick?: (event: ParticipantClickEvent) => void; + htmlProps: React.HTMLAttributes; + }; + +/** @public */ +export function useParticipantTile({ + participant, + source, + publication, + onParticipantClick, + disableSpeakingIndicator, + htmlProps, +}: UseParticipantTileProps) { + const p = useEnsureParticipant(participant); + const mergedProps = useMemo(() => { + const { className } = setupParticipantTile(); + return mergeProps(htmlProps, { + className, + onClick: (event: React.MouseEvent) => { + // @ts-ignore + htmlProps.onClick?.(event); + if (typeof onParticipantClick === 'function') { + const track = publication ?? p.getTrack(source); + onParticipantClick({ participant: p, track }); + } + }, + }); + }, [htmlProps, source, onParticipantClick, p, publication]); + + const isVideoMuted = useIsMuted(Track.Source.Camera, { participant }); + const isAudioMuted = useIsMuted(Track.Source.Microphone, { participant }); + const isSpeaking = useIsSpeaking(participant); + const facingMode = useFacingMode({ participant, publication, source }); + + mergedProps; + + return { + elementProps: { + 'data-lk-audio-muted': isAudioMuted, + 'data-lk-video-muted': isVideoMuted, + 'data-lk-speaking': + disableSpeakingIndicator === true ? false : isSpeaking, + 'data-lk-local-participant': participant.isLocal, + 'data-lk-source': source, + 'data-lk-facing-mode': facingMode, + ...mergedProps, + } as React.HTMLAttributes, + }; +} + +/** @public */ +export function ParticipantContextIfNeeded( + props: React.PropsWithChildren<{ + participant?: Participant; + }> +) { + const hasContext = !!useMaybeParticipantContext(); + return props.participant && !hasContext ? ( + + {props.children} + + ) : ( + <>{props.children} + ); +} + +/** + * The ParticipantTile component is the base utility wrapper for displaying a visual representation of a participant. + * This component can be used as a child of the `TrackLoop` component or by spreading a track reference as properties. + * + * @example + * ```tsx + * + * + * + * ``` + * @public + */ +export const ParticipantTile = ({ + participant, + children, + source = Track.Source.Camera, + onParticipantClick, + publication, + disableSpeakingIndicator, + ...htmlProps +}: ParticipantTileProps) => { + const containerEl = React.useRef(null); + const p = useEnsureParticipant(participant); + const userId = p.identity; + const trackRef: TrackReferenceOrPlaceholder = useMaybeTrackContext() ?? { + participant: p, + source, + publication, + }; + + const { elementProps } = useParticipantTile({ + participant: trackRef.participant, + htmlProps, + source: trackRef.source, + publication: trackRef.publication, + disableSpeakingIndicator, + onParticipantClick, + }); + + const layoutContext = useMaybeLayoutContext(); + + const handleSubscribe = React.useCallback( + (subscribed: boolean) => { + if ( + trackRef.source && + !subscribed && + layoutContext && + layoutContext.pin.dispatch && + isParticipantSourcePinned( + trackRef.participant, + trackRef.source, + layoutContext.pin.state + ) + ) { + layoutContext.pin.dispatch({ msg: 'clear_pin' }); + } + }, + [trackRef.participant, layoutContext, trackRef.source] + ); + + const { width, height } = useSize(containerEl); + const avatarSize = useMemo(() => { + const min = Math.min(width, height); + if (min > 320) { + return 256; + } else if (min > 144) { + return 128; + } else { + return 56; + } + }, [width, height]); + + return ( +
+ + {children ?? ( + <> + {trackRef.publication?.kind === 'video' || + trackRef.source === Track.Source.Camera || + trackRef.source === Track.Source.ScreenShare ? ( + + ) : ( + + )} +
+ +
+
+
+ {trackRef.source === Track.Source.Camera ? ( + <> + + + + ) : ( + <> + + + {Translate.someonesScreen} + + + )} +
+ + +
+ + )} + +
+
+ ); +}; diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/PreJoinView.tsx b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/PreJoinView.tsx similarity index 98% rename from server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/PreJoinView.tsx rename to server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/PreJoinView.tsx index 559b4a49..783e33e2 100644 --- a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/PreJoinView.tsx +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/PreJoinView.tsx @@ -1,9 +1,4 @@ -import { - useEvent, - getJWTUserInfo, - useAsync, - useCurrentUserInfo, -} from '@capital/common'; +import { useEvent, useCurrentUserInfo } from '@capital/common'; import { Avatar, Button } from '@capital/component'; import { MediaDeviceMenu, TrackToggle } from '@livekit/components-react'; import type { @@ -20,7 +15,7 @@ import { import * as React from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { log } from '@livekit/components-core'; -import { Translate } from '../translate'; +import { Translate } from '../../translate'; /** * Fork from "@livekit/components-react" diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/VideoConference.tsx b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/VideoConference.tsx new file mode 100644 index 00000000..51635f4a --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/VideoConference.tsx @@ -0,0 +1,162 @@ +/** + * Fork from "@livekit/components-react" + */ + +import React from 'react'; +import type { WidgetState } from '@livekit/components-core'; +import { + isEqualTrackRef, + isTrackReference, + log, + isWeb, +} from '@livekit/components-core'; +import { RoomEvent, Track } from 'livekit-client'; +import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'; +import { + ConnectionStateToast, + FocusLayout, + FocusLayoutContainer, + GridLayout, + LayoutContextProvider, + MessageFormatter, + RoomAudioRenderer, + useCreateLayoutContext, + usePinnedTracks, + useTracks, +} from '@livekit/components-react'; +import { ParticipantTile } from './ParticipantTile'; +import { CarouselLayout } from './CarouselLayout'; +import { ControlBar } from './ControlBar'; +import { Chat } from './Chat'; + +/** + * @public + */ +export interface VideoConferenceProps + extends React.HTMLAttributes { + chatMessageFormatter?: MessageFormatter; +} + +/** + * This component is the default setup of a classic LiveKit video conferencing app. + * It provides functionality like switching between participant grid view and focus view. + * + * @remarks + * The component is implemented with other LiveKit components like `FocusContextProvider`, + * `GridLayout`, `ControlBar`, `FocusLayoutContainer` and `FocusLayout`. + * + * @example + * ```tsx + * + * + * + * ``` + * @public + */ +export const VideoConference: React.FC = React.memo( + ({ chatMessageFormatter, ...props }) => { + const [widgetState, setWidgetState] = React.useState({ + showChat: false, + }); + const lastAutoFocusedScreenShareTrack = + React.useRef(null); + + const tracks = useTracks( + [ + { source: Track.Source.Camera, withPlaceholder: true }, + { source: Track.Source.ScreenShare, withPlaceholder: false }, + ], + { updateOnlyOn: [RoomEvent.ActiveSpeakersChanged] } + ); + + const widgetUpdate = (state: WidgetState) => { + log.debug('updating widget state', state); + setWidgetState(state); + }; + + const layoutContext = useCreateLayoutContext(); + + const screenShareTracks = tracks + .filter(isTrackReference) + .filter((track) => track.publication.source === Track.Source.ScreenShare); + + const focusTrack = usePinnedTracks(layoutContext)?.[0]; + const carouselTracks = tracks.filter( + (track) => !isEqualTrackRef(track, focusTrack) + ); + + React.useEffect(() => { + // If screen share tracks are published, and no pin is set explicitly, auto set the screen share. + if ( + screenShareTracks.length > 0 && + lastAutoFocusedScreenShareTrack.current === null + ) { + log.debug('Auto set screen share focus:', { + newScreenShareTrack: screenShareTracks[0], + }); + layoutContext.pin.dispatch?.({ + msg: 'set_pin', + trackReference: screenShareTracks[0], + }); + lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]; + } else if ( + lastAutoFocusedScreenShareTrack.current && + !screenShareTracks.some( + (track) => + track.publication.trackSid === + lastAutoFocusedScreenShareTrack.current?.publication?.trackSid + ) + ) { + log.debug('Auto clearing screen share focus.'); + layoutContext.pin.dispatch?.({ msg: 'clear_pin' }); + lastAutoFocusedScreenShareTrack.current = null; + } + }, [ + screenShareTracks.map((ref) => ref.publication.trackSid).join(), + focusTrack?.publication?.trackSid, + ]); + + return ( +
+ {isWeb() && ( + +
+ {!focusTrack ? ( +
+ + + +
+ ) : ( +
+ + + + + {focusTrack && } + +
+ )} + + +
+ + +
+ )} + + + + +
+ ); + } +); +VideoConference.displayName = 'VideoConference'; diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/icons/ChatIcon.tsx b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/icons/ChatIcon.tsx new file mode 100644 index 00000000..957f3c24 --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/icons/ChatIcon.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; + +export const ChatIcon = (props: SVGProps) => ( + + + + +); diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/icons/LeaveIcon.tsx b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/icons/LeaveIcon.tsx new file mode 100644 index 00000000..eed41533 --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/icons/LeaveIcon.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; + +export const LeaveIcon = (props: SVGProps) => ( + + + + +); diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/icons/ScreenShareIcon.tsx b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/icons/ScreenShareIcon.tsx new file mode 100644 index 00000000..4d5a4985 --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/components/lib/icons/ScreenShareIcon.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +export const ScreenShareIcon = (props: React.SVGProps) => ( + + + + +); diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/group/LivekitPanel.tsx b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/group/LivekitPanel.tsx index 2bb3d4ea..a73bb3b2 100644 --- a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/group/LivekitPanel.tsx +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/group/LivekitPanel.tsx @@ -1,67 +1,19 @@ -import { - showErrorToasts, - useEvent, - useGroupPanelContext, -} from '@capital/common'; +import { useGroupPanelContext } from '@capital/common'; import { GroupPanelContainer } from '@capital/component'; -import React, { useState } from 'react'; -import type { LocalUserChoices } from '@livekit/components-react'; -import { PreJoinView } from '../components/PreJoinView'; -import { LivekitContainer } from '../components/LivekitContainer'; -import { ActiveRoom } from '../components/ActiveRoom'; -import { useLivekitState } from '../store/useLivekitState'; +import React from 'react'; +import { LivekitView } from '../components/LivekitView'; -export const LivekitPanel: React.FC = React.memo(() => { +export const LivekitGroupPanel: React.FC = React.memo(() => { const { groupId, panelId } = useGroupPanelContext(); - const [preJoinChoices, setPreJoinChoices] = useState< - LocalUserChoices | undefined - >(undefined); - const { setActive, setDeactive } = useLivekitState(); - - const handleError = useEvent((err: Error) => { - showErrorToasts('error while setting up prejoin'); - console.log('error while setting up prejoin', err); - }); - - const handleJoin = useEvent((userChoices: LocalUserChoices) => { - setPreJoinChoices(userChoices); - setActive(`/main/group/${groupId}/${panelId}`); - }); - - const handleLeave = useEvent(() => { - setPreJoinChoices(undefined); - setDeactive(); - }); - const roomName = `${groupId}#${panelId}`; + const url = `/main/group/${groupId}/${panelId}`; return ( - - {roomName && preJoinChoices ? ( - - ) : ( -
- -
- )} -
+
); }); -LivekitPanel.displayName = 'LivekitPanel'; +LivekitGroupPanel.displayName = 'LivekitGroupPanel'; -export default LivekitPanel; +export default LivekitGroupPanel; diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/translate.ts b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/translate.ts index e9cfe476..acc901de 100644 --- a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/translate.ts +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/translate.ts @@ -17,8 +17,40 @@ export const Translate = { 'zh-CN': '摄像头', 'en-US': 'Camera', }), + startScreenShare: localTrans({ + 'zh-CN': '分享屏幕', + 'en-US': 'Share screen', + }), + stopScreenShare: localTrans({ + 'zh-CN': '停止分享屏幕', + 'en-US': 'Stop screen share', + }), + startAudio: localTrans({ + 'zh-CN': '启动音频播放', + 'en-US': 'Start Audio', + }), toVoiceChannel: localTrans({ 'zh-CN': '点击跳转到活跃频道', 'en-US': 'Click to Active Channel', }), + someonesScreen: localTrans({ + 'zh-CN': '的屏幕', + 'en-US': "'s screen", + }), + chat: localTrans({ + 'zh-CN': '聊天', + 'en-US': 'Chat', + }), + leave: localTrans({ + 'zh-CN': '离开', + 'en-US': 'Leave', + }), + send: localTrans({ + 'zh-CN': '发送', + 'en-US': 'Send', + }), + enterMessage: localTrans({ + 'zh-CN': '输入消息...', + 'en-US': 'Enter a message...', + }), }; diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/common.ts b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/common.ts new file mode 100644 index 00000000..e2b42aab --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/common.ts @@ -0,0 +1,16 @@ +import React from 'react'; + +export function cloneSingleChild( + children: React.ReactNode | React.ReactNode[], + props?: Record, + key?: any +) { + return React.Children.map(children, (child) => { + // Checking isValidElement is the safe way and avoids a typescript + // error too. + if (React.isValidElement(child) && React.Children.only(children)) { + return React.cloneElement(child, { ...props, key }); + } + return child; + }); +} diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/mergeProps.ts b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/mergeProps.ts new file mode 100644 index 00000000..af029da4 --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/mergeProps.ts @@ -0,0 +1,75 @@ +/** + * 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') { + callback(...args); + } + } + }; +} + +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] = `${a} ${b}`; + } else { + result[key] = b !== undefined ? b : a; + } + } + } + + return result as UnionToIntersection>; +} diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/useMediaQuery.ts b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/useMediaQuery.ts new file mode 100644 index 00000000..be13b435 --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/useMediaQuery.ts @@ -0,0 +1,45 @@ +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); + } + }; + }, [query]); + + return matches; +} diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/useObservableState.ts b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/useObservableState.ts new file mode 100644 index 00000000..e174f550 --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/useObservableState.ts @@ -0,0 +1,20 @@ +import * as React from 'react'; + +type Observable = any; // import type { Observable } from 'rxjs'; + +/** + * @internal + */ +export function useObservableState( + observable: Observable | undefined, + startWith: T +) { + const [state, setState] = React.useState(startWith); + React.useEffect(() => { + // observable state doesn't run in SSR + if (typeof window === 'undefined' || !observable) return; + const subscription = observable.subscribe(setState); + return () => subscription.unsubscribe(); + }, [observable]); + return state; +} diff --git a/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/useResizeObserver.ts b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/useResizeObserver.ts new file mode 100644 index 00000000..451c2751 --- /dev/null +++ b/server/plugins/com.msgbyte.livekit/web/plugins/com.msgbyte.livekit/src/utils/useResizeObserver.ts @@ -0,0 +1,121 @@ +/* eslint-disable no-return-assign */ +/* eslint-disable no-underscore-dangle */ +import * as React from 'react'; +import { useUpdateRef } from '@capital/common'; + +/** + * A React hook that fires a callback whenever ResizeObserver detects a change to its size + * code extracted from https://github.com/jaredLunde/react-hook/blob/master/packages/resize-observer/src/index.tsx in order to not include the polyfill for resize-observer + * + * @internal + */ +export function useResizeObserver( + target: React.RefObject, + callback: UseResizeObserverCallback +) { + const resizeObserver = getResizeObserver(); + const storedCallback = useUpdateRef(callback); + + React.useLayoutEffect(() => { + let didUnsubscribe = false; + + const targetEl = target.current; + if (!targetEl) return; + + function cb(entry: ResizeObserverEntry, observer: ResizeObserver) { + if (didUnsubscribe) return; + storedCallback.current(entry, observer); + } + + resizeObserver?.subscribe(targetEl as HTMLElement, cb); + + return () => { + didUnsubscribe = true; + resizeObserver?.unsubscribe(targetEl as HTMLElement, cb); + }; + }, [target.current, resizeObserver, storedCallback]); + + return resizeObserver?.observer; +} + +function createResizeObserver() { + let ticking = false; + let allEntries: ResizeObserverEntry[] = []; + + const callbacks: Map> = new Map(); + + if (typeof window === 'undefined') { + return; + } + + const observer = new ResizeObserver( + (entries: ResizeObserverEntry[], obs: ResizeObserver) => { + allEntries = allEntries.concat(entries); + if (!ticking) { + window.requestAnimationFrame(() => { + const triggered = new Set(); + for (let i = 0; i < allEntries.length; i++) { + if (triggered.has(allEntries[i].target)) continue; + triggered.add(allEntries[i].target); + const cbs = callbacks.get(allEntries[i].target); + cbs?.forEach((cb) => cb(allEntries[i], obs)); + } + allEntries = []; + ticking = false; + }); + } + ticking = true; + } + ); + + return { + observer, + subscribe(target: HTMLElement, callback: UseResizeObserverCallback) { + observer.observe(target); + const cbs = callbacks.get(target) ?? []; + cbs.push(callback); + callbacks.set(target, cbs); + }, + unsubscribe(target: HTMLElement, callback: UseResizeObserverCallback) { + const cbs = callbacks.get(target) ?? []; + if (cbs.length === 1) { + observer.unobserve(target); + callbacks.delete(target); + return; + } + const cbIndex = cbs.indexOf(callback); + if (cbIndex !== -1) cbs.splice(cbIndex, 1); + callbacks.set(target, cbs); + }, + }; +} + +let _resizeObserver: ReturnType; + +const getResizeObserver = () => + !_resizeObserver + ? (_resizeObserver = createResizeObserver()) + : _resizeObserver; + +export type UseResizeObserverCallback = ( + entry: ResizeObserverEntry, + observer: ResizeObserver +) => unknown; + +export const useSize = (target: React.RefObject) => { + const [size, setSize] = React.useState({ width: 0, height: 0 }); + React.useLayoutEffect(() => { + if (target.current) { + const { width, height } = target.current.getBoundingClientRect(); + setSize({ width, height }); + } + }, [target.current]); + + const resizeCallback = React.useCallback( + (entry: ResizeObserverEntry) => setSize(entry.contentRect), + [] + ); + // Where the magic happens + useResizeObserver(target, resizeCallback); + return size; +};