← 博客

浮动 WKWebView 面板里的视频全屏:macOS 上的 WebKit 到底做了什么

8 分钟

那天我在 Overlay 里点一个视频的全屏按钮,什么都没发生。没报错,也没闪一下。Overlay 是一个用 WKWebView 做的浮动网页面板,说实话,原生重写之后我就没试过全屏。老的 Tauri 版是能全屏的,里面甚至专门写了"网页全屏时把面板藏起来"的逻辑。重写时这段没带过来,更基础的东西也漏了。

下面是那一整天里查出来的东西,大部分靠搭小探针、看日志,外加几件到现在还没解决的。

requestFullscreen 根本不存在

第一步是一张只有两行脚本的测试页。用默认的 WKWebViewConfiguration,Element.prototype.requestFullscreen 是 undefined,document.fullscreenEnabled 也是 undefined,对 <video> 调 webkitEnterFullscreen() 直接抛 InvalidStateError。网站是会检测的:有的干脆把全屏按钮藏掉,有的照调不误,然后什么也不发生。

开关叫 WKPreferences.isElementFullscreenEnabled,macOS 12.3 起公开,默认 false。打开之后三样都能用了。所以"bug"本身就一行。后面的事情,全是全屏真的发生之后 WebKit 做了什么。

WebKit 把 webview 挪走了

我在一个裸探针里复刻了面板的形态:accessory App、不激活的 NSPanel、层级 8、出现在所有 Space,里面放一个 WKWebView。然后进全屏,把进程里所有窗口列出来。

WebKit 并不是在原地把视图放大。它自己建一个 WebCoreFullScreenWindow,把 webview 从你的视图层级里摘下来,原处留一个占位视图,再把这个窗口送进一个新的全屏 Space。退出时把 webview 挂回去,连它原来的约束一起恢复——我退出后改了一下面板尺寸,webview 照样跟着走,确认过。

这和 Overlay 的面板正好冲突。面板本来就要浮在全屏 App 之上,于是它跟着视频进了新的 Space,压在视频上面,中间原来放网页的地方是个空洞。规则就定成:第一个网页进全屏时把面板收走,最后一个回来时再唤回。WKWebView.fullscreenState 能 KVO(macOS 13 起),有四个状态可以接。

有两处细节跑了好几轮才弄清。

退出视频自带的全屏时,notInFullscreen 有时会连报两次。第一次报的时候 webview 还在 WebKit 的窗口里,大约 0.6 秒后才回到面板。按第一次就唤回面板,会看到一个中间开着洞的面板,持续半秒。现在只有 webview 真的挂回我自己的窗口,才算"回家";没回来就每 100 毫秒看一次,最多等 2 秒。

另外,看视频时按全局快捷键,得有办法从原生侧让网页退出全屏。closeAllMediaPresentations 看着就是干这个的。实测它把 WebKit 的窗口撤了,webview 却留在那个已经不在屏上的窗口里,再也回不来,日志里还多一条约束异常。改成请网页自己 document.exitFullscreen(),走的是 WebKit 的正常退出路径,东西都能放回原处。视频自带的全屏也一样管用,因为在 macOS 上它就是用元素全屏实现的,document.fullscreenElement 就是那个 video。

开错了屏

我接了三块屏。面板放在右边那块,全屏每次都开在中间那块。

翻 WebKit 源码很快就找到了。WKFullScreenWindowController 选屏用的是 [NSScreen mainScreen],也就是当前键盘焦点所在的屏。Safari 里点全屏时,Safari 自己就是你正在用的 App,所以永远是对的那块屏。Overlay 的面板点了不会激活——这是故意的,你的编辑器要一直保有焦点——于是 mainScreen 是你另一个 App 所在的那块屏。

好在事情发生的顺序里有一个空档。WebKit 先把窗口 frame 设到 mainScreen、把 webview 挂进去;然后 entering 的 KVO 回调;然后网页进程按新尺寸重新布局;最后 WebKit 读 window.screen 做放大动画、调 AppKit 进系统全屏。在那个 KVO 回调里把窗口挪到面板所在的屏,后面每一步读到的就都是对的屏。我先在探针里把面板放到右屏试,又在真实 App 里让中间屏上的另一个 App 保持前台再试。日志里记着"挪到网页所在的屏",视频也出现在点击的那块屏上。

