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>