卷四收官篇。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 presentationMode
video.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-49
const sync = () => set({ fullscreen: isFullscreen(container, media) }); // L51-54
sync(); // L56
listen(document, 'fullscreenchange', sync, { signal }); // L58
listen(document, 'webkitfullscreenchange', sync, { signal }); // L59
if ('webkitPresentationMode' in video) { // 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):
export function isPictureInPictureEnabled(): boolean {
if (document.pictureInPictureEnabled) {
// Safari PWA 排除:UA 匹配 Safari 且 display-mode: standalone 时禁用
if (/.*Version\/.*Safari\/.*/.test(navigator.userAgent)
&& matchMedia('(display-mode: standalone)').matches) {
return false; // L7-9
}
return true;
}
// 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 — 鸭子类型检测
export function resolveRemote(media) {
if (isObject(media.remote) && 'state' in media.remote && 'prompt' in media.remote) {
return media.remote; // cast 成 MediaRemotePlaybackCapability['remote']
}
return null;
}
// L20-26 — 请求远程播放
export function requestRemotePlayback(media) {
const remote = resolveRemote(media);
if (!remote) throw new DOMException('Remote playback not supported', 'NotSupportedError'); // L23
remote.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):
let locked = false; // 事实:lock 真正成功
let desired = false; // 意图:调用方想要锁
lock() 的完整流程(L45-65):
async lock() {
desired = true; // 先记意图
// ... lock 非函数直接 return(L52,不支持时静默)
try {
await orientation.lock(type); // L55 — 异步等真正锁上
if (desired) locked = true; // L58 — await 期间意图还在,才置事实
else releaseOrientation(); // 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 篇。
1274



被折叠的 条评论
为什么被折叠?



