卷四收官篇。presentation 子系统(
core/src/dom/presentation/)处理「播放器的呈现形态」——全屏、画中画、远程播放、屏幕方向锁。这是浏览器兼容的重灾区:iOS Safari 的 webkit 前缀、Safari PWA 的 PiP 陷阱、方向锁的异步竞态。这篇看 v10 怎么把这些坑一个个填平。
架构:纯函数库 + feature 装配
先说一个我读源码时的发现:presentation/下的文件(fullscreen.ts、pip.ts等)是纯函数库,没有任何事件监听。事件监听在 store feature 层(store/features/fullscreen.ts等)。
这个分离很清晰:
presentation/*.ts——无状态的浏览器兼容封装(「怎么请求全屏」「怎么判断在不在全屏」)。store/features/*.ts——feature 装配(「什么时候同步状态到 store」「绑什么事件」)。
另外presentation/index.ts(3 行)只导出 fullscreen/pip/remote-playback——orientation 不在桶文件里,仅由 orientation-lock feature 直接路径导入(因为它不是「能力」而是「配置型 feature」)。
fullscreen:三级降级 + 四层判定
请求全屏的三级降级(presentation/fullscreen.ts:54-79)
// L57-66:container 存在且 document 支持全屏el.requestFullscreen()// L60-62 — 标准el.webkitRequestFullscreen()// L64-66 — webkit 前缀// L69-72:都不行 →video.webkitSetPresentationMode('fullscreen')// iOS presentationMode 兜底// L75-78:最后 →video.requestFullscreen()// 媒体自定义(如 Cast 场景的 host 提供)标准 → webkit 前缀 → iOS presentationMode → 媒体自定义。四级路径覆盖所有浏览器。
退出全屏的顺序刻意相反(exitFullscreen,L81-102):webkit 的 presentationMode 先退(L84-88),再标准doc.exitFullscreen()(L90-92)。为什么?因为 iOS 的 presentation mode 是独立状态,得先解除它标准 API 才能生效。
判断「在不在全屏」的四层判定(isFullscreen,L29-52)
// 1. L30-33 — iOS presentationModevideo.webkitPresentationMode==='fullscreen'// 2. L35-38 — 标准/webkit 的 fullscreenElement 比对fullscreenElement===container||fullscreenElement===media// 3. L44-46 — :fullscreen 伪类(matchesFullscreen 内部 try/catch,L20-27)container.matches(':fullscreen')||media.matches(':fullscreen')// 4. L50-51 — 非标准 video.isFullscreen(由 video host 设置)第 3 层的注释(L40-43)解释了为什么需要它::fullscreen伪类匹配全屏元素及其祖先(跨 shadow boundary)——覆盖「内层<video>通过原生控件进全屏」的情况(此时 fullscreenElement 是内层元素,但 container 是它的祖先)。
第 4 层的video.isFullscreen是非标准属性——由 media host(15 篇)设置,覆盖 Cast 这种「概念上的全屏」(接收端全屏,本地 document 无感知)。
能力探测的动态探针(isFullscreenEnabled,L5-13)
doc.fullscreenEnabled || doc.webkitFullscreenEnabled都假时,动态创建一个<video>探针(L11),检测isFunction(video.webkitSetPresentationMode)(L12)——iOS Safari 的旧路径。这和 21 篇 volume 的canSetVolume是同一套「运行时探针」手法。
feature 装配(store/features/fullscreen.ts:44-66)
attach({target,signal,set}){set({fullscreenAvailability:isFullscreenEnabled()?'available':'unsupported'});// L47-49constsync=()=>set({fullscreen:isFullscreen(container,media)});// L51-54sync();// L56listen(document,'fullscreenchange',sync,{signal});// L58listen(document,'webkitfullscreenchange',sync,{signal});// L59if('webkitPresentationMode'invideo){// L62-65 — iOS 补充listen(media,'webkitpresentationmodechanged',sync,{signal});}}同时绑标准和非标准事件,iOS 再补一个。还有一个贴心细节(L16-19):requestFullscreen先退 PiP——注释写着 “browser behavior is inconsistent”(有些浏览器全屏时会打断 PiP,主动退更可控)。
pip:Safari PWA 陷阱
presentation/pip.ts的请求/退出逻辑和 fullscreen 对称(webkit 优先/回退),最有意思的是能力探测(L5-14):
exportfunctionisPictureInPictureEnabled():boolean{if(document.pictureInPictureEnabled){// Safari PWA 排除:UA 匹配 Safari 且 display-mode: standalone 时禁用if(/.*Version\/.*Safari\/.*/.test(navigator.userAgent)&&matchMedia('(display-mode: standalone)').matches){returnfalse;// L7-9}returntrue;}// webkit 探针回退 L12-13}Safari 的 PWA(standalone 模式)里 PiP 会崩——pictureInPictureEnabled说支持,实际用就炸。v10 用 UA +display-mode: standalone双重判定提前排除。这种坑不可能从规范推出来,只能是实踩后写进代码的。
feature 装配(store/features/pip.ts:49-71)监听enterpictureinpicture/leavepictureinpicture(L63-64)+ iOS 的webkitpresentationmodechanged(L67-70)。togglePictureInPicture同样先退 fullscreen(L34-46)——PiP 和全屏互斥。
remote-playback:鸭子类型 + 浏览器 UI
presentation/remote-playback.ts(27 行)最短:
// L4-10 — 鸭子类型检测exportfunctionresolveRemote(media){if(isObject(media.remote)&&'state'inmedia.remote&&'prompt'inmedia.remote){returnmedia.remote;// cast 成 MediaRemotePlaybackCapability['remote']}returnnull;}// L20-26 — 请求远程播放exportfunctionrequestRemotePlayback(media){constremote=resolveRemote(media);if(!remote)thrownewDOMException('Remote playback not supported','NotSupportedError');// L23remote.prompt();// L25 — AirPlay/Cast 的设备选择 UI 由浏览器弹出}注意 L25——remote.prompt()触发的是浏览器原生的设备选择 UI(AirPlay/Cast 菜单)。v10 不自己画设备列表。17 篇讲的GoogleCast组件提供了RemotePlayback的规范实现(google-cast/remote-playback.ts),这里消费的正是那个接口。
orientation:异步竞态的优雅解法
presentation/orientation.ts(77 行)是我觉得这份源码里「小而美」的典范。问题:screen.orientation.lock()是async的——await 期间用户可能已经调了unlock()。怎么防止「迟到的 lock 覆盖已生效的 unlock」?
解法是两个标志分离「事实」和「意图」(L30-31):
letlocked=false;// 事实:lock 真正成功letdesired=false;// 意图:调用方想要锁lock()的完整流程(L45-65):
asynclock(){desired=true;// 先记意图// ... lock 非函数直接 return(L52,不支持时静默)try{awaitorientation.lock(type);// L55 — 异步等真正锁上if(desired)locked=true;// L58 — await 期间意图还在,才置事实elsereleaseOrientation();// L60-64 — 意图已变,立刻释放!}catch{/* L56-58 */}}unlock(){desired=false;// L67 — 只改意图if(locked){releaseOrientation();locked=false;}// L68-70}L58 的if (desired)是竞态的裁决点:await 回来时如果desired已经是 false(unlock 抢先了),立刻调releaseOrientation()反向补偿——迟到的 lock 被立即撤销。这个「意图标志 + 完成后校验」的模式,比 Promise 链或状态机都轻。
orientation-lock feature:configurable 实战
store/features/orientation-lock.ts(53 行)是 19 篇讲的configurable feature的内置使用者:
// L16-52 — 可配置 feature,默认 { type: 'landscape' }(L51)orientationLockFeature({type:'portrait'})// 这样定制它的 attach(L21-49)做边沿检测:wasFullscreen标志(L25、L35)记住上一次全屏态,进入全屏lock()(L29-30)、退出unlock()(L31-32)——方向锁跟随全屏状态自动开关(全屏看视频锁横屏,退出解锁)。同样监听三种 fullscreenchange(L40-46),abort 兜底 unlock(L48)。
小结
presentation 子系统是「浏览器兼容教科书」,带走这些手法:
- 纯函数库 + feature 装配分离——兼容封装无状态,监听在 feature。
- 多级降级链——fullscreen 四级请求路径,退出顺序刻意相反。
- 四层全屏判定——presentationMode/fullscreenElement/
:fullscreen伪类/非标准属性,覆盖 shadow DOM 和 Cast。 - 运行时探针——动态
<video>探测 iOS 能力(和 volume 的 canSetVolume 同款)。 - Safari PWA 陷阱——UA + display-mode 双判。
- 意图/事实双标志解异步竞态——orientation 的 desired/locked。
- 互斥主动处理——全屏先退 PiP、PiP 先退全屏(“browser behavior is inconsistent”)。
至此卷四讲完。播放器核心的全貌:feature 组装(19-21)、无头 Core(22-23)、选择器(24)、输入桥(25)、presentation(26)。下一篇进入卷五——SPF 流处理框架,全系列的重头戏,15 篇。