| <!DOCTYPE html> | 
| <html lang="en" xmlns:th="http://www.thymeleaf.org"> | 
| <head> | 
|     <meta charset="UTF-8"> | 
|     <title>测试页</title> | 
|     <script th:src="@{/js/jquery-3.6.4.min.js}"></script> | 
|     <link rel="stylesheet" th:href="@{/css/bootstrap.css}"/> | 
|     <script th:src="@{/js/bootstrap.js}"></script> | 
|     <style> | 
|         .video-container { | 
|             display: inline-block; | 
|             vertical-align: top; | 
|             width: 33%; /* 3个视频平均分配一行的宽度 */ | 
|             /*padding: 2px; !* 可以根据需要调整内边距 *!*/ | 
|             box-sizing: border-box; | 
|         } | 
|   | 
|         .video-container video { | 
|             width: 100%; | 
|             height: 100%; | 
|             /*object-fit: cover; !* 视频填充容器,保持宽高比 *!*/ | 
|         } | 
|     </style> | 
| <body> | 
| <div> | 
|     <div class="row"> | 
|         <div class="video-container"> | 
|             <video id="video1" muted autoplay loop controls></video> | 
|         </div> | 
|         <div class="video-container"> | 
|             <video id="video2" muted autoplay loop controls></video> | 
|         </div> | 
|         <div class="video-container"> | 
|             <video id="video3" muted autoplay loop controls></video> | 
|         </div> | 
|         <div class="video-container"> | 
|             <video id="video4" muted autoplay loop controls></video> | 
|         </div> | 
|         <div class="video-container"> | 
|             <video id="video5" muted autoplay loop controls></video> | 
|         </div> | 
|     </div> | 
| </div> | 
| <script th:inline="javascript"> | 
|     var chanMap = new Map(); | 
|     window.onload = function () { | 
|   | 
|         chanMap.set("video1", "http://127.0.0.1:8889/245/"); | 
|         chanMap.set("video2", "http://127.0.0.1:8889/164/"); | 
|         chanMap.set("video3", "http://127.0.0.1:8889/164/"); | 
|         chanMap.set("video4", "http://127.0.0.1:8889/165/"); | 
|         chanMap.set("video5", "http://127.0.0.1:8889/165/"); | 
|         console.log(chanMap); | 
|     } | 
|     const linkToIceServers = (links) => ( | 
|         (links !== null) ? links.split(', ').map((link) => { | 
|             const m = link.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i); | 
|             const ret = { | 
|                 urls: [m[1]], | 
|             }; | 
|   | 
|             if (m[3] !== undefined) { | 
|                 ret.username = unquoteCredential(m[3]); | 
|                 ret.credential = unquoteCredential(m[4]); | 
|                 ret.credentialType = "password"; | 
|             } | 
|   | 
|             return ret; | 
|         }) : [] | 
|     ); | 
|     const parseOffer = (offer) => { | 
|         const ret = { | 
|             iceUfrag: '', | 
|             icePwd: '', | 
|             medias: [], | 
|         }; | 
|   | 
|         for (const line of offer.split('\r\n')) { | 
|             if (line.startsWith('m=')) { | 
|                 ret.medias.push(line.slice('m='.length)); | 
|             } else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) { | 
|                 ret.iceUfrag = line.slice('a=ice-ufrag:'.length); | 
|             } else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) { | 
|                 ret.icePwd = line.slice('a=ice-pwd:'.length); | 
|             } | 
|         } | 
|   | 
|         return ret; | 
|     }; | 
|     const generateSdpFragment = (offerData, candidates) => { | 
|         const candidatesByMedia = {}; | 
|         for (const candidate of candidates) { | 
|             const mid = candidate.sdpMLineIndex; | 
|             if (candidatesByMedia[mid] === undefined) { | 
|                 candidatesByMedia[mid] = []; | 
|             } | 
|             candidatesByMedia[mid].push(candidate); | 
|         } | 
|   | 
|         let frag = 'a=ice-ufrag:' + offerData.iceUfrag + '\r\n' | 
|             + 'a=ice-pwd:' + offerData.icePwd + '\r\n'; | 
|   | 
|         let mid = 0; | 
|   | 
|         for (const media of offerData.medias) { | 
|             if (candidatesByMedia[mid] !== undefined) { | 
|                 frag += 'm=' + media + '\r\n' | 
|                     + 'a=mid:' + mid + '\r\n'; | 
|   | 
|                 for (const candidate of candidatesByMedia[mid]) { | 
|                     frag += 'a=' + candidate.candidate + '\r\n'; | 
|                 } | 
|             } | 
|             mid++; | 
|         } | 
|   | 
|         return frag; | 
|     } | 
|   | 
|     class WHEPClient { | 
|         constructor(wurl, videoId) { | 
|             this.video = videoId; | 
|             this.url = new URL('whep', wurl); | 
|             this.pc = null; | 
|             this.restartTimeout = null; | 
|             this.eTag = ''; | 
|             this.queuedCandidates = []; | 
|             this.start(); | 
|         } | 
|   | 
|         start() { | 
|             console.log("requesting ICE servers"); | 
|             fetch(this.url, { | 
|                 method: 'OPTIONS', | 
|             }) | 
|                 .then((res) => this.onIceServers(res)) | 
|                 .catch((err) => { | 
|                     console.log('error: ' + err); | 
|                     this.scheduleRestart(); | 
|                 }); | 
|         } | 
|   | 
|         onIceServers(res) { | 
|             this.pc = new RTCPeerConnection({ | 
|                 iceServers: linkToIceServers(res.headers.get('Link')), | 
|             }); | 
|   | 
|             const direction = "sendrecv"; | 
|             this.pc.addTransceiver("video", {direction}); | 
|             this.pc.addTransceiver("audio", {direction}); | 
|   | 
|             this.pc.onicecandidate = (evt) => this.onLocalCandidate(evt); | 
|             this.pc.oniceconnectionstatechange = () => this.onConnectionState(); | 
|   | 
|             this.pc.ontrack = (evt) => { | 
|                 console.log("new track:", evt.track.kind); | 
|                 document.getElementById(this.video).srcObject = evt.streams[0]; | 
|             }; | 
|   | 
|             this.pc.createOffer() | 
|                 .then((offer) => this.onLocalOffer(offer)); | 
|         } | 
|   | 
|         onLocalOffer(offer) { | 
|             this.offerData = parseOffer(offer.sdp); | 
|             this.pc.setLocalDescription(offer); | 
|   | 
|             console.log("sending offer"); | 
|   | 
|             fetch(this.url, { | 
|                 method: 'POST', | 
|                 headers: { | 
|                     'Content-Type': 'application/sdp', | 
|                 }, | 
|                 body: offer.sdp, | 
|             }) | 
|                 .then((res) => { | 
|                     if (res.status !== 201) { | 
|                         throw new Error('bad status code'); | 
|                     } | 
|                     this.eTag = res.headers.get('E-Tag'); | 
|                     return res.text(); | 
|                 }) | 
|                 .then((sdp) => this.onRemoteAnswer(new RTCSessionDescription({ | 
|                     type: 'answer', | 
|                     sdp, | 
|                 }))) | 
|                 .catch((err) => { | 
|                     console.log('error: ' + err); | 
|                     this.scheduleRestart(); | 
|                 }); | 
|         } | 
|   | 
|         onConnectionState() { | 
|             if (this.restartTimeout !== null) { | 
|                 return; | 
|             } | 
|   | 
|             console.log("peer connection state:", this.pc.iceConnectionState); | 
|   | 
|             switch (this.pc.iceConnectionState) { | 
|                 case "disconnected": | 
|                     this.scheduleRestart(); | 
|             } | 
|         } | 
|   | 
|         onRemoteAnswer(answer) { | 
|             if (this.restartTimeout !== null) { | 
|                 return; | 
|             } | 
|   | 
|             this.pc.setRemoteDescription(new RTCSessionDescription(answer)); | 
|   | 
|             if (this.queuedCandidates.length !== 0) { | 
|                 this.sendLocalCandidates(this.queuedCandidates); | 
|                 this.queuedCandidates = []; | 
|             } | 
|         } | 
|   | 
|         onLocalCandidate(evt) { | 
|             if (this.restartTimeout !== null) { | 
|                 return; | 
|             } | 
|   | 
|             if (evt.candidate !== null) { | 
|                 if (this.eTag === '') { | 
|                     this.queuedCandidates.push(evt.candidate); | 
|                 } else { | 
|                     this.sendLocalCandidates([evt.candidate]) | 
|                 } | 
|             } | 
|         } | 
|   | 
|         sendLocalCandidates(candidates) { | 
|             fetch(this.url, { | 
|                 method: 'PATCH', | 
|                 headers: { | 
|                     'Content-Type': 'application/trickle-ice-sdpfrag', | 
|                     'If-Match': this.eTag, | 
|                 }, | 
|                 body: generateSdpFragment(this.offerData, candidates), | 
|             }) | 
|                 .then((res) => { | 
|                     if (res.status !== 204) { | 
|                         throw new Error('bad status code'); | 
|                     } | 
|                 }) | 
|                 .catch((err) => { | 
|                     console.log('error: ' + err); | 
|                     this.scheduleRestart(); | 
|                 }); | 
|         } | 
|   | 
|         scheduleRestart() { | 
|             if (this.restartTimeout !== null) { | 
|                 return; | 
|             } | 
|   | 
|             if (this.pc !== null) { | 
|                 this.pc.close(); | 
|                 this.pc = null; | 
|             } | 
|   | 
|             this.restartTimeout = window.setTimeout(() => { | 
|                 this.restartTimeout = null; | 
|                 this.start(); | 
|             }, restartPause); | 
|   | 
|             this.eTag = ''; | 
|             this.queuedCandidates = []; | 
|         } | 
|   | 
|         stop() { | 
|             if (this.pc) { | 
|                 try { | 
|                     this.pc.close(); | 
|                 } catch (e) { | 
|                     console.log("Failure close peer connection:" + e); | 
|                 } | 
|                 this.pc = null; | 
|             } | 
|         } | 
|     } | 
|   | 
|     let videoMap = new Map(); | 
|     $('video').click(function (e) { | 
|         let ID = e.target.id;//获取当前点击事件的元素 | 
|         console.log(ID); | 
|         console.log(videoMap); | 
|         if (videoMap.get(ID) != null) { | 
|             closeVideo(ID); | 
|         } else { | 
|             let stream = chanMap.get(ID); | 
|             let client = new WHEPClient(stream, ID); | 
|             videoMap.set(ID, client); | 
|         } | 
|     }); | 
|   | 
|     function closeVideo(id) { | 
|         console.log("关闭" + id) | 
|         let client = videoMap.get(id); | 
|         client.stop(id); | 
|         videoMap.delete(id); | 
|     } | 
| </script> | 
| </body> | 
| </html> |