1
0
mirror of git://f0xx.org/ac/ac-be-issues synced 2026-07-29 05:19:06 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:29:35 +02:00
commit d5b8c54b5b
15 changed files with 2176 additions and 0 deletions

View 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);

View 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();
}
})();

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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) + '&amp;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();
}
})();

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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();
}
})();

View 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);
}
})();