[WIP] Update around webrtc.

This commit is contained in:
2023-07-21 20:51:44 +09:00
parent 8b330d17b0
commit c97df03ca1
7 changed files with 304 additions and 5 deletions

View File

@ -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;
};

View File

@ -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>
);
};

View File

@ -0,0 +1,3 @@
export const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
};

25
src/utilities/url.ts Normal file
View 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);
};

View 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
View 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
View 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>;