<!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> 
 |