feat: rewrite all videoconference components. support useravatar and i18n

pull/105/merge
moonrailgun 2 years ago
parent 95c589df4f
commit 8eca54a77b

@ -24,10 +24,9 @@ export function createAutoMergedRequest<T, R>(
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]);
});

@ -71,6 +71,7 @@ export {
loginWithToken,
useWatch,
parseUrlStr,
useUpdateRef,
} from 'tailchat-shared';
export { navigate } from '@/components/AppRouterApi';

@ -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;

@ -6,4 +6,8 @@ export const LivekitContainer = styled.div.attrs({
})`
height: 100%;
background-color: var(--lk-bg);
.lk-message-body {
user-select: text;
}
`;

@ -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<LivekitViewProps> = 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 (
<LivekitContainer>
{props.roomName && preJoinChoices ? (
<ActiveRoom
roomName={props.roomName}
userChoices={preJoinChoices}
onLeave={handleLeave}
/>
) : (
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
<PreJoinView
onError={handleError}
defaults={{
videoEnabled: false,
audioEnabled: false,
}}
onSubmit={handleJoin}
/>
</div>
)}
</LivekitContainer>
);
});
LivekitView.displayName = 'LivekitView';

@ -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<HTMLMediaElement> {
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();
* <CarouselLayout tracks={tracks}>
* <ParticipantTile />
* </CarouselLayout>
* ```
* @public
*/
export const CarouselLayout: React.FC<CarouselLayoutProps> = React.memo(
({ tracks, orientation, ...props }) => {
const asideEl = React.useRef<HTMLDivElement>(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 (
<aside
key={carouselOrientation}
className="lk-carousel"
ref={asideEl}
{...props}
>
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
</aside>
);
}
);
CarouselLayout.displayName = 'CarouselLayout';

@ -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<HTMLDivElement> {
messageFormatter?: MessageFormatter;
}
/** @public */
export function useChat() {
const room = useRoomContext();
const [setup, setSetup] = React.useState<ReturnType<typeof setupChat>>();
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
* <LiveKitRoom>
* <Chat />
* </LiveKitRoom>
* ```
* @public
*/
export function Chat({ messageFormatter, ...props }: ChatProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
const ulRef = React.useRef<HTMLUListElement>(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 (
<div {...props} className="lk-chat">
<ul className="lk-list lk-chat-messages" ref={ulRef}>
{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 (
<ChatEntry
key={idx}
hideName={hideName}
hideTimestamp={hideName === false ? false : hideTimestamp} // If we show the name always show the timestamp as well.
entry={msg}
messageFormatter={messageFormatter}
/>
);
})}
</ul>
<form className="lk-chat-form" onSubmit={handleSubmit}>
<input
className="lk-form-control lk-chat-form-input"
disabled={isSending}
ref={inputRef}
type="text"
placeholder={Translate.enterMessage}
/>
<button
type="submit"
className="lk-button lk-chat-form-button"
disabled={isSending}
>
{Translate.send}
</button>
</form>
</div>
);
}

