WebRTC技术简介及应用场景

写在前面

本文是参考稀土掘金的文章,整理得出,版权归原作者所有!参考链接请点击跳转

查看摄像头支持分辨率地址: chrome://media-internals/
查看网络连接和获取dump日志地址: chrome://webrtc-internals/

WebRTC(Web Real-Time Communication) 是一项开源技术,允许浏览器和移动应用直接进行实时音视频通信数据传输,无需安装插件或第三方软件。它由 Google 发起,现已成为 W3C 和 IETF 的标准。

核心特点:

  1. 点对点(P2P)连接

    • 设备间直接通信,降低延迟,提升效率。

    • 但需通过 ICE/STUN/TURN 服务器解决 NAT 穿越问题。

  2. 无需插件

    • 原生支持主流浏览器(Chrome、Firefox、Safari 等)。

  3. 关键组件

    • MediaStream(getUserMedia):获取摄像头/麦克风数据。

    • RTCPeerConnection:建立音视频传输连接。

    • RTCDataChannel:支持任意数据(如文件、游戏指令)传输。

  4. 安全加密

    • 强制使用 SRTP(音视频加密)和 DTLS(数据加密)。

  5. 适应网络变化

    • 自动调整码率、抗丢包,适应不同网络条件。

常见应用场景:

  • 视频会议(如 Google Meet、Zoom 的网页版)

  • 在线教育、远程医疗

  • 文件共享、屏幕共享

  • 物联网设备控制

摄像头和麦克风属于用户的隐私设备,WebRTC既然成为了浏览器中音视频即时通信的W3C标准,因此必然会提供API,让有一定代码开发能力的人去调用;

注意敲黑板: 使用这些API是有前提条件的哦,首先在安全源访问,调用API才没有任何阻碍的。那什么是安全源呢?看下面思维导图(更详细的看:chrome官方文档),且记住这句话:安全源 是至少匹配以下( Scheme 、 Host 、 Port )模式之一的源

举个简单的例子:你本地开发用HTTP请求地址获取摄像头API没有问题,但是你的同事用他的电脑访问你电脑IP对应的项目地址时,摄像头调用失败,为什么呢?

因为在他的浏览器中,你的项目访问地址非HTTPS,在非HTTPS的情况下,如果IP不是localhost127.0.0.1,都不属于安全源

当然事非绝对,在特定情况下必须使用非HTTPS访问也是可以的,Chrome提供了对应的取消限制但是不太建议用(安全为上),因此我在这里就不再多余阐述。

所以经常有人问,为什么我的代码在自己浏览器中可以获取到摄像头,但是在区域网下别的电脑的浏览器中获取不到?同样的浏览器、同样的操作系统,为什么获取不到呢?原因就是上面的安全源限制。

getUserMedia  ★ 重要

以前的版本中我们经常使用 navigator.getUserMedia 来获取计算机的摄像头或者麦克风,但是现在这个接口废弃,变更为 navigator.mediaDevices.getUserMedia,因此后面我们均使用新的API来完成代码编写。

getUserMedia可以干什么?  ★ 重要

意如其名,那就是获取用户层面的媒体,当你的计算机通过 USB 或者其他网络形式接入了 N 多个摄像头或虚拟设备时,都是可以通过这个 API 获取到的。 当然不仅仅是视频设备,还包括音频设备和虚拟音频设备。 获取媒体设备是最简单的操作,它还可以控制获取到媒体的分辨率,以及其他的以一些可选项。

PS:在很多云会议中,我们开会只能选择一个摄像头,这并不是只能使用一个摄像头,而是厂商针对“大多数场景中只会用到一个摄像头”而设计的;但在有些业务中,我们可能需要自己设备上的N 个摄像头(带USB摄像头)同时使用,那么如何办到呢(这个场景其实蛮多的,后面留个课后题)。因此熟知这个 API 对于解决基本的会议和其他复杂场景问题很有用。

如何使用 getUserMedia  ★ 重要

有简单的用法,有复杂的用法。一般简易场景下,大多数 API 用默认参数就可以实现对应功能,getUserMedia也一样,直接调用不使用任何参数,则获取的就是 PC 的默认摄像头和麦克风。

但是,当我们遇到复杂一点的应用场景,比如你的电脑上自带麦克风,同时你连接了蓝牙耳机和有线耳机,那么在视频通话过程中,你如何主动选择使用哪个呢?也就是说, 在用摄像头或者麦克风之前,我们先要解决如何从 N 个摄像头或者麦克风中选择我们想要的。

