|  |  |  | 
|---|
|  |  |  | <!DOCTYPE html> | 
|---|
|  |  |  | <html lang="en" xmlns:th="http://www.thymeleaf.org"> | 
|---|
|  |  |  | <html> | 
|---|
|  |  |  | <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> | 
|---|
|  |  |  | <title>video.js播放rtmp流</title> | 
|---|
|  |  |  | <!--引入播放器样式--> | 
|---|
|  |  |  | <link href="http://vjs.zencdn.net/5.19/video-js.min.css" rel="stylesheet"> | 
|---|
|  |  |  | <!--引入播放器js--> | 
|---|
|  |  |  | <script src="http://vjs.zencdn.net/5.19/video.min.js"></script> | 
|---|
|  |  |  | <script src="https://cdn.jsdelivr.net/npm/videojs-flash@2/dist/videojs-flash.min.js"></script> | 
|---|
|  |  |  | </head> | 
|---|
|  |  |  | <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); | 
|---|
|  |  |  | } | 
|---|
|  |  |  | <!--vjs-big-play-centered 播放按钮居中--> | 
|---|
|  |  |  | <!--poster默认的显示界面,就是还没点播放,给你显示的界面--> | 
|---|
|  |  |  | <!--controls 规定浏览器应该为视频提供播放控件--> | 
|---|
|  |  |  | <!--preload="auto" 是否提前加载--> | 
|---|
|  |  |  | <!--autoplay 自动播放--> | 
|---|
|  |  |  | <!--loop=true 自动循环--> | 
|---|
|  |  |  | <!--data-setup='{"example_option":true}' 可以把一些属性写到这个里面来,如data-setup={"autoplay":true}--> | 
|---|
|  |  |  | <video id="my-player" | 
|---|
|  |  |  | class="video-js vjs-default-skin vjs-big-play-centered" controls | 
|---|
|  |  |  | preload="auto" autoplay="autoplay" | 
|---|
|  |  |  | poster="images/logo.png" width="500" height="400" | 
|---|
|  |  |  | data-setup='{}'> | 
|---|
|  |  |  | <!--src: 规定媒体文件的 URL  type:规定媒体资源的类型--> | 
|---|
|  |  |  | <source src='rtmp://192.168.1.194:1935/164' type='rtmp/flv' /> | 
|---|
|  |  |  | </video> | 
|---|
|  |  |  | <script type="text/javascript"> | 
|---|
|  |  |  | // 设置flash路径,用于在videojs发现浏览器不支持HTML5播放器的时候自动唤起flash播放器 | 
|---|
|  |  |  | videojs.options.flash.swf = 'https://cdn.bootcss.com/videojs-swf/5.4.1/video-js.swf'; | 
|---|
|  |  |  | //my-player为页面video元素的id | 
|---|
|  |  |  | var player = videojs('my-player'); | 
|---|
|  |  |  | //播放 | 
|---|
|  |  |  | player.play(); | 
|---|
|  |  |  | //    1. 播放   player.play() | 
|---|
|  |  |  | //    2. 停止   player.pause() | 
|---|
|  |  |  | //    3. 暂停   player.pause() | 
|---|
|  |  |  | </script> | 
|---|
|  |  |  | </body> | 
|---|
|  |  |  | </html> | 
|---|
|  |  |  | </html> | 
|---|