撞两次边才出来

Overlay 能贴在屏幕边上,光标撞到边就滑出来。一开始我在全屏期间把这个关掉了,视频上滑出一个空面板没有意义。后来想法变了:全屏时撞边应该和按全局快捷键一个意思,退出全屏、把面板带回来。这部分很好接。接完的下一个反馈是:要撞两次。

我不再"作弊"之后就复现了。之前的测试脚本是进全屏 4 秒后,把光标先跳到屏幕中间、再跳到边上。人的手是一路平滑推过去的,而且往往视频刚放大一两秒就去推。照这个做,日志里写着鼠标事件被丢掉了。

进全屏会收起菜单栏和 Dock,屏幕的可视区跟着变,系统发出 didChangeScreenParameters。贴边控制器把它当成插拔显示器,在一段静默期里无视鼠标,免得屏幕还在重排时窗口乱跳。我测到的静默期是 2 到 3 秒。更要命的是,它连"光标离开过热区"这一下也丢了,而控制器要求先离开过、才允许下一次唤起。于是静默期结束时光标已经停在边上,照样不算。

全屏期间撞边唯一的效果是请网页退出,不挪任何窗口,静默期没什么要保护的,现在就豁免了。进全屏后 0.8 秒和 1.5 秒推向边缘,都是一次就出来。0.8 秒那次进入动画还没播完,WebKit 会记下退出请求,放完马上执行。

画中画

macOS 上默认的 WKWebView 里,document.pictureInPictureEnabled 报 true,接着调 requestPictureInPicture() 却抛 NotSupportedError。卡住它的是一个私有偏好 _allowsPictureInPictureMediaPlayback。iOS 有公开的 allowsPictureInPictureMediaPlayback,macOS 没有。

设这个值错了两次。KVC 找不到下划线开头的 setter;perform(_:with:) 在该传 BOOL 的地方传了个对象指针,读回来还是 0。最后把方法的 IMP 当 C 函数调才成,系统的画中画小窗出来了。因为是私有 API,只放在官网直下版里;App Store 版把它编译掉了,商店版的打包检查现在也会扫二进制里有没有这个 selector。

还没修好的

菜单栏。全屏时顶部经常还挂着菜单栏,而且是你最近用过的那个普通 App 的菜单——我测的时候是 Claude 和 Finder——不是 Overlay 的。Overlay 是 accessory App(没有 Dock 图标),不拥有菜单栏,系统没有理由替它把别人的菜单栏藏起来。刚启动那会儿有时又是藏着的,看起来就像"第一次全屏正常,之后就坏了"。在全屏开始时把 App 切成普通 App 再激活,没有任何变化,那个时候已经晚了。我能想到两条路:在网页的全屏请求到达 WebKit 之前就把菜单栏拿过来,这会和网页"用户手势"的时效打架;或者自己做全屏,让面板盖住整块屏、层级放在菜单栏之上,再让全屏元素铺满网页区域。后一种还能让你留在当前的 Space,代价是网站自己写的 :fullscreen 样式不生效。还没定。

还有一个视频站,短视频在全屏里播完,整块屏会变成纯白。裸 WKWebView 里同样如此,我就先没管。另外刚开始时出现过一次:一个全屏窗口白着卡在桌面上,之后这个进程里每次请求全屏都在 145 毫秒内被弹回来。重启就好了,之后再也没复现出来。

什么时候别用它

如果你想要的就是看视频,浏览器或者专门的播放器比 Overlay 合适。Safari 本身就有全屏和画中画,上面这些取舍一个都没有。Overlay 是用来把几张网页留在手边的,浮在全屏 App 之上、跨 Space 可用;视频全屏只是保证其中某张网页里的视频,点全屏按钮时的表现符合你的预期。

相关