[WIP] Update around webrtc.
This commit is contained in:
@ -5,13 +5,13 @@ import { SidebarVariant, SidebarVariantSchema } from './Sidebar';
|
||||
import { z } from 'zod';
|
||||
import { LiveCollaborationTrigger } from '@excalidraw/excalidraw';
|
||||
|
||||
export const RightTopUIPropsScheme = z.object({
|
||||
export const RightTopUIPropsSchema = z.object({
|
||||
setSidebarVariant: z.function().args(SidebarVariantSchema).returns(z.void()),
|
||||
setToggleState: z.function().args(z.boolean()).returns(z.void()),
|
||||
toggleState: z.boolean(),
|
||||
});
|
||||
|
||||
export type RightTopUIProps = z.infer<typeof RightTopUIPropsScheme> & {
|
||||
export type RightTopUIProps = z.infer<typeof RightTopUIPropsSchema> & {
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
};
|
||||
|
||||
|
@ -1,7 +1,16 @@
|
||||
import { FC } from 'react';
|
||||
import { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { Button, SidebarHeader } from '@/Components/Utilities';
|
||||
import { z } from 'zod';
|
||||
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<
|
||||
typeof CollaborationSidebarPropsSchema
|
||||
@ -12,10 +21,72 @@ export const CollaborationSidebarPropsSchema = z
|
||||
.merge(SidebarBasePropsSchema);
|
||||
|
||||
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 (
|
||||
<SidebarProviderBase onClose={props.onClose}>
|
||||
<SidebarHeader>Collaboration Settings</SidebarHeader>
|
||||
<Button>Button</Button>
|
||||
<SidebarHeader>Collaboration</SidebarHeader>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
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