Files
Anton Afanasyeu 0c5c079179 SFU M0: browser lab E2E client for Janus VideoRoom.
Add lab page with screen-share publisher and subscriber viewer, join API
feed support, shared session auth, static assets route, and lab docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 22:15:42 +02:00

247 lines
7.4 KiB
JavaScript

(function () {
'use strict';
var API = '/app/androidcast_project/sfu/api';
var WS_PATH = '/app/androidcast_project/sfu/ws';
var ICE = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
];
function wsUrl() {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return proto + '//' + location.host + WS_PATH;
}
function log(el, msg) {
if (!el) return;
var line = document.createElement('div');
line.textContent = new Date().toISOString().slice(11, 19) + ' ' + msg;
el.prepend(line);
}
function JanusSession(sessionId, handleId, logEl) {
this.sessionId = sessionId;
this.handleId = handleId;
this.logEl = logEl;
this.tx = 0;
this.ws = null;
}
JanusSession.prototype.tid = function () {
this.tx += 1;
return 'lab-' + this.tx;
};
JanusSession.prototype.connect = function () {
var self = this;
return new Promise(function (resolve, reject) {
self.ws = new WebSocket(wsUrl());
self.ws.onopen = function () {
log(self.logEl, 'Janus WS open');
self.keepalive = setInterval(function () {
if (self.ws && self.ws.readyState === WebSocket.OPEN) {
self.ws.send(
JSON.stringify({
janus: 'keepalive',
session_id: self.sessionId,
transaction: self.tid(),
})
);
}
}, 25000);
resolve();
};
self.ws.onerror = function () {
reject(new Error('WebSocket error'));
};
self.ws.onmessage = function (ev) {
var msg;
try {
msg = JSON.parse(ev.data);
} catch (e) {
return;
}
if (msg.janus === 'trickle' && msg.candidate && self.onTrickle) {
self.onTrickle(msg.candidate);
}
if (msg.janus === 'webrtcup') {
log(self.logEl, 'WebRTC up');
}
if (msg.jsep && self.onJsep) {
self.onJsep(msg.jsep);
}
};
});
};
JanusSession.prototype.sendTrickle = function (candidate) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
this.ws.send(
JSON.stringify({
janus: 'trickle',
session_id: this.sessionId,
handle_id: this.handleId,
transaction: this.tid(),
candidate: candidate,
})
);
};
JanusSession.prototype.close = function () {
if (this.keepalive) clearInterval(this.keepalive);
if (this.ws) this.ws.close();
};
function wireIce(pc, janus) {
pc.onicecandidate = function (ev) {
if (!janus) return;
if (ev.candidate) {
janus.sendTrickle({
candidate: ev.candidate.candidate,
sdpMid: ev.candidate.sdpMid,
sdpMLineIndex: ev.candidate.sdpMLineIndex,
});
} else {
janus.sendTrickle({ completed: true });
}
};
janus.onTrickle = function (c) {
if (c.completed) return;
pc.addIceCandidate(c).catch(function () {});
};
}
async function apiJoin(body) {
var r = await fetch(API + '/join', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
var j = await r.json();
if (!j.ok) throw new Error(j.error || 'join failed');
return j;
}
async function startPublisher(opts) {
var roomId = opts.roomId;
var videoEl = opts.videoEl;
var logEl = opts.logEl;
var stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true,
});
videoEl.srcObject = stream;
var pc = new RTCPeerConnection({ iceServers: ICE });
stream.getTracks().forEach(function (t) {
pc.addTrack(t, stream);
});
var offer = await pc.createOffer();
await pc.setLocalDescription(offer);
var join = await apiJoin({
room_id: roomId,
role: 'publisher',
jsep: { type: offer.type, sdp: offer.sdp },
});
var janus = new JanusSession(join.session_id, join.handle_id, logEl);
wireIce(pc, janus);
await janus.connect();
if (join.jsep && join.jsep.sdp) {
await pc.setRemoteDescription(join.jsep);
}
log(logEl, 'Publisher joined room ' + roomId + ' feed=' + (join.publisher_id || '?'));
return { pc: pc, janus: janus, stream: stream, publisherId: join.publisher_id };
}
async function startSubscriber(opts) {
var roomId = opts.roomId;
var feedId = parseInt(opts.feedId, 10);
var videoEl = opts.videoEl;
var logEl = opts.logEl;
if (!feedId) throw new Error('publisher feed id required');
var pc = new RTCPeerConnection({ iceServers: ICE });
pc.ontrack = function (ev) {
if (ev.streams && ev.streams[0]) {
videoEl.srcObject = ev.streams[0];
}
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
var offer = await pc.createOffer();
await pc.setLocalDescription(offer);
var join = await apiJoin({
room_id: roomId,
role: 'subscriber',
feed_id: feedId,
jsep: { type: offer.type, sdp: offer.sdp },
});
var janus = new JanusSession(join.session_id, join.handle_id, logEl);
wireIce(pc, janus);
await janus.connect();
if (join.jsep && join.jsep.sdp) {
await pc.setRemoteDescription(join.jsep);
}
log(logEl, 'Subscriber watching feed ' + feedId);
return { pc: pc, janus: janus };
}
function bindUi() {
var pubLog = document.getElementById('pub-log');
var subLog = document.getElementById('sub-log');
var pubVideo = document.getElementById('pub-video');
var subVideo = document.getElementById('sub-video');
var pubState = null;
document.getElementById('btn-pub').addEventListener('click', function () {
var roomId = parseInt(document.getElementById('room-id').value, 10);
if (!roomId) {
alert('Enter Janus room id (from dashboard)');
return;
}
document.getElementById('btn-pub').disabled = true;
startPublisher({ roomId: roomId, videoEl: pubVideo, logEl: pubLog })
.then(function (s) {
pubState = s;
if (s.publisherId) {
document.getElementById('feed-id').value = String(s.publisherId);
}
})
.catch(function (e) {
log(pubLog, 'ERROR: ' + e.message);
document.getElementById('btn-pub').disabled = false;
});
});
document.getElementById('btn-sub').addEventListener('click', function () {
var roomId = parseInt(document.getElementById('room-id').value, 10);
var feedId = document.getElementById('feed-id').value;
document.getElementById('btn-sub').disabled = true;
startSubscriber({ roomId: roomId, feedId: feedId, videoEl: subVideo, logEl: subLog })
.catch(function (e) {
log(subLog, 'ERROR: ' + e.message);
document.getElementById('btn-sub').disabled = false;
});
});
document.getElementById('btn-stop-pub').addEventListener('click', function () {
if (pubState) {
pubState.stream.getTracks().forEach(function (t) {
t.stop();
});
pubState.janus.close();
pubState.pc.close();
pubState = null;
pubVideo.srcObject = null;
document.getElementById('btn-pub').disabled = false;
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindUi);
} else {
bindUi();
}
})();