|
<!doctype html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="utf-8"> |
|
<title>WebRTC DataChannel Test</title> |
|
<style> |
|
body {font-family:sans-serif;margin:20px} |
|
textarea{width:100%;height:120px} |
|
pre {background:#f5f5f5;padding:8px} |
|
label {font-weight:bold} |
|
#qrLocal,#qrReader{margin-top:6px;padding:4px;border:1px solid #ccc;display:inline-block} |
|
video {width:300px;height:auto} |
|
</style> |
|
|
|
<!-- ▼ Libraries --> |
|
<script type="module"> |
|
import QRCode from "https://cdn.jsdelivr.net/npm/[email protected]/+esm"; |
|
window.QRCode = QRCode; /* Keep compatibility with existing code */ |
|
</script> |
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5-qrcode/2.3.8/html5-qrcode.min.js"></script> |
|
</head> |
|
<body> |
|
|
|
<h2>① Choose Your Role</h2> |
|
<label><input type="radio" name="role" value="offer" checked> Offerer (the side that starts the connection first)</label><br> |
|
<label><input type="radio" name="role" value="answer"> Answerer (the side that receives the invitation)</label> |
|
|
|
<h2>② Your SDP</h2> |
|
<textarea id="local" placeholder="Automatically output here after Offerer/Answerer action" readonly></textarea> |
|
<button id="makeQr">Generate QR</button> |
|
<div id="qrLocal"></div> |
|
|
|
<h2>③ Paste Peer’s SDP</h2> |
|
<textarea id="remote" placeholder="Paste or scan the SDP generated in the peer browser"></textarea> |
|
<button id="set">Set Remote</button> |
|
<button id="scanQr">Start QR Scan</button> |
|
<button id="stopScan" disabled>Stop</button> |
|
<div id="qrReader"></div> |
|
|
|
<h2>④ DataChannel Chat</h2> |
|
<input id="msg" placeholder="Message to send"> |
|
<button id="send">Send</button> |
|
|
|
<h2>Log</h2> |
|
<pre id="log"></pre> |
|
|
|
<script> |
|
/* ---------- WebRTC Basics ---------- */ |
|
const pc = new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]}); |
|
let dc; |
|
const log = (...a)=>logEl.textContent += a.join(' ') + '\n'; |
|
const local = document.getElementById('local'); |
|
const remote = document.getElementById('remote'); |
|
const roleRadios = document.querySelectorAll('input[name="role"]'); |
|
|
|
/* Output SDP when ICE gathering finishes */ |
|
pc.onicecandidate = e=>{ |
|
if(e.candidate===null){ |
|
local.value = JSON.stringify(pc.localDescription); |
|
} |
|
}; |
|
|
|
/* DataChannel received by the Answerer */ |
|
pc.ondatachannel = e=>{ dc = e.channel; setupDataChannel(); }; |
|
|
|
/* DataChannel for the Offerer */ |
|
function createOffererChannel(){ |
|
dc = pc.createDataChannel('chat'); |
|
setupDataChannel(); |
|
} |
|
|
|
/* Common DataChannel handlers */ |
|
function setupDataChannel(){ |
|
dc.onopen = ()=>log('=== OPEN ==='); |
|
dc.onclose = ()=>log('=== CLOSE ==='); |
|
dc.onmessage= e =>log('peer:', e.data); |
|
} |
|
|
|
/* ---------- UI Actions ---------- */ |
|
document.getElementById('set').onclick = async()=>{ |
|
if(!remote.value.trim()) return; |
|
const desc = JSON.parse(remote.value); |
|
await pc.setRemoteDescription(desc); |
|
if(desc.type === 'offer'){ |
|
await pc.setLocalDescription(await pc.createAnswer()); |
|
} |
|
}; |
|
|
|
document.getElementById('send').onclick = ()=>{ |
|
if(!dc || dc.readyState!=='open'){ alert('DataChannel is not open'); return; } |
|
const text = msg.value; |
|
if(text){ dc.send(text); log('you:', text); msg.value=''; } |
|
}; |
|
|
|
/* ---------- Generate QR (split every 200 chars) ---------- */ |
|
document.getElementById('makeQr').onclick = ()=>generateQr(local.value); |
|
|
|
function generateQr(text){ |
|
if(!text){ alert('SDP has not been generated'); return; } |
|
|
|
const MAX_PAYLOAD = 200; // Split into ≤ 200-char chunks |
|
const chunks = []; |
|
for(let i=0; i<text.length; i += MAX_PAYLOAD){ |
|
chunks.push(text.slice(i, i+MAX_PAYLOAD)); |
|
} |
|
const total = chunks.length; |
|
|
|
const area = document.getElementById('qrLocal'); |
|
area.innerHTML = ''; // Replace content |
|
|
|
chunks.forEach((chunk, idx)=>{ |
|
const prefix = `${idx+1}/${total}:`; // e.g. "2/5:" |
|
const payload = prefix + chunk; // Fits within 200 chars incl. prefix |
|
QRCode.toCanvas( |
|
payload, |
|
{errorCorrectionLevel:'L', width:256}, |
|
(err, canvas)=>{ |
|
if(err){ alert(err); return; } |
|
area.appendChild(canvas); |
|
area.appendChild(document.createElement('br')); // Add line break for readability |
|
} |
|
); |
|
}); |
|
} |
|
|
|
/* ---------- QR Scanning ---------- */ |
|
let qrScanner; |
|
const recvMap = new Map(); // key: index(1–n) -> chunk |
|
let expectedTotal = null; |
|
|
|
function handleDecoded(text){ |
|
/* Parse prefix "idx/total:" */ |
|
const m = text.match(/^(\d+)\/(\d+):(.*)$/s); |
|
if(!m){ console.warn('Prefix format is incorrect'); return; } |
|
|
|
const idx = Number(m[1]); |
|
const total = Number(m[2]); |
|
const chunk = m[3]; |
|
|
|
/* Determine total count on first receipt */ |
|
if(expectedTotal === null){ |
|
expectedTotal = total; |
|
} |
|
|
|
/* Prevent session mixing */ |
|
if(total !== expectedTotal){ |
|
console.warn('Detected QR from a different session - ignored'); |
|
return; |
|
} |
|
|
|
recvMap.set(idx, chunk); |
|
log(`QR received ${idx}/${total}`); |
|
|
|
/* All parts received? */ |
|
if(recvMap.size === total){ |
|
/* Concatenate in index order */ |
|
const fullText = [...recvMap.keys()].sort((a,b)=>a-b).map(k=>recvMap.get(k)).join(''); |
|
remote.value = fullText; |
|
log('=== All fragments received ==='); |
|
stopScan(); // Auto-stop |
|
recvMap.clear(); |
|
expectedTotal = null; |
|
} |
|
} |
|
|
|
document.getElementById('scanQr').onclick = ()=>{ |
|
if(qrScanner) return; // Prevent double start |
|
qrScanner = new Html5Qrcode('qrReader'); |
|
|
|
qrScanner.start( |
|
{facingMode:'environment'}, |
|
{fps:10, qrbox:250}, |
|
decodedText => handleDecoded(decodedText), // ← Replace with receive handler |
|
err => {} |
|
).then(()=>{ |
|
document.getElementById('stopScan').disabled = false; |
|
|
|
/* Resize video element to camera resolution */ |
|
const video = document.querySelector('#qrReader video'); |
|
if(video){ |
|
const applySize = ()=>{ |
|
video.style.width = video.videoWidth + 'px'; |
|
video.style.height = video.videoHeight + 'px'; |
|
}; |
|
if(video.readyState >= 1){ |
|
applySize(); |
|
}else{ |
|
video.addEventListener('loadedmetadata', applySize, {once:true}); |
|
} |
|
} |
|
}).catch(e=>{ alert(e); qrScanner=null; }); |
|
}; |
|
|
|
document.getElementById('stopScan').onclick = stopScan; |
|
function stopScan(){ |
|
if(qrScanner){ |
|
qrScanner.stop().then(()=>{ |
|
qrScanner.clear(); |
|
qrScanner = null; |
|
document.getElementById('stopScan').disabled = true; |
|
document.getElementById('qrReader').innerHTML = ''; // Clear preview area |
|
}); |
|
} |
|
} |
|
|
|
/* ---------- Initialization ---------- */ |
|
const logEl = document.getElementById('log'); |
|
const msg = document.getElementById('msg'); |
|
|
|
(async()=>{ |
|
const role = [...roleRadios].find(r=>r.checked).value; |
|
if(role === 'offer'){ |
|
createOffererChannel(); |
|
await pc.setLocalDescription(await pc.createOffer()); |
|
} |
|
})(); |
|
</script> |
|
</body> |
|
</html> |