播放器有两套输入系统——手势(触摸)和热键(键盘)。它们识别的「动作」最终都要落到媒体操作上:双击快进 10 秒、按上箭头调音量。v10 在两者之间放了一个共享内核——
media-actions.ts的动作注册表。这个设计解耦得很干净,而且里面那个「速度循环导航」的实现让我眼前一亮。
问题:两套输入,一套动作
想象没有这个注册表的世界:手势系统识别「双击右侧」后,自己写media.currentTime += 10;热键系统识别→键后,也自己写一遍。两处实现,两处维护,还都绕过了 store(音量 UI 不会更新)。
v10 的做法:把「需要读当前媒体值再计算」的相对动作集中到一个注册表,手势和热键都调它;「直接调 store 方法」的 toggle 动作由各输入域自己处理。
注册表的结构
core/src/dom/media-actions.ts(47 行):
// L5 — 四个动作名exporttypeMediaInputActionName='seekStep'|'volumeStep'|'speedUp'|'speedDown';// L7-10 — 动作上下文exportinterfaceMediaInputActionContext{store:AnyPlayerStore;value?:number;}// L14-46 — 名字到 resolver 的扁平注册表exportconstMEDIA_INPUT_ACTION_OVERRIDES:Record<MediaInputActionName,MediaInputActionResolver>={seekStep:...,volumeStep:...,speedUp:...,speedDown:...,};扁平的Record<名字, resolver>,没有嵌套配置。每个 resolver 收{ store, value }——只拿 store,不碰 DOM/media 元素。
四个 resolver 的实现
seekStep(L15-20):相对跳转
seekStep({store,value}){if(value===undefined)return;// L16consttime=selectTime(store.state);// L17 — 走 selector!if(!time)return;// L18 — 未配置 time featuretime.seek(time.currentTime+value);// L19 — 相对 seek}注意 L17——走selectTimeselector,不直接摸 media。这带来两个免费好处:time feature 没配置时安全降级(L18);seek 用的是 21 篇讲的那个带supersede抢占 + 乐观更新的seek()。
volumeStep(L22-27):同理走selectVolume
speedUp / speedDown(L29-45):循环列表导航
这对最有意思。不是「固定步长加减」,而是在播放速率列表里循环移动:
speedUp({store}){constrate=selectPlaybackRate(store.state);// L30-32constidx=rate.playbackRates.indexOf(rate.playbackRate);// L33 — 当前位置// L34 — 越界即回绕到 0constnext=idx<0||idx>=rate.playbackRates.length-1?0:idx+1;rate.setPlaybackRate(rate.playbackRates[next]!);// L35}// speedDown 对称:idx <= 0 ? length - 1 : idx - 1 L43为什么循环而不是步长?因为播放速率不是连续值——是[0.5, 1, 1.5, 2]这样的离散列表(由 playbackRate feature 的playbackRates提供)。「+0.5」在列表是[1, 2]时会算出 1.5(不存在的档位),而列表导航永远落在合法档位上。从 2x 再加速回绕到 0.5x——这比「到顶就卡住」的体验好。
这四个 resolver 都体现同一个模式:通过 selector 从 store 读当前值,计算后调 store 的动作方法。全链路在 store 内闭环,UI 自动同步。
gesture 和 hotkey 怎么消费
gesture 侧(gesture/actions.ts,47 行)
// L26-34 — 把四个 resolver spread 进手势自己的注册表constGESTURE_ACTION_OVERRIDES={seekStep:MEDIA_INPUT_ACTION_OVERRIDES.seekStep,// L27volumeStep:MEDIA_INPUT_ACTION_OVERRIDES.volumeStep,speedUp:...,speedDown:...,};// L36-46 — 解析函数exportfunctionresolveGestureAction(name){constoverride=GESTURE_ACTION_OVERRIDES[name];// L37 — 先查 overrideif(override)returnoverride;// L38// L41-44 — 通用回退:直接调 store.state[name]()// __DEV__ 下未知动作 warn(L44)}手势动作上下文比 Media 版多event: PointerEvent(L20)——识别器需要原始事件。
通用回退(L41-44)是关键设计:查不到 override 的动作(togglePaused/toggleMuted/toggleFullscreen这些toggle 类),直接store.state[name]()——因为它们本身就是 state 里的方法(19 篇),不需要读值再计算。
hotkey 侧(hotkey/actions.ts,100 行)
HOTKEY_ACTIONS是完整 Record(L38-89),四个 override 复用同一条目(L65/67/69/71)。其余 toggle 动作内联实现:togglePaused(L39-43)、toggleMuted(L45-47)、toggleFullscreen(L49-53)、toggleSubtitles(L55-57)、togglePictureInPicture(L59-63)。
热键独有一个动作——seekToPercent(L73-88):
// 简化constpercent=value??(key>='0'&&key<='9'?Number(key)*10:undefined);// L79-82time.seek((percent/100)*time.duration);// L87数字键 0-9 直接跳到 0%-90% 位置——YouTube 风格的键盘交互。value优先(显式配置),否则从按键推导。
设计意图总结
把这套结构画出来:
手势识别器 ──┐ ┌─> seekStep/volumeStep/speedUp/speedDown ├─> resolveXxxAction ┤ (MEDIA_INPUT_ACTION_OVERRIDES,走 selector 读值计算) 热键解析器 ──┘ └─> togglePaused/toggleMuted/... (通用回退,直接调 store.state.xxx()) │ ▼ store 状态更新 → UI 自动同步分工规则一句话:「需要读当前值再计算」的相对动作进共享注册表,「直接调用」的 toggle 动作各输入域自己处理(或走通用回退)。
这个设计带来三个收益:
- 单一实现——seekStep 的逻辑(走 selector、带抢占)只写一遍,手势热键共用。
- 不绕过 store——所有动作经过 selector/state 方法,UI 自动同步,不存在「手势改了音量但 UI 没更新」的 bug 类别。
- 扩展对称——加新输入方式(比如游戏手柄)只需要新的 resolver 包装,动作内核复用。
小结
MEDIA_INPUT_ACTION_OVERRIDES是手势/热键的共享内核,四个相对动作。- resolver 只拿 store 不碰 DOM——走 selector 读值、调 state 方法。
- speedUp/speedDown 是循环列表导航——速率是离散档位,越界回绕比卡住体验好。
- toggle 类走通用回退——
store.state[name]()直接调。 - 热键独有
seekToPercent——数字键跳百分比位置。
手势和热键的识别器本身(recognizer/coordinator/解析器)在卷八(52、53 篇)详细讲。下一篇先看 presentation 子系统——全屏/PiP/远程播放/方向锁,浏览器兼容的重灾区。