diff --git a/public/api/join.php b/public/api/join.php
new file mode 100644
index 0000000..69a07b2
--- /dev/null
+++ b/public/api/join.php
@@ -0,0 +1,68 @@
+getByJanusId($janusRoomId);
+if (!$room) { sfuJsonErr('room not found', 404); }
+
+/* Create a fresh Janus session + handle for this participant */
+$sid = $janus->createSession();
+if (!$sid) { sfuJsonErr('Janus session failed'); }
+$hid = $janus->attachVideoRoom($sid);
+if (!$hid) { sfuJsonErr('Janus attach failed'); }
+
+/* Send join + offer to Janus */
+$joinBody = [
+ 'request' => 'join',
+ 'room' => $janusRoomId,
+ 'ptype' => $role === 'publisher' ? 'publisher' : 'subscriber',
+ 'display' => $user['email'] ?? 'anon',
+];
+$resp = $janus->sendJsep($sid, $hid, $joinBody, [
+ 'type' => $jsep['type'],
+ 'sdp' => $jsep['sdp'],
+]);
+
+/* Record participant in DB */
+$participantId = $repo->joinRoom(
+ (int) $room['id'],
+ (int) $user['id'],
+ $hid,
+ $role
+);
+
+/* Return negotiated JSEP answer + WS connection details */
+$answer = $resp['jsep'] ?? null;
+sfuJsonOk([
+ 'session_id' => $sid,
+ 'handle_id' => $hid,
+ 'participant_id' => $participantId,
+ 'jsep' => $answer,
+ 'ws_url' => '/app/androidcast_project/sfu/ws',
+]);
diff --git a/public/api/rooms.php b/public/api/rooms.php
new file mode 100644
index 0000000..2f4361a
--- /dev/null
+++ b/public/api/rooms.php
@@ -0,0 +1,60 @@
+ $repo->listActive()]);
+}
+
+if ($method === 'POST') {
+ $input = json_decode(file_get_contents('php://input'), true) ?? [];
+ $name = trim((string) ($input['name'] ?? 'Room ' . date('H:i')));
+ $purp = trim((string) ($input['purpose'] ?? 'screen_cast'));
+
+ /* Allocate a Janus session + handle to create the VideoRoom */
+ $sid = $janus->createSession();
+ if (!$sid) { sfuJsonErr('Janus unreachable'); }
+ $hid = $janus->attachVideoRoom($sid);
+ if (!$hid) { sfuJsonErr('Janus attach failed'); }
+
+ $janusRoomId = mt_rand(1_000_000, 9_999_999);
+ $ok = $janus->createRoom($sid, $hid, $janusRoomId, [
+ 'description' => $name,
+ 'videocodec' => 'vp8,h264',
+ 'audiocodec' => 'opus',
+ 'bitrate' => 1024000,
+ ]);
+ if (!$ok) { sfuJsonErr('Janus room creation failed'); }
+
+ $id = $repo->createRoom($janusRoomId, (int) $user['id'], $name, $purp);
+ sfuJsonOk(['room' => ['id' => $id, 'janus_room_id' => $janusRoomId, 'name' => $name]]);
+}
+
+if ($method === 'DELETE' && $roomId > 0) {
+ $row = $repo->getByJanusId($roomId) ?? sfuJsonErr('room not found', 404);
+ /* Best-effort Janus destroy */
+ $sid = $janus->createSession();
+ if ($sid) {
+ $hid = $janus->attachVideoRoom($sid);
+ if ($hid) { $janus->destroyRoom($sid, $hid, $roomId); }
+ }
+ $repo->closeRoom((int) $row['id']);
+ sfuJsonOk(['destroyed' => true]);
+}
+
+sfuJsonErr('method not allowed', 405);
diff --git a/public/api/stats.php b/public/api/stats.php
new file mode 100644
index 0000000..c1f3433
--- /dev/null
+++ b/public/api/stats.php
@@ -0,0 +1,25 @@
+recordStats($pid, $bw, $pl);
+sfuJsonOk(['recorded' => true]);
diff --git a/public/dashboard.php b/public/dashboard.php
new file mode 100644
index 0000000..275f506
--- /dev/null
+++ b/public/dashboard.php
@@ -0,0 +1,124 @@
+info()['janus']);
+$rooms = $repo->listActive();
+?>
+
+
+
+
+
+SFU Rooms — Android Cast
+
+
+
+
+
+
+
+
+
+
+ Janus = $janusOnline ? 'online' : 'offline' ?>
+
+
+
+
+
+
+
+
+
+
No active rooms. Create one above.
+
+
+
+
= htmlspecialchars($r['name']) ?>
+
+ = htmlspecialchars($r['purpose']) ?> ·
+ = (int)$r['participant_count'] ?> participant(s) ·
+ Janus #= (int)$r['janus_room_id'] ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/health.php b/public/health.php
new file mode 100644
index 0000000..45cbfeb
--- /dev/null
+++ b/public/health.php
@@ -0,0 +1,22 @@
+info();
+$online = isset($info['janus']) && $info['janus'] === 'server_info';
+
+echo json_encode([
+ 'ok' => true,
+ 'sfu' => [
+ 'enabled' => true,
+ 'status' => $online ? 'online' : 'unreachable',
+ 'janus_version' => $info['version_string'] ?? null,
+ 'janus_url' => sfuConfig('janus_url', 'http://10.7.16.237:8088/janus'),
+ 'signaling_url' => sfuConfig('signaling_url', 'wss://apps.f0xx.org/app/androidcast_project/sfu/ws'),
+ 'ws_url' => '/app/androidcast_project/sfu/ws',
+ 'api_base' => '/app/androidcast_project/sfu/api',
+ ],
+], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..ce28531
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,28 @@
+ 'dashboard.php',
+ '/health' => 'health.php',
+ '/health.php' => 'health.php',
+ '/api/rooms' => 'api/rooms.php',
+ '/api/join' => 'api/join.php',
+ '/api/stats' => 'api/stats.php',
+];
+
+$target = $routes[$uri] ?? null;
+if ($target && is_file(__DIR__ . '/' . $target)) {
+ require __DIR__ . '/' . $target;
+ exit;
+}
+http_response_code(404);
+header('Content-Type: application/json');
+echo json_encode(['ok' => false, 'error' => 'not found']);
diff --git a/src/JanusClient.php b/src/JanusClient.php
new file mode 100644
index 0000000..0a0486a
--- /dev/null
+++ b/src/JanusClient.php
@@ -0,0 +1,168 @@
+base = rtrim($baseUrl ?: 'http://10.7.16.237:8088/janus', '/');
+ $this->timeout = $timeoutSec;
+ }
+
+ /** GET /janus/info — returns server info or null. */
+ public function info(): ?array
+ {
+ return $this->get('');
+ }
+
+ /** Create a new Janus session. Returns session_id or null. */
+ public function createSession(): ?int
+ {
+ $body = ['janus' => 'create', 'transaction' => $this->txid()];
+ $resp = $this->post('', $body);
+ return isset($resp['data']['id']) ? (int) $resp['data']['id'] : null;
+ }
+
+ /** Attach VideoRoom plugin to a session. Returns handle_id or null. */
+ public function attachVideoRoom(int $sessionId): ?int
+ {
+ $body = [
+ 'janus' => 'attach',
+ 'plugin' => 'janus.plugin.videoroom',
+ 'transaction' => $this->txid(),
+ ];
+ $resp = $this->post("/{$sessionId}", $body);
+ return isset($resp['data']['id']) ? (int) $resp['data']['id'] : null;
+ }
+
+ /** List all VideoRoom rooms. */
+ public function listRooms(int $sessionId, int $handleId): array
+ {
+ $body = [
+ 'janus' => 'message',
+ 'transaction' => $this->txid(),
+ 'body' => ['request' => 'list'],
+ ];
+ $resp = $this->post("/{$sessionId}/{$handleId}", $body);
+ return $resp['plugindata']['data']['list'] ?? [];
+ }
+
+ /**
+ * Create a VideoRoom room.
+ * @param array $opts e.g. ['description'=>'My room','publishers'=>6,'videocodec'=>'vp8,h264']
+ */
+ public function createRoom(int $sessionId, int $handleId, int $roomId, array $opts = []): bool
+ {
+ $body = [
+ 'janus' => 'message',
+ 'transaction' => $this->txid(),
+ 'body' => array_merge([
+ 'request' => 'create',
+ 'room' => $roomId,
+ 'publishers' => 6,
+ 'bitrate' => 1024000,
+ 'videocodec' => 'vp8,h264',
+ 'audiocodec' => 'opus',
+ 'record' => false,
+ ], $opts),
+ ];
+ $resp = $this->post("/{$sessionId}/{$handleId}", $body);
+ $ack = $resp['plugindata']['data']['videoroom'] ?? '';
+ return $ack === 'created' || $ack === 'ok';
+ }
+
+ /** Destroy a VideoRoom room. */
+ public function destroyRoom(int $sessionId, int $handleId, int $roomId): bool
+ {
+ $body = [
+ 'janus' => 'message',
+ 'transaction' => $this->txid(),
+ 'body' => ['request' => 'destroy', 'room' => $roomId],
+ ];
+ $resp = $this->post("/{$sessionId}/{$handleId}", $body);
+ $ack = $resp['plugindata']['data']['videoroom'] ?? '';
+ return $ack === 'destroyed';
+ }
+
+ /**
+ * Forward a client SDP offer to Janus and get an SDP answer.
+ * Used for the join/publish flow.
+ */
+ public function sendMessage(int $sessionId, int $handleId, array $body): array
+ {
+ return $this->post("/{$sessionId}/{$handleId}", [
+ 'janus' => 'message',
+ 'transaction' => $this->txid(),
+ 'body' => $body,
+ ]) ?? [];
+ }
+
+ public function sendJsep(int $sessionId, int $handleId, array $body, array $jsep): array
+ {
+ return $this->post("/{$sessionId}/{$handleId}", [
+ 'janus' => 'message',
+ 'transaction' => $this->txid(),
+ 'body' => $body,
+ 'jsep' => $jsep,
+ ]) ?? [];
+ }
+
+ /** Long-poll for Janus events on a session. */
+ public function poll(int $sessionId, int $maxev = 4): array
+ {
+ return $this->get("/{$sessionId}?maxev={$maxev}") ?? [];
+ }
+
+ public function keepalive(int $sessionId): void
+ {
+ $this->post("/{$sessionId}", ['janus' => 'keepalive', 'transaction' => $this->txid()]);
+ }
+
+ public function destroySession(int $sessionId): void
+ {
+ $this->post("/{$sessionId}", ['janus' => 'destroy', 'transaction' => $this->txid()]);
+ }
+
+ /* ── HTTP helpers ────────────────────────────────────────────────── */
+
+ private function get(string $path): ?array
+ {
+ $url = $this->base . $path;
+ $ctx = stream_context_create(['http' => [
+ 'method' => 'GET',
+ 'timeout' => $this->timeout,
+ 'header' => "Accept: application/json\r\n",
+ 'ignore_errors' => true,
+ ]]);
+ $raw = @file_get_contents($url, false, $ctx);
+ return $raw !== false ? json_decode($raw, true) : null;
+ }
+
+ private function post(string $path, array $payload): ?array
+ {
+ $url = $this->base . $path;
+ $json = json_encode($payload);
+ $ctx = stream_context_create(['http' => [
+ 'method' => 'POST',
+ 'header' => "Content-Type: application/json\r\nAccept: application/json\r\n",
+ 'content' => $json,
+ 'timeout' => $this->timeout,
+ 'ignore_errors' => true,
+ ]]);
+ $raw = @file_get_contents($url, false, $ctx);
+ return $raw !== false ? json_decode($raw, true) : null;
+ }
+
+ private function txid(): string
+ {
+ return bin2hex(random_bytes(8));
+ }
+}
diff --git a/src/SfuRoomRepository.php b/src/SfuRoomRepository.php
new file mode 100644
index 0000000..a034196
--- /dev/null
+++ b/src/SfuRoomRepository.php
@@ -0,0 +1,109 @@
+pdo = $pdo;
+ }
+
+ /* ── room lifecycle ─────────────────────────────────────────────── */
+
+ public function createRoom(int $janusRoomId, int $ownerId, string $name, string $purpose = 'screen_cast'): int
+ {
+ $stmt = $this->pdo->prepare(
+ 'INSERT INTO sfu_rooms (janus_room_id, owner_id, name, purpose, created_at, updated_at)
+ VALUES (:jrId, :oid, :name, :purpose, NOW(), NOW())'
+ );
+ $stmt->execute([
+ ':jrId' => $janusRoomId,
+ ':oid' => $ownerId,
+ ':name' => $name,
+ ':purpose' => $purpose,
+ ]);
+ return (int) $this->pdo->lastInsertId();
+ }
+
+ public function getByJanusId(int $janusRoomId): ?array
+ {
+ $stmt = $this->pdo->prepare('SELECT * FROM sfu_rooms WHERE janus_room_id = :id LIMIT 1');
+ $stmt->execute([':id' => $janusRoomId]);
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ return $row ?: null;
+ }
+
+ public function listActive(): array
+ {
+ $stmt = $this->pdo->query(
+ "SELECT r.*, COUNT(p.id) AS participant_count
+ FROM sfu_rooms r
+ LEFT JOIN sfu_participants p ON p.room_id = r.id AND p.left_at IS NULL
+ WHERE r.closed_at IS NULL
+ GROUP BY r.id
+ ORDER BY r.created_at DESC"
+ );
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ public function closeRoom(int $roomId): void
+ {
+ $stmt = $this->pdo->prepare('UPDATE sfu_rooms SET closed_at = NOW() WHERE id = :id');
+ $stmt->execute([':id' => $roomId]);
+ }
+
+ /* ── participant tracking ───────────────────────────────────────── */
+
+ public function joinRoom(int $roomId, int $userId, int $janusHandleId, string $role = 'publisher'): int
+ {
+ $stmt = $this->pdo->prepare(
+ 'INSERT INTO sfu_participants (room_id, user_id, janus_handle_id, role, joined_at)
+ VALUES (:room, :user, :handle, :role, NOW())'
+ );
+ $stmt->execute([
+ ':room' => $roomId,
+ ':user' => $userId,
+ ':handle' => $janusHandleId,
+ ':role' => $role,
+ ]);
+ return (int) $this->pdo->lastInsertId();
+ }
+
+ public function leaveRoom(int $participantId): void
+ {
+ $stmt = $this->pdo->prepare('UPDATE sfu_participants SET left_at = NOW() WHERE id = :id');
+ $stmt->execute([':id' => $participantId]);
+ }
+
+ public function listParticipants(int $roomId): array
+ {
+ $stmt = $this->pdo->prepare(
+ 'SELECT p.*, u.email FROM sfu_participants p
+ JOIN users u ON u.id = p.user_id
+ WHERE p.room_id = :room AND p.left_at IS NULL'
+ );
+ $stmt->execute([':room' => $roomId]);
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ /* ── stats hook ─────────────────────────────────────────────────── */
+
+ /** Record a bitrate/packet-loss sample for analytics. */
+ public function recordStats(int $participantId, float $bitrateKbps, float $packetLoss): void
+ {
+ $stmt = $this->pdo->prepare(
+ 'INSERT INTO sfu_stats (participant_id, bitrate_kbps, packet_loss, sampled_at)
+ VALUES (:pid, :bw, :pl, NOW())'
+ );
+ $stmt->execute([':pid' => $participantId, ':bw' => $bitrateKbps, ':pl' => $packetLoss]);
+ }
+}
diff --git a/src/bootstrap.php b/src/bootstrap.php
new file mode 100644
index 0000000..d59ff95
--- /dev/null
+++ b/src/bootstrap.php
@@ -0,0 +1,119 @@
+ \PDO::ERRMODE_EXCEPTION,
+ \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
+ ]);
+ }
+ return $pdo;
+}
+
+/* ── Janus client ────────────────────────────────────────────────── */
+
+function janusClient(): \AndroidCast\Sfu\JanusClient
+{
+ static $client = null;
+ if ($client === null) {
+ $url = sfuConfig('janus_url', 'http://10.7.16.237:8088/janus');
+ $client = new \AndroidCast\Sfu\JanusClient($url);
+ }
+ return $client;
+}
+
+/* ── Auth helper ─────────────────────────────────────────────────── */
+
+function sfuRequireAuth(): array
+{
+ /* Try composed BE session */
+ $composedBe = '/var/www/ac/composed/backend/src/bootstrap.php';
+ if (is_file($composedBe)) {
+ require_once $composedBe;
+ if (function_exists('requireAuth')) {
+ return requireAuth();
+ }
+ }
+ /* Minimal cookie/bearer check */
+ session_start();
+ if (empty($_SESSION['user_id'])) {
+ http_response_code(401);
+ echo json_encode(['ok' => false, 'error' => 'unauthorized']);
+ exit;
+ }
+ return ['id' => (int) $_SESSION['user_id'], 'email' => $_SESSION['email'] ?? ''];
+}
+
+/* ── JSON helpers ────────────────────────────────────────────────── */
+
+function sfuJsonOk(array $data = []): void
+{
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode(['ok' => true] + $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ exit;
+}
+
+function sfuJsonErr(string $message, int $code = 400): void
+{
+ http_response_code($code);
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_SLASHES);
+ exit;
+}