@ -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<HTMLDivElement> & {
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
* <LiveKitRoom>
* <ControlBar />
* </LiveKitRoom>
* ```
* @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 (
<div className="lk-control-bar" {...props}>
{visibleControls.microphone && (
<div className="lk-button-group">
<TrackToggle source={Track.Source.Microphone} showIcon={showIcon}>
{showText && Translate.micLabel}
</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu kind="audioinput" />
</div>
</div>
)}
{visibleControls.camera && (
<div className="lk-button-group">
<TrackToggle source={Track.Source.Camera} showIcon={showIcon}>
{showText && Translate.camLabel}
</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu kind="videoinput" />
</div>
</div>
)}
{visibleControls.screenShare && browserSupportsScreenSharing && (
<TrackToggle
source={Track.Source.ScreenShare}
captureOptions={{ audio: true, selfBrowserSurface: 'include' }}
showIcon={showIcon}
onChange={onScreenShareChange}
>
{showText &&
(isScreenShareEnabled
? Translate.stopScreenShare
: Translate.startScreenShare)}
</TrackToggle>
)}
{visibleControls.chat && (
<ChatToggle>
{showIcon && <ChatIcon />}
{showText && Translate.chat}
</ChatToggle>
)}
{visibleControls.leave && (
<DisconnectButton>
{showIcon && <LeaveIcon />}
{showText && Translate.leave}
</DisconnectButton>
)}
<StartAudio label={Translate.startAudio} />
</div>
);
}

@ -0,0 +1,244 @@
/**
* Fork <ParticipantTile /> 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<HTMLDivElement> & {
disableSpeakingIndicator?: boolean;
participant?: Participant;
source?: Track.Source;
publication?: TrackPublication;
onParticipantClick?: (event: ParticipantClickEvent) => void;
};
/** @public */
export type UseParticipantTileProps<T extends HTMLDivElement> =
TrackReferenceOrPlaceholder & {
disableSpeakingIndicator?: boolean;
publication?: TrackPublication;
onParticipantClick?: (event: ParticipantClickEvent) => void;
htmlProps: React.HTMLAttributes<T>;
};
/** @public */
export function useParticipantTile<T extends HTMLDivElement>({
participant,
source,
publication,
onParticipantClick,
disableSpeakingIndicator,
htmlProps,
}: UseParticipantTileProps<T>) {
const p = useEnsureParticipant(participant);
const mergedProps = useMemo(() => {
const { className } = setupParticipantTile();
return mergeProps(htmlProps, {
className,
onClick: (event: React.MouseEvent<HTMLButtonElement, 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<HTMLDivElement>,
};
}
/** @public */
export function ParticipantContextIfNeeded(
props: React.PropsWithChildren<{
participant?: Participant;
}>
) {
const hasContext = !!useMaybeParticipantContext();
return props.participant && !hasContext ? (
<ParticipantContext.Provider value={props.participant}>
{props.children}
</ParticipantContext.Provider>
) : (
<>{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
* <ParticipantTile source={Track.Source.Camera} />
*
* <ParticipantTile {...trackReference} />
* ```
* @public
*/
export const ParticipantTile = ({
participant,
children,
source = Track.Source.Camera,
onParticipantClick,
publication,
disableSpeakingIndicator,
...htmlProps
}: ParticipantTileProps) => {
const containerEl = React.useRef<HTMLDivElement>(null);
const p = useEnsureParticipant(participant);
const userId = p.identity;
const trackRef: TrackReferenceOrPlaceholder = useMaybeTrackContext() ?? {
participant: p,
source,
publication,
};
const { elementProps } = useParticipantTile<HTMLDivElement>({
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 (
<div style={{ position: 'relative' }} ref={containerEl} {...elementProps}>
<ParticipantContextIfNeeded participant={trackRef.participant}>
{children ?? (
<>
{trackRef.publication?.kind === 'video' ||
trackRef.source === Track.Source.Camera ||
trackRef.source === Track.Source.ScreenShare ? (
<VideoTrack
participant={trackRef.participant}
source={trackRef.source}
publication={trackRef.publication}
onSubscriptionStatusChanged={handleSubscribe}
/>
) : (
<AudioTrack
participant={trackRef.participant}
source={trackRef.source}
publication={trackRef.publication}
onSubscriptionStatusChanged={handleSubscribe}
/>
)}
<div className="lk-participant-placeholder">
<UserAvatar userId={userId} size={avatarSize} />
</div>
<div className="lk-participant-metadata">
<div className="lk-participant-metadata-item">
{trackRef.source === Track.Source.Camera ? (
<>
<TrackMutedIndicator
source={Track.Source.Microphone}
show={'muted'}
/>
<ParticipantName />
</>
) : (
<>
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
<ParticipantName>
{Translate.someonesScreen}
</ParticipantName>
</>
)}
</div>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
</div>
</>
)}
<FocusToggle trackSource={trackRef.source} />
</ParticipantContextIfNeeded>
</div>
);
};

@ -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 <PreJoin /> from "@livekit/components-react"

@ -0,0 +1,162 @@
/**
* Fork <VideoConference /> 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<HTMLDivElement> {
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
* <LiveKitRoom>
* <VideoConference />
* <LiveKitRoom>
* ```
* @public
*/
export const VideoConference: React.FC<VideoConferenceProps> = React.memo(
({ chatMessageFormatter, ...props }) => {
const [widgetState, setWidgetState] = React.useState<WidgetState>({
showChat: false,
});
const lastAutoFocusedScreenShareTrack =
React.useRef<TrackReferenceOrPlaceholder | null>(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 (
<div className="lk-video-conference" {...props}>
{isWeb() && (
<LayoutContextProvider
value={layoutContext}
// onPinChange={handleFocusStateChange}
onWidgetChange={widgetUpdate}
>
<div className="lk-video-conference-inner">
{!focusTrack ? (
<div className="lk-grid-layout-wrapper">
<GridLayout tracks={tracks}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div className="lk-focus-layout-wrapper">
<FocusLayoutContainer>
<CarouselLayout tracks={carouselTracks}>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout track={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
<ControlBar controls={{ chat: true }} />
</div>
<Chat
style={{ display: widgetState.showChat ? 'flex' : 'none' }}
messageFormatter={chatMessageFormatter}
/>
</LayoutContextProvider>
)}
<RoomAudioRenderer />
<ConnectionStateToast />
</div>
);
}
);
VideoConference.displayName = 'VideoConference';

@ -0,0 +1,25 @@
import * as React from 'react';
import type { SVGProps } from 'react';
export const ChatIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}
height={18}
fill="none"
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M0 2.75A2.75 2.75 0 0 1 2.75 0h10.5A2.75 2.75 0 0 1 16 2.75v13.594a.75.75 0 0 1-1.234.572l-3.691-3.12a1.25 1.25 0 0 0-.807-.296H2.75A2.75 2.75 0 0 1 0 10.75v-8ZM2.75 1.5c-.69 0-1.25.56-1.25 1.25v8c0 .69.56 1.25 1.25 1.25h7.518c.65 0 1.279.23 1.775.65l2.457 2.077V2.75c0-.69-.56-1.25-1.25-1.25H2.75Z"
clipRule="evenodd"
/>
<path
fill="currentColor"
fillRule="evenodd"
d="M3 4.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5Z"
clipRule="evenodd"
/>
</svg>
);

@ -0,0 +1,25 @@
import * as React from 'react';
import type { SVGProps } from 'react';
export const LeaveIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={16}
height={16}
fill="none"
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M2 2.75A2.75 2.75 0 0 1 4.75 0h6.5A2.75 2.75 0 0 1 14 2.75v10.5A2.75 2.75 0 0 1 11.25 16h-6.5A2.75 2.75 0 0 1 2 13.25v-.5a.75.75 0 0 1 1.5 0v.5c0 .69.56 1.25 1.25 1.25h6.5c.69 0 1.25-.56 1.25-1.25V2.75c0-.69-.56-1.25-1.25-1.25h-6.5c-.69 0-1.25.56-1.25 1.25v.5a.75.75 0 0 1-1.5 0v-.5Z"
clipRule="evenodd"
/>
<path
fill="currentColor"
fillRule="evenodd"
d="M8.78 7.47a.75.75 0 0 1 0 1.06l-2.25 2.25a.75.75 0 1 1-1.06-1.06l.97-.97H1.75a.75.75 0 0 1 0-1.5h4.69l-.97-.97a.75.75 0 0 1 1.06-1.06l2.25 2.25Z"
clipRule="evenodd"
/>
</svg>
);

@ -0,0 +1,24 @@
import React from 'react';
export const ScreenShareIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={20}
height={16}
fill="none"
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M0 2.75A2.75 2.75 0 0 1 2.75 0h14.5A2.75 2.75 0 0 1 20 2.75v10.5A2.75 2.75 0 0 1 17.25 16H2.75A2.75 2.75 0 0 1 0 13.25V2.75ZM2.75 1.5c-.69 0-1.25.56-1.25 1.25v10.5c0 .69.56 1.25 1.25 1.25h14.5c.69 0 1.25-.56 1.25-1.25V2.75c0-.69-.56-1.25-1.25-1.25H2.75Z"
clipRule="evenodd"
/>
<path
fill="currentColor"
fillRule="evenodd"
d="M9.47 4.22a.75.75 0 0 1 1.06 0l2.25 2.25a.75.75 0 0 1-1.06 1.06l-.97-.97v4.69a.75.75 0 0 1-1.5 0V6.56l-.97.97a.75.75 0 0 1-1.06-1.06l2.25-2.25Z"
clipRule="evenodd"
/>
</svg>
);

@ -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 (
<GroupPanelContainer groupId={groupId} panelId={panelId}>
<LivekitContainer>
{roomName && preJoinChoices ? (
<ActiveRoom
roomName={roomName}
userChoices={preJoinChoices}
onLeave={handleLeave}
/>
) : (
<div
style={{ display: 'grid', placeItems: 'center', height: '100%' }}
>
<PreJoinView
onError={handleError}
defaults={{
videoEnabled: false,
audioEnabled: false,
}}
onSubmit={handleJoin}
/>
</div>
)}
</LivekitContainer>
<LivekitView roomName={roomName} url={url} />
</GroupPanelContainer>
);
});
LivekitPanel.displayName = 'LivekitPanel';
LivekitGroupPanel.displayName = 'LivekitGroupPanel';
export default LivekitPanel;
export default LivekitGroupPanel;

@ -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...',
}),
};

