Day26 [实作] 一对一视讯通话(6): 关闭镜头或麦克风

我们在视讯通话中很常用的一个功能就是关闭镜头或是麦克风,今天我们将实作此功能

1. 在 index.html 中增加

<div>
  <button id="startBtn">开始</button>
  <button id="leaveBtn">离开</button>
  <button name="audio" id="audioBtn"></button>
  <button name="video" id="VideoBtn"></button>
</div>

2. 在 main.js 中增加

  1. 按钮标签

    const audioBtn = document.querySelector('button#audioBtn')
    const VideoBtn = document.querySelector('button#VideoBtn')
    
  2. 串流的开关状态

    let streamOutput = { audio: true, video: true, }
    
  3. 设定按钮文字

    function setBtnText() {
      audioBtn.textContent = streamOutput.audio ? '关闭麦克风' : '开启麦克风'
      VideoBtn.textContent = streamOutput.video ? '关闭视讯镜头' : '开启视讯镜头'
    }
    
  4. 更新本地串流输出状态

    function setSelfStream() {
      localStream.getAudioTracks().forEach((item) => {
        item.enabled = streamOutput.audio
      })
      localStream.getVideoTracks().forEach((item) => {
        item.enabled = streamOutput.video
      })
    }
    
  5. 设定本地串流开关状态

    function handleStreamOutput(e) {
      const { name } = e.target
    
      streamOutput = {
        ...streamOutput,
        [name]: !streamOutput[name],
      }
      setBtnText()
      setSelfStream()
    }
    
  6. 监听按钮点击

    audioBtn.onclick = handleStreamOutput
    VideoBtn.onclick = handleStreamOutput
    

完整程序码如下

1. index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>1on1 webrtc</title>
  </head>
  <body>
    <div>
      <label>切换麦克风:</label>
      <select id="audioSource"></select>
    </div>

    <div>
      <label>切换摄影机:</label>
      <select id="videoSource"></select>
    </div>

    <div>
      <button id="startBtn">开始</button>
      <button id="leaveBtn">离开</button>
      <button name="audio" id="audioBtn">关闭麦克风</button>
      <button name="video" id="VideoBtn">关闭镜头</button>
    </div>

    <div>
      <video muted width="320" autoplay playsinline id="localVideo"></video>
      <video width="320" autoplay playsinline id="remoteVideo"></video>
    </div>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
    <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
    <script src="./js/main.js"></script>
  </body>
</html>

2. main.js

// video 标签
const localVideo = document.querySelector('video#localVideo')
const remoteVideo = document.querySelector('video#remoteVideo')

// button 标签
const startBtn = document.querySelector('button#startBtn')
const leaveBtn = document.querySelector('button#leaveBtn')
const audioBtn = document.querySelector('button#audioBtn')
const VideoBtn = document.querySelector('button#VideoBtn')

// 切换设备
const audioInputSelect = document.querySelector('select#audioSource')
const videoSelect = document.querySelector('select#videoSource')
const selectors = [audioInputSelect, videoSelect]

let localStream
let peerConn
let socket
const room = 'room1'

// ===================== 连线相关 =====================
/**
 * 连线 socket.io
 */
function connectIO() {
  // socket
  socket = io('ws://0.0.0.0:8080')

  socket.on('ready', async (msg) => {
    console.log(msg)
    // 发送 offer
    console.log('发送 offer ')
    await sendSDP(true)
  })

  socket.on('ice_candidate', async (data) => {
    console.log('收到 ice_candidate')
    const candidate = new RTCIceCandidate({
      sdpMLineIndex: data.label,
      candidate: data.candidate,
    })
    await peerConn.addIceCandidate(candidate)
  })

  socket.on('offer', async (desc) => {
    console.log('收到 offer')
    // 设定对方的配置
    await peerConn.setRemoteDescription(desc)

    // 发送 answer
    await sendSDP(false)
  })

  socket.on('answer', async (desc) => {
    console.log('收到 answer')

    // 设定对方的配置
    await peerConn.setRemoteDescription(desc)
  })

  socket.on('leaved', () => {
    console.log('收到 leaved')
    socket.disconnect()
    closeLocalMedia()
  })

  socket.on('bye', () => {
    console.log('收到 bye')
    hangup()
  })

  socket.emit('join', room)
}

