mirror of https://github.com/msgbyte/tailchat
feat: add <PreJoinView /> for voice channel
parent
e828c4157b
commit
79a5b76ba4
@ -0,0 +1,9 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
import '@livekit/components-styles';
|
||||||
|
|
||||||
|
export const LivekitContainer = styled.div.attrs({
|
||||||
|
'data-lk-theme': 'default',
|
||||||
|
})`
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--lk-bg);
|
||||||
|
`;
|
@ -0,0 +1,316 @@
|
|||||||
|
import {
|
||||||
|
useEvent,
|
||||||
|
getJWTUserInfo,
|
||||||
|
useAsync,
|
||||||
|
useCurrentUserInfo,
|
||||||
|
} from '@capital/common';
|
||||||
|
import { Avatar, Button } from '@capital/component';
|
||||||
|
import { MediaDeviceMenu, TrackToggle } from '@livekit/components-react';
|
||||||
|
import type {
|
||||||
|
CreateLocalTracksOptions,
|
||||||
|
LocalAudioTrack,
|
||||||
|
LocalTrack,
|
||||||
|
LocalVideoTrack,
|
||||||
|
} from 'livekit-client';
|
||||||
|
import {
|
||||||
|
createLocalTracks,
|
||||||
|
facingModeFromLocalTrack,
|
||||||
|
Track,
|
||||||
|
} from 'livekit-client';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { log } from '@livekit/components-core';
|
||||||
|
import { Translate } from '../translate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fork <PreJoin /> from "@livekit/components-react"
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export type LocalUserChoices = {
|
||||||
|
username: string;
|
||||||
|
videoEnabled: boolean;
|
||||||
|
audioEnabled: boolean;
|
||||||
|
videoDeviceId: string;
|
||||||
|
audioDeviceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_USER_CHOICES = {
|
||||||
|
username: '',
|
||||||
|
videoEnabled: true,
|
||||||
|
audioEnabled: true,
|
||||||
|
videoDeviceId: 'default',
|
||||||
|
audioDeviceId: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export type PreJoinProps = Omit<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
'onSubmit'
|
||||||
|
> & {
|
||||||
|
/** This function is called with the `LocalUserChoices` if validation is passed. */
|
||||||
|
onSubmit?: (values: LocalUserChoices) => void;
|
||||||
|
/**
|
||||||
|
* Provide your custom validation function. Only if validation is successful the user choices are past to the onSubmit callback.
|
||||||
|
*/
|
||||||
|
onValidate?: (values: LocalUserChoices) => boolean;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
/** Prefill the input form with initial values. */
|
||||||
|
defaults?: Partial<LocalUserChoices>;
|
||||||
|
/** Display a debug window for your convenience. */
|
||||||
|
debug?: boolean;
|
||||||
|
joinLabel?: string;
|
||||||
|
micLabel?: string;
|
||||||
|
camLabel?: string;
|
||||||
|
userLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PreJoin prefab component is normally presented to the user before he enters a room.
|
||||||
|
* This component allows the user to check and select the preferred media device (camera und microphone).
|
||||||
|
* On submit the user decisions are returned, which can then be passed on to the LiveKitRoom so that the user enters the room with the correct media devices.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* This component is independent from the LiveKitRoom component and don't has to be nested inside it.
|
||||||
|
* Because it only access the local media tracks this component is self contained and works without connection to the LiveKit server.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <PreJoin />
|
||||||
|
* ```
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export const PreJoinView: React.FC<PreJoinProps> = React.memo(
|
||||||
|
({
|
||||||
|
defaults = {},
|
||||||
|
onValidate,
|
||||||
|
onSubmit,
|
||||||
|
onError,
|
||||||
|
debug,
|
||||||
|
joinLabel = Translate.joinLabel,
|
||||||
|
micLabel = Translate.micLabel,
|
||||||
|
camLabel = Translate.camLabel,
|
||||||
|
...htmlProps
|
||||||
|
}) => {
|
||||||
|
const { nickname, avatar } = useCurrentUserInfo();
|
||||||
|
const [userChoices, setUserChoices] = useState(DEFAULT_USER_CHOICES);
|
||||||
|
const [videoEnabled, setVideoEnabled] = useState<boolean>(
|
||||||
|
defaults.videoEnabled ?? DEFAULT_USER_CHOICES.videoEnabled
|
||||||
|
);
|
||||||
|
const initialVideoDeviceId =
|
||||||
|
defaults.videoDeviceId ?? DEFAULT_USER_CHOICES.videoDeviceId;
|
||||||
|
const [videoDeviceId, setVideoDeviceId] =
|
||||||
|
useState<string>(initialVideoDeviceId);
|
||||||
|
const initialAudioDeviceId =
|
||||||
|
defaults.audioDeviceId ?? DEFAULT_USER_CHOICES.audioDeviceId;
|
||||||
|
const [audioEnabled, setAudioEnabled] = useState<boolean>(
|
||||||
|
defaults.audioEnabled ?? DEFAULT_USER_CHOICES.audioEnabled
|
||||||
|
);
|
||||||
|
const [audioDeviceId, setAudioDeviceId] =
|
||||||
|
useState<string>(initialAudioDeviceId);
|
||||||
|
|
||||||
|
const tracks = usePreviewTracks(
|
||||||
|
{
|
||||||
|
audio: audioEnabled ? { deviceId: initialAudioDeviceId } : false,
|
||||||
|
video: videoEnabled ? { deviceId: initialVideoDeviceId } : false,
|
||||||
|
},
|
||||||
|
onError
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoEl = useRef(null);
|
||||||
|
|
||||||
|
const videoTrack = useMemo(
|
||||||
|
() =>
|
||||||
|
tracks?.filter(
|
||||||
|
(track) => track.kind === Track.Kind.Video
|
||||||
|
)[0] as LocalVideoTrack,
|
||||||
|
[tracks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const facingMode = useMemo(() => {
|
||||||
|
if (videoTrack) {
|
||||||
|
const { facingMode } = facingModeFromLocalTrack(videoTrack);
|
||||||
|
return facingMode;
|
||||||
|
} else {
|
||||||
|
return 'undefined';
|
||||||
|
}
|
||||||
|
}, [videoTrack]);
|
||||||
|
|
||||||
|
const audioTrack = useMemo(
|
||||||
|
() =>
|
||||||
|
tracks?.filter(
|
||||||
|
(track) => track.kind === Track.Kind.Audio
|
||||||
|
)[0] as LocalAudioTrack,
|
||||||
|
[tracks]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoEl.current && videoTrack) {
|
||||||
|
videoTrack.unmute();
|
||||||
|
videoTrack.attach(videoEl.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
videoTrack?.detach();
|
||||||
|
};
|
||||||
|
}, [videoTrack]);
|
||||||
|
|
||||||
|
const [isValid, setIsValid] = useState<boolean>();
|
||||||
|
|
||||||
|
const handleValidation = useEvent((values: LocalUserChoices) => {
|
||||||
|
if (typeof onValidate === 'function') {
|
||||||
|
return onValidate(values);
|
||||||
|
} else {
|
||||||
|
return values.username !== '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newUserChoices = {
|
||||||
|
username: nickname,
|
||||||
|
videoEnabled: videoEnabled,
|
||||||
|
videoDeviceId: videoDeviceId,
|
||||||
|
audioEnabled: audioEnabled,
|
||||||
|
audioDeviceId: audioDeviceId,
|
||||||
|
};
|
||||||
|
setUserChoices(newUserChoices);
|
||||||
|
setIsValid(handleValidation(newUserChoices));
|
||||||
|
}, [
|
||||||
|
videoEnabled,
|
||||||
|
handleValidation,
|
||||||
|
audioEnabled,
|
||||||
|
audioDeviceId,
|
||||||
|
videoDeviceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (handleValidation(userChoices)) {
|
||||||
|
if (typeof onSubmit === 'function') {
|
||||||
|
onSubmit(userChoices);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn('Validation failed with: ', userChoices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lk-prejoin" {...htmlProps}>
|
||||||
|
<div className="lk-video-container" style={{ borderRadius: 10 }}>
|
||||||
|
{videoTrack && (
|
||||||
|
<video
|
||||||
|
ref={videoEl}
|
||||||
|
width="1280"
|
||||||
|
height="720"
|
||||||
|
data-lk-facing-mode={facingMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(!videoTrack || !videoEnabled) && (
|
||||||
|
<div className="lk-camera-off-note">
|
||||||
|
<Avatar size={128} src={avatar} name={nickname} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lk-button-group-container">
|
||||||
|
<div className="lk-button-group audio">
|
||||||
|
<TrackToggle
|
||||||
|
initialState={audioEnabled}
|
||||||
|
source={Track.Source.Microphone}
|
||||||
|
onChange={(enabled) => setAudioEnabled(enabled)}
|
||||||
|
>
|
||||||
|
{micLabel}
|
||||||
|
</TrackToggle>
|
||||||
|
<div className="lk-button-group-menu">
|
||||||
|
<MediaDeviceMenu
|
||||||
|
initialSelection={audioDeviceId}
|
||||||
|
kind="audioinput"
|
||||||
|
disabled={!audioTrack}
|
||||||
|
tracks={{ audioinput: audioTrack }}
|
||||||
|
onActiveDeviceChange={(_, id) => setAudioDeviceId(id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lk-button-group video">
|
||||||
|
<TrackToggle
|
||||||
|
initialState={videoEnabled}
|
||||||
|
source={Track.Source.Camera}
|
||||||
|
onChange={(enabled) => setVideoEnabled(enabled)}
|
||||||
|
>
|
||||||
|
{camLabel}
|
||||||
|
</TrackToggle>
|
||||||
|
<div className="lk-button-group-menu">
|
||||||
|
<MediaDeviceMenu
|
||||||
|
initialSelection={videoDeviceId}
|
||||||
|
kind="videoinput"
|
||||||
|
disabled={!videoTrack}
|
||||||
|
tracks={{ videoinput: videoTrack }}
|
||||||
|
onActiveDeviceChange={(_, id) => setVideoDeviceId(id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
type="primary"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!isValid}
|
||||||
|
>
|
||||||
|
{joinLabel}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{debug && (
|
||||||
|
<>
|
||||||
|
<strong>User Choices:</strong>
|
||||||
|
<ul
|
||||||
|
className="lk-list"
|
||||||
|
style={{ overflow: 'hidden', maxWidth: '15rem' }}
|
||||||
|
>
|
||||||
|
<li>Video Enabled: {`${userChoices.videoEnabled}`}</li>
|
||||||
|
<li>Audio Enabled: {`${userChoices.audioEnabled}`}</li>
|
||||||
|
<li>Video Device: {`${userChoices.videoDeviceId}`}</li>
|
||||||
|
<li>Audio Device: {`${userChoices.audioDeviceId}`}</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
PreJoinView.displayName = 'PreJoinView';
|
||||||
|
|
||||||
|
/** @alpha */
|
||||||
|
function usePreviewTracks(
|
||||||
|
options: CreateLocalTracksOptions,
|
||||||
|
onError?: (err: Error) => void
|
||||||
|
) {
|
||||||
|
const [tracks, setTracks] = useState<LocalTrack[]>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let trackPromise: Promise<LocalTrack[]> | undefined = undefined;
|
||||||
|
let needsCleanup = false;
|
||||||
|
if (options.audio || options.video) {
|
||||||
|
trackPromise = createLocalTracks(options);
|
||||||
|
trackPromise
|
||||||
|
.then((tracks) => {
|
||||||
|
if (needsCleanup) {
|
||||||
|
tracks.forEach((tr) => tr.stop());
|
||||||
|
} else {
|
||||||
|
setTracks(tracks);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
needsCleanup = true;
|
||||||
|
trackPromise?.then((tracks) =>
|
||||||
|
tracks.forEach((track) => {
|
||||||
|
track.stop();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [JSON.stringify(options)]);
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
showErrorToasts,
|
||||||
|
useEvent,
|
||||||
|
useGroupPanelContext,
|
||||||
|
} from '@capital/common';
|
||||||
|
import { GroupPanelContainer } from '@capital/component';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
LiveKitRoom,
|
||||||
|
LocalUserChoices,
|
||||||
|
useToken,
|
||||||
|
VideoConference,
|
||||||
|
formatChatMessageLinks,
|
||||||
|
} from '@livekit/components-react';
|
||||||
|
import { LogLevel, RoomOptions, VideoPresets } from 'livekit-client';
|
||||||
|
import { PreJoinView } from '../components/PreJoinView';
|
||||||
|
import { LivekitContainer } from '../components/LivekitContainer';
|
||||||
|
|
||||||
|
export const LivekitPanel: React.FC = React.memo(() => {
|
||||||
|
const { groupId, panelId } = useGroupPanelContext();
|
||||||
|
const [preJoinChoices, setPreJoinChoices] = useState<
|
||||||
|
LocalUserChoices | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const handleError = useEvent((err: Error) => {
|
||||||
|
showErrorToasts('error while setting up prejoin');
|
||||||
|
console.log('error while setting up prejoin', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GroupPanelContainer groupId={groupId} panelId={panelId}>
|
||||||
|
<LivekitContainer>
|
||||||
|
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
|
||||||
|
<PreJoinView
|
||||||
|
onError={handleError}
|
||||||
|
defaults={{
|
||||||
|
videoEnabled: false,
|
||||||
|
audioEnabled: false,
|
||||||
|
}}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
console.log('Joining with: ', values);
|
||||||
|
setPreJoinChoices(values);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</LivekitContainer>
|
||||||
|
</GroupPanelContainer>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
LivekitPanel.displayName = 'LivekitPanel';
|
||||||
|
|
||||||
|
export default LivekitPanel;
|
@ -1 +1,16 @@
|
|||||||
|
import { regGroupPanel } from '@capital/common';
|
||||||
|
import { Loadable } from '@capital/component';
|
||||||
|
import { Translate } from './translate';
|
||||||
|
|
||||||
|
const PLUGIN_ID = 'com.msgbyte.livekit';
|
||||||
|
|
||||||
console.log('Plugin livekit is loaded');
|
console.log('Plugin livekit is loaded');
|
||||||
|
|
||||||
|
regGroupPanel({
|
||||||
|
name: `${PLUGIN_ID}/livekitPanel`,
|
||||||
|
label: Translate.voiceChannel,
|
||||||
|
provider: PLUGIN_ID,
|
||||||
|
render: Loadable(() => import('./group/LivekitPanel'), {
|
||||||
|
componentName: `${PLUGIN_ID}:LivekitPanel`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
import { localTrans } from '@capital/common';
|
||||||
|
|
||||||
|
export const Translate = {
|
||||||
|
voiceChannel: localTrans({
|
||||||
|
'zh-CN': '语音频道',
|
||||||
|
'en-US': 'Voice Channel',
|
||||||
|
}),
|
||||||
|
joinLabel: localTrans({
|
||||||
|
'zh-CN': '加入房间',
|
||||||
|
'en-US': 'Join Room',
|
||||||
|
}),
|
||||||
|
micLabel: localTrans({
|
||||||
|
'zh-CN': '麦克风',
|
||||||
|
'en-US': 'Microphone',
|
||||||
|
}),
|
||||||
|
camLabel: localTrans({
|
||||||
|
'zh-CN': '摄像头',
|
||||||
|
'en-US': 'Camera',
|
||||||
|
}),
|
||||||
|
};
|
Loading…
Reference in New Issue