播放器有两套输入系统——手势触摸和热键键盘。它们识别的「动作」最终都要落到媒体操作上双击快进 10 秒、按上箭头调音量。v10 在两者之间放了一个共享内核——media-actions.ts的动作注册表。这个设计解耦得很干净而且里面那个「速度循环导航」的实现让我眼前一亮。问题两套输入一套动作想象没有这个注册表的世界手势系统识别「双击右侧」后自己写media.currentTime 10热键系统识别→键后也自己写一遍。两处实现两处维护还都绕过了 store音量 UI 不会更新。v10 的做法把「需要读当前媒体值再计算」的相对动作集中到一个注册表手势和热键都调它「直接调 store 方法」的 toggle 动作由各输入域自己处理。注册表的结构core/src/dom/media-actions.ts47 行// L5 — 四个动作名exporttypeMediaInputActionNameseekStep|volumeStep|speedUp|speedDown;// L7-10 — 动作上下文exportinterfaceMediaInputActionContext{store:AnyPlayerStore;value?:number;}// L14-46 — 名字到 resolver 的扁平注册表exportconstMEDIA_INPUT_ACTION_OVERRIDES:RecordMediaInputActionName,MediaInputActionResolver{seekStep:...,volumeStep:...,speedUp:...,speedDown:...,};扁平的Record名字, resolver没有嵌套配置。每个 resolver 收{ store, value }——只拿 store不碰 DOM/media 元素。四个 resolver 的实现seekStepL15-20相对跳转seekStep({store,value}){if(valueundefined)return;// L16consttimeselectTime(store.state);// L17 — 走 selectorif(!time)return;// L18 — 未配置 time featuretime.seek(time.currentTimevalue);// L19 — 相对 seek}注意 L17——走selectTimeselector不直接摸 media。这带来两个免费好处time feature 没配置时安全降级L18seek 用的是 21 篇讲的那个带supersede抢占 乐观更新的seek()。volumeStepL22-27同理走selectVolumespeedUp / speedDownL29-45循环列表导航这对最有意思。不是「固定步长加减」而是在播放速率列表里循环移动speedUp({store}){constrateselectPlaybackRate(store.state);// L30-32constidxrate.playbackRates.indexOf(rate.playbackRate);// L33 — 当前位置// L34 — 越界即回绕到 0constnextidx0||idxrate.playbackRates.length-1?0:idx1;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.ts47 行// 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){constoverrideGESTURE_ACTION_OVERRIDES[name];// L37 — 先查 overrideif(override)returnoverride;// L38// L41-44 — 通用回退直接调 store.state[name]()// __DEV__ 下未知动作 warnL44}手势动作上下文比 Media 版多event: PointerEventL20——识别器需要原始事件。通用回退L41-44是关键设计查不到 override 的动作togglePaused/toggleMuted/toggleFullscreen这些toggle 类直接store.state[name]()——因为它们本身就是 state 里的方法19 篇不需要读值再计算。hotkey 侧hotkey/actions.ts100 行HOTKEY_ACTIONS是完整 RecordL38-89四个 override 复用同一条目L65/67/69/71。其余 toggle 动作内联实现togglePausedL39-43、toggleMutedL45-47、toggleFullscreenL49-53、toggleSubtitlesL55-57、togglePictureInPictureL59-63。热键独有一个动作——seekToPercentL73-88// 简化constpercentvalue??(key0key9?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/远程播放/方向锁浏览器兼容的重灾区。