记一次 Safari 下调用 navigator.mediaDevices.getDisplayMedia 获取录屏失败问题
前言 🔗
记一次 Safari 下调用 navigator.mediaDevices.getDisplayMedia 获取录屏失败问题。
公司的连麦系统在 Safari 的测试下出现无法开启录屏推流的问题,而在 Chrome 下完全正常。
然后 boss 叫我看看这个问题是什么原因。
正文 🔗
在 Safari 上调用失败的时候,会出现 getDisplayMedia must be called from a user gesture handler.
这个错误。
二话不说咱就是直接一个面向谷歌编程,搜索这个错误。
一下子就在 StackOverflow 上找到了相关的一些帖子。
Safari getDisplayMedia must be called from a user gesture handler
大致的意思就是说,在用户进行了手势操作之后才能调用 getDisplayMedia
方法。
即 Safari 无法通过类似 onload
的事件,或者 Vue 里面的 onMounted
钩子来直接调用这个方法。
document.addEventListener("load", function () {
navigator.mediaDevices.getDisplayMedia().then(() => {}, error => {
// 报错
// getDisplayMedia must be called from a user gesture handler.
});
});
所以我们得通过一个按钮来触发这个操作,但是我转念一想,我们的系统是在点击按钮之后才进行 getDisplayMedia
调用的啊。
而网上的帖子基本上都是在说需要通过交互来触发,这时候就陷入了尴尬的境地。
于是我想了另一个的办法,我写了一个测试的函数,这个函数就是直接调用 navigator.mediaDevices.getDisplayMedia
。
然后在点击切换成录屏之后的逻辑中的各个位置执行一次这个函数。
测试函数长如下的样子,因为我们的项目用的 Vue ,所以下面用 Vue 的写法。
<template>
// ...
</template>
<script>
export default {
data() {
return {
// ...
}
},
methods: {
async testMethod(position) {
await navigator.mediaDevices.getDisplayMedia().then(() => {
console.log("success, position: ", position);
}, error => {
console.error("error: ", error);
})
}
}
}
</script>
然后在主逻辑中的各个位置加上这个方法的调用:
<template>
// ...
</template>
<script>
export default {
data() {
return {
// ...
}
},
methods: {
testMethod(position) {
// ...
},
mainMethod() {
await this.testMethod(1);
// 一些操作
await this.testMethod(2);
// 一些操作
await this.testMethod(3);
// 真正调用 navigator.mediaDevices.getDisplayMedia 的地方
}
}
}
</script>
执行之后我发现,只有第一个 testMethod
能执行成功,第二个开始就出现上面说到的错误了。
我又把 this.testMethod(1)
去掉,然后执行,发现第二个能执行成功了,但第三个就失败了。
所以这里可以排除是源代码的问题(因为我们代码里面引入了 owt.js ,有些操作还是在这个库内部执行的)。
到这里虽然得出不是代码写法的问题,但是报错依然没有解决。
然后我又在 Google 上找啊找,然后我就发现了在腾讯的 QCloud 的 sdk 文档里面找到了原因。
原来用户手势还有个 1s 的限制,在参考那里贴了一个页面,这个页面是用来提交浏览器 bug 的。
在 bug 的评论中,我们可以看到下面这一段。
至此我们确定了 1s 限制的准确性。
为了确认这个特性是存在的,我写了一个小 demo 来测试。
demo 的内容也很简单,就是两个按钮,一个立即获取录屏,一个在 1.5s 后获取录屏。
<script setup lang="ts">
const immediate = async () => {
openDisplayMedia();
};
const delay = async () => {
await new Promise<void>((resolve, reject) => {
setTimeout(() => {
resolve();
}, 1500);
});
openDisplayMedia();
};
const openDisplayMedia = async () => {
await navigator.mediaDevices
.getDisplayMedia({
video: true,
audio: true,
})
.then(
() => {
console.log("success");
},
(err) => {
console.log("err", err);
}
);
};
</script>
<template>
<div>
<button @click="immediate">立即打开录屏</button>
<button @click="delay">延迟一秒录屏</button>
</div>
</template>
经过测试我们发现确实在延迟了 1.5s 后无法成功调用 getDisplayMedia
。而在 Chrome 上是没有这个限制的,并且 Chrome 也不需要手势操作就能拉起录屏选项。
(Chrome 你真的太温柔了!)
回到系统的代码中,在实际的代码中,我们是有一些操作是可能会造成时间消耗的,由于使用 await 来同步操作,可能就会造成调用时长超过 1s 导致的调用失败。
于是我想了两个方法。
- 延迟这些 await 的操作,使用
setTimeout
放到下一个宏任务中,尽可能快地执行到getDisplayMedia
函数。 - 提前调用
getDisplayMedia
方法,再执行相关的操作。 - 在调用
getDisplayMedia
之前弹出一个确认框,类似重新生成一个 1s 范围,这样子确保 1s 内能调用到getDisplayMedia
方法。
由于一些操作是需要确保顺序性的,所以第一个方法排除了。
本来是想使用第二种方法的,但是项目使用 owt.js 的内置 API 来调用 getDisplayMedia
方法的,本着不修改依赖库源码的原则也放弃了。
于是我使用了第三个方法,为了最大程度减少对原代码流程的修改,我们这里使用了一个简单地 hack ,代码如下:
<script setup lang="ts">
import { ref } from "vue";
const usePromise = () => {
let resolve: Function;
const promise = new Promise((_resolve, reject) => {
resolve = _resolve;
});
return [promise, resolve!];
};
let resolve: any;
const showOverlay = ref(false);
const delay = async () => {
// 模拟一段耗时的异步操作
await new Promise<void>((resolve, reject) => {
setTimeout(() => {
resolve();
}, 1500);
});
const [promise, r] = usePromise();
resolve = r;
showOverlay.value = true;
await promise;
openDisplayMedia();
};
const openDisplayMedia = () => {
navigator.mediaDevices
.getDisplayMedia({
video: true,
audio: true,
})
.then(
() => {
console.log("success");
},
(err) => {
console.log("err", err);
}
);
};
</script>
<template>
<div>
<button @click="delay">延迟一秒录屏</button>
<div
v-show="showOverlay"
style="
position: fixed;
inset: 0;
background: #000;
display: flex;
align-items: center;
justify-content: center;
color: white;
"
@click="
resolve?.();
showOverlay = false;
"
>
点击
</div>
</div>
</template>
上面的代码中,核心就是下面这段
const [promise, r] = usePromise();
resolve = r;
showOverlay.value = true;
await promise;
这段逻辑可以无缝植入到原有的逻辑中,并且无需更改任何的流程,只是在写法上有点奇葩…
这样在 Safari 上就能成功在延迟 1s 后调用方法了,不过这样的交互个人而言还是有点丑…
同时,这也解释了前面我们所说的为什么只有一个 testMethod
能执行成功的问题。
当我们执行 getDisplayMedia
时,接口返回了一个 Promise ,此时我们需要去选择特定的窗口,这个过程很多时候都会超过 1s ,所以就导致了后面的 testMethod
调用失败了。
后记 🔗
最后这个解决的方法并没有合并到代码中,因为我们的系统就是只支持 Chrome 的。并且测试也只是在 Chrome 上测试的。
所以我也不明白为什么客户要在 Safari 上测试…
就算这个 bug 修复了, Safari 依然用不了,因为 Safari 上调用 navigator.mediaDevices.enumerateDevices
拿不到 videooutput
类型的设备…
果然浏览器还是 Chrome 好,它真的太强大了,太让人省心了~
Safari ? 真不熟!