mirror of https://github.com/rsp2k/rtc_demo.git
357 lines
10 KiB
JavaScript
357 lines
10 KiB
JavaScript
|
'use strict';
|
||
|
|
||
|
const $self = {
|
||
|
user_name: "",
|
||
|
rtc_config: {
|
||
|
iceServers: [
|
||
|
{ urls: 'stun:kww.us:3478' },
|
||
|
{ urls: 'stun:stun.l.google.com:19302' },
|
||
|
{
|
||
|
username: "dcus",
|
||
|
credential: "dcus2024",
|
||
|
urls: 'turn:kww.us:3478',
|
||
|
},
|
||
|
],
|
||
|
iceTransportPolicy: "all"
|
||
|
},
|
||
|
media_constraints: {
|
||
|
audio: false,
|
||
|
video: true,
|
||
|
},
|
||
|
video_constraints: {
|
||
|
height: {max:240, min:48, ideal:120},
|
||
|
width: {max:320, min:64, ideal:160}
|
||
|
},
|
||
|
media_stream: new MediaStream(),
|
||
|
media_tracks: {},
|
||
|
features: { audio: false },
|
||
|
ws: null,
|
||
|
ws_json: function(data) {
|
||
|
this.ws.send(JSON.stringify(data));
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const $others = new Map();
|
||
|
|
||
|
function element_id(id='self') {
|
||
|
if (id === 'self') return '#self';
|
||
|
else return $others.get(id).short_name;
|
||
|
}
|
||
|
|
||
|
function signal(recipient, signal) {
|
||
|
$self.ws_json(
|
||
|
{ 'rtc': {
|
||
|
'type': 'signal', 'recipient': recipient,
|
||
|
'sender': $self.id, 'signal': signal
|
||
|
}
|
||
|
}
|
||
|
)
|
||
|
};
|
||
|
|
||
|
function connected({channel_name}) {
|
||
|
if (channel_name) {
|
||
|
$self.id = channel_name;
|
||
|
}
|
||
|
};
|
||
|
|
||
|
function connected_other(conn_info) {
|
||
|
initialize_other(conn_info, true);
|
||
|
establish_features(conn_info.channel_name);
|
||
|
};
|
||
|
|
||
|
function connected_others({ids}) {
|
||
|
for (let conn_info of ids) {
|
||
|
if (conn_info.channel_name === $self.id) continue;
|
||
|
initialize_other(conn_info, false);
|
||
|
establish_features(conn_info.channel_name);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
function disconnected_other({channel_name}) {
|
||
|
console.log(`disconnected_other: ${channel_name}`);
|
||
|
reset_other(channel_name);
|
||
|
};
|
||
|
|
||
|
async function signalled({sender,
|
||
|
signal: {candidate, description} }) {
|
||
|
|
||
|
const id = sender;
|
||
|
const other = $others.get(id);
|
||
|
const self_state = other.self_states;
|
||
|
|
||
|
if (description) {
|
||
|
if (description.type === '_reset') {
|
||
|
retry_connection(id);
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Work with incoming description
|
||
|
const readyForOffer =
|
||
|
!self_state.making_offer &&
|
||
|
(other.connection.signalingState === 'stable'
|
||
|
|| self_state.remote_answer_pending);
|
||
|
|
||
|
const offerCollision = description.type === 'offer' && !readyForOffer;
|
||
|
|
||
|
self_state.ignoring_offer = !self_state.is_polite && offerCollision;
|
||
|
|
||
|
if (self_state.ignoring_offer) {
|
||
|
return;
|
||
|
}
|
||
|
self_state.remote_answer_pending = description.type === 'answer';
|
||
|
|
||
|
try {
|
||
|
await other.connection.setRemoteDescription(description);
|
||
|
} catch(e) {
|
||
|
retry_connection(id);
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
self_state.remote_answer_pending = false;
|
||
|
|
||
|
if (description.type === 'offer') {
|
||
|
try {
|
||
|
await other.connection.setLocalDescription();
|
||
|
} catch(e) {
|
||
|
const answer = await other.connection.createAnswer();
|
||
|
await other.connection.setLocalDescription(answer);
|
||
|
} finally {
|
||
|
signal(id, {'description': other.connection.localDescription});
|
||
|
self_state.suppressing_offer = false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
} else if (candidate) {
|
||
|
// Work with incoming ICE
|
||
|
try {
|
||
|
await other.connection.addIceCandidate(candidate);
|
||
|
} catch(e) {
|
||
|
// Log error unless $self is ignoring offers
|
||
|
// and candidate is not an empty string
|
||
|
if (!self_state.ignoring_offer && candidate.candidate.length > 1) {
|
||
|
console.error(`Unable to add ICE candidate for other ID: ${id}`, e);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
apps._add('rtc', 'connect', connected)
|
||
|
apps._add('rtc', 'other', connected_other);
|
||
|
apps._add('rtc', 'others', connected_others);
|
||
|
apps._add('rtc', 'disconnected', disconnected_other);
|
||
|
apps._add('rtc', 'signal', signalled);
|
||
|
|
||
|
|
||
|
function display_stream(stream, id = 'self') {
|
||
|
var selector = `${element_id(id)} video`;
|
||
|
var element = document.querySelector(selector);
|
||
|
if (element) { element.srcObject = stream; };
|
||
|
}
|
||
|
|
||
|
async function request_user_media(media_constraints) {
|
||
|
$self.media = await navigator.mediaDevices.getUserMedia(media_constraints);
|
||
|
$self.media_tracks.video = $self.media.getVideoTracks()[0];
|
||
|
$self.media_tracks.video.applyConstraints($self.video_constraints);
|
||
|
$self.media_stream.addTrack($self.media_tracks.video);
|
||
|
display_stream($self.media_stream);
|
||
|
}
|
||
|
|
||
|
function add_features(id) {
|
||
|
const other = $others.get(id);
|
||
|
function manage_video(video_feature) {
|
||
|
other.features['video'] = video_feature;
|
||
|
if (other.media_tracks.video) {
|
||
|
if (video_feature) {
|
||
|
other.media_stream.addTrack(other.media_tracks.video);
|
||
|
} else {
|
||
|
other.media_stream.removeTrack(other.media_tracks.video);
|
||
|
display_stream(other.media_stream, id);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
other.features_channel = other.connection.createDataChannel('features', {negotiated: true, id: 500 });
|
||
|
other.features_channel.onopen = function(event){
|
||
|
other.features_channel.send(JSON.stringify($self.features))
|
||
|
};
|
||
|
other.features_channel.onmessage = function(event) {
|
||
|
const features = JSON.parse(event.data);
|
||
|
if ('video' in features) {
|
||
|
manage_video(features['video']);
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
|
||
|
function share_features(id, ...features) {
|
||
|
const other = $others.get(id);
|
||
|
|
||
|
const shared_features = {};
|
||
|
|
||
|
if (!other.features_channel) return;
|
||
|
|
||
|
for (let f of features) {
|
||
|
shared_features[f] = $self.features[f];
|
||
|
}
|
||
|
|
||
|
try {
|
||
|
other.features_channel.send(JSON.stringify(shared_features));
|
||
|
} catch(e) {
|
||
|
console.error('Error sending features:', e);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
request_user_media($self.media_constraints);
|
||
|
|
||
|
function establish_features(id) {
|
||
|
register_rtc_callbacks(id);
|
||
|
add_features(id);
|
||
|
const other = $others.get(id);
|
||
|
for (let track in $self.media_tracks) {
|
||
|
other.connection.addTrack($self.media_tracks[track]);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function initialize_other({channel_name, user_name, short_name}, polite) {
|
||
|
$others.set(channel_name, {
|
||
|
user_name: user_name,
|
||
|
short_name: '#' + short_name,
|
||
|
connection: new RTCPeerConnection($self.rtc_config),
|
||
|
media_stream: new MediaStream(),
|
||
|
media_tracks: {},
|
||
|
features: { 'connection_count': 0},
|
||
|
self_states: {
|
||
|
is_polite: polite,
|
||
|
making_offer: false,
|
||
|
ignoring_offer: false,
|
||
|
remote_answer_pending: false,
|
||
|
suppressing_offer: false
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
function reset_other(channel_name, preserve) {
|
||
|
const other = $others.get(channel_name);
|
||
|
display_stream(null, channel_name);
|
||
|
if (other) {
|
||
|
if (other.connection) {
|
||
|
other.connection.close();
|
||
|
}
|
||
|
}
|
||
|
if (!preserve) {
|
||
|
let qs = document.querySelector(`${other.short_name}-div`);
|
||
|
if (qs) { qs.remove(); };
|
||
|
$others.delete(channel_name);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function retry_connection(channel_name) {
|
||
|
const polite = $others.get(channel_name).self_states.is_polite;
|
||
|
reset_other(channel_name, true);
|
||
|
//TODO bundle id with username for this call
|
||
|
initialize_other({channel_name, user_name}, polite);
|
||
|
$others.get(channel_name).self_states.suppressing_offer = polite;
|
||
|
|
||
|
establish_features(channel_name);
|
||
|
|
||
|
if (polite) {
|
||
|
signal(channel_name,
|
||
|
{'description': {'type': '_reset'}}
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function register_rtc_callbacks(id) {
|
||
|
const other = $others.get(id);
|
||
|
other.connection.onconnectionstatechange = conn_state_change(id);
|
||
|
other.connection.onnegotiationneeded = conn_negotiation(id);
|
||
|
other.connection.onicecandidate = ice_candidate(id);
|
||
|
other.connection.ontrack = other_track(id);
|
||
|
}
|
||
|
|
||
|
function conn_state_change(id) {
|
||
|
return function() {
|
||
|
const other = $others.get(id);
|
||
|
const otherElement = document.querySelector(`${other.short_name}`);
|
||
|
if (otherElement) {
|
||
|
otherElement.dataset.connectionState = other.connection.connectionState;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function conn_negotiation(id) {
|
||
|
return async function() {
|
||
|
const other = $others.get(id);
|
||
|
const self_state = other.self_states;
|
||
|
if (self_state.suppressing_offer) return;
|
||
|
try {
|
||
|
self_state.making_offer = true;
|
||
|
await other.connection.setLocalDescription();
|
||
|
} catch(e) {
|
||
|
const offer = await other.connection.createOffer();
|
||
|
await other.connection.setLocalDescription(offer);
|
||
|
} finally {
|
||
|
signal(id, {'description': other.connection.localDescription});
|
||
|
self_state.making_offer = false;
|
||
|
};
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function ice_candidate(id) {
|
||
|
return function({candidate}) {
|
||
|
signal(id, {candidate});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function other_track(id) {
|
||
|
return function({track}) {
|
||
|
const other = $others.get(id);
|
||
|
other.media_tracks[track.kind] = track;
|
||
|
other.media_stream.addTrack(track);
|
||
|
display_stream(other.media_stream, id);
|
||
|
};
|
||
|
}
|
||
|
|
||
|
htmx.on('htmx:wsOpen', function(e) {
|
||
|
$self.ws = e.detail.socketWrapper;
|
||
|
$self.ws_json({'room': 'lobby'});
|
||
|
});
|
||
|
|
||
|
function handleCallButton(event) {
|
||
|
const call_button = event.target;
|
||
|
if (call_button.className === 'join') {
|
||
|
call_button.className = 'leave';
|
||
|
call_button.innerText = 'Leave Call';
|
||
|
if ($self.ws) {
|
||
|
$self.ws_json({'join': 'video'});
|
||
|
}
|
||
|
} else {
|
||
|
// Leave the call
|
||
|
call_button.className = 'join';
|
||
|
call_button.innerText = 'Join Call';
|
||
|
$self.ws_json({'hangup': true});
|
||
|
for (let channel_name of $others.keys()) {
|
||
|
reset_other(channel_name);
|
||
|
}
|
||
|
let node_list = document.querySelectorAll('[id^="other-"][id$="-div"]');
|
||
|
for (let node of node_list) { node.remove(); }
|
||
|
}
|
||
|
};
|
||
|
|
||
|
function toggleCam(event) {
|
||
|
const button = event.target;
|
||
|
const video = $self.media_tracks.video;
|
||
|
const state = video.enabled = !video.enabled;
|
||
|
$self.features.video = state;
|
||
|
button.setAttribute('aria-checked', state);
|
||
|
|
||
|
for (let id of $others.keys()) {
|
||
|
share_features(id, 'video');
|
||
|
}
|
||
|
|
||
|
if (state) {
|
||
|
$self.media_stream.addTrack($self.media_tracks.video);
|
||
|
} else {
|
||
|
$self.media_stream.removeTrack($self.media_tracks.video);
|
||
|
display_stream($self.media_stream);
|
||
|
}
|
||
|
}
|