Packages
bpe
0.7.16
13.5.22-aleph
11.4.16
11.4.15
11.4.14
11.4.13
9.9.7
9.9.6
8.12.4
8.12.3
8.12.1
8.12.0
retired
8.2.1
8.2.0
8.1.0
7.11.0
7.10.4
7.10.3
7.10.2
7.10.1
7.9.1
7.9.0
7.8.2
7.8.1
7.8.0
7.6.4
7.6.3
7.6.2
7.6.1
7.6.0
7.5.15
7.5.14
7.5.13
7.5.12
7.5.11
7.5.10
7.5.9
7.5.8
7.5.7
7.5.6
7.5.5
7.5.3
7.5.2
7.5.1
7.5.0
7.4.11
7.4.10
7.4.9
7.4.8
7.4.7
7.4.6
7.4.5
7.4.4
7.4.3
7.4.2
7.4.1
7.4.0
7.3.0
7.2.8
7.2.7
7.2.6
7.1.6
7.1.5
7.1.4
7.1.3
7.1.2
6.12.7
6.12.6
6.12.5
6.12.3
6.12.2
6.12.1
6.12.0
6.11.0
6.10.0
6.5.3
6.5.2
6.5.1
6.5.0
6.4.0
6.3.0
5.12.0
5.11.4
5.11.3
5.11.2
5.11.1
5.11.0
5.8.7
5.8.6
5.8.5
5.8.4
5.8.3
5.8.2
5.8.1
5.8.0
5.7.0
5.6.0
5.5.2
5.5.1
5.4.0
5.2.0
5.1.3
5.1.2
5.1.1
4.12.4
4.12.3
4.12.2
4.12.1
4.12.0
4.11.8
4.11.7
4.11.6
4.11.5
4.11.4
4.11.3
4.11.2
4.11.1
4.11.0
4.10.24
4.10.23
4.10.22
4.10.21
4.10.20
4.10.19
4.10.18
4.10.17
4.10.16
4.10.15
4.10.14
4.10.13
4.10.12
4.10.11
4.10.10
4.10.9
4.10.8
4.10.7
4.10.6
4.10.5
4.10.4
4.10.3
4.10.2
4.10.1
4.10.0
4.9.18
4.9.17
4.9.16
4.9.15
4.9.14
4.9.13
4.9.12
4.9.11
4.9.10
4.9.9
4.9.8
4.9.7
4.9.6
4.9.5
4.9.4
4.9.3
4.9.2
4.9.1
4.9.0
4.8.1
4.8.0
4.7.5
4.7.3
4.7.2
4.7.1
4.7.0
4.6.0
2.4.0
0.7.16
ERP/1: RTP GST WebRTC ICE SDP H.264 H.265 MP4 MPEG-2 HLS HEVC
Current section
Files
Jump to
Current section
Files
priv/static/app/index.htm
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VKZ Ukrainian Court - Video Conference</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<link rel="stylesheet" href="/rtp.css" />
</head>
<body>
<header>
<div class="logo-section">
<h1>УАК ВЗ</h1>
<span id="heading"></span>
</div>
<div style="display: flex; align-items: center; gap: 1.5rem;">
<div class="court-badge" id="currentUserSpan">Connecting...</div>
<span id="logout"></span>
</div>
</header>
<div class="main-layout">
<div class="video-container-wrapper">
<div class="video-grid" id="videoGrid">
<!-- MCU Mixed Feed Area -->
<div class="video-card">
<!-- MCU Mixed Feed Area (WebRTC) -->
<video id="remoteVideo" autoplay playsinline style="display:none; width:100%; height:100%; object-fit:cover; border-radius: 12px;"></video>
<!-- Stream Feed (HLS/MP4) -->
<video id="hlsVideo" autoplay muted controls playsinline style="width:100%; height:100%; object-fit:cover; border-radius: 12px;"></video>
<div class="participant-overlay">
<span class="participant-status-dot"></span>
<span id="streamLabel">⬛ Очікування трансляції...</span>
</div>
<!-- Local PiP camera feed -->
<video id="localVideo" autoplay playsinline muted
style="position:absolute; bottom:16px; right:16px;
width:160px; aspect-ratio:16/9;
object-fit:cover; border-radius:8px;
border:2px solid var(--primary-accent);
transform:scaleX(-1); display:none; z-index:10;"></video>
</div>
</div>
<div class="control-bar">
<button class="control-btn" id="btnMuteAudio" title="Вимкнути мікрофон" disabled>🎙️</button>
<button class="control-btn" id="btnMuteVideo" title="Вимкнути камеру" disabled>📹</button>
<button class="control-btn" id="btnShare" title="Поділитися екраном" style="display:none;">🖥️</button>
<button class="control-btn join-btn" id="btnJoin" title="Приєднатися">📞</button>
</div>
</div>
<div class="sidebar">
<div class="telemetry-dashboard">
<div class="telemetry-title">
<span>Мережева Телеметрія</span>
<span style="font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 4px; background: rgba(59, 130, 246, 0.15); color: #60a5fa;">QoS Priority EF</span>
</div>
<div class="telemetry-grid">
<div class="telemetry-metric">
<div class="metric-label">RTT затримка</div>
<div class="metric-value status-ok" id="rttVal">0 ms</div>
</div>
<div class="telemetry-metric">
<div class="metric-label">Втрати пакетів</div>
<div class="metric-value status-ok" id="lossVal">0.00%</div>
</div>
</div>
</div>
<div class="sidebar-tabs">
<button class="tab-btn active" id="tabChat">Чат кімнати</button>
<button class="tab-btn" id="tabMembers">Учасники</button>
</div>
<!-- Chat Tab View -->
<div class="chat-section" id="chatSection">
<div class="messages-list" id="history">
<!-- Messages inserted dynamically here -->
</div>
<div class="chat-input-wrapper">
<div class="chat-input-row">
<input type="text" class="chat-input" id="message" placeholder="Введіть повідомлення...">
<span id="send"></span>
</div>
</div>
</div>
<!-- Active Members Tab View -->
<div class="chat-section" id="membersSection" style="display: none; padding: 1.2rem; overflow-y: auto;">
<div class="users-list" id="membersList">
<div class="user-item">
<div class="user-status"></div>
<span id="currentUserSpanSidebar">Connecting...</span>
</div>
</div>
</div>
<!-- Recording Status & Room Controls -->
<div style="padding: 1.2rem; border-top: 1px solid var(--border-color); background: rgba(15, 18, 29, 0.45);">
<div id="recording_info" style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.8rem; word-break: break-all;"></div>
<span id="terminate"></span>
</div>
</div>
</div>
<!-- N2O / NITRO Scripts -->
<script src="https://ws.n2o.dev/priv/utf8.js"></script>
<script src="https://ws.n2o.dev/priv/bert.js"></script>
<script src="https://ws.n2o.dev/priv/heart.js"></script>
<script src="https://ws.n2o.dev/priv/n2o.js"></script>
<script>host = location.hostname; port = 8001; debug = true;</script>
<script src="https://ws.n2o.dev/priv/ftp.js"></script>
<script src="https://nitro.n2o.dev/priv/js/nitro.js"></script>
<script>protos = [$bert]; N2O_start();</script>
<!-- WebRTC signaling and UI controller script -->
<script>
// Room/user survive F5: URL params take priority, localStorage as fallback.
const urlParams = new URLSearchParams(window.location.search);
const roomName = urlParams.get('room')
|| localStorage.getItem('rtp_room')
|| 'lobby';
const userName = urlParams.get('user')
|| localStorage.getItem('rtp_user')
|| 'guest';
const sessionToken = urlParams.get('token')
|| localStorage.getItem('rtp_token')
|| '';
// Persist so F5 without URL params still knows who/where we are
localStorage.setItem('rtp_room', roomName);
localStorage.setItem('rtp_user', userName);
if (sessionToken) {
localStorage.setItem('rtp_token', sessionToken);
}
// Keep URL in sync (makes sharing/bookmarking work)
if (!urlParams.get('room') || !urlParams.get('user') || (sessionToken && !urlParams.get('token'))) {
const next = new URL(window.location.href);
next.searchParams.set('room', roomName);
next.searchParams.set('user', userName);
if (sessionToken) next.searchParams.set('token', sessionToken);
history.replaceState(null, '', next.toString());
}
document.getElementById('currentUserSpan').textContent = userName + ' (You)';
document.getElementById('currentUserSpanSidebar').textContent = userName + ' (You)';
// Sidebar tab selector buttons
const tabChat = document.getElementById('tabChat');
const tabMembers = document.getElementById('tabMembers');
const chatSection = document.getElementById('chatSection');
const membersSection = document.getElementById('membersSection');
tabChat.onclick = () => {
tabChat.classList.add('active');
tabMembers.classList.remove('active');
chatSection.style.display = 'flex';
membersSection.style.display = 'none';
};
tabMembers.onclick = () => {
tabMembers.classList.add('active');
tabChat.classList.remove('active');
chatSection.style.display = 'none';
membersSection.style.display = 'flex';
};
// WebRTC Signaling elements
const remoteVideo = document.getElementById('remoteVideo');
const localVideo = document.getElementById('localVideo');
const hlsVideo = document.getElementById('hlsVideo');
const btnJoin = document.getElementById('btnJoin');
const btnMuteVideo = document.getElementById('btnMuteVideo');
const btnMuteAudio = document.getElementById('btnMuteAudio');
let signalingWs = null;
let pc = null;
let localStream = null;
let peerId = null;
// Persisted join state: true if user was joined before F5, false if they explicitly disconnected
let autoJoin = localStorage.getItem('rtp_joined') === 'true';
function connectSignaling() {
// Connect to signaling websocket using fallback auth query params
signalingWs = new WebSocket('ws://' + window.location.hostname + ':8001/ws/signaling?room=' + encodeURIComponent(roomName) + '&user=' + encodeURIComponent(userName) + '&token=' + encodeURIComponent(sessionToken));
signalingWs.onopen = () => {
if (autoJoin) {
console.log('Signaling connected — auto-joining conference');
startConference();
} else {
console.log('Signaling connected — standing by (Disconnect was pressed)');
}
};
signalingWs.onclose = () => {
console.log('WebRTC Signaling disconnected');
resetWebRTC();
};
signalingWs.onmessage = async (e) => {
const msg = JSON.parse(e.data);
console.log('WebRTC signaling msg:', msg);
if (msg.type === 'init') {
peerId = msg.peer_id;
console.log('My Peer ID:', peerId);
} else if (msg.type === 'room_info') {
const startedAt = msg.started_at;
const now = Date.now();
const deltaMs = now - startedAt;
console.log(`Room started at: ${startedAt}. Delta from now: ${deltaMs}ms`);
window.roomStartedAt = startedAt;
window.roomDeltaMs = deltaMs;
window.hlsFormat = msg.hls_format || 'fmp4';
document.getElementById('streamLabel').style.display = 'none';
// Always start the player when room_info is received
if (!window.hlsPlayer) {
setTimeout(initHlsPlayer, 100);
}
} else if (msg.sdp) {
await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
if (msg.sdp.type === 'offer') {
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signalingWs.send(JSON.stringify({ sdp: pc.localDescription }));
}
} else if (msg.candidate) {
try {
await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
} catch (err) {
console.error('Error adding ICE candidate:', err);
}
}
};
}
btnJoin.addEventListener('click', () => {
autoJoin = true;
localStorage.setItem('rtp_joined', 'true');
if (!signalingWs) {
connectSignaling();
} else {
startConference();
}
});
function initHlsPlayer(retryCount = 0) {
const seekTime = Math.max(0, (window.roomDeltaMs || 0) / 1000 - 2);
console.log(`Initializing player, seeking to live edge: ${seekTime} seconds`);
if (window.hlsFormat === 'fmp4' || window.hlsFormat === 'mp4') {
const mp4Url = `/rooms/${encodeURIComponent(roomName)}/recording.mp4`;
hlsVideo.src = mp4Url;
hlsVideo.onloadedmetadata = function() {
if (seekTime > 0) {
hlsVideo.currentTime = seekTime;
}
hlsVideo.play().catch(e => console.warn('fMP4 Autoplay prevented:', e));
};
// Add error handler for early fetches
hlsVideo.onerror = function() {
if (retryCount < 10) {
console.log('Stream not ready, retrying in 1s...');
setTimeout(() => initHlsPlayer(retryCount + 1), 1000);
}
};
return;
}
const playlistUrl = `/rooms/${encodeURIComponent(roomName)}/index.m3u8`;
if (Hls.isSupported()) {
window.hlsPlayer = new Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 10,
backBufferLength: 90
});
window.hlsPlayer.loadSource(playlistUrl);
window.hlsPlayer.attachMedia(hlsVideo);
window.hlsPlayer.on(Hls.Events.MANIFEST_PARSED, function() {
// Hls.js handles the live edge natively for live playlists
hlsVideo.play().catch(e => console.warn('HLS Autoplay prevented:', e));
});
window.hlsPlayer.on(Hls.Events.ERROR, function (event, data) {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.warn('HLS Network error, retrying in 1s...', data);
setTimeout(() => {
if (window.hlsPlayer) {
if (data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR || data.details === 'manifestLoadError') {
const bustUrl = playlistUrl + '?t=' + Date.now();
window.hlsPlayer.loadSource(bustUrl);
} else {
window.hlsPlayer.startLoad();
}
}
}, 1000);
break;
default:
window.hlsPlayer.destroy();
break;
}
}
});
} else if (hlsVideo.canPlayType('application/vnd.apple.mpegurl')) {
hlsVideo.src = playlistUrl;
hlsVideo.addEventListener('loadedmetadata', function() {
// Safari handles live edge natively
hlsVideo.play().catch(e => console.warn('Safari Autoplay prevented:', e));
});
}
}
async function startConference() {
try {
autoJoin = true;
localStorage.setItem('rtp_joined', 'true'); // remember: user is joined
btnJoin.disabled = true;
btnJoin.textContent = '⌛';
try {
localStream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 360, frameRate: 30 },
audio: true
});
} catch (e) {
console.warn('Webcam acquisition failed, falling back to audio only:', e);
localStream = await navigator.mediaDevices.getUserMedia({
audio: true
});
}
const hasVideo = localStream.getVideoTracks().length > 0;
if (hasVideo) {
localVideo.srcObject = localStream;
localVideo.style.display = 'block';
btnMuteVideo.disabled = false;
btnMuteVideo.classList.add('active');
} else {
localVideo.style.display = 'none';
btnMuteVideo.disabled = true;
btnMuteVideo.classList.remove('active');
}
btnMuteAudio.disabled = false;
btnMuteAudio.classList.add('active');
pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
pc.ontrack = (event) => {
console.log('Received remote track', event.track);
let stream = event.streams[0];
if (!stream) {
let currentStream = remoteVideo.srcObject || new MediaStream();
currentStream.addTrack(event.track);
remoteVideo.srcObject = null;
remoteVideo.srcObject = currentStream;
} else {
if (remoteVideo.srcObject !== stream) remoteVideo.srcObject = stream;
}
// Switch UI from HLS to WebRTC
hlsVideo.style.display = 'none';
if (window.hlsPlayer && typeof window.hlsPlayer.destroy === 'function') {
window.hlsPlayer.destroy();
window.hlsPlayer = null;
} else {
hlsVideo.src = '';
}
remoteVideo.style.display = 'block';
remoteVideo.play().catch(e => {
console.error("WebRTC play error:", e);
if (e.name === 'NotAllowedError') {
const overlay = document.createElement('button');
overlay.textContent = '🔊 Натисніть для відтворення (Autoplay заблоковано)';
overlay.style.position = 'absolute';
overlay.style.top = '50%';
overlay.style.left = '50%';
overlay.style.transform = 'translate(-50%, -50%)';
overlay.style.zIndex = '9999';
overlay.style.padding = '15px 25px';
overlay.style.fontSize = '16px';
overlay.style.cursor = 'pointer';
overlay.style.borderRadius = '8px';
overlay.style.backgroundColor = '#1e88e5';
overlay.style.color = '#fff';
overlay.style.border = 'none';
overlay.onclick = () => {
remoteVideo.play();
overlay.remove();
};
// Add position relative to the container if not already
remoteVideo.parentElement.style.position = 'relative';
remoteVideo.parentElement.appendChild(overlay);
}
});
document.getElementById('streamLabel').textContent = '🔴 Ефір — GStreamer MCU Потік';
document.getElementById('streamLabel').style.display = 'block';
};
pc.onicecandidate = (event) => {
if (event.candidate) signalingWs.send(JSON.stringify({ candidate: event.candidate }));
};
localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));
signalingWs.send(JSON.stringify({ type: 'ready' }));
btnJoin.textContent = '❌';
btnJoin.classList.remove('join-btn');
btnJoin.classList.add('decline');
btnJoin.onclick = leaveConference;
btnJoin.disabled = false;
} catch (err) {
console.error('Join failed:', err);
alert('Microphone/Webcam access denied or connection issue: ' + err.message);
resetWebRTC();
}
}
function leaveConference() {
autoJoin = false;
localStorage.removeItem('rtp_joined'); // F5 after Disconnect → stays disconnected
if (signalingWs) {
signalingWs.onclose = null; // prevent double-reset
signalingWs.close();
signalingWs = null;
}
resetWebRTC();
connectSignaling();
}
function resetWebRTC() {
if (localStream) { localStream.getTracks().forEach(t => t.stop()); localStream = null; }
if (pc) { pc.close(); pc = null; }
remoteVideo.srcObject = null;
localVideo.srcObject = null;
localVideo.style.display = 'none';
document.getElementById('streamLabel').textContent = '⬛ Очікування трансляції MCU...';
btnJoin.disabled = false;
btnJoin.textContent = '📞';
btnJoin.classList.remove('decline');
btnJoin.classList.add('join-btn');
btnJoin.onclick = startConference;
btnMuteVideo.disabled = true;
btnMuteVideo.classList.remove('active');
btnMuteAudio.disabled = true;
btnMuteAudio.classList.remove('active');
}
btnMuteVideo.onclick = () => {
const videoTrack = localStream.getVideoTracks()[0];
if (videoTrack) {
videoTrack.enabled = !videoTrack.enabled;
btnMuteVideo.classList.toggle('active', videoTrack.enabled);
localVideo.style.display = videoTrack.enabled ? 'block' : 'none';
}
};
btnMuteAudio.onclick = () => {
const audioTrack = localStream.getAudioTracks()[0];
if (audioTrack) {
audioTrack.enabled = !audioTrack.enabled;
btnMuteAudio.classList.toggle('active', audioTrack.enabled);
}
};
btnJoin.onclick = startConference;
// WebRTC real-time telemetry stats
setInterval(async () => {
if (pc && pc.signalingState !== 'closed') {
try {
const stats = await pc.getStats();
stats.forEach(report => {
if (report.type === 'remote-inbound-rtp') {
if (report.roundTripTime !== undefined) {
document.getElementById('rttVal').textContent = Math.round(report.roundTripTime * 1000) + ' ms';
}
if (report.packetsLost !== undefined && report.packetsReceived !== undefined) {
const total = report.packetsLost + report.packetsReceived;
const lossRate = total > 0 ? (report.packetsLost / total) * 100 : 0;
document.getElementById('lossVal').textContent = lossRate.toFixed(2) + '%';
}
}
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
if (report.currentRoundTripTime !== undefined) {
document.getElementById('rttVal').textContent = Math.round(report.currentRoundTripTime * 1000) + ' ms';
}
}
});
} catch (e) {
console.warn('Telemetry check failed:', e);
}
} else {
document.getElementById('rttVal').textContent = '0 ms';
document.getElementById('lossVal').textContent = '0.00%';
}
}, 2000);
connectSignaling();
</script>
</body>
</html>