☰
videojs v10 源代码系列解读: 25 · 输入动作注册表:手势/热键→媒体的桥
2026/9/26 3:43:13 网站建设 项目流程

播放器有两套输入系统——手势(触摸)和热键(键盘)。它们识别的「动作」最终都要落到媒体操作上:双击快进 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 动作各输入域自己处理(或走通用回退)。

这个设计带来三个收益:

  1. 单一实现——seekStep 的逻辑(走 selector、带抢占)只写一遍,手势热键共用。
  2. 不绕过 store——所有动作经过 selector/state 方法,UI 自动同步,不存在「手势改了音量但 UI 没更新」的 bug 类别。
  3. 扩展对称——加新输入方式(比如游戏手柄)只需要新的 resolver 包装,动作内核复用。

小结

  • MEDIA_INPUT_ACTION_OVERRIDES是手势/热键的共享内核,四个相对动作。
  • resolver 只拿 store 不碰 DOM——走 selector 读值、调 state 方法。
  • speedUp/speedDown 是循环列表导航——速率是离散档位,越界回绕比卡住体验好。
  • toggle 类走通用回退——store.state[name]()直接调。
  • 热键独有seekToPercent——数字键跳百分比位置。

手势和热键的识别器本身(recognizer/coordinator/解析器)在卷八(52、53 篇)详细讲。下一篇先看 presentation 子系统——全屏/PiP/远程播放/方向锁,浏览器兼容的重灾区。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询