2024-03-22 16:40:16 +01:00

507 lines
14 KiB
TypeScript

import { black, silence } from './blacksilence';
import type { ApiPhoneCallConnection, P2pParsedSdp } from './types';
import parseSdp from './parseSdp';
import type { MediaContent, MediaStateMessage, P2pMessage } from './p2pMessage';
import {
fromTelegramSource,
IS_ECHO_CANCELLATION_SUPPORTED,
IS_NOISE_SUPPRESSION_SUPPORTED,
p2pPayloadTypeToConference,
} from './utils';
import buildSdp, { Conference } from './buildSdp';
import { StreamType } from './secretsauce';
type P2pState = {
connection: RTCPeerConnection;
dataChannel: RTCDataChannel;
emitSignalingData: (data: P2pMessage) => void;
onUpdate: (...args: any[]) => void;
conference?: Partial<Conference>;
isOutgoing: boolean;
pendingCandidates: string[];
streams: {
video?: MediaStream;
audio?: MediaStream;
presentation?: MediaStream;
ownAudio?: MediaStream;
ownVideo?: MediaStream;
ownPresentation?: MediaStream;
};
silence: MediaStream;
blackVideo: MediaStream;
blackPresentation: MediaStream;
mediaState: Omit<MediaStateMessage, '@type'>;
audio: HTMLAudioElement;
gotInitialSetup?: boolean;
facingMode?: VideoFacingModeEnum;
};
let state: P2pState | undefined;
const ICE_CANDIDATE_POOL_SIZE = 10;
export function getStreams() {
return state?.streams;
}
function updateStreams() {
state?.onUpdate({
...state.mediaState,
'@type': 'updatePhoneCallMediaState',
});
}
function getUserStream(streamType: StreamType, facing: VideoFacingModeEnum = 'user') {
if (streamType === 'presentation') {
return (navigator.mediaDevices as any).getDisplayMedia({
audio: false,
video: true,
});
}
return navigator.mediaDevices.getUserMedia({
audio: streamType === 'audio' ? {
...(IS_ECHO_CANCELLATION_SUPPORTED && { echoCancellation: true }),
...(IS_NOISE_SUPPRESSION_SUPPORTED && { noiseSuppression: true }),
} : false,
video: streamType === 'video' ? {
facingMode: facing,
} : false,
});
}
export async function switchCameraInputP2p() {
if (!state || !state.facingMode) {
return;
}
const stream = state.streams.ownVideo;
if (!stream) return;
const track = stream.getTracks()[0];
if (!track) {
return;
}
const sender = state.connection.getSenders().find((l) => track.id === l.track?.id);
if (!sender) {
return;
}
state.facingMode = state.facingMode === 'environment' ? 'user' : 'environment';
try {
const newStream = await getUserStream('video', state.facingMode);
await sender.replaceTrack(newStream.getTracks()[0]);
state.streams.ownVideo = newStream;
updateStreams();
} catch (e) {
}
}
export async function toggleStreamP2p(streamType: StreamType, value: boolean | undefined = undefined) {
if (!state) return;
const stream = streamType === 'audio' ? state.streams.ownAudio
: (streamType === 'video' ? state.streams.ownVideo : state.streams.ownPresentation);
if (!stream) return;
const track = stream.getTracks()[0];
if (!track) {
return;
}
const sender = state.connection.getSenders().find((l) => track.id === l.track?.id);
if (!sender) {
return;
}
value = value === undefined ? !track.enabled : value;
try {
if (value && !track.enabled) {
const newStream = await getUserStream(streamType);
newStream.getTracks()[0].onended = () => {
toggleStreamP2p(streamType, false);
};
await sender.replaceTrack(newStream.getTracks()[0]);
if (streamType === 'audio') {
state.streams.ownAudio = newStream;
} else if (streamType === 'video') {
state.streams.ownVideo = newStream;
state.facingMode = 'user';
} else {
state.streams.ownPresentation = newStream;
}
if (streamType === 'video' || streamType === 'presentation') {
toggleStreamP2p(streamType === 'video' ? 'presentation' : 'video', false);
}
// if (streamType === 'video') {
// state.facingMode = 'user';
// }
} else if (!value && track.enabled) {
track.stop();
const newStream = streamType === 'audio' ? state.silence
: (streamType === 'video' ? state.blackVideo : state.blackPresentation);
if (!newStream) return;
await sender.replaceTrack(newStream.getTracks()[0]);
if (streamType === 'audio') {
state.streams.ownAudio = newStream;
} else if (streamType === 'video') {
state.streams.ownVideo = newStream;
} else {
state.streams.ownPresentation = newStream;
}
// if (streamType === 'video') {
// state.facingMode = undefined;
// }
}
updateStreams();
sendMediaState();
} catch (err) {
console.error(err)
}
}
export async function joinPhoneCall(
connections: ApiPhoneCallConnection[],
emitSignalingData: (data: P2pMessage) => void,
isOutgoing: boolean,
shouldStartVideo: boolean,
isP2p: boolean,
onUpdate: (...args: any[]) => void,
) {
const conn = new RTCPeerConnection({
iceServers: connections.map((connection) => (
{
urls: [
connection.isTurn && `turn:${connection.ip}:${connection.port}`,
connection.isStun && `stun:${connection.ip}:${connection.port}`,
].filter(Boolean),
username: connection.username,
credentialType: 'password',
credential: connection.password,
}
)),
iceTransportPolicy: isP2p ? 'all' : 'relay',
bundlePolicy: 'max-bundle',
iceCandidatePoolSize: ICE_CANDIDATE_POOL_SIZE,
});
conn.onicecandidate = (e) => {
if (!e.candidate) {
return;
};
emitSignalingData({
'@type': 'Candidates',
candidates: [{
sdpString: e.candidate.candidate,
}],
});
};
conn.onconnectionstatechange = () => {
onUpdate({
'@type': 'updatePhoneCallConnectionState',
connectionState: conn.connectionState,
});
};
conn.ontrack = (e) => {
if (!state) return;
const stream = e.streams[0];
if (e.track.kind === 'audio') {
state.audio.srcObject = stream;
state.audio.play().catch();
state.streams.audio = stream;
} else if (e.transceiver.mid === '1') {
state.streams.video = stream;
} else {
state.streams.presentation = stream;
}
updateStreams();
};
conn.oniceconnectionstatechange = async (e) => {
switch(conn.iceConnectionState) {
case 'disconnected':
case 'failed':
if (isOutgoing) {
await createOffer(conn, {
offerToReceiveAudio: true,
offerToReceiveVideo: true,
iceRestart: true,
});
}
default:
break;
}
}
const slnc = silence(new AudioContext());
const video = black({ width: 640, height: 480 });
const screenshare = black({ width: 640, height: 480 });
conn.addTrack(slnc.getTracks()[0], slnc);
conn.addTrack(video.getTracks()[0], video);
conn.addTrack(screenshare.getTracks()[0], screenshare);
const dc = conn.createDataChannel('data', {
id: 0,
negotiated: true,
});
dc.onmessage = (e) => {
processSignalingMessage(JSON.parse(e.data));
};
const audio = new Audio();
state = {
audio,
connection: conn,
emitSignalingData,
isOutgoing,
pendingCandidates: [],
onUpdate,
streams: {
ownVideo: video,
ownAudio: slnc,
ownPresentation: screenshare,
},
mediaState: {
isBatteryLow: false,
screencastState: 'inactive',
videoState: 'inactive',
videoRotation: 0,
isMuted: true,
},
blackVideo: video,
blackPresentation: screenshare,
silence: slnc,
dataChannel: dc,
};
try {
toggleStreamP2p('audio', true);
} catch (err) {
console.error(err)
}
if (isOutgoing) {
await createOffer(conn, {
offerToReceiveAudio: true,
offerToReceiveVideo: true,
})
}
}
export function stopPhoneCall() {
if (!state) return;
state.streams.ownVideo?.getTracks().forEach((track) => track.stop());
state.streams.ownPresentation?.getTracks().forEach((track) => track.stop());
state.streams.ownAudio?.getTracks().forEach((track) => track.stop());
state.dataChannel.close();
state.connection.close();
state = undefined;
}
function sendMediaState() {
if (!state) return;
const { emitSignalingData, streams } = state;
emitSignalingData({
'@type': 'MediaState',
videoRotation: 0,
isMuted: !streams.ownAudio?.getTracks()[0].enabled,
isBatteryLow: true,
videoState: streams.ownVideo?.getTracks()[0].enabled ? 'active' : 'inactive',
screencastState: streams.ownPresentation?.getTracks()[0].enabled ? 'active' : 'inactive',
});
}
function filterVP8(mediaContent: MediaContent) {
if (!state || state.isOutgoing) return mediaContent;
const { payloadTypes } = mediaContent!;
const idx = payloadTypes.findIndex((payloadType) => payloadType.name === 'VP8');
const vp8PayloadType = payloadTypes[idx];
const rtxIdx = payloadTypes.findIndex((payloadType) => Number(payloadType.parameters?.apt) === vp8PayloadType.id);
mediaContent.payloadTypes = [payloadTypes[idx], payloadTypes[rtxIdx]];
return mediaContent;
}
function sendInitialSetup(sdp: P2pParsedSdp) {
if (!state) return;
const { emitSignalingData } = state;
if (!sdp.ssrc || !sdp['ssrc-groups'] || !sdp['ssrc-groups'][0] || !sdp['ssrc-groups'][1]) return;
emitSignalingData({
'@type': 'InitialSetup',
fingerprints: sdp.fingerprints,
ufrag: sdp.ufrag,
pwd: sdp.pwd,
audio: {
ssrc: fromTelegramSource(sdp.ssrc).toString(),
ssrcGroups: [],
payloadTypes: sdp.audioPayloadTypes,
rtpExtensions: sdp.audioExtmap,
},
video: filterVP8({
ssrc: fromTelegramSource(sdp['ssrc-groups'][0].sources[0]).toString(),
ssrcGroups: [{
semantics: sdp['ssrc-groups'][0].semantics,
ssrcs: sdp['ssrc-groups'][0].sources.map(fromTelegramSource),
}],
payloadTypes: sdp.videoPayloadTypes,
rtpExtensions: sdp.videoExtmap,
}),
screencast: filterVP8({
ssrc: fromTelegramSource(sdp['ssrc-groups'][1].sources[0]).toString(),
ssrcGroups: [{
semantics: sdp['ssrc-groups'][1].semantics,
ssrcs: sdp['ssrc-groups'][1].sources.map(fromTelegramSource),
}],
payloadTypes: sdp.screencastPayloadTypes,
rtpExtensions: sdp.screencastExtmap,
}),
});
}
export async function processSignalingMessage(message: P2pMessage) {
if (!state || !state.connection) return;
switch (message['@type']) {
case 'MediaState': {
state.mediaState = message;
updateStreams();
sendMediaState();
break;
}
case 'Candidates': {
const { pendingCandidates, gotInitialSetup } = state;
message.candidates.forEach((candidate) => {
pendingCandidates.push(candidate.sdpString);
});
if (gotInitialSetup) {
await commitPendingIceCandidates();
}
break;
}
case 'InitialSetup': {
const {
connection, isOutgoing,
} = state;
if (!connection) return;
const newConference = {
transport: {
candidates: [],
ufrag: message.ufrag,
pwd: message.pwd,
fingerprints: message.fingerprints,
'rtcp-mux': false,
xmlns: '',
},
sessionId: Date.now(),
ssrcs: [
message.audio && {
isVideo: false,
isMain: false,
userId: '123',
endpoint: '0',
mid: '0',
sourceGroups: [{
sources: [message.audio.ssrc],
}],
},
message.video && {
isVideo: true,
isPresentation: false,
isMain: false,
userId: '123',
endpoint: '1',
mid: '1',
sourceGroups: message.video.ssrcGroups.map((l) => ({
semantics: l.semantics,
sources: l.ssrcs,
})),
},
message.screencast && {
isVideo: true,
isPresentation: true,
isMain: false,
userId: '123',
endpoint: '2',
mid: '2',
sourceGroups: message.screencast.ssrcGroups.map((l) => ({
semantics: l.semantics,
sources: l.ssrcs,
})),
},
],
audioPayloadTypes: message.audio!.payloadTypes?.map(p2pPayloadTypeToConference) || [],
audioExtensions: message.audio!.rtpExtensions,
videoPayloadTypes: filterVP8(message.video!).payloadTypes?.map(p2pPayloadTypeToConference) || [],
videoExtensions: message.video!.rtpExtensions,
} as Conference;
await connection.setRemoteDescription({
sdp: buildSdp(newConference, isOutgoing, undefined, true),
type: isOutgoing ? 'answer' : 'offer',
});
state.conference = newConference;
if (!isOutgoing) {
const answer = await connection.createAnswer();
await connection.setLocalDescription(answer);
sendInitialSetup(parseSdp(connection.localDescription!, true) as P2pParsedSdp);
}
state.gotInitialSetup = true;
await commitPendingIceCandidates();
break;
}
}
}
async function commitPendingIceCandidates() {
if (!state) {
return;
}
const { pendingCandidates, connection } = state;
if (!pendingCandidates.length) {
return;
}
await Promise.all(pendingCandidates.map((c) => tryAddCandidate(connection, c)));
state.pendingCandidates = [];
}
async function tryAddCandidate(connection: RTCPeerConnection, candidate: string) {
try {
await connection.addIceCandidate({
candidate,
sdpMLineIndex: 0,
})
} catch (err) {
console.error(err);
}
}
async function createOffer(conn: RTCPeerConnection, params: RTCOfferOptions) {
const offer = await conn.createOffer(params);
await conn.setLocalDescription(offer);
sendInitialSetup(parseSdp(conn.localDescription!, true) as P2pParsedSdp);
}