/**
 * 取得本地串流
 */
async function createStream() {
  try {
    const audioSource = audioInputSelect.value
    const videoSource = videoSelect.value
    const constraints = {
      audio: { deviceId: audioSource ? { exact: audioSource } : undefined },
      video: { deviceId: videoSource ? { exact: videoSource } : undefined },
    }
    // 取得影音的Stream
    const stream = await navigator.mediaDevices.getUserMedia(constraints)

    // 提升作用域
    localStream = stream

    // 导入<video>
    localVideo.srcObject = stream
  } catch (err) {
    throw err
  }
}

/**
 * 初始化Peer连结
 */
function initPeerConnection() {
  const configuration = {
    iceServers: [
      {
        urls: 'stun:stun.l.google.com:19302',
      },
    ],
  }
  peerConn = new RTCPeerConnection(configuration)

  // 增加本地串流
  localStream.getTracks().forEach((track) => {
    peerConn.addTrack(track, localStream)
  })

  // 找寻到 ICE 候选位置後,送去 Server 与另一位配对
  peerConn.onicecandidate = (e) => {
    if (e.candidate) {
      console.log('发送 ICE')
      // 发送 ICE
      socket.emit('ice_candidate', room, {
        label: e.candidate.sdpMLineIndex,
        id: e.candidate.sdpMid,
        candidate: e.candidate.candidate,
      })
    }
  }

  // 监听 ICE 连接状态
  peerConn.oniceconnectionstatechange = (e) => {
    if (e.target.iceConnectionState === 'disconnected') {
      remoteVideo.srcObject = null
    }
  }

  // 监听是否有流传入,如果有的话就显示影像
  peerConn.onaddstream = ({ stream }) => {
    // 接收流并显示远端视讯
    remoteVideo.srcObject = stream
  }
}

/**
 * 处理信令
 * @param {Boolean} isOffer 是 offer 还是 answer
 */
async function sendSDP(isOffer) {
  try {
    if (!peerConn) {
      initPeerConnection()
    }

    // 创建SDP信令
    const localSDP = await peerConn[isOffer ? 'createOffer' : 'createAnswer']({
      offerToReceiveAudio: true, // 是否传送声音流给对方
      offerToReceiveVideo: true, // 是否传送影像流给对方
    })

    // 设定本地SDP信令
    await peerConn.setLocalDescription(localSDP)

    // 寄出SDP信令
    let e = isOffer ? 'offer' : 'answer'
    socket.emit(e, room, peerConn.localDescription)
  } catch (err) {
    throw err
  }
}

/**
 * 关闭自己的视讯
 */
function closeLocalMedia() {
  if (localStream && localStream.getTracks()) {
    localStream.getTracks().forEach((track) => {
      track.stop()
    })
  }
  localStream = null
}

/**
 * 挂掉电话
 */
function hangup() {
  if (peerConn) {
    peerConn.close()
    peerConn = null
  }
}

/**
 * 初始化
 */
async function init() {
  await createStream()
  initPeerConnection()
  connectIO()
  startBtn.disabled = true
  leaveBtn.disabled = false
}

// ===================== 切换设备 =====================
/**
 * 将读取到的设备加入到 select 标签中
 * @param {*} deviceInfos
 */
function gotDevices(deviceInfos) {
  const values = selectors.map((select) => select.value)
  selectors.forEach((select) => {
    while (select.firstChild) {
      select.removeChild(select.firstChild)
    }
  })
  for (let i = 0; i !== deviceInfos.length; ++i) {
    const deviceInfo = deviceInfos[i]
    const option = document.createElement('option')
    option.value = deviceInfo.deviceId
    if (deviceInfo.kind === 'audioinput') {
      option.text =
        deviceInfo.label || `microphone ${audioInputSelect.length + 1}`
      audioInputSelect.appendChild(option)
    } else if (deviceInfo.kind === 'videoinput') {
      option.text = deviceInfo.label || `camera ${videoSelect.length + 1}`
      videoSelect.appendChild(option)
    } else {
      console.log('Some other kind of source/device: ', deviceInfo)
    }
  }
  selectors.forEach((select, selectorIndex) => {
    if (
      Array.prototype.slice
        .call(select.childNodes)
        .some((n) => n.value === values[selectorIndex])
    ) {
      select.value = values[selectorIndex]
    }
  })
}

