mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:18:09 +03:00
features
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||||
|
$sessionId = trim((string) ($_GET['session_id'] ?? ''));
|
||||||
|
$sinceId = (int) ($_GET['since_id'] ?? 0);
|
||||||
|
$msgType = trim((string) ($_GET['msg_type'] ?? ''));
|
||||||
|
$fromRole = trim((string) ($_GET['from_role'] ?? ''));
|
||||||
|
$body = [];
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$raw = file_get_contents('php://input') ?: '';
|
||||||
|
$body = json_decode($raw, true);
|
||||||
|
if (!is_array($body)) {
|
||||||
|
json_out(['ok' => 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]);
|
||||||
@@ -2383,3 +2383,41 @@ button.report-tag--filter:hover {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 0.88rem;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
var secondsLeft = MAX_DEMO_S;
|
var secondsLeft = MAX_DEMO_S;
|
||||||
var mediaStream = null;
|
var mediaStream = null;
|
||||||
var sessionId = '';
|
var sessionId = '';
|
||||||
|
var webrtc = null;
|
||||||
|
|
||||||
function basePath() {
|
function basePath() {
|
||||||
return document.body.getAttribute('data-base-path') || '';
|
return document.body.getAttribute('data-base-path') || '';
|
||||||
@@ -59,11 +60,31 @@
|
|||||||
node.textContent = m + ':' + (s < 10 ? '0' : '') + s;
|
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 = '<h2>Share this demo</h2><p class="muted">Viewers open this link in another browser (same LAN or online).</p>';
|
||||||
|
html += '<p><a href="' + joinUrl + '" target="_blank" rel="noopener">' + joinUrl + '</a></p>';
|
||||||
|
if (qrUrl) {
|
||||||
|
html += '<figure class="twofa-qr-wrap"><img src="' + qrUrl + '" width="160" height="160" alt="Viewer QR code"><figcaption class="muted">Scan to open viewer page</figcaption></figure>';
|
||||||
|
}
|
||||||
|
box.hidden = false;
|
||||||
|
box.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
function stopDemo() {
|
function stopDemo() {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
}
|
}
|
||||||
|
if (webrtc) {
|
||||||
|
webrtc.stop();
|
||||||
|
webrtc = null;
|
||||||
|
}
|
||||||
if (mediaStream) {
|
if (mediaStream) {
|
||||||
mediaStream.getTracks().forEach(function (t) { t.stop(); });
|
mediaStream.getTracks().forEach(function (t) { t.stop(); });
|
||||||
mediaStream = null;
|
mediaStream = null;
|
||||||
@@ -74,16 +95,41 @@
|
|||||||
postJson({ action: 'heartbeat', session_id: sessionId, status: 'ended' }, function () {});
|
postJson({ action: 'heartbeat', session_id: sessionId, status: 'ended' }, function () {});
|
||||||
sessionId = '';
|
sessionId = '';
|
||||||
}
|
}
|
||||||
|
var share = el('edu-share');
|
||||||
|
if (share) share.hidden = true;
|
||||||
el('edu-start').disabled = false;
|
el('edu-start').disabled = false;
|
||||||
el('edu-stop').disabled = true;
|
el('edu-stop').disabled = true;
|
||||||
setStatus('Demo ended. Thanks for trying AndroidCast education mode.');
|
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() {
|
function startDemo() {
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
||||||
setStatus('Screen sharing is not supported in this browser.');
|
setStatus('Screen sharing is not supported in this browser.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!window.RTCPeerConnection) {
|
||||||
|
setStatus('WebRTC is not available in this browser.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStatus('Requesting screen share permission…');
|
setStatus('Requesting screen share permission…');
|
||||||
navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
|
navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
|
||||||
.then(function (stream) {
|
.then(function (stream) {
|
||||||
@@ -105,22 +151,21 @@
|
|||||||
bw_mode: 'demo'
|
bw_mode: 'demo'
|
||||||
}, function (resp) {
|
}, function (resp) {
|
||||||
if (!resp || !resp.ok || !resp.session) {
|
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();
|
stopDemo();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sessionId = resp.session.session_id || '';
|
sessionId = resp.session.session_id || '';
|
||||||
|
renderShare(resp.session);
|
||||||
|
startWebRtc();
|
||||||
secondsLeft = MAX_DEMO_S;
|
secondsLeft = MAX_DEMO_S;
|
||||||
updateTimer();
|
updateTimer();
|
||||||
el('edu-start').disabled = true;
|
el('edu-start').disabled = true;
|
||||||
el('edu-stop').disabled = false;
|
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 () {
|
timer = setInterval(function () {
|
||||||
secondsLeft -= 1;
|
secondsLeft -= 1;
|
||||||
updateTimer();
|
updateTimer();
|
||||||
if (sessionId && secondsLeft % 60 === 0) {
|
|
||||||
postJson({ action: 'heartbeat', session_id: sessionId, status: 'active' }, function () {});
|
|
||||||
}
|
|
||||||
if (secondsLeft <= 0) {
|
if (secondsLeft <= 0) {
|
||||||
stopDemo();
|
stopDemo();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,50 @@
|
|||||||
return st;
|
return st;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStats(statsEl, stats) {
|
||||||
|
if (!statsEl || !stats) return;
|
||||||
|
statsEl.hidden = false;
|
||||||
|
statsEl.innerHTML =
|
||||||
|
'<strong>Statistics for nerds</strong><br>'
|
||||||
|
+ 'ICE: ' + escapeHtml(stats.ice_state || '—')
|
||||||
|
+ ' · sig: ' + escapeHtml(stats.signaling_state || '—')
|
||||||
|
+ '<br>In: ' + (stats.inbound_kbps || 0) + ' kbps · Out: ' + (stats.outbound_kbps || 0) + ' kbps'
|
||||||
|
+ '<br>RTT: ' + (stats.rtt_ms || 0) + ' ms · frames: ' + (stats.frames_decoded || 0)
|
||||||
|
+ (stats.frame_w ? '<br>Frame: ' + stats.frame_w + '×' + stats.frame_h : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function startViewer(sessionId, session, playerEl, statusEl, statsEl) {
|
||||||
|
if (!window.RTCPeerConnection || !window.LiveCastWebRtc) {
|
||||||
|
playerEl.innerHTML = '<p class="muted">WebRTC is not available in this browser.</p>';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
playerEl.innerHTML =
|
||||||
|
'<video id="live-remote-video" class="live-preview" playsinline autoplay controls></video>'
|
||||||
|
+ '<div id="live-join-stats" class="live-stats-nerds" hidden></div>';
|
||||||
|
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() {
|
function boot() {
|
||||||
var root = el('live-join-app');
|
var root = el('live-join-app');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -94,6 +138,7 @@
|
|||||||
var sessionId = params.get('session_id') || root.getAttribute('data-session-id') || '';
|
var sessionId = params.get('session_id') || root.getAttribute('data-session-id') || '';
|
||||||
var direct = params.get('direct') === '1' || root.getAttribute('data-direct') === '1';
|
var direct = params.get('direct') === '1' || root.getAttribute('data-direct') === '1';
|
||||||
var role = params.get('role') || (direct ? 'direct' : 'viewer');
|
var role = params.get('role') || (direct ? 'direct' : 'viewer');
|
||||||
|
var webrtcConn = null;
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
el('live-join-status').textContent = 'Missing session_id in URL.';
|
el('live-join-status').textContent = 'Missing session_id in URL.';
|
||||||
@@ -105,6 +150,7 @@
|
|||||||
var statusEl = el('live-join-status');
|
var statusEl = el('live-join-status');
|
||||||
var metaEl = el('live-join-meta');
|
var metaEl = el('live-join-meta');
|
||||||
var playerEl = el('live-join-player');
|
var playerEl = el('live-join-player');
|
||||||
|
var statsEl = el('live-join-stats-panel');
|
||||||
|
|
||||||
function render(session) {
|
function render(session) {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -112,7 +158,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var live = session.status === 'live' || session.status === 'intent';
|
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 owner = session.owner_username || 'host';
|
||||||
var lines = [
|
var lines = [
|
||||||
'Host: ' + owner,
|
'Host: ' + owner,
|
||||||
@@ -121,24 +167,24 @@
|
|||||||
'Audio: ' + (session.codec_audio || '—'),
|
'Audio: ' + (session.codec_audio || '—'),
|
||||||
'Bandwidth: ' + (session.bw_mode || '—')
|
'Bandwidth: ' + (session.bw_mode || '—')
|
||||||
];
|
];
|
||||||
|
if (session.join_short_url) {
|
||||||
|
lines.push('Viewer link: ' + session.join_short_url);
|
||||||
|
}
|
||||||
metaEl.innerHTML = lines.map(function (l) {
|
metaEl.innerHTML = lines.map(function (l) {
|
||||||
return '<li>' + escapeHtml(l) + '</li>';
|
return '<li>' + escapeHtml(l) + '</li>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
if (direct && /android|ios/i.test(detectPlatform())) {
|
if (direct && /android|ios/i.test(detectPlatform())) {
|
||||||
playerEl.innerHTML =
|
playerEl.innerHTML =
|
||||||
'<p class="muted">Open in the AndroidCast app to join this P2P session.</p>' +
|
'<p class="muted">Open in the AndroidCast app for direct P2P on LAN.</p>' +
|
||||||
'<p><code>androidcast://join?session_id=' + escapeHtml(sessionId) + '&direct=1</code></p>';
|
'<p><code>androidcast://join?session_id=' + escapeHtml(sessionId) + '&direct=1</code></p>' +
|
||||||
|
'<p class="muted">Mobile WebRTC signaling uses the same REST API when the app transport is wired.</p>';
|
||||||
postJoinEvent(sessionId, 'join', true);
|
postJoinEvent(sessionId, 'join', true);
|
||||||
} else if (role === 'viewer' || !direct) {
|
} else if (live) {
|
||||||
playerEl.innerHTML =
|
webrtcConn = startViewer(sessionId, session, playerEl, statusEl, statsEl);
|
||||||
'<div class="live-player-shell">' +
|
|
||||||
'<p class="live-player-placeholder">HTML5 viewer placeholder</p>' +
|
|
||||||
'<p class="muted">WebRTC media plane is not wired yet. Session control plane is active.</p>' +
|
|
||||||
'</div>';
|
|
||||||
postJoinEvent(sessionId, 'join', false);
|
postJoinEvent(sessionId, 'join', false);
|
||||||
} else {
|
} else {
|
||||||
playerEl.innerHTML = '<p class="muted">Use a mobile device with direct=1 for P2P join.</p>';
|
playerEl.innerHTML = '<p class="muted">Session is not live.</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) {
|
if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) {
|
||||||
@@ -154,13 +200,20 @@
|
|||||||
fetchSession(sessionId, render);
|
fetchSession(sessionId, render);
|
||||||
setInterval(function () {
|
setInterval(function () {
|
||||||
fetchSession(sessionId, function (session) {
|
fetchSession(sessionId, function (session) {
|
||||||
if (session && (session.status === 'live' || session.status === 'intent')) {
|
if (!session) return;
|
||||||
statusEl.textContent = formatStatus(session) + ' — waiting for stream';
|
if (!(session.status === 'live' || session.status === 'intent')) {
|
||||||
} else if (session) {
|
|
||||||
statusEl.textContent = formatStatus(session);
|
statusEl.textContent = formatStatus(session);
|
||||||
|
if (webrtcConn) {
|
||||||
|
webrtcConn.stop();
|
||||||
|
webrtcConn = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function () {
|
||||||
|
if (webrtcConn) webrtcConn.stop();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ if ($route === '/api/live_cast.php' || str_ends_with($route, '/api/live_cast.php
|
|||||||
exit;
|
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')) {
|
if ($route === '/live/join' || str_ends_with($route, '/live/join')) {
|
||||||
require __DIR__ . '/../views/live_join.php';
|
require __DIR__ . '/../views/live_join.php';
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
@@ -138,8 +138,16 @@ final class Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static function basePath(): string {
|
public static function basePath(): string {
|
||||||
$bp = cfg('base_path', '');
|
$bp = rtrim((string) cfg('base_path', ''), '/');
|
||||||
return rtrim($bp, '/');
|
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 */
|
/** Shared project prefix, e.g. /app/androidcast_project */
|
||||||
|
|||||||
@@ -236,6 +236,23 @@ final class LiveCastRepository {
|
|||||||
if ($stmt->rowCount() < 1) {
|
if ($stmt->rowCount() < 1) {
|
||||||
return null;
|
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);
|
return self::sessionById($sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,6 +426,7 @@ final class LiveCastRepository {
|
|||||||
'codec_video' => (string) ($row['codec_video'] ?? ''),
|
'codec_video' => (string) ($row['codec_video'] ?? ''),
|
||||||
'codec_audio' => (string) ($row['codec_audio'] ?? ''),
|
'codec_audio' => (string) ($row['codec_audio'] ?? ''),
|
||||||
'bw_mode' => (string) ($row['bw_mode'] ?? ''),
|
'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 {
|
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);
|
$qs = 'session_id=' . rawurlencode($sessionId);
|
||||||
if ($direct) {
|
if ($direct) {
|
||||||
$qs .= '&direct=1';
|
$qs .= '&direct=1';
|
||||||
@@ -549,6 +570,9 @@ final class LiveCastRepository {
|
|||||||
'codec_video' => $row['codec_video'],
|
'codec_video' => $row['codec_video'],
|
||||||
'codec_audio' => $row['codec_audio'],
|
'codec_audio' => $row['codec_audio'],
|
||||||
'bw_mode' => $row['bw_mode'],
|
'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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** WebRTC signaling store (offer/answer/ICE) — REST polling, no MCU/SFU. */
|
||||||
|
final class LiveCastSignalingRepository {
|
||||||
|
private static bool $schemaEnsured = false;
|
||||||
|
|
||||||
|
public static function ensureSchema(): void {
|
||||||
|
if (self::$schemaEnsured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pdo = Database::pdo();
|
||||||
|
Database::withMigrationLock($pdo, 'live_cast_signal_messages', static function () use ($pdo): void {
|
||||||
|
if (!Database::tableExists($pdo, 'live_cast_signal_messages')) {
|
||||||
|
if (Database::isMysql()) {
|
||||||
|
$pdo->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<string,mixed> $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<array<string,mixed>> */
|
||||||
|
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<string,mixed>|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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,8 @@ require_once __DIR__ . '/TicketRepository.php';
|
|||||||
require_once __DIR__ . '/GraphRepository.php';
|
require_once __DIR__ . '/GraphRepository.php';
|
||||||
require_once __DIR__ . '/RemoteAccessRepository.php';
|
require_once __DIR__ . '/RemoteAccessRepository.php';
|
||||||
require_once __DIR__ . '/LiveCastRepository.php';
|
require_once __DIR__ . '/LiveCastRepository.php';
|
||||||
|
require_once __DIR__ . '/LiveCastSignalingRepository.php';
|
||||||
|
require_once __DIR__ . '/LiveCastSignalingRepository.php';
|
||||||
require_once __DIR__ . '/UrlShortenerDatabase.php';
|
require_once __DIR__ . '/UrlShortenerDatabase.php';
|
||||||
require_once __DIR__ . '/ShortLinksRepository.php';
|
require_once __DIR__ . '/ShortLinksRepository.php';
|
||||||
require_once __DIR__ . '/WireGuardPeerProvisioner.php';
|
require_once __DIR__ . '/WireGuardPeerProvisioner.php';
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ $bp = Auth::basePath();
|
|||||||
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
|
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
|
||||||
<script src="<?= h($bp) ?>/assets/js/cookie_consent.js" defer></script>
|
<script src="<?= h($bp) ?>/assets/js/cookie_consent.js" defer></script>
|
||||||
<?php AnalyticsHead::render('live_education'); ?>
|
<?php AnalyticsHead::render('live_education'); ?>
|
||||||
|
<script src="<?= h($bp) ?>/assets/js/live_cast_webrtc.js" defer></script>
|
||||||
<script src="<?= h($bp) ?>/assets/js/live_education.js" defer></script>
|
<script src="<?= h($bp) ?>/assets/js/live_education.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="live-page" data-base-path="<?= h($bp) ?>" data-view="live_education">
|
<body class="live-page" data-base-path="<?= h($bp) ?>" data-view="live_education">
|
||||||
@@ -31,13 +32,15 @@ $bp = Auth::basePath();
|
|||||||
<p id="edu-status" class="reports-status muted" aria-live="polite">Ready when you are.</p>
|
<p id="edu-status" class="reports-status muted" aria-live="polite">Ready when you are.</p>
|
||||||
<p class="live-timer-label">Time left: <strong id="edu-timer">5:00</strong></p>
|
<p class="live-timer-label">Time left: <strong id="edu-timer">5:00</strong></p>
|
||||||
<video id="edu-preview" class="live-preview" playsinline muted autoplay hidden></video>
|
<video id="edu-preview" class="live-preview" playsinline muted autoplay hidden></video>
|
||||||
|
<div id="edu-share" class="live-share card card--lift" hidden></div>
|
||||||
|
<pre id="edu-stats" class="live-stats-nerds" hidden aria-live="polite"></pre>
|
||||||
<div class="live-actions">
|
<div class="live-actions">
|
||||||
<button type="button" class="btn btn--primary" id="edu-start">Start screen share</button>
|
<button type="button" class="btn btn--primary" id="edu-start">Start screen share</button>
|
||||||
<button type="button" class="btn" id="edu-stop">Stop</button>
|
<button type="button" class="btn" id="edu-stop">Stop</button>
|
||||||
<button type="button" class="btn btn--ghost" id="edu-notify">Enable notifications</button>
|
<button type="button" class="btn btn--ghost" id="edu-notify">Enable notifications</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted graphs-footnote">
|
<p class="muted graphs-footnote">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ $role = trim((string) ($_GET['role'] ?? ''));
|
|||||||
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
|
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
|
||||||
<script src="<?= h($bp) ?>/assets/js/cookie_consent.js" defer></script>
|
<script src="<?= h($bp) ?>/assets/js/cookie_consent.js" defer></script>
|
||||||
<?php AnalyticsHead::render('live_join'); ?>
|
<?php AnalyticsHead::render('live_join'); ?>
|
||||||
|
<script src="<?= h($bp) ?>/assets/js/live_cast_webrtc.js" defer></script>
|
||||||
<script src="<?= h($bp) ?>/assets/js/live_join.js" defer></script>
|
<script src="<?= h($bp) ?>/assets/js/live_join.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="live-page" data-base-path="<?= h($bp) ?>"
|
<body class="live-page" data-base-path="<?= h($bp) ?>"
|
||||||
@@ -37,6 +38,7 @@ $role = trim((string) ($_GET['role'] ?? ''));
|
|||||||
<p id="live-join-status" class="reports-status muted" aria-live="polite">Loading session…</p>
|
<p id="live-join-status" class="reports-status muted" aria-live="polite">Loading session…</p>
|
||||||
<ul id="live-join-meta" class="live-meta-list"></ul>
|
<ul id="live-join-meta" class="live-meta-list"></ul>
|
||||||
<div id="live-join-player" class="live-player-wrap"></div>
|
<div id="live-join-player" class="live-player-wrap"></div>
|
||||||
|
<pre id="live-join-stats-panel" class="live-stats-nerds" hidden aria-live="polite"></pre>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<?php require __DIR__ . '/partials/cookie_consent.php'; ?>
|
<?php require __DIR__ . '/partials/cookie_consent.php'; ?>
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ return [
|
|||||||
'bottom' => null,
|
'bottom' => null,
|
||||||
|
|
||||||
// --- Default left column when `left` is null ---
|
// --- Default left column when `left` is null ---
|
||||||
'holder' => '<a href="https://f0xx.org">Anton Afanaasyeu</a>, 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}",
|
'copyright_symbol' => "\u{00A9}",
|
||||||
'show_year' => false,
|
'show_year' => false,
|
||||||
// null = current calendar year at render time (end of range).
|
// null = current calendar year at render time (end of range).
|
||||||
|
|||||||
@@ -74,13 +74,17 @@ function platform_footer_year_label(array $cfg): string
|
|||||||
function platform_footer_default_left(array $cfg): string
|
function platform_footer_default_left(array $cfg): string
|
||||||
{
|
{
|
||||||
$symbol = trim((string) ($cfg['copyright_symbol'] ?? "\u{00A9}"));
|
$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'] ?? ''));
|
$holder = trim((string) ($cfg['holder'] ?? ''));
|
||||||
$showYear = (bool) ($cfg['show_year'] ?? true);
|
$showYear = (bool) ($cfg['show_year'] ?? true);
|
||||||
$parts = [];
|
$parts = [];
|
||||||
if ($symbol !== '') {
|
if ($symbol !== '') {
|
||||||
$parts[] = $symbol;
|
$parts[] = $symbol;
|
||||||
}
|
}
|
||||||
if ($holder !== '') {
|
if ($holderName !== '') {
|
||||||
|
$parts[] = $holderName;
|
||||||
|
} elseif ($holder !== '') {
|
||||||
$parts[] = $holder;
|
$parts[] = $holder;
|
||||||
}
|
}
|
||||||
$line = implode(' ', $parts);
|
$line = implode(' ', $parts);
|
||||||
@@ -95,6 +99,41 @@ function platform_footer_default_left(array $cfg): string
|
|||||||
return $line . ', ' . $yearLabel;
|
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 = '<a href="' . platform_footer_h($holderUrl) . '">' . $name . '</a>';
|
||||||
|
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.
|
* 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);
|
$useI18n = $opts['use_i18n'] ?? (bool) ($cfg['use_i18n'] ?? true);
|
||||||
$i18nKey = (string) ($cfg['i18n_key'] ?? 'footer.copyright');
|
$i18nKey = (string) ($cfg['i18n_key'] ?? 'footer.copyright');
|
||||||
$leftUsesDefault = platform_footer_field($cfg, $opts, 'left') === null;
|
$leftUsesDefault = platform_footer_field($cfg, $opts, 'left') === null;
|
||||||
|
$leftHasLink = platform_footer_left_has_link($cfg, $opts);
|
||||||
$yearEnd = platform_footer_year_end($cfg);
|
$yearEnd = platform_footer_year_end($cfg);
|
||||||
|
$leftHtml = platform_footer_render_left_html($cfg, $opts);
|
||||||
|
|
||||||
echo '<footer class="' . platform_footer_h($classes) . '">';
|
echo '<footer class="' . platform_footer_h($classes) . '">';
|
||||||
|
|
||||||
@@ -179,12 +220,12 @@ function platform_render_footer(array $opts = []): void
|
|||||||
echo '<div class="platform-footer__main">';
|
echo '<div class="platform-footer__main">';
|
||||||
if ($left !== null) {
|
if ($left !== null) {
|
||||||
echo '<div class="platform-footer__left"';
|
echo '<div class="platform-footer__left"';
|
||||||
if ($useI18n && $i18nKey !== '' && $leftUsesDefault) {
|
if ($useI18n && $i18nKey !== '' && $leftUsesDefault && !$leftHasLink) {
|
||||||
echo ' data-i18n="' . platform_footer_h($i18nKey) . '"'
|
echo ' data-i18n="' . platform_footer_h($i18nKey) . '"'
|
||||||
. ' data-year="' . platform_footer_h(platform_footer_year_label($cfg)) . '"'
|
. ' data-year="' . platform_footer_h(platform_footer_year_label($cfg)) . '"'
|
||||||
. ' data-year-end="' . $yearEnd . '"';
|
. ' data-year-end="' . $yearEnd . '"';
|
||||||
}
|
}
|
||||||
echo '>' . platform_footer_h($left) . '</div>';
|
echo '>' . ($leftHtml ?? platform_footer_h($left)) . '</div>';
|
||||||
}
|
}
|
||||||
if ($right !== null) {
|
if ($right !== null) {
|
||||||
echo '<div class="platform-footer__right">' . platform_footer_h($right) . '</div>';
|
echo '<div class="platform-footer__right">' . platform_footer_h($right) . '</div>';
|
||||||
|
|||||||
Reference in New Issue
Block a user