要解决这个问题,我们必须先有个大体的思路(当然这个思路并不是凭空想象出来的,而是在一定的技术储备下才有的。如果你开始前没有任何思路也没关系,可以参考他人的经验),如下:

  1. 获取当前设备所有的摄像头和麦克风信息;

  1. 从所有的设备信息中遍历筛选出我们想要使用的设备;

  1. 将我们想要使用的设备以某种参数的形式传递给浏览器 API

  1. 浏览器API去执行获取的任务。

上面提到的设备以某种参数的形式传递给 API,那么这个设备必然是以参数存在的,因此这里有几个概念需要提前知道,如下:

设备分成了图中的三个大类型,每个类型都有固定的字段,比如 ID、kind、label ,而其中用于区分它们的就是kind字段中的固定值最核心的字段就是 ID,后面我们经常用的就是这个 ID。

那么,在前端如何使用 JavaScript获取到这些信息?

大家先看下面这段代码,大体上过一遍,并留意 initInnerLocalDevice函数内部执行顺序。

function handleError(error) {
    alert("摄像头无法正常使用,请检查是否占用或缺失")
    console.error('navigator.MediaDevices.getUserMedia error: ', error.message, error.name);
}
/**
 * @author suke
 * device list init 
 */
function initInnerLocalDevice(){
        const that  = this
        var localDevice = {
            audioIn:[],
            videoIn: [],
            audioOut: []

        }
        let constraints = {video:true, audio: true}
        if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
            console.log("浏览器不支持获取媒体设备");
            return;
        }
        navigator.mediaDevices.getUserMedia(constraints)
            .then(function(stream) {
                stream.getTracks().forEach(trick => {
                    trick.stop()
                })
                // List cameras and microphones.
                navigator.mediaDevices.enumerateDevices()
                    .then(function(devices) {
                        devices.forEach(function(device) {
                            let obj = {id:device.deviceId, kind:device.kind, label:device.label}
                            if(device.kind === 'audioinput'){
                                if(localDevice.audioIn.filter(e=>e.id === device.deviceId).length === 0){
                                    localDevice.audioIn.push(obj)
                                }
                            }if(device.kind === 'audiooutput'){
                                if(localDevice.audioOut.filter(e=>e.id === device.deviceId).length === 0){
                                    localDevice.audioOut.push(obj)
                                }
                            }else if(device.kind === 'videoinput' ){
                                if(localDevice.videoIn.filter(e=>e.id === device.deviceId).length === 0){
                                    localDevice.videoIn.push(obj)
                                }
                            }
                        });
                    })
                    .catch(handleError);

            })
            .catch(handleError);
    }

这个代码片段的主要作用就是获取用户设备上所有的摄像头和麦克风信息,起关键作用的是enumerateDevices函数,但是在调用这个关键函数之前,getUserMedia函数出现在了这里,它的出现是用户在访问服务时直接调用用户摄像头,此时如果用户授权且同意使用设备摄像头、麦克风,那么enumerateDevices函数就能获取设备信息了,在这里getUserMedia函数可以理解为获取摄像头或者麦克风权限集合的探路函数

看下图,我将我电脑上使用enumerateDevices函数加载到的信息,根据前面提到的字段kind,将其分三类并打印到控制台。

千万不要小看现在获取到的这些信息哦,在后面视频通话或会议过程中,我们需要抉择摄像头用前置还是后置,麦克风是用蓝牙还是有线,都是离不开这些信息的。

在拿到所有的摄像头麦克风信息之后,我们需选出最终要参与视频通话的那个信息体,看上图中 VideoIn数组里面label:"eseSoft Vcam"  这个摄像头就是我想要参会的摄像头,那么我怎样指定让代码去选择这个摄像头呢?这里就涉及到了getUserMedia的约束参数constraints 。

判断摄像头是前置还是后置 ★ 常用
获取设备列表:使用 navigator.mediaDevices.enumerateDevices() 方法获取设备列表。
检查设备类型:在设备列表中,查找 kind 为 videoinput 的设备。
判断前置或后置:通常,前置摄像头的 label 包含“front”或“前置”等字样,
而后置摄像头的 label 可能包含“back”或“后置”等字样。
具体内容可能因设备和浏览器而异,因此需要根据实际情况进行判断。

