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 += '
Viewer QR code
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

    + +

    - Media is previewed locally only until the WebRTC viewer ships. Session metadata is recorded for analytics. + WebRTC P2P (no SFU): share the viewer link or QR. Same REST signaling API supports future mobile cast to browser on LAN.

    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 '