{"type":"offer","sdp":{"type":"offer","sdp":"v=0\r\no=- 8547853977160375259 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 7106 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 210.21.60.43\r\na=candidate:3499802579 1 udp 2113937151 e71f1e39-fac1-4b45-9dec-f7f893f76eaf.local 64586 typ host generation 0 network-cost 999\r\na=candidate:1749569424 1 udp 1677729535 210.21.60.43 7106 typ srflx raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=ice-ufrag:9S0r\r\na=ice-pwd:tyJMkCS/IQiYZHp0ru0GO5Rt\r\na=ice-options:trickle\r\na=fingerprint:sha-256 2D:80:61:03:F4:92:13:EC:84:E9:FC:56:40:C7:7B:78:49:BF:A0:C8:86:65:32:C8:30:C4:35:35:39:8A:A7:DF\r\na=setup:actpass\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"},"candidates":[{"candidate":"candidate:3499802579 1 udp 2113937151 e71f1e39-fac1-4b45-9dec-f7f893f76eaf.local 64586 typ host generation 0 ufrag 9S0r network-cost 999","sdpMid":"0","sdpMLineIndex":0,"usernameFragment":"9S0r"},{"candidate":"candidate:1749569424 1 udp 1677729535 210.21.60.43 7106 typ srflx raddr 0.0.0.0 rport 0 generation 0 ufrag 9S0r network-cost 999","sdpMid":"0","sdpMLineIndex":0,"usernameFragment":"9S0r"}]}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>局域网多人 P2P 聊天-全局顶层通知</title>
<style>
body { font-family: monospace; max-width: 700px; margin: 20px auto; }
.box { border: 1px solid #ccc; padding: 12px; margin: 10px 0; border-radius: 6px; }
textarea { width: 100%; height: 90px; font-family: monospace; font-size: 0.85em; }
button { margin: 4px 4px 4px 0; padding: 6px 14px; cursor: pointer; }
.hidden { display: none; }
#chat { height: 220px; overflow-y: auto; background: #f5f5f5; padding: 8px; border: 1px solid #ddd; margin-bottom: 6px; }
.msg { margin: 3px 0; }
.sys { color: #888; }
#status { font-weight: bold; margin: 8px 0; color: #333; }
.mode-select { margin-bottom: 10px; }
.member-list { font-size: 0.9em; color: #555; }
/* 页面内居中弹窗,仅浏览器内生效 */
.msg-toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.85);
color: #fff;
padding: 20px 32px;
border-radius: 10px;
font-size: 17px;
z-index: 999999; /* 浏览器内拉满层级 */
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
max-width: 350px;
text-align: center;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.msg-toast.show {
opacity: 1;
}
</style>
</head>
<body>
<h2>🌐 局域网多人 P2P 聊天</h2>
<p style="color:gray;">无需服务器 · 主持人转发 · 纯手动交换信息</p>
<!-- 浏览器内居中弹窗容器 -->
<div id="msgToast" class="msg-toast"></div>
<div class="mode-select">
<label><input type="radio" name="mode" value="host" onchange="switchMode()"> 主持房间</label>
<label><input type="radio" name="mode" value="guest" onchange="switchMode()" checked> 加入房间</label>
</div>
<div id="status">请选择模式开始</div>
<!-- 主持人面板 -->
<div id="hostPanel" class="hidden">
<div class="box">
<button onclick="hostCreateInvitation()">➕ 添加新成员</button>
<span class="member-list" id="memberList"></span>
</div>
<div id="inviteArea" class="box hidden">
<p>📤 <b>邀请信息</b>(复制发送给新成员):</p>
<textarea id="inviteText" readonly></textarea>
<p>📥 粘贴该成员的<b>应答信息</b>:</p>
<textarea id="answerPaste" placeholder="对方生成的应答信息"></textarea>
<button onclick="hostApplyAnswer()">✅ 确认添加</button>
</div>
</div>
<!-- 参与者面板 -->
<div id="guestPanel">
<div class="box">
<p>📥 粘贴主持人的<b>邀请信息</b>:</p>
<textarea id="offerPaste" placeholder="粘贴邀请信息(JSON)"></textarea>
<button onclick="guestJoin()">🔗 加入</button>
</div>
<div id="answerArea" class="box hidden">
<p>📤 你的<b>应答信息</b>(全选复制并发回给主持人):</p>
<textarea id="answerText" readonly></textarea>
</div>
</div>
<!-- 聊天区 -->
<div id="chatBox" class="box hidden">
<div id="chat"></div>
<div style="display:flex; gap:6px;">
<input type="text" id="msgInput" placeholder="输入消息,回车发送" style="flex:1;"
onkeydown="if(event.key==='Enter') sendMessage()">
<button onclick="sendMessage()">发送</button>
</div>
</div>
<script>
const config = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
};
let currentMode = 'guest';
let hostPeers = [];
let hostPeerIdCounter = 0;
let hostPending = null;
let guestPC = null;
let guestDC = null;
// ===================== 顶层通知核心工具 =====================
// 页面加载申请通知权限
function requestNotifyPermission() {
if (!('Notification' in window)) return;
if (Notification.permission !== 'granted') {
Notification.requestPermission();
}
}
// 浏览器内居中弹窗(仅窗口内可见)
function showCenterToast(text) {
const toastEl = document.getElementById('msgToast');
toastEl.textContent = text;
toastEl.classList.add('show');
setTimeout(() => toastEl.classList.remove('show'), 2800);
}
// 系统级顶层桌面通知(真正全局置顶,最小化也可见)
function sendSystemTopNotify(title, body) {
if (!('Notification' in window) || Notification.permission !== 'granted') return;
new Notification(title, {
body: body,
icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzAwY2NjYyI+PHBhdGggZD0iTTEyIDJjNS41IDAgMTAgNC41IDEwIDEwYzAgNS41LTQuNSAxMC0xMCAxMHMtMTAtNC41LTEwLTEwYzAtNS41IDQuNS0xMCAxMC0xMHptMCAyYy00LjQgMC04IDMuNi04IDhjMCA0LjQgMy42IDggOCA4czgtMy42IDgtOGMwLTQuNC0zLjYtOC04LTh6Ii8+PC9zdmc+',
tag: 'p2p-chat-msg' // 合并重复通知
});
}
// 判断窗口是否后台,自动切换提醒方式
function triggerGlobalAlert(msgText) {
// 窗口激活:弹页面中间浮窗
if (!document.hidden) {
showCenterToast('新消息:' + msgText);
}
// 无论窗口状态,强制发系统顶层通知(最小化必弹)
sendSystemTopNotify('局域网P2P聊天', msgText);
}
// ======================================================
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
function appendChat(msg, isSystem = false, isSelf = false) {
const div = document.createElement('div');
div.className = 'msg' + (isSystem ? ' sys' : '');
div.textContent = msg;
const chatEl = document.getElementById('chat');
chatEl.appendChild(div);
chatEl.scrollTop = chatEl.scrollHeight;
// 非系统、非自己发送的消息,触发全局顶层提醒
if (!isSystem && !isSelf) {
triggerGlobalAlert(msg);
}
}
function updateMemberList() {
const list = hostPeers.map(p => `👤 成员${p.id}`).join(' · ');
document.getElementById('memberList').textContent = list ? `已连接:${list}` : '';
}
function switchMode() {
const mode = document.querySelector('input[name="mode"]:checked').value;
currentMode = mode;
if (mode === 'host') {
document.getElementById('hostPanel').classList.remove('hidden');
document.getElementById('guestPanel').classList.add('hidden');
setStatus('主持人模式,点击“添加新成员”开始');
} else {
document.getElementById('hostPanel').classList.add('hidden');
document.getElementById('guestPanel').classList.remove('hidden');
setStatus('参与者模式,粘贴邀请信息加入');
}
document.getElementById('chatBox').classList.add('hidden');
cleanupHost();
cleanupGuest();
requestNotifyPermission(); // 切换模式时申请通知权限
}
function waitForIceComplete(pc) {
return new Promise(resolve => {
if (pc.iceGatheringState === 'complete') resolve();
else pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') resolve();
};
});
}
function buildConnectionInfo(type, pc, candidates) {
return JSON.stringify({ type, sdp: pc.localDescription, candidates });
}
async function applyRemoteInfo(pc, jsonStr) {
const info = JSON.parse(jsonStr);
await pc.setRemoteDescription(new RTCSessionDescription(info.sdp));
for (let cand of info.candidates) await pc.addIceCandidate(new RTCIceCandidate(cand));
}
// 主持人逻辑
function cleanupHost() {
hostPeers.forEach(p => { try{p.dc.close()}catch(e){} try{p.pc.close()}catch(e){} });
hostPeers = [];
if (hostPending) { try{hostPending.dc.close()}catch(e){} try{hostPending.pc.close()}catch(e){} hostPending=null; }
updateMemberList();
document.getElementById('inviteArea').classList.add('hidden');
}
async function hostCreateInvitation() {
if (hostPending) { try{hostPending.dc.close()}catch(e){} try{hostPending.pc.close()}catch(e){} hostPending=null; }
const id = ++hostPeerIdCounter;
const pc = new RTCPeerConnection(config);
const dc = pc.createDataChannel(`chat-${id}`);
const candidates = [];
pc.onicecandidate = e => e.candidate && candidates.push(e.candidate);
hostPending = { id, pc, dc, candidates };
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceComplete(pc);
document.getElementById('inviteText').value = buildConnectionInfo('offer', pc, candidates);
document.getElementById('inviteArea').classList.remove('hidden');
setStatus(`已生成成员${id}邀请信息,发给对方`);
}
async function hostApplyAnswer() {
if (!hostPending) return alert('无待处理邀请');
const ans = document.getElementById('answerPaste').value.trim();
if (!ans) return alert('粘贴应答信息');
const { id, pc, dc } = hostPending;
await applyRemoteInfo(pc, ans);
setupHostDataChannel(id, dc);
hostPending = null;
document.getElementById('inviteArea').classList.add('hidden');
setStatus(`成员${id}等待通道打开`);
}
function setupHostDataChannel(id, dc) {
dc.onopen = () => {
hostPeers.push({ id, dc });
updateMemberList();
document.getElementById('chatBox').classList.remove('hidden');
appendChat(`👤 成员${id} 已加入`, true);
setStatus(`成员${id}已连接,共${hostPeers.length}人在线`);
};
dc.onclose = () => {
hostPeers = hostPeers.filter(p => p.dc !== dc);
updateMemberList();
appendChat(`👤 成员${id} 已离开`, true);
setStatus(`成员${id}断开,剩余${hostPeers.length}人`);
};
dc.onmessage = e => {
const msg = `${`成员${id}`}: ${e.data}`;
appendChat(msg, false, false);
hostPeers.forEach(p => {
if (p.dc !== dc && p.dc.readyState === 'open') p.dc.send(msg);
});
};
}
function hostSendMessage(msg) {
appendChat(`我(主持人): ${msg}`, false, true);
hostPeers.forEach(p => p.dc.readyState === 'open' && p.dc.send(`主持人: ${msg}`));
}
// 参与者逻辑
function cleanupGuest() {
if (guestDC) try{guestDC.close()}catch(e){} guestDC=null;
if (guestPC) try{guestPC.close()}catch(e){} guestPC=null;
document.getElementById('answerArea').classList.add('hidden');
}
async function guestJoin() {
cleanupGuest();
const offerStr = document.getElementById('offerPaste').value.trim();
if (!offerStr) return alert('粘贴主持人邀请信息');
guestPC = new RTCPeerConnection(config);
const candidates = [];
guestPC.onicecandidate = e => e.candidate && candidates.push(e.candidate);
guestPC.ondatachannel = e => { guestDC = e.channel; setupGuestDataChannel(); };
await applyRemoteInfo(guestPC, offerStr);
const answer = await guestPC.createAnswer();
await guestPC.setLocalDescription(answer);
await waitForIceComplete(guestPC);
document.getElementById('answerText').value = buildConnectionInfo('answer', guestPC, candidates);
document.getElementById('answerArea').classList.remove('hidden');
setStatus('复制应答信息发回主持人');
}
function setupGuestDataChannel() {
guestDC.onopen = () => {
document.getElementById('chatBox').classList.remove('hidden');
appendChat('--- 成功连接房间 ---', true);
setStatus('已连接,可以聊天');
};
guestDC.onclose = () => {
appendChat('--- 连接已断开 ---', true);
setStatus('连接断开');
document.getElementById('chatBox').classList.add('hidden');
};
guestDC.onmessage = e => appendChat(e.data, false, false);
}
function guestSendMessage(msg) {
if (!guestDC || guestDC.readyState !== 'open') return;
guestDC.send(msg);
appendChat(`我: ${msg}`, false, true);
}
// 发送消息统一入口
function sendMessage() {
const input = document.getElementById('msgInput');
const text = input.value.trim();
if (!text) return;
input.value = '';
if (currentMode === 'host') {
hostPeers.length === 0 ? appendChat('暂无在线成员', true) : hostSendMessage(text);
} else {
guestDC?.readyState === 'open' ? guestSendMessage(text) : appendChat('未连接无法发送', true);
}
}
// 初始化
switchMode();
</script>
</body>
</html>