判断音频设备是麦克风还是扬声器 ★ 常用
获取设备列表:同样使用 navigator.mediaDevices.enumerateDevices() 方法。
检查设备类型:查找 kind 为 audioinput(麦克风)和 audiooutput(扬声器)的设备。
判断设备类型:通常,audioinput 设备会被标识为麦克风,而 audiooutput 设备会被标识为扬声器。
耳机是归类为音频输出设备(扬声器)kind字段

媒体约束 constraints ★ 基础

在具体讲解约束参数 constraints 之前,大家先看下面这段示例代码。

let constraints = {video:true, audio: true} 

    function handleError(error) {
        console.error('navigator.MediaDevices.getUserMedia error: ', error.message, error.name);
    }

    /**
     * 获取设备 stream
     * @param constraints
     * @returns {Promise<MediaStream>}
     */
    async function getLocalUserMedia(constraints){
        return await navigator.mediaDevices.getUserMedia(constraints)
    }

    let stream = await this.getLocalUserMedia(constraints).catch(handleError);
console.log(stream)

 

上面的代码片段为JavaScript获取计算机摄像头和麦克风的媒体流(视频和音频流我们统称为媒体流)的一种方式,大多数情况下都是这么用的,如果电脑有摄像头、麦克风,这样获取没有任何问题,但就担心你用的时候,你的电脑上没有配摄像头或麦克风,或者有多个摄像头而你想指定其中某一个。 为了兼容更多情况,我们需要知道constraints这个参数的详细用法。

接下来我们看下这个参数在几种常见场景下的具体配置,以及为什么这样配置。

1.同时获取视频和音频输入

使用下面约束, 如果遇到计算机没有摄像头的话,你调用上述代码的过程中就会报错,因此我们在调用之前可以通过enumerateDevices返回结果主动判断有无视频输入源,没有的话,可以动态将这个参数中的 video设置为false

{ audio: true, video: true }

2.获取指定分辨率

在会议宽带足够且流媒体传输合理的情况下,无需考虑服务端压力,而需考虑客户端用户摄像头的分辨率范围,通常我们会设置一个分辨率区间。

下面展示的①约束是请求一个 1920×1080 分辨率的视频,但是还提到 min 参数,将 320×240 作为最小分辨率,因为并不是所有的网络摄像头都可以支持 1920×1080 。当请求包含一个 ideal(应用最理想的)值时,这个值有着更高的权重,意味着浏览器会先尝试找到最接近指定理想值的设定或者摄像头(如果设备拥有不止一个摄像头)。

但是,在多人会议简单架构场景中,在不改变会议稳定性的情况下,为了让更多的客户端加入,我们通常会把高分辨率主动降低到低分辨率,约束特定摄像头获取指定分辨率如下面②配置。


    --------------------①:1--------------------------
    {
        audio: true,
        video: {
            width: { min: 320, ideal: 1280, max: 1920 },
            height: { min: 240, ideal: 720, max: 1080 }
        }
    }
    --------------------②:2--------------------------
    {
    audio: true,
    video: { width: 720, height: 480}
}

3.指定视频轨道约束:获取移动设备的前置或者后置摄像头

facingMode属性。可接受的值有:user(前置摄像头)、environment(后置摄像头);需要注意的是,这个属性在移动端可用,当我们的会议项目通过 h5 在移动端打开时,我们可以动态设置这个属性从而达到切换前后摄像头的场景。

{ audio: true, video: { facingMode: "user" } }
{ audio: true, video: { facingMode: { exact: "environment" } } }

 4.定帧速率frameRate

 

帧速率(你可以理解为FPS)不仅对视频质量,还对带宽有着影响,所以在我们通话过程中,如果判定网络状况不好,那么可以限制帧速率。

我们都知道,视频是通过一定速率的连续多张图像形成的,比如每秒 24 张图片才会形成一个基础流畅的视频,因此帧速率对于实时通话的质量也有影响,你可以想象成和你的游戏的FPS一个道理。

const constraints = {
    audio: true,
    video: {
        width:1920,
        height:1080,
        frameRate: { ideal: 10, max: 15 }
    }
};

实际上,通过FPS我们可以引申出来一些场合,在特定场合选择特定的FPS搭配前面的分辨率配置,以提高我们会议系统的质量,比如:

  • 屏幕分享过程中,我们应当很重视高分辨率而不是帧速率,稍微卡点也没关系;
  • 在普通会议过程中,我们应当重视的是画面的流畅,即帧速率而不是高分辨率;
  • 在开会人数多但宽带又受限的情况下,我们重视的同样是会议的流程性,同样低分辨率更适合宽带受限的多人会议;
  • ……

