[WIP] Update around webrtc.
This commit is contained in:
@ -5,13 +5,13 @@ import { SidebarVariant, SidebarVariantSchema } from './Sidebar';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { LiveCollaborationTrigger } from '@excalidraw/excalidraw';
|
import { LiveCollaborationTrigger } from '@excalidraw/excalidraw';
|
||||||
|
|
||||||
export const RightTopUIPropsScheme = z.object({
|
export const RightTopUIPropsSchema = z.object({
|
||||||
setSidebarVariant: z.function().args(SidebarVariantSchema).returns(z.void()),
|
setSidebarVariant: z.function().args(SidebarVariantSchema).returns(z.void()),
|
||||||
setToggleState: z.function().args(z.boolean()).returns(z.void()),
|
setToggleState: z.function().args(z.boolean()).returns(z.void()),
|
||||||
toggleState: z.boolean(),
|
toggleState: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RightTopUIProps = z.infer<typeof RightTopUIPropsScheme> & {
|
export type RightTopUIProps = z.infer<typeof RightTopUIPropsSchema> & {
|
||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,7 +1,16 @@
|
|||||||
import { FC } from 'react';
|
import { FC, useCallback, useEffect, useState } from 'react';
|
||||||
import { Button, SidebarHeader } from '@/Components/Utilities';
|
import { Button, SidebarHeader } from '@/Components/Utilities';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import SidebarProviderBase, { SidebarBasePropsSchema } from './Base';
|
import SidebarProviderBase, { SidebarBasePropsSchema } from './Base';
|
||||||
|
import { startCollaboration } from '@/webrtc/collaboration';
|
||||||
|
import {
|
||||||
|
SignalingData,
|
||||||
|
decodeSignalingData,
|
||||||
|
encodeSignalingData,
|
||||||
|
} from '@/webrtc/utilities';
|
||||||
|
import { Input, Link } from '@mui/material';
|
||||||
|
import { fromUrl, toUrl } from '@/utilities/url';
|
||||||
|
import { copyToClipboard } from '@/utilities/clipboard';
|
||||||
|
|
||||||
export type CollaborationSidebarProps = z.infer<
|
export type CollaborationSidebarProps = z.infer<
|
||||||
typeof CollaborationSidebarPropsSchema
|
typeof CollaborationSidebarPropsSchema
|
||||||
@ -12,10 +21,72 @@ export const CollaborationSidebarPropsSchema = z
|
|||||||
.merge(SidebarBasePropsSchema);
|
.merge(SidebarBasePropsSchema);
|
||||||
|
|
||||||
const CollaborationSidebar: FC<CollaborationSidebarProps> = (props) => {
|
const CollaborationSidebar: FC<CollaborationSidebarProps> = (props) => {
|
||||||
|
const signalingDataCallback = useCallback((data: SignalingData) => {
|
||||||
|
const encodedData = encodeSignalingData(data);
|
||||||
|
const url = toUrl({ signalingData: encodedData });
|
||||||
|
|
||||||
|
setCollaborationUrl(url);
|
||||||
|
|
||||||
|
console.log(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [collaborationUrl, setCollaborationUrl] = useState<URL | null>(null);
|
||||||
|
const [isCollaborationStarted, setIsCollaborationStarted] = useState(false);
|
||||||
|
const [providedCollaborationUrl, setProvidedCollaborationUrl] =
|
||||||
|
useState<URL | null>(null);
|
||||||
|
|
||||||
|
const onMessage = useCallback((event: MessageEvent) => {
|
||||||
|
console.log(event);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startCollaborationWrapper = useCallback(async () => {
|
||||||
|
console.log('start collaboration');
|
||||||
|
|
||||||
|
const signalingData = providedCollaborationUrl
|
||||||
|
? decodeSignalingData(fromUrl(providedCollaborationUrl).signalingData!)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
console.log(signalingData);
|
||||||
|
|
||||||
|
await startCollaboration(signalingData, signalingDataCallback, {
|
||||||
|
onMessage,
|
||||||
|
});
|
||||||
|
}, [signalingDataCallback, onMessage, providedCollaborationUrl]);
|
||||||
|
|
||||||
|
const copyCollaborationUrl = useCallback(async () => {
|
||||||
|
console.log('copy collaboration url');
|
||||||
|
console.log(collaborationUrl?.toString());
|
||||||
|
await copyToClipboard(collaborationUrl ? collaborationUrl?.toString() : '');
|
||||||
|
}, [collaborationUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCollaborationStarted || providedCollaborationUrl) {
|
||||||
|
startCollaborationWrapper().catch((error) => console.error(error));
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isCollaborationStarted,
|
||||||
|
startCollaborationWrapper,
|
||||||
|
providedCollaborationUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProviderBase onClose={props.onClose}>
|
<SidebarProviderBase onClose={props.onClose}>
|
||||||
<SidebarHeader>Collaboration Settings</SidebarHeader>
|
<SidebarHeader>Collaboration</SidebarHeader>
|
||||||
<Button>Button</Button>
|
<Button onClick={() => setIsCollaborationStarted(true)}>
|
||||||
|
Start Collaboration
|
||||||
|
</Button>
|
||||||
|
<Link>{collaborationUrl?.toString()}</Link>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
copyCollaborationUrl().catch((e) => console.error(e));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
value={providedCollaborationUrl ?? ''}
|
||||||
|
onChange={(e) => setProvidedCollaborationUrl(new URL(e.target.value))}
|
||||||
|
></Input>
|
||||||
</SidebarProviderBase>
|
</SidebarProviderBase>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
3
src/utilities/clipboard.ts
Normal file
3
src/utilities/clipboard.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const copyToClipboard = async (text: string) => {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
};
|
25
src/utilities/url.ts
Normal file
25
src/utilities/url.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const AvailableURLParametersSchema = z.object({
|
||||||
|
signalingData: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AvailableURLParameters = z.infer<
|
||||||
|
typeof AvailableURLParametersSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const toUrl = (params: AvailableURLParameters) => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fromUrl = (url: URL): AvailableURLParameters => {
|
||||||
|
const params = Object.fromEntries(url.searchParams.entries());
|
||||||
|
|
||||||
|
return AvailableURLParametersSchema.parse(params);
|
||||||
|
};
|
28
src/webrtc/collaboration.ts
Normal file
28
src/webrtc/collaboration.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import establishConnection from './connection';
|
||||||
|
import type { Connection, DataChannelCallbacks } from './connection';
|
||||||
|
import type { SignalingData } from './utilities';
|
||||||
|
|
||||||
|
export type SignalingDataCallback = (signalingData: SignalingData) => void;
|
||||||
|
|
||||||
|
export type OnAnswerCallback = (answer: RTCSessionDescription) => void;
|
||||||
|
export type OnOfferCallback = (offer: RTCSessionDescription) => void;
|
||||||
|
|
||||||
|
export const startCollaboration = async (
|
||||||
|
signalingData: SignalingData | undefined,
|
||||||
|
onSignalingData: SignalingDataCallback,
|
||||||
|
dataChannelCallbacks: DataChannelCallbacks
|
||||||
|
): Promise<void> => {
|
||||||
|
let connection: Connection = {
|
||||||
|
connectionState: 'noConnection',
|
||||||
|
dataChannelCallbacks,
|
||||||
|
signalingData,
|
||||||
|
};
|
||||||
|
|
||||||
|
while (connection?.connectionState !== 'established') {
|
||||||
|
connection = await establishConnection(connection);
|
||||||
|
|
||||||
|
if (connection.signalingData) {
|
||||||
|
onSignalingData(connection.signalingData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
148
src/webrtc/connection.ts
Normal file
148
src/webrtc/connection.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { SignalingDataType, SignalingData } from './utilities';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const PeerConfiguration: RTCConfiguration = {
|
||||||
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EstablishingConnectionStateSchema = z.enum([
|
||||||
|
'noConnection',
|
||||||
|
'waiting',
|
||||||
|
'established',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type EstablishingConnectionState = z.infer<
|
||||||
|
typeof EstablishingConnectionStateSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type Connection = {
|
||||||
|
connectionState: EstablishingConnectionState;
|
||||||
|
signalingData?: SignalingData;
|
||||||
|
dataChannelCallbacks?: DataChannelCallbacks;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const establishConnection = async ({
|
||||||
|
connectionState,
|
||||||
|
signalingData,
|
||||||
|
dataChannelCallbacks,
|
||||||
|
}: Connection): Promise<Connection> => {
|
||||||
|
const peerConnection = createPeerConnection();
|
||||||
|
|
||||||
|
return await match(connectionState)
|
||||||
|
.with('noConnection', async () => {
|
||||||
|
const _signalingData = await createOffer(peerConnection);
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionState: 'waiting',
|
||||||
|
signalingData: _signalingData,
|
||||||
|
dataChannelCallbacks,
|
||||||
|
} as Connection;
|
||||||
|
})
|
||||||
|
.with('waiting', async () => {
|
||||||
|
if (!dataChannelCallbacks) {
|
||||||
|
throw new Error('Data channel callbacks are not set');
|
||||||
|
}
|
||||||
|
if (!signalingData) {
|
||||||
|
throw new Error('Signaling data is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
await peerConnection.setRemoteDescription(signalingData.sdp);
|
||||||
|
|
||||||
|
const connection: Connection = await match(signalingData.type)
|
||||||
|
.with('offer', async () => {
|
||||||
|
const answer = await createAnswer(peerConnection);
|
||||||
|
return {
|
||||||
|
connectionState: 'waiting',
|
||||||
|
signalingData: answer,
|
||||||
|
dataChannelCallbacks,
|
||||||
|
} as Connection;
|
||||||
|
})
|
||||||
|
.with('answer', () => {
|
||||||
|
return {
|
||||||
|
connectionState: 'established',
|
||||||
|
signalingData: undefined,
|
||||||
|
dataChannelCallbacks,
|
||||||
|
} as Connection;
|
||||||
|
})
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
dataChannelHandler(
|
||||||
|
signalingData.type,
|
||||||
|
peerConnection,
|
||||||
|
dataChannelCallbacks
|
||||||
|
);
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
})
|
||||||
|
.with('established', () => {
|
||||||
|
throw new Error(
|
||||||
|
"Connection can't be established because it is already established"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.exhaustive();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default establishConnection;
|
||||||
|
|
||||||
|
export const createPeerConnection = () => {
|
||||||
|
const configuration = PeerConfiguration;
|
||||||
|
|
||||||
|
return new RTCPeerConnection(configuration);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createOffer = async (peerConnection: RTCPeerConnection) => {
|
||||||
|
const offer = await peerConnection.createOffer();
|
||||||
|
await peerConnection.setLocalDescription(offer);
|
||||||
|
|
||||||
|
if (peerConnection.localDescription) {
|
||||||
|
const signalingData: SignalingData = {
|
||||||
|
type: 'offer',
|
||||||
|
sdp: peerConnection.localDescription,
|
||||||
|
};
|
||||||
|
|
||||||
|
return signalingData;
|
||||||
|
} else {
|
||||||
|
throw new Error('Local description is not set');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataChannelCallbacks = {
|
||||||
|
onMessage: (event: MessageEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dataChannelHandler = (
|
||||||
|
type: SignalingDataType,
|
||||||
|
peerConnection: RTCPeerConnection,
|
||||||
|
DataChannelCallbacks: DataChannelCallbacks
|
||||||
|
) => {
|
||||||
|
const { onMessage } = DataChannelCallbacks;
|
||||||
|
|
||||||
|
match(type)
|
||||||
|
.with('offer', () => {
|
||||||
|
const dataChannel = peerConnection.createDataChannel('dataChannel');
|
||||||
|
dataChannel.onmessage = onMessage;
|
||||||
|
})
|
||||||
|
.with('answer', () => {
|
||||||
|
peerConnection.ondatachannel = (event) => {
|
||||||
|
event.channel.onmessage = onMessage;
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.exhaustive();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAnswer = async (peerConnection: RTCPeerConnection) => {
|
||||||
|
const answer = await peerConnection.createAnswer();
|
||||||
|
await peerConnection.setLocalDescription(answer);
|
||||||
|
|
||||||
|
if (peerConnection.localDescription) {
|
||||||
|
const signalingData: SignalingData = {
|
||||||
|
type: 'answer',
|
||||||
|
sdp: peerConnection.localDescription,
|
||||||
|
};
|
||||||
|
|
||||||
|
return signalingData;
|
||||||
|
} else {
|
||||||
|
throw new Error('Local description is not set');
|
||||||
|
}
|
||||||
|
};
|
24
src/webrtc/utilities.ts
Normal file
24
src/webrtc/utilities.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const encodeSignalingData = (
|
||||||
|
data: SignalingData
|
||||||
|
): EncodedSignalingData => {
|
||||||
|
return encodeURIComponent(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodeSignalingData = (encodedData: string) => {
|
||||||
|
return JSON.parse(decodeURIComponent(encodedData)) as SignalingData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SignanlingDataTypeSchema = z.enum(['offer', 'answer']);
|
||||||
|
|
||||||
|
export type SignalingDataType = z.infer<typeof SignanlingDataTypeSchema>;
|
||||||
|
|
||||||
|
export type SignalingData = {
|
||||||
|
type: SignalingDataType;
|
||||||
|
sdp: RTCSessionDescription;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EncodedSignalingDataSchema = z.string();
|
||||||
|
|
||||||
|
export type EncodedSignalingData = z.infer<typeof EncodedSignalingDataSchema>;
|
Reference in New Issue
Block a user