diff --git a/examples/sfu/README.md b/examples/sfu/README.md index 5f7f434..80d0f0f 100644 --- a/examples/sfu/README.md +++ b/examples/sfu/README.md @@ -1,21 +1,29 @@ -# SFU relay (Janus-class) — hidden preview +# SFU relay (Janus VideoRoom) — lab preview -**Not in alpha.** Implementation branch: `feature/sfu-relay`. User-visible UI remains off until owner spec is merged. +**Post-alpha product path.** Lab stack: Janus on **cast02**, signaling on app nodes, coturn on cast02. ## Layout | Path | Role | |------|------| -| `examples/sfu/README.md` | This file | -| `backend/public/api/sfu_health.php` | Health stub (`enabled: false`) | -| `config.example.php` → `sfu` | BE feature gate | -| `app/.../sfu/SfuRelayGate.java` | Compile-time preview gate | +| `public/dashboard.php` | Room list / create (auth required) | +| `public/lab.php` | **M0** browser E2E — screen-share publish + subscriber viewer | +| `public/assets/sfu-lab.js` | WebRTC + Janus WS trickle client | +| `public/api/rooms.php` | Room CRUD | +| `public/api/join.php` | JSEP join relay (publisher / subscriber) | +| `ac-deploy/.../smoke_sfu.sh` | Janus + health smoke | -## Next steps (when spec lands) +## Lab E2E -1. Signaling API + room lifecycle tied to cast session IDs / RBAC. -2. Janus or mediasoup on dedicated port (see INFRA — **not** androidcast `:80` vhost). -3. Android: enable `TRANSPORT_WEBRTC` behind `SfuRelayGate` + dev setting only. -4. FE nginx: UDP/TCP ports + TLS for WebRTC (separate from hub/crashes). +1. Sign in on issues console (shared `ac_crash_sess`). +2. Open `/app/androidcast_project/sfu/dashboard` → create room → note **Janus room id**. +3. Open `/app/androidcast_project/sfu/lab` → publish screen share. +4. Second tab/browser → same lab URL → enter room id + **publisher feed id** → Subscribe. -See [20260608_BE_SERVICES_and_infra.md](../../docs/20260608_BE_SERVICES_and_infra.md) § Planned SFU. +## Next (M1+) + +1. Android libwebrtc publish/subscribe behind `SfuRelayGate`. +2. Education screen share on SFU path (replace P2P demo). +3. Session RBAC + stats → graphs. + +See [ac-docs BUILD_DEPLOY](../../ac-docs/BUILD_DEPLOY.md) and cluster `deploy-janus-sfu.sh`. diff --git a/public/api/join.php b/public/api/join.php index 69a07b2..7cc39fc 100644 --- a/public/api/join.php +++ b/public/api/join.php @@ -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', ]); diff --git a/public/assets/sfu-lab.js b/public/assets/sfu-lab.js new file mode 100644 index 0000000..20e69fc --- /dev/null +++ b/public/assets/sfu-lab.js @@ -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(); + } +})(); diff --git a/public/dashboard.php b/public/dashboard.php index 275f506..2c9b2b2 100644 --- a/public/dashboard.php +++ b/public/dashboard.php @@ -40,6 +40,7 @@ $rooms = $repo->listActive(); ← Back to dashboard
+ Janus VideoRoom browser E2E — signed in as = htmlspecialchars($user['email'] ?? 'user') ?>. + Create a room on the dashboard, paste Janus room id below. + Open a second browser/tab as subscriber with the publisher feed id. + TURN: = htmlspecialchars($turnHost) ?>:3478. +
+