5.使用特定的网络摄像头或者麦克风

重点哦,我们最前面enumerateDevices函数获取到的设备集合可以派上用场了。

/**
 * 获取指定媒体设备id对应的媒体流
 * @author suke
 * @param videoId
 * @param audioId
 * @returns {Promise<void>}
 */
async function getTargetIdStream(videoId,audioId){
    const constraints = {
        audio: {deviceId: audioId ? {exact: audioId} : undefined},
        video: {
            deviceId: videoId ? {exact: videoId} : undefined,
            width:1920,
            height:1080,
            frameRate: { ideal: 10, max: 15 }
        }
    };
    if (window.stream) {
        window.stream.getTracks().forEach(track => {
            track.stop();
        });
    }
    //被调用方法前面有,此处不再重复
    let stream = await this.getLocalUserMedia(constraints).catch(handleError);

}

getDisplayMedia ★ 重要

我们日常开会,多数需要通过会议 App 来分享自己的屏幕,或者仅分享桌面上固定的应用程序那么在浏览器中实现视频通话,能否实现分享屏幕呢?答案是肯定的, W3C的 Screen Capture 标准中有说明,就是使用getDisplayMedia

const constraints = {
  audio: true,
  video: true
};

const promise = navigator.mediaDevices.getDisplayMedia(constraints);

console.info('--promise--', promise);

navigator.mediaDevices.getDisplayMedia(constraints)
  .then((stream) => {
     /* use the stream */
    console.info('--use the stream--', stream);
  })
  .catch((err) => {
    /* handle the error */
    console.error('--handle the erro--', err);
  });

参数 Constraints

同上一个函数一样,同样需要配置constraints约束,当然这个也是可选的, 如果选择传参的话,那么参数设置如下:

getDisplayMedia({
  audio: true,
  video: true
})

但是这里的constraints配置和前面getUserMedia的约束配置是有差别的。又一个重点来了,在屏幕分享的约束中,video 是不能设置为false 的,但是可以设置指定的分辨率,如下:

getDisplayMedia({
  audio: true,
  video: {width:1920,height:1080}
})
  1. audiotrue

  2. audiofalse

 请留意上面两图的对比,当去掉音频后,第二张图少了个勾选系统音频的 radio 框

完整案例

/**
 * 获取屏幕分享的媒体流
 * @author suke
 * @returns {Promise<void>}
 */
async function getShareMedia(){
    const constraints = {
        video:{width:1920,height:1080},
        audio:false
    };
    if (window.stream) {
        window.stream.getTracks().forEach(track => {
            track.stop();
        });
    }
    return await navigator.mediaDevices.getDisplayMedia(constraints).catch(handleError);
}

小提示

  • 在前面的案例代码中,我们在获取系统的音频或者视频的stream之前,一般会调用以下代码,目的是清除当前标签页中没有销毁的媒体流。   
       if (window.stream) {
               window.stream.getTracks().forEach(track => {
                   track.stop();
               });
           }
    

    如果不销毁,你可以看到在标签页旁边一直有个小红圈闪烁,鼠标按上去提示正在使用当前设备的摄像头,因此在后面的开发中保持好习惯:结束自己会议后或页面用完摄像头后,一般除了强制刷新,也可以调用上面代码清除正在使用的stream调用。

    好了,这节课我们我们掌握了两个最重要的 API,下节课我们开始搭建一个信令服务器,同时完成 P2P (单人对单人)的视频通话(跑代码的时候一定要记得前面提到的安全源哦)。

检测函数

githup上检测webRtc链接:Select audio and video sources

静默基础检测 ★ 常用

