|
| 1 | +# SmartVideoPlayer 开发与使用指南 |
| 2 | + |
| 3 | +本文档面向项目内的 `SmartVideoPlayer`(视频页播放器组件),用于说明: |
| 4 | +- 组件能力与整体架构 |
| 5 | +- HTML5 / YouTube / 通用 Embed 三种模式的差异 |
| 6 | +- 配置项(受控/非受控)与对接方式 |
| 7 | +- 常见问题与踩坑记录(本次迭代中实际遇到的问题) |
| 8 | + |
| 9 | +> 适用范围:`src/components/stateless/SmartVideoPlayer/` 以及 `src/pages/video/`。 |
| 10 | +
|
| 11 | +--- |
| 12 | + |
| 13 | +## 1. 组件目标与边界 |
| 14 | + |
| 15 | +### 1.1 目标 |
| 16 | + |
| 17 | +- 提供接近 YouTube 风格的播放器控制条(按钮 + tooltip + 统一布局)。 |
| 18 | +- HTML5 video 模式下支持: |
| 19 | + - 播放/暂停、快进/快退 |
| 20 | + - 音量控制(滑杆 + 静音切换 + 音量记忆) |
| 21 | + - 进度条与时间显示 |
| 22 | + - 全屏与画中画(PiP) |
| 23 | + - HLS(`.m3u8`)可选接入(通过 `hls.js`,浏览器支持时自动启用) |
| 24 | + - 字幕 UI(设置面板里可开关/选择字幕轨道) |
| 25 | + - 懒播放(IntersectionObserver)与滚出视口的 mini 小窗 |
| 26 | + - 设置面板(Portal 渲染,避免被父容器裁切) |
| 27 | +- YouTube / Embed 模式下:以 `iframe` 方式嵌入展示,并提供「新窗口打开」与设置入口。 |
| 28 | + |
| 29 | +### 1.2 边界(刻意不做/不承诺) |
| 30 | + |
| 31 | +- **Embed / YouTube iframe 并不一定可控**:默认不提供播放/暂停/进度/音量等“看似可控但实际上不可控”的 UI。 |
| 32 | +- 对于第三方站点嵌入:可能被 CSP 或 `X-Frame-Options` 禁止 iframe。 |
| 33 | +- 不提供 DASH/DRM、广告、章节、缩略图等高级功能(可作为后续扩展)。 |
| 34 | + |
| 35 | +--- |
| 36 | + |
| 37 | +## 2. 代码位置与关键文件 |
| 38 | + |
| 39 | +- 组件:`src/components/stateless/SmartVideoPlayer/index.jsx` |
| 40 | +- 样式:`src/components/stateless/SmartVideoPlayer/index.module.css` |
| 41 | +- HTML5 video Hook:`src/components/hooks/useVideo/index.tsx` |
| 42 | +- 示例页面:`src/pages/video/index.jsx` |
| 43 | + |
| 44 | +--- |
| 45 | + |
| 46 | +## 3. Provider 模式说明 |
| 47 | + |
| 48 | +`provider` 用于选择渲染策略: |
| 49 | + |
| 50 | +### 3.1 `provider="html5"` |
| 51 | + |
| 52 | +- 渲染 `<video>` 元素。 |
| 53 | +- 启用 `useVideo(..., { enabled: true })`,负责订阅事件、维护状态(paused/muted/currentTime/volume 等)。 |
| 54 | +- 启用懒播放 / mini / PiP / 进度条 / 音量条等完整控制条。 |
| 55 | + |
| 56 | +### 3.2 `provider="youtube"` |
| 57 | + |
| 58 | +- 渲染 `<iframe>`,src 为 `https://www.youtube.com/embed/${youtubeId}?...`。 |
| 59 | +- 设置项 `ytControls` 控制 YouTube 原生控制条是否显示。 |
| 60 | +- 控制条不展示 play/pause、进度、音量等(避免误导)。 |
| 61 | +- 提供「新窗口打开」:打开 `https://www.youtube.com/watch?v=${youtubeId}`。 |
| 62 | + |
| 63 | +### 3.3 `provider="embed"` |
| 64 | + |
| 65 | +- 渲染 `<iframe>`,src 由 `embedUrl` 或 `getEmbedUrl(config)` 生成。 |
| 66 | +- 控制条不展示 play/pause、进度、音量等(避免误导)。 |
| 67 | +- 提供「新窗口打开」:优先打开 `externalUrl`,否则打开 `sourceUrl`,再否则 fallback 为 `embedUrl`。 |
| 68 | + |
| 69 | +--- |
| 70 | + |
| 71 | +## 4. Props(使用说明) |
| 72 | + |
| 73 | +> 以实际实现为准;此处描述的是本次迭代后的关键对外接口。 |
| 74 | +
|
| 75 | +### 4.1 基础 Props |
| 76 | + |
| 77 | +- `provider?: 'html5' | 'youtube' | 'embed'` |
| 78 | +- `title?: string` |
| 79 | + |
| 80 | +### 4.2 HTML5 video 相关 |
| 81 | + |
| 82 | +- `src?: string`:支持 mp4;当为 `.m3u8` 时,会尝试走 HLS 播放(原生支持则直接播,否则使用 `hls.js`)。 |
| 83 | +- `trackSrc?: string`:字幕 vtt 地址(可选)。 |
| 84 | +- `trackLang?: string`:字幕语言(默认 `en`)。 |
| 85 | + |
| 86 | +### 4.3 YouTube 相关 |
| 87 | + |
| 88 | +- `youtubeId?: string` |
| 89 | + |
| 90 | +### 4.4 Embed 相关 |
| 91 | + |
| 92 | +- `embedUrl?: string`:直接传 iframe src。 |
| 93 | +- `getEmbedUrl?: (config) => string`:用配置动态生成 iframe src(更通用)。 |
| 94 | +- `externalUrl?: string`:**最高优先级**,用于「新窗口打开」按钮。 |
| 95 | +- `sourceUrl?: string`:当没有 externalUrl 时使用(例如“源站播放页”)。 |
| 96 | + |
| 97 | +### 4.5 配置(受控/非受控) |
| 98 | + |
| 99 | +- `initialConfig?: Partial<Config>`:非受控模式下的初始值。 |
| 100 | +- `config?: Config`:受控模式配置。 |
| 101 | +- `onConfigChange?: (nextConfig: Config) => void`:受控模式的变更回调。 |
| 102 | + |
| 103 | +配置项(当前): |
| 104 | +- `lazyPlay: boolean`:懒播放(滚出视口自动暂停) |
| 105 | +- `miniPlayer: boolean`:滚出视口右下角 mini 小窗 |
| 106 | +- `autoPlay: boolean`:自动播放 |
| 107 | +- `autoMute: boolean`:自动静音(为自动播放策略服务) |
| 108 | +- `playbackRate: number`:播放速度(仅 HTML5 生效) |
| 109 | +- `ytControls: boolean`:YouTube 控制条开关(仅 YouTube 生效) |
| 110 | + |
| 111 | +### 4.6 事件回调(埋点/排障) |
| 112 | + |
| 113 | +- `onEvent?: (name: string, detail?: any) => void` |
| 114 | +- `onError?: (payload: { message: string; provider: string; src?: string; youtubeId?: string; embedUrl?: string; ... }) => void` |
| 115 | + |
| 116 | +`onEvent` 事件名(目前实现): |
| 117 | +- `play` / `pause` |
| 118 | +- `seek`(绝对定位) / `seekRelative`(相对跳转) |
| 119 | +- `volume` / `mute` |
| 120 | +- `fullscreen` |
| 121 | +- `captions`(字幕开关/切换) |
| 122 | +- `pipEnter` / `pipLeave` / `pipToggle` |
| 123 | +- `openExternal` |
| 124 | +- `embedLoad` / `embedTimeout` |
| 125 | +- `hlsNative` / `hlsAttach` / `hlsError` |
| 126 | +- `error`(与 `onError` 同步触发) |
| 127 | + |
| 128 | +### 4.7 键盘快捷键(需先让播放器获得焦点) |
| 129 | + |
| 130 | +播放器外层容器已支持 `tabIndex=0`:可以用 Tab 聚焦,或鼠标点一下播放器区域再按键。 |
| 131 | + |
| 132 | +- Space / K:播放/暂停 |
| 133 | +- ← / →:后退/前进 5 秒 |
| 134 | +- ↑ / ↓:音量 +5% / -5% |
| 135 | +- M:静音切换 |
| 136 | +- F:全屏 |
| 137 | +- P:画中画(PiP) |
| 138 | +- S:打开/关闭设置 |
| 139 | +- Esc:关闭设置 |
| 140 | +- Embed/YouTube 模式:O 打开「新窗口」 |
| 141 | + |
| 142 | +--- |
| 143 | + |
| 144 | +## 5. 示例 |
| 145 | + |
| 146 | +### 5.1 HTML5 模式 |
| 147 | + |
| 148 | +```jsx |
| 149 | +<SmartVideoPlayer |
| 150 | + provider="html5" |
| 151 | + src={trailerSource} |
| 152 | + title="预告片" |
| 153 | +/> |
| 154 | +``` |
| 155 | + |
| 156 | +### 5.2 YouTube 模式 |
| 157 | + |
| 158 | +```jsx |
| 159 | +<SmartVideoPlayer |
| 160 | + provider="youtube" |
| 161 | + youtubeId="xJyWbjATtIE" |
| 162 | + title="科幻预告片" |
| 163 | + initialConfig={{ autoPlay: true, autoMute: true, ytControls: true }} |
| 164 | +/> |
| 165 | +``` |
| 166 | + |
| 167 | +### 5.3 通用 embed(直接传 embedUrl + 外部打开) |
| 168 | + |
| 169 | +```jsx |
| 170 | +<SmartVideoPlayer |
| 171 | + provider="embed" |
| 172 | + title="Embed 示例" |
| 173 | + embedUrl="https://player.vimeo.com/video/76979871?autoplay=1&muted=1" |
| 174 | + externalUrl="https://vimeo.com/76979871" |
| 175 | +/> |
| 176 | +``` |
| 177 | + |
| 178 | +### 5.4 通用 embed(用 getEmbedUrl 动态拼接) |
| 179 | + |
| 180 | +```jsx |
| 181 | +<SmartVideoPlayer |
| 182 | + provider="embed" |
| 183 | + title="动态 Embed" |
| 184 | + sourceUrl="https://example.com/watch/123" |
| 185 | + getEmbedUrl={(cfg) => { |
| 186 | + const base = 'https://example.com/embed/123' |
| 187 | + const params = new URLSearchParams({ |
| 188 | + autoplay: cfg.autoPlay ? '1' : '0', |
| 189 | + muted: cfg.autoMute ? '1' : '0', |
| 190 | + }) |
| 191 | + return `${base}?${params.toString()}` |
| 192 | + }} |
| 193 | +/> |
| 194 | +``` |
| 195 | + |
| 196 | +### 5.5 受控 config(推荐用于外部统一设置) |
| 197 | + |
| 198 | +```jsx |
| 199 | +const [playerConfig, setPlayerConfig] = useState({ |
| 200 | + lazyPlay: true, |
| 201 | + miniPlayer: true, |
| 202 | + autoPlay: true, |
| 203 | + autoMute: true, |
| 204 | + playbackRate: 1, |
| 205 | + ytControls: true, |
| 206 | +}) |
| 207 | + |
| 208 | +<SmartVideoPlayer |
| 209 | + provider={provider} |
| 210 | + src={src} |
| 211 | + config={playerConfig} |
| 212 | + onConfigChange={setPlayerConfig} |
| 213 | +/> |
| 214 | +``` |
| 215 | + |
| 216 | +--- |
| 217 | + |
| 218 | +## 6. 开发流程(本次迭代实践总结) |
| 219 | + |
| 220 | +这部分是“从需求到稳定上线”的流程复盘,方便后续继续维护。 |
| 221 | + |
| 222 | +### 6.1 需求拆分 |
| 223 | + |
| 224 | +1) UI 风格:参考 YouTube(按钮图标、tooltip、布局、hover 行为) |
| 225 | +2) 行为增强:IntersectionObserver 懒播放、mini、小窗恢复/关闭 |
| 226 | +3) 兼容性:autoplay 策略(muted 兜底)、play() Promise 错误处理 |
| 227 | +4) 设置面板:Portal 避免父容器裁切;定位与最大高度策略 |
| 228 | +5) provider 抽象:统一到 SmartVideoPlayer 内部,支持 youtube + 通用 embed |
| 229 | + |
| 230 | +### 6.2 关键问题与处理方式 |
| 231 | + |
| 232 | +- 自动播放不触发: |
| 233 | + - 浏览器通常要求 muted 才允许 autoplay。 |
| 234 | + - 方案:在 play 前同步确保 `video.muted = true`(当 autoMute 打开时),并在 `canplay/loadedmetadata` 时机触发。 |
| 235 | + |
| 236 | +- 切换播放列表点击后不播放: |
| 237 | + - `<source src>` 更新不等价于媒体已加载。 |
| 238 | + - 方案:切源后执行 `video.load()`;并在用户点击手势内调用播放(避免被策略拦截)。 |
| 239 | + |
| 240 | +- 设置面板被裁切/高度不对: |
| 241 | + - 父容器 overflow/布局容易裁切绝对定位弹层。 |
| 242 | + - 方案:Portal 到 `document.body`,再用按钮 `getBoundingClientRect()` 计算 fixed 定位。 |
| 243 | + - 高度:不要固定高度,使用“靠近按钮可用空间”的 maxHeight,并让内部滚动区滚动。 |
| 244 | + |
| 245 | +- TDZ(Cannot access ... before initialization): |
| 246 | + - 常见于 hook 解构出来的函数在声明前被引用。 |
| 247 | + - 方案:确保依赖函数(例如 `unmute`)解构完成后再创建 callback。 |
| 248 | + |
| 249 | +- CSS Module 污染导致页面异常: |
| 250 | + - 典型问题:写了裸 `article { ... }` 这样的全局选择器。 |
| 251 | + - 方案:所有选择器都挂在 module 根 class 下(例如 `.section-center article`)。 |
| 252 | + |
| 253 | +- Embed 模式控件遮挡原站点播放器控制区: |
| 254 | + - 方案:embed 模式不渲染底部覆盖式控制条,改为右上角小工具条,并用 pointer-events 让 iframe 交互不被拦截。 |
| 255 | + |
| 256 | +--- |
| 257 | + |
| 258 | +## 7. 已知限制与建议 |
| 259 | + |
| 260 | +- 站点是否可嵌入:被 CSP / `X-Frame-Options` 限制时 iframe 会失败。 |
| 261 | + - 建议:提供 `externalUrl/sourceUrl` 的“新窗口打开”作为兜底路径。 |
| 262 | + |
| 263 | +- 若需要“可控的 YouTube/Vimeo”: |
| 264 | + - 建议:接入官方 Player API(iframe + postMessage),做成 provider adapter(见改进文档)。 |
| 265 | + |
| 266 | +--- |
| 267 | + |
| 268 | +## 8. 调试建议 |
| 269 | + |
| 270 | +- HTML5 模式: |
| 271 | + - 查看 `video.play()` 的 Promise 报错(策略/AbortError/网络错误)。 |
| 272 | + - 切源后是否执行 `load()`。 |
| 273 | + |
| 274 | +- Embed 模式: |
| 275 | + - 如果 iframe 空白:先看浏览器控制台是否提示 `refused to display` / CSP。 |
| 276 | + - 优先保证 “新窗口打开” 路径可用。 |
0 commit comments