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');
|
||||
|
||||
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