function isSupportWebRtcFlag() {
    // 获取用户代理字符串,用于检测浏览器类型
    const userAgent = navigator.userAgent,
        isIphone = userAgent.indexOf('iPhone') > -1,
        isUcBrowser = userAgent.indexOf('UCBrowser') > -1,
        isIphoneUC = isIphone && isUcBrowser;
    let canIUseDataChannel = true,
        canIUseRTCPeer = true,
        canIUseGetUserMedia = false,
        canIUseRealTime = false;

    // 检测是否支持 getUserMedia(获取设备列表)
    if (navigator.mediaDevices
        && navigator.mediaDevices.getUserMedia
        || navigator.getUserMedia
        || navigator.mozGetUserMedia
        || navigator.mozGetUserMedia) {
        canIUseGetUserMedia = true;
    }

    // 检测是否支持 RTCPeerConnection (数据通道)
    canIUseRTCPeer = Boolean(window.RTCPeerConnection)
        || Boolean(window.webkitRTCPeerConnection)
        || Boolean(window.mozRTCPeerConnection)
        || Boolean(window.msRTCPeerConnection)
        || Boolean(window.oRTCPeerConnection);

    try {
        const o = new (window.RTCPeerConnection || window.msRTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection)(null);

        // eslint-disable-next-line no-restricted-syntax
        canIUseDataChannel = 'createDataChannel' in o;
    } catch (e) {
        console.error('尝试创建 RTCPeerConnection 对象,以检测是否支持数据通道错误,error:', e);
        canIUseDataChannel = false;
    }

    // 综合判断是否支持所有 WebRTC 功能
    canIUseRealTime = canIUseGetUserMedia && canIUseRTCPeer && canIUseDataChannel && !isIphoneUC;

    if (!canIUseGetUserMedia) {
        console.warn('webRtcUtils[isSupportWebRtcFlag] --> 不支持getUserMedia');
    }
    if (!canIUseRTCPeer) {
        console.warn('webRtcUtils[isSupportWebRtcFlag] --> 不支持RTCPeerConnection');
    }

    if (!canIUseDataChannel) {
        console.warn('webRtcUtils[isSupportWebRtcFlag] --> 不支持createDataChannel');
    }

    if (canIUseRealTime) {
        console.info('webRtcUtils[isSupportWebRtcFlag] --> 支持炫彩api');
    } else {
        console.warn('webRtcUtils[isSupportWebRtcFlag] --> 不支持炫彩api');
    }

    return {
        canIUseGetUserMedia,
        canIUseRTCPeer,
        canIUseDataChannel,
        canIUseRealTime
    };
}

isSupportWebRtcFlag();

静默黑名单检测

function isSupportWebRtcSilently() {
    const ua = navigator.userAgent;
    const isMobile = (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i).test(ua);

    // 1. 检测关键 API 是否存在
    const hasGetUserMedia = Boolean(navigator.mediaDevices?.getUserMedia
        || navigator.getUserMedia
        || navigator.webkitGetUserMedia
        || navigator.mozGetUserMedia);

    const hasRTCPeerConnection = Boolean(window.RTCPeerConnection
        || window.webkitRTCPeerConnection
        || window.mozRTCPeerConnection);

    // 2. 检测 DataChannel 支持
    let hasDataChannel = false;

    if (hasRTCPeerConnection) {
        try {
            const pc = new (window.RTCPeerConnection || window.webkitRTCPeerConnection)({iceServers: []});

            // eslint-disable-next-line no-restricted-syntax
            hasDataChannel = 'createDataChannel' in pc;
            pc.close();
        } catch (e) {
            console.error('检测 DataChannel 支持', e);
            hasDataChannel = false;
        }
    }

    // 3. 排除已知有问题的浏览器或场景
    const isBlockedBrowser

        // 排除 UC 浏览器、QQ 浏览器、MIUI 浏览器等
        = (/UCBrowser|QQBrowser|MiuiBrowser|Quark|baiduboxapp/i).test(ua)

        // iOS 第三方浏览器(如 Firefox Focus)可能限制 WebRTC
        || isMobile && (/Firefox/i).test(ua) && !(/FxiOS/).test(ua);

    // 4. 综合判断
    const isSupported
        = hasGetUserMedia
        && hasRTCPeerConnection
        && hasDataChannel
        && !isBlockedBrowser;
		
	const result = {
        isSupported,
        details: {
            hasGetUserMedia,
            hasRTCPeerConnection,
            hasDataChannel,
            isBlockedBrowser
        }
    };	
		console.info('--result--', result);

    return result;
}
isSupportWebRtcSilently();

精准检测 (需用户授权)

