{"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>