/**
 * 读取设备
 */
navigator.mediaDevices
  .enumerateDevices()
  .then(gotDevices)
  .catch((err) => {
    console.error('Error happens:', err)
  })

/**
 * 切换设备
 * @param {*} isAudio
 * @returns
 */
async function switchDevice(isAudio) {
  if (!peerConn) return
  const audioSource = audioInputSelect.value
  const videoSource = videoSelect.value
  const constraints = {
    audio: { deviceId: audioSource ? { exact: audioSource } : undefined },
    video: { deviceId: videoSource ? { exact: videoSource } : undefined },
  }
  const stream = await navigator.mediaDevices.getUserMedia(constraints)
  let track = stream[isAudio ? 'getAudioTracks' : 'getVideoTracks']()[0]
  let sender = peerConn.getSenders().find(function (s) {
    return s.track.kind == track.kind
  })
  console.log('found sender:', sender)
  sender.replaceTrack(track)

  localStream = stream
  localVideo.srcObject = stream
}

// ===================== 关闭镜头或麦克风 =====================
// 串流开关状态
let streamOutput = { audio: true, video: true, }

/**
 *  设定按钮文字
 */
function setBtnText() {
  audioBtn.textContent = streamOutput.audio ? '关闭麦克风' : '开启麦克风'
  VideoBtn.textContent = streamOutput.video ? '关闭镜头' : '开启镜头'
}

/**
 * 更新本地串流输出状态
 */
function setSelfStream() {
  localStream.getAudioTracks().forEach((item) => {
    item.enabled = streamOutput.audio
  })
  localStream.getVideoTracks().forEach((item) => {
    item.enabled = streamOutput.video
  })
}

/**
 * 设定本地串流开关状态
 * @param  {Object} e
 */
function handleStreamOutput(e) {
  const { name } = e.target

  streamOutput = {
    ...streamOutput,
    [name]: !streamOutput[name],
  }
  setBtnText()
  setSelfStream()
}

// ===================== 监听事件 =====================
/**
 * 监听按钮点击
 */
audioBtn.onclick = handleStreamOutput
VideoBtn.onclick = handleStreamOutput
startBtn.onclick = init
leaveBtn.onclick = () => {
  if (socket) {
    socket.emit('leave', room)
  }
  hangup()
  startBtn.disabled = false
  leaveBtn.disabled = true
}

/**
 * 监听 select 改变状态
 */
audioInputSelect.onchange = () => {
  switchDevice(true)
}
videoSelect.onchange = () => {
  switchDevice(false)
}

<<:  #25 No-code 之旅 — 实作 Notion 部落格 Pagination (分页) 功能 ft. SWR

>>:  Day25 Android - MVP架构(简易)

[Day 29]用Django架构建置专属的LINEBOT吧 - LIFF(II)Django Template样板

Django的Template(样板) 在Django专案刚建立的时候, 我们可以从views.py...

狗狗币的技术与理想的深入解析

或许你想问我 狗狗币有突出的技术吗? 它不是只是照抄程序码而诞生的加密货币吗? 事实并非如此, 我认...

Day8 Android - 切换页面(Intent)

intent可以使一个Activity切换至另一个Activity,而一个application可能...

[Day-15] - Spring 标示说明性注解运用与设计

Abstract 随者Spring各种嗨到爆的注解模式来势汹汹,满足所有开发者配置各类型的元件注解,...

EP23 - [TDD] OrderPayQuery 查询付款结果 (1/2)

Youtube 频道:https://www.youtube.com/c/kaochenlong ...