async function preciseWebRTCSupportCheck() {
  const result = {
    supportsWebRTC: false,
    details: {
      hasRTCPeerConnection: false,
      hasDataChannel: false,
      hasGetUserMedia: false,
      hasIceSupport: false,
      hasCodecSupport: { video: [], audio: [] },
      errors: []
    }
  };

  try {
    // 1. 检测 RTCPeerConnection 和 DataChannel
    const RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
    if (!RTCPeerConnection) {
      result.details.errors.push('RTCPeerConnection API missing');
      return result;
    }
    result.details.hasRTCPeerConnection = true;

    const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
    result.details.hasDataChannel = 'createDataChannel' in pc;

    // 2. 检测 ICE 支持(网络穿透)
    let hasIce = false;
    pc.onicecandidate = (e) => {
      if (e.candidate && e.candidate.candidate) {
        hasIce = true;
        result.details.hasIceSupport = true;
      }
    };

    // 3. 检测编解码器支持(H.264/VP8/Opus)
    const sender = pc.addTransceiver('video');
    const capabilities = sender.sender.getCapabilities();
    result.details.hasCodecSupport.video = capabilities.codecs.filter(c => c.mimeType.includes('video'));
    result.details.hasCodecSupport.audio = capabilities.codecs.filter(c => c.mimeType.includes('audio'));

    // 4. 实际创建 Offer 以触发 ICE 收集
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);

    // 等待 ICE 收集完成(最多 2 秒)
    await new Promise(resolve => setTimeout(resolve, 2000));
    pc.close();

    // 5. 检测 getUserMedia(需用户授权)
    if (navigator.mediaDevices?.getUserMedia) {
      try {
        const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
        stream.getTracks().forEach(track => track.stop());
        result.details.hasGetUserMedia = true;
      } catch (e) {
        result.details.errors.push(`getUserMedia failed: ${e.name}`);
      }
    }

    // 综合判定
    result.supportsWebRTC = (
      result.details.hasRTCPeerConnection &&
      result.details.hasDataChannel &&
      result.details.hasIceSupport &&
      result.details.hasGetUserMedia &&
      result.details.hasCodecSupport.video.length > 0
    );

  } catch (e) {
    result.details.errors.push(`Critical error: ${e.message}`);
  }

  return result;
}

 

WebRTC 常用 API 列表 ★ 重要

API 名称作用
getUserMedia()请求访问用户的摄像头、麦克风或屏幕,返回 MediaStream 对象。
RTCPeerConnection建立点对点连接,处理音视频传输、NAT 穿透和加密。
RTCDataChannel在 P2P 连接中传输任意数据(如文件、文本)。
createOffer()由呼叫方生成一个 SDP 提议(offer),描述本地媒体能力。
createAnswer()由接收方生成一个 SDP 应答(answer),响应对方的提议。
setLocalDescription()设置本地 SDP 描述(offer/answer)。
setRemoteDescription()设置远端 SDP 描述。
addIceCandidate()添加 ICE 候选地址,用于 NAT 穿透。
onicecandidateICE 候选地址生成时触发的事件。
ontrack当远端流添加到 RTCPeerConnection 时触发。

 WebRTC API 测试页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebRTC API 全功能测试</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            line-height: 1.6;
        }
        h1, h2 {
            color: #2c3e50;
        }
        button {
            padding: 10px 15px;
            margin: 5px;
            background: #3498db;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
        }
        button:disabled {
            background: #95a5a6;
        }
        video {
            width: 100%;
            max-width: 400px;
            border: 1px solid #ddd;
            margin: 10px 0;
            background: #000;
        }
        #status {
            padding: 10px;
            margin: 10px 0;
            border: 1px solid #eee;
            border-radius: 4px;
            min-height: 60px;
            background: #f9f9f9;
        }
        .section {
            margin: 20px 0;
            padding: 15px;
            border: 1px solid #ddd;
            border-radius: 5px;
        }
        pre {
            background: #f0f0f0;
            padding: 10px;
            border-radius: 4px;
            overflow-x: auto;
        }
    </style>
