mirror of
git://f0xx.org/ac/ac-be-issues
synced 2026-07-29 04:18:08 +03:00
initial
This commit is contained in:
256
public/assets/js/live_cast_webrtc.js
Normal file
256
public/assets/js/live_cast_webrtc.js
Normal file
@@ -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);
|
||||
210
public/assets/js/live_education.js
Normal file
210
public/assets/js/live_education.js
Normal file
@@ -0,0 +1,210 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var MAX_DEMO_S = 300;
|
||||
var timer = null;
|
||||
var secondsLeft = MAX_DEMO_S;
|
||||
var mediaStream = null;
|
||||
var sessionId = '';
|
||||
var webrtc = null;
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function detectPlatform() {
|
||||
var ua = navigator.userAgent || '';
|
||||
if (/Firefox\//i.test(ua)) return 'firefox';
|
||||
if (/Edg\//i.test(ua)) return 'edge';
|
||||
if (/Chrome\//i.test(ua)) return 'chrome';
|
||||
if (/Safari\//i.test(ua)) return 'safari';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
function apiUrl() {
|
||||
return basePath() + '/api/live_cast.php';
|
||||
}
|
||||
|
||||
function postJson(body, cb) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', apiUrl(), true);
|
||||
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(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
var node = el('edu-status');
|
||||
if (node) node.textContent = text;
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
var node = el('edu-timer');
|
||||
if (!node) return;
|
||||
var m = Math.floor(secondsLeft / 60);
|
||||
var s = secondsLeft % 60;
|
||||
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() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (webrtc) {
|
||||
webrtc.stop();
|
||||
webrtc = null;
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(function (t) { t.stop(); });
|
||||
mediaStream = null;
|
||||
}
|
||||
var preview = el('edu-preview');
|
||||
if (preview) preview.srcObject = null;
|
||||
if (sessionId) {
|
||||
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) {
|
||||
mediaStream = stream;
|
||||
var preview = el('edu-preview');
|
||||
if (preview) {
|
||||
preview.hidden = false;
|
||||
preview.srcObject = stream;
|
||||
preview.play().catch(function () {});
|
||||
}
|
||||
stream.getVideoTracks()[0].addEventListener('ended', stopDemo);
|
||||
setStatus('Creating demo session…');
|
||||
postJson({
|
||||
action: 'create_intent',
|
||||
platform: 'education_' + detectPlatform(),
|
||||
max_duration_s: MAX_DEMO_S,
|
||||
codec_video: 'browser_screen',
|
||||
codec_audio: 'none',
|
||||
bw_mode: 'demo'
|
||||
}, function (resp) {
|
||||
if (!resp || !resp.ok || !resp.session) {
|
||||
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 — share the link so others can watch.');
|
||||
timer = setInterval(function () {
|
||||
secondsLeft -= 1;
|
||||
updateTimer();
|
||||
if (secondsLeft <= 0) {
|
||||
stopDemo();
|
||||
}
|
||||
}, 1000);
|
||||
if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) {
|
||||
window.AndroidCastAnalytics.trackEvent('live_education_start', { platform: detectPlatform() });
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
setStatus('Screen share permission denied or cancelled.');
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (!el('live-education-app')) return;
|
||||
updateTimer();
|
||||
var startBtn = el('edu-start');
|
||||
var stopBtn = el('edu-stop');
|
||||
if (startBtn) startBtn.addEventListener('click', startDemo);
|
||||
if (stopBtn) {
|
||||
stopBtn.disabled = true;
|
||||
stopBtn.addEventListener('click', stopDemo);
|
||||
}
|
||||
var notifBtn = el('edu-notify');
|
||||
if (notifBtn && 'Notification' in window) {
|
||||
notifBtn.addEventListener('click', function () {
|
||||
Notification.requestPermission().then(function (perm) {
|
||||
setStatus(perm === 'granted' ? 'Notifications enabled.' : 'Notifications not granted.');
|
||||
});
|
||||
});
|
||||
} else if (notifBtn) {
|
||||
notifBtn.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
224
public/assets/js/live_join.js
Normal file
224
public/assets/js/live_join.js
Normal file
@@ -0,0 +1,224 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function detectPlatform() {
|
||||
var ua = navigator.userAgent || '';
|
||||
if (/Android/i.test(ua)) return 'android';
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
|
||||
if (/Firefox\//i.test(ua)) return 'firefox';
|
||||
if (/Edg\//i.test(ua)) return 'edge';
|
||||
if (/Chrome\//i.test(ua)) return 'chrome';
|
||||
if (/Safari\//i.test(ua)) return 'safari';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
function anonId() {
|
||||
var key = 'ac_live_anon_id';
|
||||
try {
|
||||
var existing = localStorage.getItem(key);
|
||||
if (existing) return existing;
|
||||
var id = 'anon-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
localStorage.setItem(key, id);
|
||||
return id;
|
||||
} catch (e) {
|
||||
return 'anon-' + Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
function apiUrl() {
|
||||
return basePath() + '/api/live_cast.php';
|
||||
}
|
||||
|
||||
function postJoinEvent(sessionId, eventType, direct) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', apiUrl(), true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
xhr.send(JSON.stringify({
|
||||
action: 'join_event',
|
||||
session_id: sessionId,
|
||||
event_type: eventType,
|
||||
platform: detectPlatform(),
|
||||
channel: direct ? 'direct_link' : 'viewer_link',
|
||||
direct: !!direct,
|
||||
anon_id: anonId()
|
||||
}));
|
||||
}
|
||||
|
||||
function fetchSession(sessionId, cb) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', apiUrl() + '?action=session&session_id=' + encodeURIComponent(sessionId), true);
|
||||
xhr.onload = function () {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
cb(null);
|
||||
return;
|
||||
}
|
||||
cb(payload && payload.ok ? payload.session : null);
|
||||
};
|
||||
xhr.onerror = function () { cb(null); };
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function formatStatus(session) {
|
||||
if (!session) return 'Unknown';
|
||||
var st = session.status || '';
|
||||
if (st === 'live' || st === 'intent') return 'Live';
|
||||
if (st === 'closed') return 'Ended';
|
||||
if (st === 'expired') return 'Expired';
|
||||
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() {
|
||||
var root = el('live-join-app');
|
||||
if (!root) return;
|
||||
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
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.';
|
||||
return;
|
||||
}
|
||||
|
||||
postJoinEvent(sessionId, 'open', direct);
|
||||
|
||||
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) {
|
||||
statusEl.textContent = 'Session not found or no longer available.';
|
||||
return;
|
||||
}
|
||||
var live = session.status === 'live' || session.status === 'intent';
|
||||
statusEl.textContent = formatStatus(session) + (live ? ' — connecting…' : '');
|
||||
var owner = session.owner_username || 'host';
|
||||
var lines = [
|
||||
'Host: ' + owner,
|
||||
'Platform: ' + (session.platform || '—'),
|
||||
'Video: ' + (session.codec_video || '—'),
|
||||
'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 '<li>' + escapeHtml(l) + '</li>';
|
||||
}).join('');
|
||||
|
||||
if (direct && /android|ios/i.test(detectPlatform())) {
|
||||
playerEl.innerHTML =
|
||||
'<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 class="muted">Mobile WebRTC signaling uses the same REST API when the app transport is wired.</p>';
|
||||
postJoinEvent(sessionId, 'join', true);
|
||||
} else if (live) {
|
||||
webrtcConn = startViewer(sessionId, session, playerEl, statusEl, statsEl);
|
||||
postJoinEvent(sessionId, 'join', false);
|
||||
} else {
|
||||
playerEl.innerHTML = '<p class="muted">Session is not live.</p>';
|
||||
}
|
||||
|
||||
if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) {
|
||||
window.AndroidCastAnalytics.trackEvent('live_join_view', {
|
||||
session_id: sessionId,
|
||||
direct: direct ? 1 : 0,
|
||||
platform: detectPlatform(),
|
||||
status: session.status || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fetchSession(sessionId, render);
|
||||
setInterval(function () {
|
||||
fetchSession(sessionId, function (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') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
113
public/assets/js/live_sessions.js
Normal file
113
public/assets/js/live_sessions.js
Normal file
@@ -0,0 +1,113 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function formatMs(ms) {
|
||||
if (!ms) return '—';
|
||||
try {
|
||||
return new Date(ms).toLocaleString();
|
||||
} catch (e) {
|
||||
return String(ms);
|
||||
}
|
||||
}
|
||||
|
||||
function durationS(startMs, endMs) {
|
||||
if (!startMs) return '—';
|
||||
var end = endMs || Date.now();
|
||||
var sec = Math.max(0, Math.round((end - startMs) / 1000));
|
||||
if (sec < 60) return sec + 's';
|
||||
return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === 'live' || status === 'intent') return 'tag-pill tag-pill--ok';
|
||||
if (status === 'expired') return 'tag-pill tag-pill--warn';
|
||||
return 'tag-pill';
|
||||
}
|
||||
|
||||
function renderRows(sessions) {
|
||||
var tbody = el('live-sessions-tbody');
|
||||
if (!tbody) return;
|
||||
if (!sessions || !sessions.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="muted">No sessions in this window.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = sessions.map(function (s) {
|
||||
var joinUrl = s.join_short_url || '';
|
||||
var joinCell = joinUrl
|
||||
? '<a href="' + escapeHtml(joinUrl) + '" target="_blank" rel="noopener">Open</a>'
|
||||
: '—';
|
||||
return (
|
||||
'<tr>' +
|
||||
'<td><span class="' + statusClass(s.status) + '">' + escapeHtml(s.status) + '</span></td>' +
|
||||
'<td>' + escapeHtml(s.owner_username || '—') + '</td>' +
|
||||
'<td>' + escapeHtml(s.platform || '—') + '</td>' +
|
||||
'<td>' + escapeHtml(s.codec_video || '—') + '</td>' +
|
||||
'<td>' + durationS(s.started_at_ms, s.ended_at_ms) + '</td>' +
|
||||
'<td>' + formatMs(s.last_heartbeat_ms) + '</td>' +
|
||||
'<td>' + Number(s.join_opens || 0) + '</td>' +
|
||||
'<td>' + joinCell + '</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function load() {
|
||||
var status = el('live-sessions-status');
|
||||
var daysSel = el('live-sessions-days');
|
||||
var days = daysSel && daysSel.value ? daysSel.value : '14';
|
||||
if (status) status.textContent = 'Loading…';
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', basePath() + '/api/live_cast.php?action=list&days=' + encodeURIComponent(days), true);
|
||||
xhr.onload = function () {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
if (status) status.textContent = 'Invalid response';
|
||||
return;
|
||||
}
|
||||
if (!payload || !payload.ok) {
|
||||
if (status) status.textContent = (payload && payload.error) || 'Failed';
|
||||
return;
|
||||
}
|
||||
renderRows(payload.sessions || []);
|
||||
if (status) {
|
||||
status.textContent = 'Loaded ' + (payload.sessions || []).length + ' session(s) · ' + days + 'd window';
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
if (status) status.textContent = 'Network error';
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (!el('live-sessions-app')) return;
|
||||
var daysSel = el('live-sessions-days');
|
||||
if (daysSel) daysSel.addEventListener('change', load);
|
||||
load();
|
||||
setInterval(load, 60000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
132
public/assets/js/ticket_create.js
Normal file
132
public/assets/js/ticket_create.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Create ticket/issue dialog — Issues toolbar uses "issue" type tag; Tickets uses "ticket".
|
||||
*/
|
||||
(function () {
|
||||
const META = {
|
||||
ticket: { id: 'ticket', label: 'ticket', bg: '#6366f1' },
|
||||
issue: { id: 'issue', label: 'issue', bg: '#0d9488' },
|
||||
};
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function t(key) {
|
||||
if (window.CrashI18n && window.CrashI18n.t) {
|
||||
return window.CrashI18n.t(key);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function openDialog(kind) {
|
||||
const dialog = document.getElementById('ticket-create-dialog');
|
||||
const form = document.getElementById('ticket-create-form');
|
||||
const status = document.getElementById('ticket-create-status');
|
||||
const heading = document.getElementById('ticket-create-heading');
|
||||
if (!dialog || !form) return;
|
||||
const createKind = kind === 'issue' ? 'issue' : 'ticket';
|
||||
form.dataset.createKind = createKind;
|
||||
if (heading) {
|
||||
heading.textContent = t(createKind === 'issue' ? 'issues.new' : 'tickets.new');
|
||||
}
|
||||
if (status) status.textContent = '';
|
||||
form.reset();
|
||||
if (typeof dialog.showModal === 'function') {
|
||||
dialog.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
const dialog = document.getElementById('ticket-create-dialog');
|
||||
const form = document.getElementById('ticket-create-form');
|
||||
const cancelBtn = document.getElementById('ticket-create-cancel');
|
||||
const closeBtn = document.getElementById('ticket-create-close');
|
||||
const status = document.getElementById('ticket-create-status');
|
||||
if (!dialog || !form) return;
|
||||
|
||||
document.querySelectorAll('.js-new-issue-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
openDialog('issue');
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.js-new-ticket-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
openDialog('ticket');
|
||||
});
|
||||
});
|
||||
|
||||
function closeDialog() {
|
||||
dialog.close();
|
||||
}
|
||||
if (cancelBtn) cancelBtn.addEventListener('click', closeDialog);
|
||||
if (closeBtn) closeBtn.addEventListener('click', closeDialog);
|
||||
dialog.addEventListener('click', (ev) => {
|
||||
if (ev.target === dialog) closeDialog();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (ev) => {
|
||||
ev.preventDefault();
|
||||
const titleEl = document.getElementById('ticket-create-title');
|
||||
const briefEl = document.getElementById('ticket-create-brief');
|
||||
const bodyEl = document.getElementById('ticket-create-body');
|
||||
const kind = form.dataset.createKind === 'issue' ? 'issue' : 'ticket';
|
||||
const meta = META[kind] || META.ticket;
|
||||
const payload = {
|
||||
title: titleEl && titleEl.value ? titleEl.value.trim() : '',
|
||||
brief: briefEl && briefEl.value ? briefEl.value.trim() : '',
|
||||
body: bodyEl && bodyEl.value ? bodyEl.value.trim() : '',
|
||||
tags: [
|
||||
meta,
|
||||
{ id: 'open', label: 'open', bg: '#22c55e' },
|
||||
],
|
||||
};
|
||||
if (!payload.title) return;
|
||||
if (status) status.textContent = 'Creating…';
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', basePath() + '/api/ticket_create.php', true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
if (status) status.textContent = 'Invalid response';
|
||||
return;
|
||||
}
|
||||
if (!data || !data.ok) {
|
||||
if (status) status.textContent = (data && data.error) || 'Create failed';
|
||||
return;
|
||||
}
|
||||
dialog.close();
|
||||
const view = document.body.getAttribute('data-view') || '';
|
||||
if (view === 'tickets' && typeof window.__ticketsReload === 'function') {
|
||||
window.__ticketsReload();
|
||||
} else if (data.id) {
|
||||
window.location.href = basePath() + '/?view=ticket&id=' + encodeURIComponent(String(data.id));
|
||||
} else {
|
||||
window.location.href = basePath() + '/?view=tickets';
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
if (status) status.textContent = 'Network error';
|
||||
};
|
||||
xhr.send(JSON.stringify(payload));
|
||||
});
|
||||
}
|
||||
|
||||
function onReady(fn) {
|
||||
if (window.CrashI18n && window.CrashI18n.ready) {
|
||||
window.CrashI18n.ready.then(fn);
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => onReady(init));
|
||||
} else {
|
||||
onReady(init);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user