Vue + WebRTC 实现音视频直播(附自定义播放器样式)

1. 什么是WebRTC

1.1 WebRTC简介

WebRTC,名称源自网页即时通信(英语:Web Real-Time Communication)的缩写,是一个支持网页浏览器进行实时语音对话或视频对话的实时通信框架,提供了一系列页面可调用API。

参考定义: 谷歌开放实时通信框架

在上一篇博客Vue +WebSocket + WaveSurferJS 实现H5聊天对话交互 中,已经涉及到WebRTC接口的使用,使用到了getUserMedia方法,用于通过浏览器获取设备麦克风,从而采集音频。

最近项目中的需求则是与服务端建立即时通信,实现低延迟音视频直播。

RTC的特征是(参考来源:https://www.zhihu.com/question/22301898

  • 复杂度较高
  • 半可靠传输,对于特定情境(比如网络环境较差时)可以对音视频进行有损传输,降低延迟
  • 音视频友好:可针对音视频做定制化优化
  • 提供端对端优化方案。 对于传统连接模式,使用C/S架构,A=>服务端=>B,而WebRTC使用的是peer-to-peer模式,A=>B,一旦点和点之间的连接形成,它们之间的数据传输是不经过服务端的,大大降低了服务端的压力。
  • 理论延迟较低,能应用在各种低延迟场景。

2. 业务描述

功能描述
实现对摄像设备的管理列表,在设备列表点击查看视频时,弹出页面浮窗,进行摄像机摄像的视频和音频实时转播。
视频弹窗下方有自己实现的控制条,实现播放/暂停控制,能显示播放时间、切换分辨率、是否全屏等。

效果如图

视频浮窗 - hover状态,显示控制条

视频浮窗 - 非hover状态,隐藏控制条

3. 代码实现

3.1 Html模板代码

<el-dialog ref="videoDialog" title="视频播放" :visible.sync="dialogShowed" :close-on-click-modal="false">
        <div id="dialog-wrap">
            <div id="video-wrap" v-if="isSuccess" v-loading="isLoading" element-loading-text="视频加载中" element-loading-spinner="el-icon-loading"
                element-loading-background="rgba(0, 0, 0, 0.8)" />
            <div class="video-onloading" v-else v-loading="isLoading" element-loading-text="视频加载中" element-loading-spinner="el-icon-loading"
                element-loading-background="rgba(0, 0, 0, 0.8)">
                <span><i class="el-icon-error" v-if="!isLoading" />{{errorMessage}}</span>
            </div>
            <!-- 遮罩层 -->
            <div class="cover" v-if="isSuccess">
                <div class="controls">
                  
                    <i class="el-icon-video-play" v-if="!isPlaying" @click="playOrPauseVideo" />
                    <i class="el-icon-video-pause" v-else @click="playOrPauseVideo" />
                    <div id="currentTime">播放时长:{{currentTime}}</div>
                    <div class="control-resolution">
                        分辨率:
                        <el-select v-model="selectResolution" @change="changeResolution">
                            <el-option v-for="item in resolutions" :key="item" :value="item">
                                {{item}}
                            </el-option>
                        </el-select>
                    </div>
                    <i class="el-icon-full-screen" @click="onClickFullScreen"></i>
                </div>
            </div>
        </div>
    </el-dialog>

  • 使用了Element-UI框架提供的v-loading指令,该指令根据isLoading属性决定是否在区域内加载loading动画

    视频加载状态

  • 若视频加载失败,则显示错误信息


    显示错误信息
  • 预留标签,用于挂载`video和audio DOM元素
    <div id="video-wrap" ></div>
    注意该标签内最好不要再加其他元素,这样后续判断比较简单。

3.2 建立连接、接收音频

       getVideo() {
                let that = this;
                that.isLoading = true;
                that.pc = new RTCPeerConnection();
                that.pc.addTransceiver("video");
                that.pc.addTransceiver("audio");
                that.pc.ontrack = function (event) {
                    var el = document.createElement(event.track.kind);
                    el.srcObject = event.streams[0];
                    el.autoplay = true;
                    document.getElementById("video-wrap").appendChild(el);
                    if (el.nodeName === "VIDEO") {
                        el.oncanplay = () => {
                            that.isLoading = false;
                            // 播放状态设置为true
                            that.isPlaying = true;
                            that.getVideoDuration();
                        };
                    } else if (el.nodeName === "AUDIO") {
                        el.oncanplay = () => {
   
                        };
                    }
                };
                that.pc
                    .createOffer()
                    .then((offer) => {
                        that.pc.setLocalDescription(offer);
                        let req = {
                            webrtc: offer,
                        };
                        console.log(offer);
                        return that.$api.device.getSignaling(
                            that.deviceData.id,
                            that.origin,
                            that.selectResolution,
                            req
                        );
                    })
                    .then((res) => {
                        if (res.code === 0) {
                            that.isSuccess = true;
                            that.pc.setRemoteDescription(res.body.webrtc);
                            that.connId = res.body.connId;
                        } else {
                        
                            that.errorMessage = res.message || "视频加载错误";
                        }
                    })
                    .catch(alert);
            }

参考https://www.jianshu.com/p/43957ee18f1a,查看Peer Connection建立连接的流程。
参考 https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection 查看RTCPeerConnection 支持的接口

createOffer() 方法: 主动与其他peer建立P2P连接,把自己的SDP信息整理好,通过signaling server转发给其他peer。
在上面的代码中,通过向后端发送POST请求,实现信令交换。

 that.pc.addTransceiver("video");
 that.pc.addTransceiver("audio");

指明同时接收音频和视频。

 that.pc.ontrack = function(event){
}

该方法进行音视频的接收,使用接收到的数据创建video和audio元素。
只对pc状态进行监听无法监听到实际视频可以播放的状态,因此需要对video添加监听方法:

  el.oncanplay = () => {
     that.isLoading = false;
     // 播放状态设置为true
    that.isPlaying = true;
    that.getVideoDuration();
};

在video可以播放时,才将loading状态取消,并开始获取video时长。

3.3 控制音视频的JS代码

获取视频播放时长方法:

getVideoDuration() {
    var video = document.getElementsByTagName("video")[0];
    //  如果没有获取到视频元素
    if (!video) {
        return;
    }
    let that = this;

    video.addEventListener("timeupdate", () => {
        that.currentTime = getTime(video.currentTime);
    });

    var getTime = function (time) {
        let hour =
            Math.floor(time / 3600) < 10
                ? "0" + Math.floor(time / 3600)
                : Math.floor(time / 3600);
        let min =
            Math.floor((time % 3600) / 60) < 10
                ? "0" + Math.floor((time % 3600) / 60)
                : Math.floor((time % 3600) / 60);
        var sec =
            Math.floor(time % 60) < 10
                ? "0" + Math.floor(time % 60)
                : Math.floor(time % 60);
        return hour + ":" + min + ":" + sec;
    };
}

控制音频/视频同步暂停的方法:

  playOrPauseVideo() {
    var video = document.getElementsByTagName("video")[0];
    var audio = document.getElementsByTagName("audio")[0];
    if (this.isPlaying) {
        video.pause();
        audio.pause();
    } else {
        // audio
        video.play();
        audio.play();
    }
    this.isPlaying = !this.isPlaying;
}

全屏方法

onClickFullScreen() {
    let dialogElement = document.getElementById("dialog-wrap");
    dialogElement.webkitRequestFullScreen();
}

3.4 样式表

样式部分较为简单,值得注意的有以下几点:

  • 隐藏原有视频控制条,便于对控制条进行自定义
video::-webkit-media-controls {
    /* 去掉全屏时显示的自带控制条 */
    display: none !important;
}
  • 扩大hover热区,视频下半部分(高度为400px部分)悬浮显示控制条
    (不设置为全部部分是因为如果设置为全部部分,则全屏状态无法隐藏控制条)
    以下完整样式表(scss):
    $controlFontColor: rgb(136 141 150);
    $backgroundColor: rgba(0, 0, 0, 0.8);
    $height: 60px;

    .el-dialog .el-dialog__body {
        padding: 0 !important;
        margin-bottom: 0 !important;
        width: unset !important;
    }

    .video-onloading {
        min-height: 500px;
        background-color: $backgroundColor;

        span {
            width: 100%;
            display: block;

            line-height: 500px;
            text-align: center;
            color: $controlFontColor;
            i {
                margin-right: 5px;
            }

            i::before {
                font-size: 17px;
            }
        }
    }

  .cover {
        bottom: 0px;
        height: 300px;
        position: absolute;
        width: 100%;
        z-index: 2;
        &:hover,
        &:focus,
        &:focus-within {
            .controls {
                display: flex;
            }
        }
    }
  .controls {
        width: 100%;
        height: $height;
        line-height: $height;
        font-size: 15px;
        display: none;
        z-index: 2;
        background-color: $backgroundColor;
        color: $controlFontColor;
        position: absolute;
        bottom: 0
        justify-content: space-between;

        & > [class^="el-icon-"] {
            &::before {
                font-size: 26px;
                line-height: $height;
                padding: 0 15px;
                cursor: pointer;
            }
        }

        .playStatus {
            width: 64px;
            height: $height;
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
        }
        #currentTime {
            width: 140px;
            height: $height;
            text-align: center;
        }

        .control-resolution {
            line-height: $height;
            .el-input__inner {
                background: $backgroundColor;
            }
            .el-input {
                width: 95px;
            }
            input {
                border: none;
                font-size: 15px !important;
                color: $controlFontColor;
                &::-webkit-input-placeholder {
                    color: $controlFontColor;
                }
            }
        }
        #fullScreen {
            width: 32px;
            height: 32px;
            position: relative;
            top: 16px;
         
        }
    }

总结

本次的前端业务WebRTC只做了浅显的了解和应用,只应用了接收流,还没有用到推流,WebRTC还有更多用法,比如实现实时视频通话、语音通话等,也许以后的业务中会用到,所以以这篇博客做一个入门记录~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容