16、屏幕共享暂停/恢复:暂停轨道、恢复轨道、状态指示器设计

屏幕共享做到一半,突然想切出去处理个私密消息,或者需要临时遮挡一下画面——这时候如果直接关掉共享,再重新开启,流程就太笨重了。

更好的做法是什么?暂停轨道

说白了,就是让视频轨道暂时停止发送数据,但保留连接和轨道对象。等需要的时候,再恢复发送。整个过程对远端用户来说,看到的要么是黑屏,要么是冻结帧,要么是一个「已暂停」的提示。

我早期做远程协作工具时,就踩过这个坑。用户反馈说「我只是想暂停一下,为什么每次都要重新选窗口?」嗯,从那以后,暂停/恢复就成了我屏幕共享模块的标配功能。

16.1 暂停轨道:让 MediaStreamTrack 休眠

暂停的核心 API 其实很简单——MediaStreamTrack.enabled = false

你可能会问:enabledmuted 有什么区别?

我简单解释一下:

  • enabled:由应用层控制。设为 false 时,轨道会停止发送实际数据,远端会收到黑帧或静音帧。
  • muted:由浏览器控制。比如用户拔掉摄像头,浏览器会自动将轨道设为 muted。

所以,暂停屏幕共享,本质上就是把 enabled 置为 false。

// 暂停屏幕共享轨道
function pauseScreenShare(sender) {
  const track = sender.track;
  if (track && track.kind === 'video') {
    track.enabled = false;
    console.log('屏幕共享已暂停');
    updateIndicator('paused');
  }
}

这里有个细节:sender.track 是 RTCRtpSender 上关联的轨道对象。暂停后,RTCPeerConnection 不会断开,只是视频流变成了黑帧。

注意: 暂停后,远端会收到一个黑帧。如果你希望显示「屏幕已暂停」的静态画面,可以在暂停前用 Canvas 捕获最后一帧,然后通过 replaceTrack() 替换成一个静态视频轨道。我后面会讲这个进阶技巧。

16.2 恢复轨道:重新激活数据流

恢复就更直接了——把 enabled 设回 true。

// 恢复屏幕共享轨道
function resumeScreenShare(sender) {
  const track = sender.track;
  if (track && track.kind === 'video') {
    track.enabled = true;
    console.log('屏幕共享已恢复');
    updateIndicator('active');
  }
}

恢复后,浏览器会重新开始采集屏幕内容并发送。远端会从黑帧切回正常画面。

不过,我遇到过一个问题:恢复后画面卡在最后一帧。怎么回事?

排查后发现,是某些浏览器在 enabled 从 false 切回 true 时,需要重新触发一次 getDisplayMedia 的约束。嗯,解决方案是在恢复后,手动触发一次 track.applyConstraints(),强制浏览器重新采集。

async function resumeWithRefresh(sender) {
  const track = sender.track;
  if (track && track.kind === 'video') {
    track.enabled = true;
    // 强制刷新采集
    try {
      await track.applyConstraints({
        width: { ideal: 1920 },
        height: { ideal: 1080 },
        frameRate: { ideal: 30 }
      });
    } catch (e) {
      console.warn('刷新约束失败,但不影响恢复', e);
    }
    updateIndicator('active');
  }
}
小技巧: 如果你在暂停时用 Canvas 替换了轨道,恢复时记得用 sender.replaceTrack(originalTrack) 换回原来的屏幕采集轨道。

16.3 状态指示器设计:让用户知道当前状态

暂停/恢复不能偷偷摸摸做。用户需要明确知道:

  • 当前是「共享中」还是「已暂停」
  • 暂停了多久
  • 远端看到的是什么

我习惯设计三种状态:

状态 图标 文字提示 行为
active 🟢 绿色圆点 屏幕共享中 正常采集并发送
paused 🟡 黄色暂停符号 屏幕共享已暂停 轨道 enabled = false
inactive 🔴 红色圆点 屏幕共享已停止 轨道已移除或关闭

状态指示器的更新逻辑,我建议放在一个统一的方法里:

function updateIndicator(state) {
  const indicator = document.getElementById('screen-share-indicator');
  const statusText = document.getElementById('screen-share-status');

  switch (state) {
    case 'active':
      indicator.className = 'indicator active';
      statusText.textContent = '屏幕共享中';
      break;
    case 'paused':
      indicator.className = 'indicator paused';
      statusText.textContent = '屏幕共享已暂停';
      break;
    case 'inactive':
      indicator.className = 'indicator inactive';
      statusText.textContent = '屏幕共享已停止';
      break;
    default:
      break;
  }
}