</head>
<body>
    <h1>WebRTC API 全功能测试</h1>
    <p>点击下方按钮测试不同的 WebRTC API 功能:</p>
    
    <div id="status">状态:等待操作...</div>

    <!-- 1. MediaStream (getUserMedia) 测试 -->
    <div class="section">
        <h2>1. MediaStream API (getUserMedia)</h2>
        <p>测试摄像头/麦克风访问:</p>
        <button id="startCamera">启动摄像头</button>
        <button id="stopCamera" disabled>停止摄像头</button>
        <button id="switchCamera" disabled>切换摄像头</button>
        <video id="localVideo" playsinline autoplay muted></video>
    </div>

    <!-- 2. RTCPeerConnection 测试 -->
    <div class="section">
        <h2>2. RTCPeerConnection API</h2>
        <p>模拟点对点连接(本地两个 PeerConnection 对象互连):</p>
        <button id="startPeerConnection">创建 PeerConnection</button>
        <button id="closePeerConnection" disabled>关闭连接</button>
        <div>
            <video id="remoteVideo" playsinline autoplay muted></video>
        </div>
    </div>

    <!-- 3. RTCDataChannel 测试 -->
    <div class="section">
        <h2>3. RTCDataChannel API</h2>
        <p>测试点对点数据传输:</p>
        <button id="startDataChannel" disabled>创建 DataChannel</button>
        <button id="sendData" disabled>发送测试消息</button>
        <div>
            <p>接收到的消息: <span id="receivedMessage">暂无</span></p>
        </div>
    </div>

    <script>
        // 全局变量
        const statusDiv = document.getElementById('status');
        let localStream = null;
        let peerConnection1 = null;
        let peerConnection2 = null;
        let dataChannel = null;

        // ==============================================
        // 1. 测试 MediaStream (getUserMedia)
        // ==============================================
        const startCameraBtn = document.getElementById('startCamera');
        const stopCameraBtn = document.getElementById('stopCamera');
        const switchCameraBtn = document.getElementById('switchCamera');
        const localVideo = document.getElementById('localVideo');

        startCameraBtn.addEventListener('click', async () => {
            try {
                updateStatus("请求摄像头和麦克风权限...");
                
                // 调用 getUserMedia API
                localStream = await navigator.mediaDevices.getUserMedia({
                    video: { 
                        width: 640, 
                        height: 480,
                        facingMode: 'user' // 前置摄像头
                    },
                    audio: true
                });
                
                localVideo.srcObject = localStream;
                updateStatus("摄像头已开启!", true);
                
                startCameraBtn.disabled = true;
                stopCameraBtn.disabled = false;
                switchCameraBtn.disabled = false;
                
                // 启用 PeerConnection 测试按钮
                document.getElementById('startPeerConnection').disabled = false;
            } catch (error) {
                updateStatus(`错误: ${error.message}`, false);
                console.error("getUserMedia 错误:", error);
            }
        });

        stopCameraBtn.addEventListener('click', () => {
            if (localStream) {
                localStream.getTracks().forEach(track => track.stop());
                localVideo.srcObject = null;
                updateStatus("摄像头已关闭", true);
                
                startCameraBtn.disabled = false;
                stopCameraBtn.disabled = true;
                switchCameraBtn.disabled = true;
            }
        });

        switchCameraBtn.addEventListener('click', async () => {
            if (!localStream) return;
            
            try {
                updateStatus("切换摄像头中...");
                const videoTrack = localStream.getVideoTracks()[0];
                const constraints = {
                    video: {
                        deviceId: { exact: videoTrack.getSettings().deviceId },
                        facingMode: videoTrack.getSettings().facingMode === 'user' ? 'environment' : 'user'
                    }
                };
                
                const newStream = await navigator.mediaDevices.getUserMedia(constraints);
                localStream.getVideoTracks()[0].stop();
                localStream.removeTrack(localStream.getVideoTracks()[0]);
                localStream.addTrack(newStream.getVideoTracks()[0]);
                localVideo.srcObject = localStream;
                updateStatus("摄像头已切换", true);
            } catch (error) {
                updateStatus(`切换失败: ${error.message}`, false);
            }
        });

        // ==============================================
        // 2. 测试 RTCPeerConnection
        // ==============================================
        const startPeerBtn = document.getElementById('startPeerConnection');
        const closePeerBtn = document.getElementById('closePeerConnection');
        const remoteVideo = document.getElementById('remoteVideo');

        startPeerBtn.addEventListener('click', async () => {
            try {
                updateStatus("创建 RTCPeerConnection...");
                
                // 创建两个 PeerConnection 对象(模拟两端)
                const configuration = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] };
                peerConnection1 = new RTCPeerConnection(configuration);
                peerConnection2 = new RTCPeerConnection(configuration);
                
                // 添加本地流到 peerConnection1
                localStream.getTracks().forEach(track => {
                    peerConnection1.addTrack(track, localStream);
                });
                
                // 监听 ICE 候选地址
                peerConnection1.onicecandidate = (event) => {
                    if (event.candidate) {
                        peerConnection2.addIceCandidate(event.candidate);
                    }
                };
                
                peerConnection2.onicecandidate = (event) => {
                    if (event.candidate) {
                        peerConnection1.addIceCandidate(event.candidate);
                    }
                };
                
                // 当 peerConnection2 收到远程流时,显示在 remoteVideo 上
                peerConnection2.ontrack = (event) => {
                    remoteVideo.srcObject = event.streams[0];
                };
                
                // 创建 Offer 和 Answer 模拟连接
                const offer = await peerConnection1.createOffer();
                await peerConnection1.setLocalDescription(offer);
                await peerConnection2.setRemoteDescription(offer);
                
                const answer = await peerConnection2.createAnswer();
                await peerConnection2.setLocalDescription(answer);
                await peerConnection1.setRemoteDescription(answer);
                
                updateStatus("PeerConnection 连接成功!远程视频已显示。", true);
                
                startPeerBtn.disabled = true;
                closePeerBtn.disabled = false;
                document.getElementById('startDataChannel').disabled = false;
            } catch (error) {
                updateStatus(`PeerConnection 错误: ${error.message}`, false);
                console.error("PeerConnection 错误:", error);
            }
        });

        closePeerBtn.addEventListener('click', () => {
            if (peerConnection1) peerConnection1.close();
            if (peerConnection2) peerConnection2.close();
            remoteVideo.srcObject = null;
            
            updateStatus("PeerConnection 已关闭", true);
            startPeerBtn.disabled = false;
            closePeerBtn.disabled = true;
            document.getElementById('startDataChannel').disabled = true;
        });

        // ==============================================
        // 3. 测试 RTCDataChannel
        // ==============================================
        const startDataBtn = document.getElementById('startDataChannel');
        const sendDataBtn = document.getElementById('sendData');
        const receivedMessage = document.getElementById('receivedMessage');

        startDataBtn.addEventListener('click', () => {
            try {
                // 在 peerConnection1 上创建 DataChannel
                dataChannel = peerConnection1.createDataChannel('testChannel');
                
                dataChannel.onopen = () => {
                    updateStatus("DataChannel 已连接", true);
                    sendDataBtn.disabled = false;
                };
                
                dataChannel.onmessage = (event) => {
                    receivedMessage.textContent = event.data;
                };
                
                dataChannel.onclose = () => {
                    updateStatus("DataChannel 已关闭", true);
                    sendDataBtn.disabled = true;
                };
                
                // peerConnection2 监听 DataChannel
                peerConnection2.ondatachannel = (event) => {
                    const channel = event.channel;
                    channel.onmessage = (event) => {
                        receivedMessage.textContent = event.data;
                    };
                };
                
                startDataBtn.disabled = true;
            } catch (error) {
                updateStatus(`DataChannel 错误: ${error.message}`, false);
            }
        });

        sendDataBtn.addEventListener('click', () => {
            if (dataChannel && dataChannel.readyState === 'open') {
                const message = `测试消息 ${new Date().toLocaleTimeString()}`;
                dataChannel.send(message);
                updateStatus(`已发送: "${message}"`, true);
            }
        });

        // ==============================================
        // 辅助函数
        // ==============================================
        function updateStatus(message, isSuccess = true) {
            statusDiv.innerHTML = `状态:${message}`;
            statusDiv.style.color = isSuccess ? 'green' : 'red';
            console.log(`[状态] ${message}`);
        }
    </script>
