| <!DOCTYPE html> | 
| <html lang="en"> | 
| <head> | 
|     <meta charset="UTF-8"> | 
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | 
|     <title>自适应容器示例</title> | 
|     <script th:src="@{/js/jquery-3.6.4.min.js}"></script> | 
|     <style> | 
|         body, html { | 
|             margin: 0; | 
|             padding: 0; | 
|             height: 100%; | 
|         } | 
|   | 
|         .container-wrapper { | 
|             display: flex; | 
|             flex-direction: column; | 
|             height: 100%; | 
|         } | 
|   | 
|         .container { | 
|             background-color: #151414; /* 将网格项目的颜色设置为红色背景 */ | 
|             flex: 30; | 
|             border: 10px solid; | 
|             box-sizing: border-box; | 
|             display: grid; | 
|             grid-template-columns: repeat(2, 1fr); /* 默认 2x2 网格 */ | 
|             grid-gap: 10px; | 
|             overflow: hidden; /* 防止视频溢出容器 */ | 
|         } | 
|   | 
|         .grid-item { | 
|             background-color: #151414; /* 将网格项目的颜色设置为红色背景 */ | 
|             text-align: center; | 
|             display: flex; | 
|             justify-content: center; | 
|             align-items: center; | 
|             border: 2px solid #384551; | 
|             box-sizing: border-box; | 
|             padding: 10px; /* 内边距为 10px */ | 
|             position: relative; /* 添加相对定位 */ | 
|         } | 
|   | 
|         .video-container { | 
|             width: 100%; | 
|             height: 100%; | 
|             display: flex; | 
|             justify-content: center; | 
|             align-items: center; | 
|             overflow: hidden; /* 防止视频溢出容器 */ | 
|             position: absolute; /* 添加绝对定位 */ | 
|             top: 0; | 
|             left: 0; | 
|             right: 0; | 
|             bottom: 0; | 
|         } | 
|   | 
|         .container2 { | 
|             flex: 1; | 
|             border: 1px solid #ccc; | 
|             padding: 10px; | 
|             box-sizing: border-box; | 
|         } | 
|   | 
|         /* 修改 video 样式 */ | 
|         video { | 
|             width: 100%; | 
|             height: 100%; | 
|             object-fit: fill; | 
|         } | 
|   | 
|         #loadingMessage { | 
|             position: absolute; | 
|             top: 50%; | 
|             left: 50%; | 
|             transform: translate(-50%, -50%); | 
|             font-size: 24px; | 
|             color: white; | 
|             background-color: rgba(0, 0, 0, 0.7); | 
|             padding: 10px 20px; | 
|             border-radius: 5px; | 
|             display: none; | 
|         } | 
|     </style> | 
| </head> | 
| <body> | 
| <div class="container-wrapper"> | 
|     <div class="container" id="gridContainer"> | 
|         <!-- 网格项目将由 JavaScript 动态生成 --> | 
|     </div> | 
|     <div id="loadingMessage">正在取流</div> | 
|     <div class="container2"> | 
|         <div class="button-container"> | 
|             <button class="toggle-button" onclick="closeAllVideo()">关闭</button> | 
|             <button class="toggle-button" onclick="changeGrid(1, 1)">1x1</button> | 
|             <button class="toggle-button" onclick="changeGrid(2, 2)">2x2</button> | 
|             <button class="toggle-button" onclick="changeGrid(3, 3)">3x3</button> | 
|             <button class="toggle-button" onclick="changeGrid(4, 4)">4x4</button> | 
|             <button class="toggle-button" onclick="changeGrid(5, 5)">5x5</button> | 
|             <button class="toggle-button" onclick="changeGrid(6, 6)">6x6</button> | 
|             <button class="toggle-button" onclick="changeGrid(7, 7)">7x7</button> | 
|             <button class="toggle-button" onclick="changeGrid(8, 8)">8x8</button> | 
|             <button class="toggle-button" onclick="changeGrid(9, 9)">9x9</button> | 
|             <input id="videoUrl" type="text" value="http://192.168.1.227:8889/164/" style="width: 250px"/> | 
|         </div> | 
|     </div> | 
| </div> | 
|   | 
| <script> | 
|     console.log(RTCRtpReceiver.getCapabilities('video').codecs) | 
|     console.log(RTCRtpReceiver.getCapabilities('audio').codecs) | 
|     //whep操作方法 | 
|     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(whepUrl, videoId) { | 
|             this.video = videoId; | 
|             this.wurl = new URL('whep', whepUrl); | 
|             this.pc = null; | 
|             this.restartTimeout = null; | 
|             this.eTag = ''; | 
|             this.queuedCandidates = []; | 
|             this.start(); | 
|         } | 
|   | 
|         start() { | 
|             console.log("requesting ICE servers"); | 
|             fetch(this.wurl, { | 
|                 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.wurl, { | 
|                 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('ETag'); | 
|                     this.eTag = res.headers.get("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.wurl, { | 
|                 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; | 
|             } | 
|         } | 
|     } | 
|   | 
|     //初始化加载 | 
|     var chanMap = new Map(); | 
|     window.onload = function () { | 
|         let videoUrl = $("#videoUrl").val(); | 
|         for (let i = 1; i < 82; i++) { | 
|             chanMap.set("video" + i, videoUrl); | 
|         } | 
|         console.log(chanMap); | 
|         changeGrid(2, 2); | 
|     } | 
|     //绑定点击事件 | 
|     let playMap = new Map(); | 
|     $(document).on('click', 'video', function () { | 
|         let ID = this.id;//获取当前点击事件的元素 | 
|         console.log(ID); | 
|         console.log(playMap); | 
|         if (playMap.get(ID) != null) { | 
|             closeVideo(ID); | 
|         } else { | 
|             let stream = chanMap.get(ID); | 
|             let client = new WHEPClient(stream, ID); | 
|             playMap.set(ID, client); | 
|         } | 
|     }); | 
|   | 
|     //关闭一个video | 
|     function closeVideo(id) { | 
|         console.log("关闭" + id) | 
|         let client = playMap.get(id); | 
|         client.stop(id); | 
|         playMap.delete(id); | 
|     } | 
|   | 
|     //关闭所有video | 
|     function closeAllVideo() { | 
|         playMap.forEach((val, key) => { | 
|             val.stop(key); | 
|             closeVideo(key); | 
|             playMap.delete(key); | 
|         }) | 
|     } | 
|   | 
|     //动态改变grid | 
|     function calculateAspectRatio(videoWidth, videoHeight) { | 
|         return videoWidth / videoHeight; | 
|     } | 
|   | 
|     function adjustGridItemSize(gridItem, videoWidth, videoHeight) { | 
|         const aspectRatio = calculateAspectRatio(videoWidth, videoHeight); | 
|         gridItem.style.aspectRatio = aspectRatio; /* 设置宽高比 */ | 
|     } | 
|   | 
|     function changeGrid(rows, cols) { | 
|         closeAllVideo(); | 
|         let num = rows * cols; | 
|         let videoUrl = $("#videoUrl").val(); | 
|         for (let i = 1; i < num + 1; i++) { | 
|             chanMap.set("video" + i, videoUrl); | 
|         } | 
|         const gridContainer = document.getElementById('gridContainer'); | 
|         gridContainer.innerHTML = ''; | 
|   | 
|         for (let i = 1; i <= rows * cols; i++) { | 
|             const gridItem = document.createElement('div'); | 
|             gridItem.className = 'grid-item'; | 
|             const videoContainer = document.createElement('div'); | 
|             videoContainer.className = 'video-container'; | 
|             const video = document.createElement('video'); | 
|             video.id = "video" + i; | 
|             video.controls = true; | 
|             video.autoplay = true; | 
|             video.muted = true; | 
|             video.loop = true; | 
|   | 
|             videoContainer.appendChild(video); | 
|             gridItem.appendChild(videoContainer); | 
|             gridContainer.appendChild(gridItem); | 
|   | 
|             video.addEventListener('loadedmetadata', function () { | 
|                 adjustGridItemSize(gridItem, gridItem.videoWidth, gridItem.videoHeight); | 
|             }); | 
|             video.addEventListener("click", function () { | 
|                 loadingMessage.style.display = "block"; | 
|                 video.play().then(function () { | 
|                     loadingMessage.style.display = "none"; | 
|                 }).catch(function (error) { | 
|                     console.error("Error playing the video:", error); | 
|                     loadingMessage.style.display = "none"; | 
|                 }); | 
|             }); | 
|             console.log(video.id) | 
|             let stream = chanMap.get(video.id); | 
|             let client = new WHEPClient(stream, video.id); | 
|             playMap.set(video.id, client); | 
|         } | 
|   | 
|         gridContainer.style.gridTemplateColumns = `repeat(${cols}, 1fr)`; | 
|     } | 
| </script> | 
| </body> | 
| </html> |