¶Ô±ÈÐÂÎļþ |
| | |
| | | <!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: 9; |
| | | border: 1px solid #ccc; |
| | | 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: 1px solid #ccc; |
| | | 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; |
| | | } |
| | | </style> |
| | | </head> |
| | | <body> |
| | | <div class="container-wrapper"> |
| | | <div class="container" id="gridContainer"> |
| | | <!-- ç½æ ¼é¡¹ç®å°ç± JavaScript å¨æçæ --> |
| | | </div> |
| | | <div class="container2"> |
| | | <div class="button-container"> |
| | | <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> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <script> |
| | | 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(); |
| | | 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; |
| | | videoContainer.appendChild(video); |
| | | gridItem.appendChild(videoContainer); |
| | | gridContainer.appendChild(gridItem); |
| | | |
| | | video.addEventListener('loadedmetadata', function () { |
| | | adjustGridItemSize(gridItem, gridItem.videoWidth, gridItem.videoHeight); |
| | | }); |
| | | console.log(video.id) |
| | | } |
| | | |
| | | gridContainer.style.gridTemplateColumns = `repeat(${cols}, 1fr)`; |
| | | } |
| | | |
| | | |
| | | var chanMap = new Map(); |
| | | window.onload = function () { |
| | | changeGrid(2, 2); |
| | | 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(); |
| | | $(document).on('click', 'video', function() { |
| | | let ID = this.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); |
| | | } |
| | | function closeAllVideo(){ |
| | | videoMap.forEach((val,key) => { |
| | | console.log(val,key); |
| | | val.stop(key); |
| | | videoMap.delete(key); |
| | | }) |
| | | } |
| | | |
| | | </script> |
| | | </body> |
| | | </html> |