From 0d8b06f096b1a1bfb376975ecd29f47724dead75 Mon Sep 17 00:00:00 2001
From: Anton Afanasyeu
Date: Sat, 20 Jun 2026 22:56:48 +0200
Subject: [PATCH] features
---
.../public/api/live_cast_signaling.php | 84 ++++++
.../backend/public/assets/css/app.css | 38 +++
.../public/assets/js/live_cast_webrtc.js | 256 ++++++++++++++++++
.../public/assets/js/live_education.js | 55 +++-
.../backend/public/assets/js/live_join.js | 79 +++++-
.../crash_reporter/backend/public/index.php | 5 +
examples/crash_reporter/backend/src/Auth.php | 12 +-
.../backend/src/LiveCastRepository.php | 26 +-
.../src/LiveCastSignalingRepository.php | 134 +++++++++
.../crash_reporter/backend/src/bootstrap.php | 2 +
.../backend/views/live_education.php | 5 +-
.../backend/views/live_join.php | 2 +
examples/platform/footer.config.example.php | 5 +-
examples/platform/footer.php | 47 +++-
14 files changed, 724 insertions(+), 26 deletions(-)
create mode 100644 examples/crash_reporter/backend/public/api/live_cast_signaling.php
create mode 100644 examples/crash_reporter/backend/public/assets/js/live_cast_webrtc.js
create mode 100644 examples/crash_reporter/backend/src/LiveCastSignalingRepository.php
diff --git a/examples/crash_reporter/backend/public/api/live_cast_signaling.php b/examples/crash_reporter/backend/public/api/live_cast_signaling.php
new file mode 100644
index 0000000..da26ea9
--- /dev/null
+++ b/examples/crash_reporter/backend/public/api/live_cast_signaling.php
@@ -0,0 +1,84 @@
+ false, 'error' => 'invalid_json'], 400);
+ }
+ $sessionId = trim((string) ($body['session_id'] ?? $sessionId));
+}
+
+if ($sessionId === '') {
+ json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
+}
+
+$session = LiveCastRepository::sessionById($sessionId);
+if ($session === null) {
+ json_out(['ok' => false, 'error' => 'not_found'], 404);
+}
+
+$platform = strtolower((string) ($session['platform'] ?? ''));
+$status = strtolower((string) ($session['status'] ?? ''));
+$isPublic = str_starts_with($platform, 'education')
+ || in_array($status, ['intent', 'live'], true);
+
+if (!$isPublic) {
+ Auth::check();
+}
+
+if ($method === 'GET') {
+ $action = trim((string) ($_GET['action'] ?? 'poll'));
+ if ($action === 'latest') {
+ $latest = LiveCastSignalingRepository::latest(
+ $sessionId,
+ $msgType !== '' ? $msgType : 'offer',
+ $fromRole !== '' ? $fromRole : null
+ );
+ json_out(['ok' => true, 'message' => $latest]);
+ }
+ $messages = LiveCastSignalingRepository::poll(
+ $sessionId,
+ $sinceId,
+ $msgType !== '' ? $msgType : null,
+ $fromRole !== '' ? $fromRole : null
+ );
+ $lastId = $sinceId;
+ foreach ($messages as $m) {
+ $lastId = max($lastId, (int) ($m['id'] ?? 0));
+ }
+ json_out(['ok' => true, 'messages' => $messages, 'since_id' => $lastId]);
+}
+
+if ($method !== 'POST') {
+ json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
+}
+
+$action = trim((string) ($body['action'] ?? 'post'));
+if ($action !== 'post') {
+ json_out(['ok' => false, 'error' => 'unknown_action'], 400);
+}
+
+$peerRole = trim((string) ($body['peer_role'] ?? 'caster'));
+$type = trim((string) ($body['msg_type'] ?? ''));
+$payload = $body['payload'] ?? null;
+if (!is_array($payload)) {
+ $payload = ['sdp' => (string) ($body['sdp'] ?? ''), 'type' => (string) ($body['type'] ?? '')];
+}
+if ($type === '') {
+ json_out(['ok' => false, 'error' => 'missing_msg_type'], 400);
+}
+
+$id = LiveCastSignalingRepository::post($sessionId, $peerRole, $type, $payload);
+json_out(['ok' => true, 'id' => $id]);
diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css
index 8406f61..e8cda5e 100644
--- a/examples/crash_reporter/backend/public/assets/css/app.css
+++ b/examples/crash_reporter/backend/public/assets/css/app.css
@@ -2383,3 +2383,41 @@ button.report-tag--filter:hover {
margin-top: 8px;
font-size: 0.88rem;
}
+
+.live-share {
+ margin-top: 16px;
+ padding: 16px;
+}
+.live-share h2 {
+ margin: 0 0 8px;
+ font-size: 1.05rem;
+}
+.live-stats-nerds {
+ margin-top: 12px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.72);
+ border: 1px solid rgba(148, 163, 184, 0.25);
+ font-size: 0.78rem;
+ line-height: 1.45;
+ white-space: pre-wrap;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+[data-theme="light"] .live-stats-nerds {
+ background: rgba(241, 245, 249, 0.95);
+ border-color: rgba(100, 116, 139, 0.35);
+}
+.live-player-wrap video.live-preview {
+ width: 100%;
+ max-height: 60vh;
+ background: #000;
+ border-radius: 8px;
+}
+.platform-footer__left a {
+ color: var(--accent, #5b8def);
+ text-decoration: none;
+}
+.platform-footer__left a:hover,
+.platform-footer__left a:focus-visible {
+ text-decoration: underline;
+}
diff --git a/examples/crash_reporter/backend/public/assets/js/live_cast_webrtc.js b/examples/crash_reporter/backend/public/assets/js/live_cast_webrtc.js
new file mode 100644
index 0000000..4e98a09
--- /dev/null
+++ b/examples/crash_reporter/backend/public/assets/js/live_cast_webrtc.js
@@ -0,0 +1,256 @@
+(function (global) {
+ 'use strict';
+
+ var STUN = [{ urls: 'stun:stun.l.google.com:19302' }];
+
+ function signalingUrl(basePath) {
+ return basePath + '/api/live_cast_signaling.php';
+ }
+
+ function castApiUrl(basePath) {
+ return basePath + '/api/live_cast.php';
+ }
+
+ function xhrJson(method, url, body, cb) {
+ var xhr = new XMLHttpRequest();
+ xhr.open(method, url, true);
+ if (body != null) {
+ xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
+ }
+ xhr.onload = function () {
+ var payload = null;
+ try {
+ payload = JSON.parse(xhr.responseText);
+ } catch (e) {
+ cb(null);
+ return;
+ }
+ cb(payload);
+ };
+ xhr.onerror = function () { cb(null); };
+ xhr.send(body != null ? JSON.stringify(body) : null);
+ }
+
+ function postSignal(basePath, sessionId, peerRole, msgType, payload, cb) {
+ xhrJson('POST', signalingUrl(basePath), {
+ action: 'post',
+ session_id: sessionId,
+ peer_role: peerRole,
+ msg_type: msgType,
+ payload: payload
+ }, cb);
+ }
+
+ function pollSignals(basePath, sessionId, sinceId, fromRole, cb) {
+ var qs = '?action=poll&session_id=' + encodeURIComponent(sessionId)
+ + '&since_id=' + encodeURIComponent(String(sinceId || 0));
+ if (fromRole) {
+ qs += '&from_role=' + encodeURIComponent(fromRole);
+ }
+ xhrJson('GET', signalingUrl(basePath) + qs, null, cb);
+ }
+
+ function collectStats(pc, videoEl) {
+ var stats = {
+ ice_state: pc ? pc.iceConnectionState : 'new',
+ signaling_state: pc ? pc.signalingState : 'stable',
+ fps: 0,
+ inbound_kbps: 0,
+ outbound_kbps: 0,
+ rtt_ms: 0,
+ frames_decoded: 0
+ };
+ if (videoEl && videoEl.videoWidth) {
+ stats.frame_w = videoEl.videoWidth;
+ stats.frame_h = videoEl.videoHeight;
+ }
+ return stats;
+ }
+
+ function enrichStatsFromRtc(pc, stats, cb) {
+ if (!pc || !pc.getStats) {
+ cb(stats);
+ return;
+ }
+ pc.getStats().then(function (report) {
+ report.forEach(function (r) {
+ if (r.type === 'inbound-rtp' && r.kind === 'video') {
+ stats.frames_decoded = r.framesDecoded || stats.frames_decoded;
+ if (r.bytesReceived && r.timestamp) {
+ stats.inbound_kbps = Math.round((r.bytesReceived * 8) / 1000);
+ }
+ }
+ if (r.type === 'outbound-rtp' && r.kind === 'video') {
+ if (r.bytesSent) {
+ stats.outbound_kbps = Math.round((r.bytesSent * 8) / 1000);
+ }
+ }
+ if (r.type === 'candidate-pair' && r.state === 'succeeded' && r.currentRoundTripTime) {
+ stats.rtt_ms = Math.round(r.currentRoundTripTime * 1000);
+ }
+ });
+ cb(stats);
+ }).catch(function () { cb(stats); });
+ }
+
+ function sendHeartbeat(basePath, sessionId, status, stats, cb) {
+ var body = { action: 'heartbeat', session_id: sessionId, status: status || 'active' };
+ if (stats) body.stats = stats;
+ xhrJson('POST', castApiUrl(basePath), body, cb || function () {});
+ }
+
+ /**
+ * @param {object} opts
+ * @param {string} opts.basePath
+ * @param {string} opts.sessionId
+ * @param {'caster'|'viewer'|'mobile'} opts.role
+ * @param {MediaStream|null} opts.localStream
+ * @param {function(MediaStream)} [opts.onRemoteStream]
+ * @param {function(object)} [opts.onStats]
+ * @param {function(string)} [opts.onStatus]
+ */
+ function connect(opts) {
+ var basePath = opts.basePath || '';
+ var sessionId = opts.sessionId || '';
+ var role = opts.role || 'viewer';
+ var localStream = opts.localStream || null;
+ var onRemoteStream = opts.onRemoteStream || function () {};
+ var onStats = opts.onStats || function () {};
+ var onStatus = opts.onStatus || function () {};
+ var remoteRole = role === 'caster' ? 'viewer' : 'caster';
+ var sinceId = 0;
+ var pc = new RTCPeerConnection({ iceServers: STUN });
+ var pollTimer = null;
+ var hbTimer = null;
+ var remoteVideo = null;
+ var stopped = false;
+
+ function status(msg) {
+ onStatus(msg);
+ }
+
+ function teardown() {
+ stopped = true;
+ if (pollTimer) clearInterval(pollTimer);
+ if (hbTimer) clearInterval(hbTimer);
+ pollTimer = null;
+ hbTimer = null;
+ try { pc.close(); } catch (e) { /* ignore */ }
+ }
+
+ pc.onicecandidate = function (ev) {
+ if (!ev.candidate) return;
+ postSignal(basePath, sessionId, role, 'candidate', {
+ candidate: ev.candidate.candidate,
+ sdpMid: ev.candidate.sdpMid,
+ sdpMLineIndex: ev.candidate.sdpMLineIndex
+ }, function () {});
+ };
+
+ pc.ontrack = function (ev) {
+ var stream = ev.streams && ev.streams[0] ? ev.streams[0] : new MediaStream([ev.track]);
+ onRemoteStream(stream);
+ };
+
+ pc.oniceconnectionstatechange = function () {
+ status('ICE: ' + pc.iceConnectionState);
+ };
+
+ if (localStream) {
+ localStream.getTracks().forEach(function (t) { pc.addTrack(t, localStream); });
+ }
+
+ function applyRemoteDescription(payload, cb) {
+ if (!payload || !payload.type || !payload.sdp) {
+ cb(false);
+ return;
+ }
+ pc.setRemoteDescription(payload).then(function () {
+ cb(true);
+ }).catch(function () { cb(false); });
+ }
+
+ function handlePollMessages(messages) {
+ if (!messages || !messages.length) return;
+ messages.forEach(function (msg) {
+ sinceId = Math.max(sinceId, msg.id || 0);
+ var payload = msg.payload || {};
+ if (msg.msg_type === 'offer' && role === 'viewer') {
+ applyRemoteDescription(payload, function (ok) {
+ if (!ok) return;
+ pc.createAnswer().then(function (answer) {
+ return pc.setLocalDescription(answer);
+ }).then(function () {
+ postSignal(basePath, sessionId, role, 'answer', pc.localDescription, function () {});
+ status('Answer sent — connecting…');
+ }).catch(function () {
+ status('Could not create WebRTC answer.');
+ });
+ });
+ } else if (msg.msg_type === 'answer' && role === 'caster') {
+ applyRemoteDescription(payload, function (ok) {
+ if (ok) status('Viewer connected — streaming.');
+ });
+ } else if (msg.msg_type === 'candidate' && payload.candidate) {
+ pc.addIceCandidate({
+ candidate: payload.candidate,
+ sdpMid: payload.sdpMid,
+ sdpMLineIndex: payload.sdpMLineIndex
+ }).catch(function () {});
+ }
+ });
+ }
+
+ pollTimer = setInterval(function () {
+ if (stopped) return;
+ pollSignals(basePath, sessionId, sinceId, remoteRole, function (resp) {
+ if (!resp || !resp.ok) return;
+ handlePollMessages(resp.messages || []);
+ if (typeof resp.since_id === 'number') {
+ sinceId = Math.max(sinceId, resp.since_id);
+ }
+ });
+ }, 800);
+
+ hbTimer = setInterval(function () {
+ if (stopped) return;
+ enrichStatsFromRtc(pc, collectStats(pc, remoteVideo), function (stats) {
+ onStats(stats);
+ sendHeartbeat(basePath, sessionId, 'active', stats);
+ });
+ }, 5000);
+
+ if (role === 'caster') {
+ status('Publishing offer…');
+ pc.createOffer().then(function (offer) {
+ return pc.setLocalDescription(offer);
+ }).then(function () {
+ postSignal(basePath, sessionId, role, 'offer', pc.localDescription, function (resp) {
+ if (resp && resp.ok) {
+ status('Waiting for viewer…');
+ } else {
+ status('Signaling failed.');
+ }
+ });
+ }).catch(function () {
+ status('Could not create WebRTC offer.');
+ });
+ } else {
+ status('Waiting for caster offer…');
+ }
+
+ return {
+ setRemoteVideo: function (el) { remoteVideo = el; },
+ stop: function () {
+ sendHeartbeat(basePath, sessionId, 'ended', null);
+ teardown();
+ }
+ };
+ }
+
+ global.LiveCastWebRtc = {
+ connect: connect,
+ sendHeartbeat: sendHeartbeat,
+ collectStats: collectStats
+ };
+})(typeof window !== 'undefined' ? window : this);
diff --git a/examples/crash_reporter/backend/public/assets/js/live_education.js b/examples/crash_reporter/backend/public/assets/js/live_education.js
index 0e16f6d..ddd51c0 100644
--- a/examples/crash_reporter/backend/public/assets/js/live_education.js
+++ b/examples/crash_reporter/backend/public/assets/js/live_education.js
@@ -6,6 +6,7 @@
var secondsLeft = MAX_DEMO_S;
var mediaStream = null;
var sessionId = '';
+ var webrtc = null;
function basePath() {
return document.body.getAttribute('data-base-path') || '';
@@ -59,11 +60,31 @@
node.textContent = m + ':' + (s < 10 ? '0' : '') + s;
}
+ function renderShare(session) {
+ var box = el('edu-share');
+ if (!box || !session) return;
+ var joinUrl = session.join_short_url || '';
+ var qrUrl = session.join_qr_url || '';
+ var longJoin = basePath() + '/live/join?session_id=' + encodeURIComponent(session.session_id || sessionId);
+ if (!joinUrl) joinUrl = longJoin;
+ var html = 'Share this demo Viewers open this link in another browser (same LAN or online).
';
+ html += '' + joinUrl + '
';
+ if (qrUrl) {
+ html += 'Scan to open viewer page ';
+ }
+ box.hidden = false;
+ box.innerHTML = html;
+ }
+
function stopDemo() {
if (timer) {
clearInterval(timer);
timer = null;
}
+ if (webrtc) {
+ webrtc.stop();
+ webrtc = null;
+ }
if (mediaStream) {
mediaStream.getTracks().forEach(function (t) { t.stop(); });
mediaStream = null;
@@ -74,16 +95,41 @@
postJson({ action: 'heartbeat', session_id: sessionId, status: 'ended' }, function () {});
sessionId = '';
}
+ var share = el('edu-share');
+ if (share) share.hidden = true;
el('edu-start').disabled = false;
el('edu-stop').disabled = true;
setStatus('Demo ended. Thanks for trying AndroidCast education mode.');
}
+ function startWebRtc() {
+ if (!window.LiveCastWebRtc || !sessionId || !mediaStream) return;
+ webrtc = window.LiveCastWebRtc.connect({
+ basePath: basePath(),
+ sessionId: sessionId,
+ role: 'caster',
+ localStream: mediaStream,
+ onStatus: setStatus,
+ onStats: function (stats) {
+ var nerds = el('edu-stats');
+ if (!nerds) return;
+ nerds.hidden = false;
+ nerds.textContent = 'Stats: ICE ' + (stats.ice_state || '—')
+ + ' · out ' + (stats.outbound_kbps || 0) + ' kbps'
+ + ' · RTT ' + (stats.rtt_ms || 0) + ' ms';
+ }
+ });
+ }
+
function startDemo() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
setStatus('Screen sharing is not supported in this browser.');
return;
}
+ if (!window.RTCPeerConnection) {
+ setStatus('WebRTC is not available in this browser.');
+ return;
+ }
setStatus('Requesting screen share permission…');
navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
.then(function (stream) {
@@ -105,22 +151,21 @@
bw_mode: 'demo'
}, function (resp) {
if (!resp || !resp.ok || !resp.session) {
- setStatus('Could not register demo session. Sign in or try again later.');
+ setStatus('Could not register demo session. Try again later.');
stopDemo();
return;
}
sessionId = resp.session.session_id || '';
+ renderShare(resp.session);
+ startWebRtc();
secondsLeft = MAX_DEMO_S;
updateTimer();
el('edu-start').disabled = true;
el('edu-stop').disabled = false;
- setStatus('Live demo — max ' + MAX_DEMO_S / 60 + ' minutes.');
+ setStatus('Live demo — share the link so others can watch.');
timer = setInterval(function () {
secondsLeft -= 1;
updateTimer();
- if (sessionId && secondsLeft % 60 === 0) {
- postJson({ action: 'heartbeat', session_id: sessionId, status: 'active' }, function () {});
- }
if (secondsLeft <= 0) {
stopDemo();
}
diff --git a/examples/crash_reporter/backend/public/assets/js/live_join.js b/examples/crash_reporter/backend/public/assets/js/live_join.js
index e6fb0ab..45d4658 100644
--- a/examples/crash_reporter/backend/public/assets/js/live_join.js
+++ b/examples/crash_reporter/backend/public/assets/js/live_join.js
@@ -86,6 +86,50 @@
return st;
}
+ function renderStats(statsEl, stats) {
+ if (!statsEl || !stats) return;
+ statsEl.hidden = false;
+ statsEl.innerHTML =
+ 'Statistics for nerds '
+ + 'ICE: ' + escapeHtml(stats.ice_state || '—')
+ + ' · sig: ' + escapeHtml(stats.signaling_state || '—')
+ + ' In: ' + (stats.inbound_kbps || 0) + ' kbps · Out: ' + (stats.outbound_kbps || 0) + ' kbps'
+ + ' RTT: ' + (stats.rtt_ms || 0) + ' ms · frames: ' + (stats.frames_decoded || 0)
+ + (stats.frame_w ? ' Frame: ' + stats.frame_w + '×' + stats.frame_h : '');
+ }
+
+ function startViewer(sessionId, session, playerEl, statusEl, statsEl) {
+ if (!window.RTCPeerConnection || !window.LiveCastWebRtc) {
+ playerEl.innerHTML = 'WebRTC is not available in this browser.
';
+ return null;
+ }
+ playerEl.innerHTML =
+ ' '
+ + '
';
+ var video = el('live-remote-video');
+ var nerds = el('live-join-stats');
+ var conn = window.LiveCastWebRtc.connect({
+ basePath: basePath(),
+ sessionId: sessionId,
+ role: 'viewer',
+ onStatus: function (msg) {
+ statusEl.textContent = formatStatus(session) + ' — ' + msg;
+ },
+ onRemoteStream: function (stream) {
+ if (video) {
+ video.srcObject = stream;
+ video.play().catch(function () {});
+ }
+ statusEl.textContent = formatStatus(session) + ' — playing stream';
+ },
+ onStats: function (stats) {
+ renderStats(nerds || statsEl, stats);
+ }
+ });
+ if (conn && video) conn.setRemoteVideo(video);
+ return conn;
+ }
+
function boot() {
var root = el('live-join-app');
if (!root) return;
@@ -94,6 +138,7 @@
var sessionId = params.get('session_id') || root.getAttribute('data-session-id') || '';
var direct = params.get('direct') === '1' || root.getAttribute('data-direct') === '1';
var role = params.get('role') || (direct ? 'direct' : 'viewer');
+ var webrtcConn = null;
if (!sessionId) {
el('live-join-status').textContent = 'Missing session_id in URL.';
@@ -105,6 +150,7 @@
var statusEl = el('live-join-status');
var metaEl = el('live-join-meta');
var playerEl = el('live-join-player');
+ var statsEl = el('live-join-stats-panel');
function render(session) {
if (!session) {
@@ -112,7 +158,7 @@
return;
}
var live = session.status === 'live' || session.status === 'intent';
- statusEl.textContent = formatStatus(session) + (live ? ' — waiting for stream' : '');
+ statusEl.textContent = formatStatus(session) + (live ? ' — connecting…' : '');
var owner = session.owner_username || 'host';
var lines = [
'Host: ' + owner,
@@ -121,24 +167,24 @@
'Audio: ' + (session.codec_audio || '—'),
'Bandwidth: ' + (session.bw_mode || '—')
];
+ if (session.join_short_url) {
+ lines.push('Viewer link: ' + session.join_short_url);
+ }
metaEl.innerHTML = lines.map(function (l) {
return '' + escapeHtml(l) + ' ';
}).join('');
if (direct && /android|ios/i.test(detectPlatform())) {
playerEl.innerHTML =
- 'Open in the AndroidCast app to join this P2P session.
' +
- 'androidcast://join?session_id=' + escapeHtml(sessionId) + '&direct=1
';
+ 'Open in the AndroidCast app for direct P2P on LAN.
' +
+ 'androidcast://join?session_id=' + escapeHtml(sessionId) + '&direct=1
' +
+ 'Mobile WebRTC signaling uses the same REST API when the app transport is wired.
';
postJoinEvent(sessionId, 'join', true);
- } else if (role === 'viewer' || !direct) {
- playerEl.innerHTML =
- '' +
- '
HTML5 viewer placeholder
' +
- '
WebRTC media plane is not wired yet. Session control plane is active.
' +
- '
';
+ } else if (live) {
+ webrtcConn = startViewer(sessionId, session, playerEl, statusEl, statsEl);
postJoinEvent(sessionId, 'join', false);
} else {
- playerEl.innerHTML = 'Use a mobile device with direct=1 for P2P join.
';
+ playerEl.innerHTML = 'Session is not live.
';
}
if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) {
@@ -154,13 +200,20 @@
fetchSession(sessionId, render);
setInterval(function () {
fetchSession(sessionId, function (session) {
- if (session && (session.status === 'live' || session.status === 'intent')) {
- statusEl.textContent = formatStatus(session) + ' — waiting for stream';
- } else if (session) {
+ if (!session) return;
+ if (!(session.status === 'live' || session.status === 'intent')) {
statusEl.textContent = formatStatus(session);
+ if (webrtcConn) {
+ webrtcConn.stop();
+ webrtcConn = null;
+ }
}
});
}, 30000);
+
+ window.addEventListener('beforeunload', function () {
+ if (webrtcConn) webrtcConn.stop();
+ });
}
if (document.readyState === 'loading') {
diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php
index c7f6ef4..2f5cdfa 100644
--- a/examples/crash_reporter/backend/public/index.php
+++ b/examples/crash_reporter/backend/public/index.php
@@ -107,6 +107,11 @@ if ($route === '/api/live_cast.php' || str_ends_with($route, '/api/live_cast.php
exit;
}
+if ($route === '/api/live_cast_signaling.php' || str_ends_with($route, '/api/live_cast_signaling.php')) {
+ require __DIR__ . '/api/live_cast_signaling.php';
+ exit;
+}
+
if ($route === '/live/join' || str_ends_with($route, '/live/join')) {
require __DIR__ . '/../views/live_join.php';
exit;
diff --git a/examples/crash_reporter/backend/src/Auth.php b/examples/crash_reporter/backend/src/Auth.php
index 9981227..f7db881 100644
--- a/examples/crash_reporter/backend/src/Auth.php
+++ b/examples/crash_reporter/backend/src/Auth.php
@@ -138,8 +138,16 @@ final class Auth {
}
public static function basePath(): string {
- $bp = cfg('base_path', '');
- return rtrim($bp, '/');
+ $bp = rtrim((string) cfg('base_path', ''), '/');
+ if ($bp === '') {
+ return $bp;
+ }
+ // If nginx serves /issues/ but config.php still has legacy /crashes, asset URLs must match nginx.
+ $uri = (string) ($_SERVER['REQUEST_URI'] ?? '');
+ if (str_contains($uri, '/issues') && str_ends_with($bp, '/crashes')) {
+ return substr($bp, 0, -strlen('/crashes')) . '/issues';
+ }
+ return $bp;
}
/** Shared project prefix, e.g. /app/androidcast_project */
diff --git a/examples/crash_reporter/backend/src/LiveCastRepository.php b/examples/crash_reporter/backend/src/LiveCastRepository.php
index f8b7953..0b5398a 100644
--- a/examples/crash_reporter/backend/src/LiveCastRepository.php
+++ b/examples/crash_reporter/backend/src/LiveCastRepository.php
@@ -236,6 +236,23 @@ final class LiveCastRepository {
if ($stmt->rowCount() < 1) {
return null;
}
+ if (isset($input['stats']) && is_array($input['stats'])) {
+ $row = self::sessionById($sessionId);
+ $meta = [];
+ if ($row !== null && !empty($row['metadata_json'])) {
+ $decoded = json_decode((string) $row['metadata_json'], true);
+ if (is_array($decoded)) {
+ $meta = $decoded;
+ }
+ }
+ $meta['last_stats'] = $input['stats'];
+ $meta['last_stats_ms'] = $now;
+ $upd = $pdo->prepare('UPDATE live_cast_sessions SET metadata_json = ? WHERE session_id = ?');
+ $upd->execute([
+ safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
+ $sessionId,
+ ]);
+ }
return self::sessionById($sessionId);
}
@@ -409,6 +426,7 @@ final class LiveCastRepository {
'codec_video' => (string) ($row['codec_video'] ?? ''),
'codec_audio' => (string) ($row['codec_audio'] ?? ''),
'bw_mode' => (string) ($row['bw_mode'] ?? ''),
+ 'metadata_json' => (string) ($row['metadata_json'] ?? ''),
];
}
@@ -418,7 +436,10 @@ final class LiveCastRepository {
}
private static function joinLongUrl(string $sessionId, bool $direct): string {
- $base = rtrim((string) cfg('base_path', '/app/androidcast_project/crashes'), '/');
+ $base = rtrim((string) cfg('base_path', '/app/androidcast_project/issues'), '/');
+ if (str_ends_with($base, '/crashes')) {
+ $base = substr($base, 0, -strlen('/crashes')) . '/issues';
+ }
$qs = 'session_id=' . rawurlencode($sessionId);
if ($direct) {
$qs .= '&direct=1';
@@ -549,6 +570,9 @@ final class LiveCastRepository {
'codec_video' => $row['codec_video'],
'codec_audio' => $row['codec_audio'],
'bw_mode' => $row['bw_mode'],
+ 'join_short_url' => $row['join_short_url'] ?? '',
+ 'join_qr_url' => $row['join_qr_url'] ?? '',
+ 'signaling_api' => Auth::basePath() . '/api/live_cast_signaling.php',
];
}
diff --git a/examples/crash_reporter/backend/src/LiveCastSignalingRepository.php b/examples/crash_reporter/backend/src/LiveCastSignalingRepository.php
new file mode 100644
index 0000000..219eddb
--- /dev/null
+++ b/examples/crash_reporter/backend/src/LiveCastSignalingRepository.php
@@ -0,0 +1,134 @@
+exec(
+ "CREATE TABLE live_cast_signal_messages (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ session_id VARCHAR(96) NOT NULL,
+ peer_role VARCHAR(16) NOT NULL DEFAULT 'caster',
+ msg_type VARCHAR(24) NOT NULL,
+ payload_json LONGTEXT NOT NULL,
+ created_at_ms BIGINT NOT NULL,
+ KEY idx_live_signal_session_id (session_id, id),
+ KEY idx_live_signal_session_type (session_id, msg_type, id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
+ );
+ } else {
+ $pdo->exec(
+ "CREATE TABLE live_cast_signal_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ peer_role TEXT NOT NULL DEFAULT 'caster',
+ msg_type TEXT NOT NULL,
+ payload_json TEXT NOT NULL,
+ created_at_ms INTEGER NOT NULL
+ )"
+ );
+ $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_signal_session_id ON live_cast_signal_messages (session_id, id)');
+ }
+ }
+ });
+ self::$schemaEnsured = true;
+ }
+
+ /** @param array $payload */
+ public static function post(string $sessionId, string $peerRole, string $msgType, array $payload): int {
+ self::ensureSchema();
+ $sessionId = trim($sessionId);
+ $peerRole = strtolower(trim($peerRole));
+ $msgType = strtolower(trim($msgType));
+ if ($sessionId === '' || $msgType === '') {
+ return 0;
+ }
+ if (!in_array($peerRole, ['caster', 'viewer', 'mobile'], true)) {
+ $peerRole = 'caster';
+ }
+ $now = (int) floor(microtime(true) * 1000);
+ $pdo = Database::pdo();
+ $stmt = $pdo->prepare(
+ 'INSERT INTO live_cast_signal_messages (session_id, peer_role, msg_type, payload_json, created_at_ms)
+ VALUES (?, ?, ?, ?, ?)'
+ );
+ $stmt->execute([
+ $sessionId,
+ $peerRole,
+ $msgType,
+ safe_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
+ $now,
+ ]);
+ return (int) $pdo->lastInsertId();
+ }
+
+ /** @return list> */
+ public static function poll(string $sessionId, int $sinceId, ?string $msgType = null, ?string $fromRole = null): array {
+ self::ensureSchema();
+ $sessionId = trim($sessionId);
+ if ($sessionId === '') {
+ return [];
+ }
+ $sinceId = max(0, $sinceId);
+ $where = ['session_id = ?', 'id > ?'];
+ $params = [$sessionId, $sinceId];
+ if ($msgType !== null && trim($msgType) !== '') {
+ $where[] = 'msg_type = ?';
+ $params[] = strtolower(trim($msgType));
+ }
+ if ($fromRole !== null && trim($fromRole) !== '') {
+ $where[] = 'peer_role = ?';
+ $params[] = strtolower(trim($fromRole));
+ }
+ $pdo = Database::pdo();
+ $stmt = $pdo->prepare(
+ 'SELECT id, session_id, peer_role, msg_type, payload_json, created_at_ms
+ FROM live_cast_signal_messages
+ WHERE ' . implode(' AND ', $where) . '
+ ORDER BY id ASC LIMIT 200'
+ );
+ $stmt->execute($params);
+ $out = [];
+ foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ $payload = json_decode((string) ($row['payload_json'] ?? ''), true);
+ $out[] = [
+ 'id' => (int) ($row['id'] ?? 0),
+ 'session_id' => (string) ($row['session_id'] ?? ''),
+ 'peer_role' => (string) ($row['peer_role'] ?? ''),
+ 'msg_type' => (string) ($row['msg_type'] ?? ''),
+ 'payload' => is_array($payload) ? $payload : [],
+ 'created_at_ms' => (int) ($row['created_at_ms'] ?? 0),
+ ];
+ }
+ return $out;
+ }
+
+ /** @return array|null */
+ public static function latest(string $sessionId, string $msgType, ?string $fromRole = null): ?array {
+ self::ensureSchema();
+ $rows = self::poll($sessionId, 0, $msgType, $fromRole);
+ if ($rows === []) {
+ return null;
+ }
+ return $rows[count($rows) - 1];
+ }
+
+ public static function purgeSession(string $sessionId): void {
+ self::ensureSchema();
+ $sessionId = trim($sessionId);
+ if ($sessionId === '') {
+ return;
+ }
+ $stmt = Database::pdo()->prepare('DELETE FROM live_cast_signal_messages WHERE session_id = ?');
+ $stmt->execute([$sessionId]);
+ }
+}
diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php
index 9d1f07a..ea17cf0 100644
--- a/examples/crash_reporter/backend/src/bootstrap.php
+++ b/examples/crash_reporter/backend/src/bootstrap.php
@@ -65,6 +65,8 @@ require_once __DIR__ . '/TicketRepository.php';
require_once __DIR__ . '/GraphRepository.php';
require_once __DIR__ . '/RemoteAccessRepository.php';
require_once __DIR__ . '/LiveCastRepository.php';
+require_once __DIR__ . '/LiveCastSignalingRepository.php';
+require_once __DIR__ . '/LiveCastSignalingRepository.php';
require_once __DIR__ . '/UrlShortenerDatabase.php';
require_once __DIR__ . '/ShortLinksRepository.php';
require_once __DIR__ . '/WireGuardPeerProvisioner.php';
diff --git a/examples/crash_reporter/backend/views/live_education.php b/examples/crash_reporter/backend/views/live_education.php
index 709bdd7..c63bd94 100644
--- a/examples/crash_reporter/backend/views/live_education.php
+++ b/examples/crash_reporter/backend/views/live_education.php
@@ -17,6 +17,7 @@ $bp = Auth::basePath();
+
@@ -31,13 +32,15 @@ $bp = Auth::basePath();
Ready when you are.
Time left: 5:00
+
+
Start screen share
Stop
Enable notifications
diff --git a/examples/crash_reporter/backend/views/live_join.php b/examples/crash_reporter/backend/views/live_join.php
index e6637ab..954dc0b 100644
--- a/examples/crash_reporter/backend/views/live_join.php
+++ b/examples/crash_reporter/backend/views/live_join.php
@@ -20,6 +20,7 @@ $role = trim((string) ($_GET['role'] ?? ''));
+
Loading session…
+
diff --git a/examples/platform/footer.config.example.php b/examples/platform/footer.config.example.php
index 67fa1d5..0d3209f 100644
--- a/examples/platform/footer.config.example.php
+++ b/examples/platform/footer.config.example.php
@@ -20,7 +20,10 @@ return [
'bottom' => null,
// --- Default left column when `left` is null ---
- 'holder' => 'Anton Afanaasyeu , 2026 - current',
+ // Use holder_name + holder_url for a safe link (holder HTML is escaped if used alone).
+ 'holder_name' => 'Anton Afanaasyeu',
+ 'holder_url' => 'https://f0xx.org',
+ 'holder' => null,
'copyright_symbol' => "\u{00A9}",
'show_year' => false,
// null = current calendar year at render time (end of range).
diff --git a/examples/platform/footer.php b/examples/platform/footer.php
index ec5c045..d7dee69 100644
--- a/examples/platform/footer.php
+++ b/examples/platform/footer.php
@@ -74,13 +74,17 @@ function platform_footer_year_label(array $cfg): string
function platform_footer_default_left(array $cfg): string
{
$symbol = trim((string) ($cfg['copyright_symbol'] ?? "\u{00A9}"));
+ $holderName = trim((string) ($cfg['holder_name'] ?? ''));
+ $holderUrl = trim((string) ($cfg['holder_url'] ?? ''));
$holder = trim((string) ($cfg['holder'] ?? ''));
$showYear = (bool) ($cfg['show_year'] ?? true);
$parts = [];
if ($symbol !== '') {
$parts[] = $symbol;
}
- if ($holder !== '') {
+ if ($holderName !== '') {
+ $parts[] = $holderName;
+ } elseif ($holder !== '') {
$parts[] = $holder;
}
$line = implode(' ', $parts);
@@ -95,6 +99,41 @@ function platform_footer_default_left(array $cfg): string
return $line . ', ' . $yearLabel;
}
+/** Render left footer HTML (holder link when configured). */
+function platform_footer_render_left_html(array $cfg, array $opts): ?string
+{
+ $raw = platform_footer_resolve_left($cfg, $opts);
+ if ($raw === null) {
+ return null;
+ }
+ $holderName = trim((string) ($cfg['holder_name'] ?? ''));
+ $holderUrl = trim((string) ($cfg['holder_url'] ?? ''));
+ if (platform_footer_field($cfg, $opts, 'left') === null
+ && $holderName !== '' && $holderUrl !== '') {
+ $text = platform_footer_h($raw);
+ $name = platform_footer_h($holderName);
+ $link = '' . $name . ' ';
+ if (str_contains($text, $name)) {
+ return substr_replace($text, $link, strpos($text, $name), strlen($name));
+ }
+ return $link . ($text !== '' ? ' ' . $text : '');
+ }
+
+ return platform_footer_h($raw);
+}
+
+/** True when default left uses holder_name + holder_url (skip i18n text replacement). */
+function platform_footer_left_has_link(array $cfg, array $opts): bool
+{
+ if (platform_footer_field($cfg, $opts, 'left') !== null) {
+ return false;
+ }
+ $holderName = trim((string) ($cfg['holder_name'] ?? ''));
+ $holderUrl = trim((string) ($cfg['holder_url'] ?? ''));
+
+ return $holderName !== '' && $holderUrl !== '';
+}
+
/**
* Resolve optional row text (null/empty = omit row). Supports per-render overrides in $opts.
*
@@ -167,7 +206,9 @@ function platform_render_footer(array $opts = []): void
$useI18n = $opts['use_i18n'] ?? (bool) ($cfg['use_i18n'] ?? true);
$i18nKey = (string) ($cfg['i18n_key'] ?? 'footer.copyright');
$leftUsesDefault = platform_footer_field($cfg, $opts, 'left') === null;
+ $leftHasLink = platform_footer_left_has_link($cfg, $opts);
$yearEnd = platform_footer_year_end($cfg);
+ $leftHtml = platform_footer_render_left_html($cfg, $opts);
echo '