UI 上,我推荐用 CSS 动画让指示器更直观:

.indicator {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  display: inline-block;
  margin-right: 8px;
  transition: background-color 0.3s ease;
}

.indicator.active {
  background-color: #4caf50;
  box-shadow: 0 0 6px #4caf50;
  animation: pulse 1.5s infinite;
}

.indicator.paused {
  background-color: #ff9800;
  box-shadow: 0 0 6px #ff9800;
}

.indicator.inactive {
  background-color: #f44336;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

你看,active 状态加了呼吸动画,paused 是静态橙色,inactive 是红色。用户一眼就能分辨。

16.4 暂停/恢复的完整流程

我把整个流程画了一张图,方便你理解:

屏幕共享暂停/恢复流程 开始屏幕共享 🟢 活跃 (enabled=true) 点击暂停 🟡 暂停 (enabled=false) 点击恢复 停止共享 🔴 停止 (track.stop()) 远端用户看到的画面 活跃 → 正常画面 暂停 → 黑帧 / 冻结帧 / 静态提示图 停止 → 画面消失

从图里你能看到,暂停和恢复是在活跃和暂停状态之间来回切换,而停止则是彻底结束共享。远端用户看到的反馈也完全不同。

16.5 进阶:暂停时显示自定义提示画面

黑帧太单调了。我建议在暂停时,用 Canvas 生成一张「屏幕共享已暂停」的提示图,然后通过 replaceTrack() 替换掉原来的屏幕轨道。

let canvasStream = null;

async function pauseWithOverlay(sender) {
  const originalTrack = sender.track;

  // 创建 Canvas 并绘制提示
  const canvas = document.createElement('canvas');
  canvas.width = 640;
  canvas.height = 360;
  const ctx = canvas.getContext('2d');

  ctx.fillStyle = '#2c3e50';
  ctx.fillRect(0, 0, 640, 360);
  ctx.fillStyle = '#ffffff';
  ctx.font = 'bold 32px Arial';
  ctx.textAlign = 'center';
  ctx.fillText('⏸ 屏幕共享已暂停', 320, 180);
  ctx.font = '18px Arial';
  ctx.fillText('点击"恢复"继续共享', 320, 230);

  // 生成 MediaStream
  canvasStream = canvas.captureStream(1); // 1 fps 就够了
  const fakeTrack = canvasStream.getVideoTracks()[0];

  // 替换轨道
  await sender.replaceTrack(fakeTrack);
  originalTrack.enabled = false;

  updateIndicator('paused');
}

async function resumeWithOriginal(sender, originalTrack) {
  await sender.replaceTrack(originalTrack);
  originalTrack.enabled = true;

  // 停止 Canvas 流
  if (canvasStream) {
    canvasStream.getTracks().forEach(t => t.stop());
    canvasStream = null;
  }

  updateIndicator('active');
}
核心思路: 暂停时用静态 Canvas 流替换真实屏幕流,恢复时再换回来。这样远端看到的就不是黑帧,而是一张友好的提示图。

16.6 避坑指南

最后,分享几个我踩过的坑:

  • enabled 切换后,部分浏览器会触发 track.onended:我遇到过 Chrome 在 enabled 设为 false 后,如果用户切走了屏幕窗口,轨道会直接 ended。解决方案是监听 onended 事件,如果是暂停状态,不要自动停止共享。
  • 恢复后画面撕裂:某些显卡驱动下,恢复后前几帧会出现撕裂。我建议恢复后延迟 200ms 再更新状态指示器,给浏览器一点缓冲时间。
  • 多显示器场景:如果用户暂停时切换了显示器,恢复后可能采集到错误的屏幕。嗯,我一般会在恢复时重新检查 getDisplayMedia 的约束,确保采集源正确。

暂停/恢复功能虽然代码不多,但用户体验提升巨大。你想想看,用户不用重新选窗口、不用重新授权,一键暂停一键恢复,多顺畅。

好了,这一节就到这里。下一节我们聊聊屏幕共享中的帧率控制和分辨率适配——这两个参数调好了,画面质量和性能才能兼得。


公众号:蓝海资料掘金营,微信deep3321