@ -0,0 +1,16 @@
import React from 'react';
export function cloneSingleChild(
children: React.ReactNode | React.ReactNode[],
props?: Record<string, any>,
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;
});
}

@ -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<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] = `${a} ${b}`;
} else {
result[key] = b !== undefined ? b : a;
}
}
}
return result as UnionToIntersection<TupleTypes<T>>;
}

@ -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<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);
}
};
}, [query]);
return matches;
}

@ -0,0 +1,20 @@
import * as React from 'react';
type Observable<T> = any; // import type { Observable } from 'rxjs';
/**
* @internal
*/
export function useObservableState<T>(
observable: Observable<T> | undefined,
startWith: T
) {
const [state, setState] = React.useState<T>(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;
}

@ -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<T extends HTMLElement>(
target: React.RefObject<T>,
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<unknown, Array<UseResizeObserverCallback>> = 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<Element>();
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<typeof createResizeObserver>;
const getResizeObserver = () =>
!_resizeObserver
? (_resizeObserver = createResizeObserver())
: _resizeObserver;
export type UseResizeObserverCallback = (
entry: ResizeObserverEntry,
observer: ResizeObserver
) => unknown;
export const useSize = (target: React.RefObject<HTMLDivElement>) => {
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;
};
Loading…
Cancel
Save