</body>
</html>

功能说明

1. MediaStream (getUserMedia)

  • 启动摄像头:请求摄像头和麦克风权限,显示本地视频。

  • 停止摄像头:关闭媒体轨道。

  • 切换摄像头:在前置/后置摄像头之间切换(需设备支持多摄像头)。

2. RTCPeerConnection

  • 创建连接:模拟两个端点的 P2P 连接(实际是本地两个对象互连)。

  • 关闭连接:终止连接并清除视频流。

  • 关键流程

    • 创建 Offer/Answer

    • 交换 ICE 候选地址

    • 显示远程视频流

3. RTCDataChannel

  • 创建通道:在已建立的 P2P 连接上创建数据通道。

  • 发送消息:通过通道发送文本消息。

  • 接收消息:实时显示接收到的消息。


使用说明

  1. 按顺序测试功能(先启动摄像头,再创建 PeerConnection,最后测试 DataChannel)。

  2. 需要在 HTTPS 或 localhost 环境下运行。

  3. 查看控制台(Console)获取详细日志。


扩展建议

  • 如果要测试真实设备间的通信,需部署 信令服务器(如 Socket.io)交换 SDP 和 ICE 候选。

  • 添加屏幕共享功能(getDisplayMedia)。

  • 测试 TURN 服务器穿透防火墙。

  • 信令服务器后面补充...

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值