<!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; 
 | 
        } 
 | 
        #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="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; 
 | 
            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) 
 | 
        } 
 | 
  
 | 
        gridContainer.style.gridTemplateColumns = `repeat(${cols}, 1fr)`; 
 | 
    } 
 | 
  
 | 
  
 | 
    let mediamtxHost = "192.168.1.12" 
 | 
    var chanMap = new Map(); 
 | 
    window.onload = function () { 
 | 
        changeGrid(2, 2); 
 | 
        chanMap.set("video1", "http://" + mediamtxHost + ":8889/164/"); 
 | 
        chanMap.set("video2", "http://" + mediamtxHost + ":8889/165/"); 
 | 
        chanMap.set("video3", "http://" + mediamtxHost + ":8889/245/"); 
 | 
        chanMap.set("video4", "http://" + mediamtxHost + ":8889/164/"); 
 | 
        chanMap.set("video5", "http://" + mediamtxHost + ":8889/165/"); 
 | 
        chanMap.set("video6", "http://" + mediamtxHost + ":8889/245/"); 
 | 
        chanMap.set("video7", "http://" + mediamtxHost + ":8889/164/"); 
 | 
        chanMap.set("video8", "http://" + mediamtxHost + ":8889/165/"); 
 | 
        chanMap.set("video9", "http://" + mediamtxHost + ":8889/245/"); 
 | 
        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> 
 |