|
@@ -1,163 +1,366 @@
|
|
|
import type { Ref } from 'vue';
|
|
|
-import { Message as $msg } from '@arco-design/web-vue';
|
|
|
+// import { Message as $msg } from '@arco-design/web-vue';
|
|
|
+import { getToken } from '@/api/system';
|
|
|
+import E2EEWorker from 'livekit-client/e2ee-worker?worker';
|
|
|
+import type {
|
|
|
+ // ChatMessage,
|
|
|
+ RoomConnectOptions,
|
|
|
+ RoomOptions,
|
|
|
+ // ScalabilityMode,
|
|
|
+ // SimulationScenario,
|
|
|
+ // VideoCaptureOptions,
|
|
|
+ // VideoCodec,
|
|
|
+} from 'livekit-client';
|
|
|
+import {
|
|
|
+ ConnectionQuality,
|
|
|
+ ConnectionState,
|
|
|
+ DisconnectReason,
|
|
|
+ ExternalE2EEKeyProvider,
|
|
|
+ LocalAudioTrack,
|
|
|
+ // LocalParticipant,
|
|
|
+ LogLevel,
|
|
|
+ MediaDeviceFailure,
|
|
|
+ Participant,
|
|
|
+ ParticipantEvent,
|
|
|
+ RemoteParticipant,
|
|
|
+ // RemoteTrackPublication,
|
|
|
+ // RemoteVideoTrack,
|
|
|
+ Room,
|
|
|
+ RoomEvent,
|
|
|
+ ScreenSharePresets,
|
|
|
+ // Track,
|
|
|
+ TrackPublication,
|
|
|
+ // VideoPresets,
|
|
|
+ // VideoQuality,
|
|
|
+ // createAudioAnalyser,
|
|
|
+ setLogLevel,
|
|
|
+ // supportsAV1,
|
|
|
+ // supportsVP9,
|
|
|
+} from 'livekit-client';
|
|
|
+import FingerprintJS from '@fingerprintjs/fingerprintjs';
|
|
|
|
|
|
-// 获取摄像头返回的MediaStream
|
|
|
-let localStream: MediaStream;
|
|
|
+// 指纹
|
|
|
+const getFingerprint = async () => {
|
|
|
+ const fpPromise = FingerprintJS.load();
|
|
|
+ const fp = await fpPromise;
|
|
|
+ const result = await fp.get();
|
|
|
+ // const components = {
|
|
|
+ // languages: result.components.languages,
|
|
|
+ // colorDepth: result.components.colorDepth,
|
|
|
+ // deviceMemory: result.components.deviceMemory,
|
|
|
+ // hardwareConcurrency: result.components.hardwareConcurrency,
|
|
|
+ // timezone: result.components.timezone,
|
|
|
+ // platform: result.components.platform,
|
|
|
+ // vendor: result.components.vendor,
|
|
|
+ // vendorFlavors: result.components.vendorFlavors,
|
|
|
+ // cookiesEnabled: result.components.cookiesEnabled,
|
|
|
+ // colorGamut: result.components.colorGamut,
|
|
|
+ // hdr: result.components.hdr,
|
|
|
+ // videoCard: result.components.videoCard,
|
|
|
+ // };
|
|
|
+ return result.visitorId;
|
|
|
+};
|
|
|
|
|
|
-let iceType: any;
|
|
|
+const state = {
|
|
|
+ isFrontFacing: false,
|
|
|
+ encoder: new TextEncoder(),
|
|
|
+ decoder: new TextDecoder(),
|
|
|
+ defaultDevices: new Map<MediaDeviceKind, string>(),
|
|
|
+ bitrateInterval: undefined as any,
|
|
|
+ e2eeKeyProvider: new ExternalE2EEKeyProvider(),
|
|
|
+};
|
|
|
|
|
|
-let ws: WebSocket;
|
|
|
+let currentRoom: Room | undefined;
|
|
|
|
|
|
-const audio = document.createElement('audio');
|
|
|
-audio.autoplay = true;
|
|
|
-audio.style.display = 'none';
|
|
|
-document.body.appendChild(audio);
|
|
|
+let startTime: number;
|
|
|
|
|
|
-// 推流用的MediaStream
|
|
|
-const pc: RTCPeerConnection = new RTCPeerConnection({
|
|
|
- iceServers: [
|
|
|
- {
|
|
|
- urls: ['stun:39.107.83.190:3478'],
|
|
|
- },
|
|
|
- {
|
|
|
- urls: ['turn:39.107.83.190:3478'], // 你的TURN服务器地址和端口
|
|
|
- username: 'test', // 如果需要,填入你的TURN用户名
|
|
|
- credential: 'password', // 如果需要,填入你的TURN密码
|
|
|
- },
|
|
|
- ],
|
|
|
-});
|
|
|
-pc.addEventListener('icecandidate', (event) => {
|
|
|
- if (event.candidate) {
|
|
|
- ws.send(
|
|
|
- JSON.stringify({
|
|
|
- type: iceType,
|
|
|
- candidate: event.candidate,
|
|
|
- })
|
|
|
- );
|
|
|
+function appendLog(...args: any[]) {
|
|
|
+ let logger = '';
|
|
|
+ for (let i = 0; i < arguments.length; i += 1) {
|
|
|
+ if (typeof args[i] === 'object') {
|
|
|
+ logger += `${
|
|
|
+ JSON && JSON.stringify ? JSON.stringify(args[i], undefined, 2) : args[i]
|
|
|
+ } `;
|
|
|
+ } else {
|
|
|
+ logger += `${args[i]} `;
|
|
|
+ }
|
|
|
}
|
|
|
-});
|
|
|
-
|
|
|
-pc.addEventListener('track', (event) => {
|
|
|
- // eslint-disable-next-line prefer-destructuring
|
|
|
- audio.srcObject = event.streams[0];
|
|
|
-});
|
|
|
-/**
|
|
|
- * 获取摄像头
|
|
|
- */
|
|
|
-function getDevice() {
|
|
|
- return new Promise((resolve, reject) => {
|
|
|
- navigator.mediaDevices
|
|
|
- .getUserMedia({ audio: true, video: false })
|
|
|
- .then((mediaStream: MediaStream) => {
|
|
|
- localStream = mediaStream;
|
|
|
- // localVideo.srcObject = mediaStream;
|
|
|
- resolve(mediaStream);
|
|
|
- })
|
|
|
- .catch((err) => {
|
|
|
- reject(err);
|
|
|
- });
|
|
|
- });
|
|
|
+ // eslint-disable-next-line no-console
|
|
|
+ console.log(logger);
|
|
|
}
|
|
|
|
|
|
-function sendOffer() {
|
|
|
- iceType = 'offer_ice';
|
|
|
- // pc.addTrack(localStream.getVideoTracks()[0], localStream);
|
|
|
- pc.addTrack(localStream.getAudioTracks()[0], localStream);
|
|
|
- pc.createOffer({
|
|
|
- offerToReceiveAudio: true,
|
|
|
- offerToReceiveVideo: false,
|
|
|
- }).then((offer) => {
|
|
|
- pc.setLocalDescription(offer).then(() => {
|
|
|
- ws.send(JSON.stringify(offer));
|
|
|
+function participantConnected(participant: Participant) {
|
|
|
+ appendLog(
|
|
|
+ 'participant',
|
|
|
+ participant.identity,
|
|
|
+ 'connected',
|
|
|
+ participant.metadata
|
|
|
+ );
|
|
|
+ // eslint-disable-next-line no-console
|
|
|
+ console.log('tracks', participant.trackPublications);
|
|
|
+ participant
|
|
|
+ .on(ParticipantEvent.TrackMuted, (pub: TrackPublication) => {
|
|
|
+ appendLog('track was muted', pub.trackSid, participant.identity);
|
|
|
+ })
|
|
|
+ .on(ParticipantEvent.TrackUnmuted, (pub: TrackPublication) => {
|
|
|
+ appendLog('track was unmuted', pub.trackSid, participant.identity);
|
|
|
+ })
|
|
|
+ .on(ParticipantEvent.IsSpeakingChanged, () => {
|
|
|
+ // eslint-disable-next-line no-console
|
|
|
+ console.log(participant);
|
|
|
+ })
|
|
|
+ .on(ParticipantEvent.ConnectionQualityChanged, () => {
|
|
|
+ // eslint-disable-next-line no-console
|
|
|
+ console.log(participant);
|
|
|
});
|
|
|
- });
|
|
|
}
|
|
|
|
|
|
-function recvOffer(offer: any) {
|
|
|
- iceType = 'answer_ice';
|
|
|
- // pc.addTrack(localStream.getVideoTracks()[0], localStream);
|
|
|
- pc.addTrack(localStream.getAudioTracks()[0], localStream);
|
|
|
- pc.setRemoteDescription(offer).then(() => {
|
|
|
- pc.createAnswer().then((answer) => {
|
|
|
- pc.setLocalDescription(answer).then(() => {
|
|
|
- ws.send(JSON.stringify(answer));
|
|
|
- });
|
|
|
- });
|
|
|
- });
|
|
|
+function participantDisconnected(participant: RemoteParticipant) {
|
|
|
+ appendLog('participant', participant.sid, 'disconnected');
|
|
|
+
|
|
|
+ // eslint-disable-next-line no-console
|
|
|
+ console.log(participant);
|
|
|
}
|
|
|
|
|
|
-function recvAnswer(answer: any) {
|
|
|
- pc.setRemoteDescription(answer);
|
|
|
+function handleRoomDisconnect(reason?: DisconnectReason) {
|
|
|
+ if (!currentRoom) return;
|
|
|
+ appendLog('disconnected from room', { reason });
|
|
|
+ currentRoom = undefined;
|
|
|
+ (window as { [k: string]: any }).currentRoom = undefined;
|
|
|
}
|
|
|
|
|
|
-export const IntercomFn = (
|
|
|
- disabled: Ref<boolean>,
|
|
|
- host: string = window.location.host
|
|
|
-) => {
|
|
|
- if (disabled.value) {
|
|
|
- ws.close();
|
|
|
- pc.close();
|
|
|
- return;
|
|
|
- }
|
|
|
- ws = new WebSocket(`wss://${host}/webrtc`);
|
|
|
- // websocket 连接成功消息
|
|
|
- ws.addEventListener('open', () => {
|
|
|
- // 向服务端发送连接消息
|
|
|
- ws.send(
|
|
|
- JSON.stringify({
|
|
|
- type: 'connect',
|
|
|
- })
|
|
|
- );
|
|
|
- });
|
|
|
+const connectToRoom = async (
|
|
|
+ url: string,
|
|
|
+ token: string,
|
|
|
+ roomOptions?: RoomOptions,
|
|
|
+ connectOptions?: RoomConnectOptions,
|
|
|
+ shouldPublish?: boolean
|
|
|
+): Promise<Room | undefined> => {
|
|
|
+ const room = new Room(roomOptions);
|
|
|
+
|
|
|
+ startTime = Date.now();
|
|
|
+ await room.prepareConnection(url, token);
|
|
|
+ const prewarmTime = Date.now() - startTime;
|
|
|
+ appendLog(`prewarmed connection in ${prewarmTime}ms`);
|
|
|
+
|
|
|
+ room
|
|
|
+ .on(RoomEvent.ParticipantConnected, participantConnected)
|
|
|
+ .on(RoomEvent.ParticipantDisconnected, participantDisconnected)
|
|
|
+ .on(RoomEvent.Disconnected, handleRoomDisconnect)
|
|
|
+ .on(RoomEvent.Reconnecting, () => appendLog('Reconnecting to room'))
|
|
|
+ .on(RoomEvent.Reconnected, async () => {
|
|
|
+ appendLog(
|
|
|
+ 'Successfully reconnected. server',
|
|
|
+ await room.engine.getConnectedServerAddress()
|
|
|
+ );
|
|
|
+ })
|
|
|
+ .on(RoomEvent.LocalTrackPublished, (pub) => {
|
|
|
+ const track = pub.track as LocalAudioTrack;
|
|
|
|
|
|
- // 收到服务端消息
|
|
|
- ws.addEventListener('message', (event: MessageEvent) => {
|
|
|
- const msg = JSON.parse(event.data);
|
|
|
- switch (msg.type) {
|
|
|
- case 'connect':
|
|
|
- if (msg.code === 200) {
|
|
|
- $msg?.success('连接成功,等待其他用户');
|
|
|
- disabled.value = true;
|
|
|
- } else {
|
|
|
- $msg?.error('连接失败,已经满员');
|
|
|
- }
|
|
|
- break;
|
|
|
-
|
|
|
- case 'create_offer':
|
|
|
- sendOffer();
|
|
|
- break;
|
|
|
-
|
|
|
- case 'offer':
|
|
|
- recvOffer(msg);
|
|
|
- break;
|
|
|
-
|
|
|
- case 'answer':
|
|
|
- recvAnswer(msg);
|
|
|
- break;
|
|
|
-
|
|
|
- case 'offer_ice':
|
|
|
- case 'answer_ice':
|
|
|
- pc.addIceCandidate(msg.candidate);
|
|
|
- break;
|
|
|
-
|
|
|
- default:
|
|
|
- break;
|
|
|
+ if (track instanceof LocalAudioTrack) {
|
|
|
+ // const { calculateVolume } = createAudioAnalyser(track);
|
|
|
+ // setInterval(() => {
|
|
|
+ // $('local-volume')?.setAttribute(
|
|
|
+ // 'value',
|
|
|
+ // calculateVolume().toFixed(4)
|
|
|
+ // );
|
|
|
+ // }, 200);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .on(RoomEvent.RoomMetadataChanged, (metadata) => {
|
|
|
+ appendLog('new metadata for room', metadata);
|
|
|
+ })
|
|
|
+ .on(RoomEvent.MediaDevicesError, (e: Error) => {
|
|
|
+ const failure = MediaDeviceFailure.getFailure(e);
|
|
|
+ appendLog('media device failure', failure);
|
|
|
+ })
|
|
|
+ .on(
|
|
|
+ RoomEvent.ConnectionQualityChanged,
|
|
|
+ (quality: ConnectionQuality, participant?: Participant) => {
|
|
|
+ appendLog('connection quality changed', participant?.identity, quality);
|
|
|
+ }
|
|
|
+ )
|
|
|
+ .on(RoomEvent.TrackSubscribed, (track, pub, participant) => {
|
|
|
+ appendLog('subscribed to track', pub.trackSid, participant.identity);
|
|
|
+ })
|
|
|
+ .on(RoomEvent.TrackUnsubscribed, (_, pub) => {
|
|
|
+ appendLog('unsubscribed from track', pub.trackSid);
|
|
|
+ })
|
|
|
+ .on(RoomEvent.SignalConnected, async () => {
|
|
|
+ const signalConnectionTime = Date.now() - startTime;
|
|
|
+ appendLog(`signal connection established in ${signalConnectionTime}ms`);
|
|
|
+ // speed up publishing by starting to publish before it's fully connected
|
|
|
+ // publishing is accepted as soon as signal connection has established
|
|
|
+ if (shouldPublish) {
|
|
|
+ await room.localParticipant.enableCameraAndMicrophone();
|
|
|
+ appendLog(`tracks published in ${Date.now() - startTime}ms`);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .on(RoomEvent.TrackStreamStateChanged, (pub, streamState, participant) => {
|
|
|
+ appendLog(
|
|
|
+ `stream state changed for ${pub.trackSid} (${
|
|
|
+ participant.identity
|
|
|
+ }) to ${streamState.toString()}`
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ try {
|
|
|
+ // read and set current key from input
|
|
|
+ const cryptoKey = '';
|
|
|
+ const e2eChack = false;
|
|
|
+ state.e2eeKeyProvider.setKey(cryptoKey);
|
|
|
+ if (e2eChack) {
|
|
|
+ await room.setE2EEEnabled(true);
|
|
|
}
|
|
|
- });
|
|
|
|
|
|
- ws.addEventListener('close', () => {
|
|
|
- $msg?.warning('websocket 连接断开');
|
|
|
- disabled.value = false;
|
|
|
+ await room.connect(url, token, connectOptions);
|
|
|
+ const elapsed = Date.now() - startTime;
|
|
|
+ appendLog(
|
|
|
+ `successfully connected to ${room.name} in ${Math.round(elapsed)}ms`,
|
|
|
+ await room.engine.getConnectedServerAddress()
|
|
|
+ );
|
|
|
+ } catch (error: any) {
|
|
|
+ let message: any = error;
|
|
|
+ if (error.message) {
|
|
|
+ message = error.message;
|
|
|
+ }
|
|
|
+ appendLog('could not connect:', message);
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ currentRoom = room;
|
|
|
+ (window as { [k: string]: any }).currentRoom = room;
|
|
|
+
|
|
|
+ room.remoteParticipants.forEach((participant) => {
|
|
|
+ participantConnected(participant);
|
|
|
});
|
|
|
+ participantConnected(room.localParticipant);
|
|
|
+
|
|
|
+ return room;
|
|
|
};
|
|
|
-getDevice()
|
|
|
- .then(() => {
|
|
|
- // 获取摄像头成功
|
|
|
- })
|
|
|
- .catch((err) => {
|
|
|
- $msg?.error('获取摄像头失败');
|
|
|
+
|
|
|
+function renderBitrate() {
|
|
|
+ if (!currentRoom || currentRoom.state !== ConnectionState.Connected) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const participants: Participant[] = [
|
|
|
+ ...currentRoom.remoteParticipants.values(),
|
|
|
+ ];
|
|
|
+ participants.push(currentRoom.localParticipant);
|
|
|
+
|
|
|
+ // eslint-disable-next-line no-restricted-syntax
|
|
|
+ for (const p of participants) {
|
|
|
+ let totalBitrate = 0;
|
|
|
+ // eslint-disable-next-line no-restricted-syntax
|
|
|
+ for (const t of p.trackPublications.values()) {
|
|
|
+ if (t.track) {
|
|
|
+ totalBitrate += t.track.currentBitrate;
|
|
|
+ }
|
|
|
+
|
|
|
+ // if (t.source === Track.Source.Camera) {
|
|
|
+ // if (t.videoTrack instanceof RemoteVideoTrack) {
|
|
|
+ // // eslint-disable-next-line no-console
|
|
|
+ // console.log(t.videoTrack.getDecoderImplementation() ?? '');
|
|
|
+ // }
|
|
|
+ // }
|
|
|
+ }
|
|
|
+ let displayText = '';
|
|
|
+ if (totalBitrate > 0) {
|
|
|
+ displayText = `${Math.round(totalBitrate / 1024).toLocaleString()} kbps`;
|
|
|
+ }
|
|
|
// eslint-disable-next-line no-console
|
|
|
- console.error(err);
|
|
|
- });
|
|
|
+ console.log(displayText);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const connectWithFormInput = async (
|
|
|
+ url: any,
|
|
|
+ token: any,
|
|
|
+ disabled: Ref<string>
|
|
|
+) => {
|
|
|
+ const simulcast = true; // 同步播出
|
|
|
+ const dynacast = true; // Dynacast
|
|
|
+ const forceTURN = false; // 强制转向
|
|
|
+ const adaptiveStream = true; // 自适应流
|
|
|
+ const shouldPublish = true; // 发布
|
|
|
+ // const preferredCodec = '' as VideoCodec;
|
|
|
+ // const scalabilityMode = '';
|
|
|
+ // const cryptoKey = '';
|
|
|
+ const autoSubscribe = true; // 自动订阅
|
|
|
+ const e2eeEnabled = false;
|
|
|
+ const audioOutputId = ''; // E2EE密钥
|
|
|
+ setLogLevel(LogLevel.debug);
|
|
|
+
|
|
|
+ const roomOpts: RoomOptions = {
|
|
|
+ adaptiveStream,
|
|
|
+ dynacast,
|
|
|
+ audioOutput: {
|
|
|
+ deviceId: audioOutputId,
|
|
|
+ },
|
|
|
+ publishDefaults: {
|
|
|
+ simulcast,
|
|
|
+ // videoSimulcastLayers: [VideoPresets.h90, VideoPresets.h216],
|
|
|
+ // videoCodec: preferredCodec || 'vp8',
|
|
|
+ dtx: true,
|
|
|
+ red: true,
|
|
|
+ forceStereo: false,
|
|
|
+ screenShareEncoding: ScreenSharePresets.h1080fps30.encoding,
|
|
|
+ scalabilityMode: 'L3T3_KEY',
|
|
|
+ },
|
|
|
+ // videoCaptureDefaults: {
|
|
|
+ // resolution: VideoPresets.h720.resolution,
|
|
|
+ // },
|
|
|
+ e2ee: e2eeEnabled
|
|
|
+ ? { keyProvider: state.e2eeKeyProvider, worker: new E2EEWorker() }
|
|
|
+ : undefined,
|
|
|
+ };
|
|
|
+ // if (
|
|
|
+ // roomOpts.publishDefaults?.videoCodec === 'av1' ||
|
|
|
+ // roomOpts.publishDefaults?.videoCodec === 'vp9'
|
|
|
+ // ) {
|
|
|
+ // roomOpts.publishDefaults.backupCodec = true;
|
|
|
+ // if (scalabilityMode !== '') {
|
|
|
+ // roomOpts.publishDefaults.scalabilityMode =
|
|
|
+ // scalabilityMode as ScalabilityMode;
|
|
|
+ // }
|
|
|
+ // }
|
|
|
+
|
|
|
+ const connectOpts: RoomConnectOptions = {
|
|
|
+ autoSubscribe,
|
|
|
+ };
|
|
|
+ if (forceTURN) {
|
|
|
+ connectOpts.rtcConfig = {
|
|
|
+ iceTransportPolicy: 'relay',
|
|
|
+ };
|
|
|
+ }
|
|
|
+ await connectToRoom(url, token, roomOpts, connectOpts, shouldPublish);
|
|
|
+ disabled.value = 'open';
|
|
|
+
|
|
|
+ state.bitrateInterval = setInterval(renderBitrate, 1000);
|
|
|
+};
|
|
|
+
|
|
|
+const disconnectRoom = (disabled: Ref<string>) => {
|
|
|
+ if (currentRoom) {
|
|
|
+ currentRoom.disconnect();
|
|
|
+ }
|
|
|
+ if (state.bitrateInterval) {
|
|
|
+ clearInterval(state.bitrateInterval);
|
|
|
+ }
|
|
|
+ disabled.value = 'close';
|
|
|
+};
|
|
|
+
|
|
|
+export const IntercomFn = async (disabled: Ref<string>, guid: string) => {
|
|
|
+ if (disabled.value === 'open') {
|
|
|
+ disconnectRoom(disabled);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ disabled.value = 'loading';
|
|
|
+ const fingerprint = await getFingerprint();
|
|
|
+ const token = await getToken({ Room: guid, Identity: fingerprint });
|
|
|
+ connectWithFormInput(
|
|
|
+ `wss://${import.meta.env.VITE_API_INTERCOM_IP}`,
|
|
|
+ token,
|
|
|
+ disabled
|
|
|
+ );
|
|
|
+};
|
|
|
|
|
|
export default null;
|