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>
This commit is contained in:
Anton Afanasyeu
2026-08-07 22:15:42 +02:00
parent 61248e6c8b
commit 0c5c079179
7 changed files with 374 additions and 14 deletions

View File

@@ -21,9 +21,11 @@ $janusRoomId = (int) ($input['room_id'] ?? 0);
$jsep = $input['jsep'] ?? null;
$role = in_array($input['role'] ?? '', ['publisher', 'subscriber'], true)
? $input['role'] : 'publisher';
$feedId = (int) ($input['feed_id'] ?? 0);
if (!$janusRoomId) { sfuJsonErr('room_id required'); }
if (!$jsep || empty($jsep['sdp'])) { sfuJsonErr('jsep.sdp required'); }
if ($role === 'subscriber' && $feedId <= 0) { sfuJsonErr('feed_id required for subscriber'); }
$repo = new \AndroidCast\Sfu\SfuRoomRepository(sfuPdo());
$janus = janusClient();
@@ -44,6 +46,9 @@ $joinBody = [
'ptype' => $role === 'publisher' ? 'publisher' : 'subscriber',
'display' => $user['email'] ?? 'anon',
];
if ($role === 'subscriber') {
$joinBody['streams'] = [['feed' => $feedId]];
}
$resp = $janus->sendJsep($sid, $hid, $joinBody, [
'type' => $jsep['type'],
'sdp' => $jsep['sdp'],
@@ -59,10 +64,13 @@ $participantId = $repo->joinRoom(
/* Return negotiated JSEP answer + WS connection details */
$answer = $resp['jsep'] ?? null;
$plugin = $resp['plugindata']['data'] ?? [];
$publisherId = isset($plugin['id']) ? (int) $plugin['id'] : null;
sfuJsonOk([
'session_id' => $sid,
'handle_id' => $hid,
'participant_id' => $participantId,
'publisher_id' => $publisherId,
'jsep' => $answer,
'ws_url' => '/app/androidcast_project/sfu/ws',
]);

246
public/assets/sfu-lab.js Normal file
View File

@@ -0,0 +1,246 @@
(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();
}
})();

View File

@@ -40,6 +40,7 @@ $rooms = $repo->listActive();
<a href="/app/androidcast_project/issues/" style="color:var(--muted);font-size:.82rem">← Back to dashboard</a>
</div>
<div style="display:flex;align-items:center;gap:12px">
<a class="btn-sm btn-join" href="/app/androidcast_project/sfu/lab" style="text-decoration:none">Lab E2E</a>
<span class="sfu-badge <?= $janusOnline ? 'online' : 'offline' ?>">
<span class="dot"></span>
Janus <?= $janusOnline ? 'online' : 'offline' ?>

View File

@@ -4,15 +4,23 @@ require_once dirname(__DIR__) . '/src/bootstrap.php';
/* ── Simple front-controller ─────────────────────────────────────── */
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$script = basename($uri);
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
/* Strip project prefix /app/androidcast_project/sfu */
$uri = preg_replace('#^/app/androidcast_project/sfu#', '', $uri);
$uri = $uri === '' ? '/' : $uri;
if (preg_match('#^/api/rooms/\d+$#', $uri)) {
require __DIR__ . '/api/rooms.php';
exit;
}
$routes = [
'/' => 'dashboard.php',
'/dashboard' => 'dashboard.php',
'/dashboard.php' => 'dashboard.php',
'/lab' => 'lab.php',
'/lab.php' => 'lab.php',
'/health' => 'health.php',
'/health.php' => 'health.php',
'/api/rooms' => 'api/rooms.php',
@@ -28,6 +36,19 @@ if ($target && is_file(__DIR__ . '/' . $target)) {
require __DIR__ . '/' . $target;
exit;
}
/* Static assets under /assets/ */
if (str_starts_with($uri, '/assets/')) {
$file = __DIR__ . $uri;
if (is_file($file)) {
$ext = pathinfo($file, PATHINFO_EXTENSION);
$types = ['js' => 'application/javascript', 'css' => 'text/css'];
header('Content-Type: ' . ($types[$ext] ?? 'application/octet-stream'));
readfile($file);
exit;
}
}
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => 'not found']);

65
public/lab.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/src/bootstrap.php';
$user = sfuRequireAuth();
$turnHost = sfuConfig('turn_host', '');
?>
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SFU Lab — Browser E2E</title>
<link rel="stylesheet" href="/app/androidcast_project/issues/assets/css/app.css">
<style>
.lab-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
@media (max-width:900px) { .lab-grid { grid-template-columns:1fr; } }
.panel { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:16px; }
.panel h2 { margin:0 0 12px; font-size:1.05rem; }
video { width:100%; max-height:240px; background:#000; border-radius:6px; }
.log { max-height:140px; overflow:auto; font:12px/1.4 monospace; color:var(--muted); margin-top:10px; }
.row { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; align-items:center; }
input[type=number], input[type=text] { background:var(--surface2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:6px 10px; min-width:120px; }
.btn { padding:6px 14px; border-radius:6px; border:none; cursor:pointer; font-weight:600; }
.btn-primary { background:var(--accent); color:#fff; }
.btn-danger { background:rgba(248,113,113,.2); color:var(--danger); }
.hint { color:var(--muted); font-size:.82rem; margin:0 0 14px; }
</style>
</head>
<body>
<div class="layout" style="max-width:1100px;margin:0 auto;padding:24px 16px">
<h1 style="margin:0 0 6px">SFU Lab (M0)</h1>
<p class="hint">
Janus VideoRoom browser E2E — signed in as <?= htmlspecialchars($user['email'] ?? 'user') ?>.
Create a room on the <a href="/app/androidcast_project/sfu/dashboard">dashboard</a>, paste Janus room id below.
Open a second browser/tab as subscriber with the publisher feed id.
<?php if ($turnHost): ?> TURN: <?= htmlspecialchars($turnHost) ?>:3478. <?php endif; ?>
</p>
<div class="row">
<label>Janus room id <input id="room-id" type="number" placeholder="e.g. 1234567"></label>
<label>Publisher feed id <input id="feed-id" type="number" placeholder="after publish"></label>
</div>
<div class="lab-grid">
<div class="panel">
<h2>Publisher (screen share)</h2>
<video id="pub-video" autoplay playsinline muted></video>
<div class="row">
<button class="btn btn-primary" id="btn-pub">Share screen &amp; publish</button>
<button class="btn btn-danger" id="btn-stop-pub">Stop</button>
</div>
<div class="log" id="pub-log"></div>
</div>
<div class="panel">
<h2>Subscriber (viewer)</h2>
<video id="sub-video" autoplay playsinline></video>
<div class="row">
<button class="btn btn-primary" id="btn-sub">Subscribe</button>
</div>
<div class="log" id="sub-log"></div>
</div>
</div>
</div>
<script src="/app/androidcast_project/sfu/assets/sfu-lab.js" defer></script>
</body>
</html>