diff --git a/CLAUDE.md b/CLAUDE.md index 44755b4..1f72d38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,9 +39,14 @@ bun run lint:fix # 自动修复代码问题 新增文件前先判断职责归属,优先沿用现有目录结构: -- `src/components/common/`:通用可复用组件。新增通用组件后,按需在 `src/components/common/index.ts` 维护导出。 +- `src/components/ui/`:无课程业务和全局状态依赖的基础 UI copied source。 +- `src/components/common/`:模板通用组件和适配器;其中 `GuideDialog`、`GuideSpotlight` 负责连接 Zustand、CourseCanvas 与交互内核。新增通用组件后,按需在 `src/components/common/index.ts` 维护导出。 - `src/components/business/`:业务组合组件,不放通用基础能力。 +- `src/course/canvas/`:设计稿画布、缩放、viewport 和课程 Portal 根节点。 +- `src/course/orientation/`:横竖屏访问控制。 +- `src/course/interactions/`:由 CLI 复制的受控课程交互内核,不依赖具体页面、Store 或 common 适配器。 - `src/pages//`:页面组件。页面目录入口使用 `index.tsx`,页面组件使用默认导出。 +- 模块内私有组件:仅本模块使用、其他模块用不到的,放到本模块目录的 `components/` 子目录(没有就创建);被多个模块或组件复用才下沉到 `src/components/common/`(通用基础能力)或 `business/`(业务组合),按组件核心类型区分。该规则约束新代码,存量平铺在页面目录下的模块内组件不强制迁移。 - `src/router/index.tsx`:集中维护路由配置。新增页面优先复用 `lazyRoute` 和 `RouteFallback`。 - `src/stores/`:Zustand 状态管理。 - `src/utils/`:通用工具、hooks 或平台封装。 @@ -50,7 +55,7 @@ bun run lint:fix # 自动修复代码问题 ### 类型放置规则 -组件、页面、store、API 的 `props`、`interface`、`type` 不要直接写在实现文件里: +组件、页面、store、API 的对外 `props` 与跨文件共享的 `interface`、`type` 放在 `.types.ts`;文件内私有的 helper 类型留在实现文件即可: - 组件类型放在同组件目录的 `组件名.types.ts`,例如 `Button/Button.types.ts`。 - 页面类型如确实需要,放在页面目录的 `PageName.types.ts`。 @@ -61,7 +66,7 @@ bun run lint:fix # 自动修复代码问题 ## Tailwind、cn 与样式提取规则 -项目已有 [src/utils/tailwind.ts](src/utils/tailwind.ts) 中的 `cn`,用于合并 Tailwind class 并处理冲突。使用时遵守以下边界。 +项目已有 [src/lib/cn.ts](src/lib/cn.ts) 中的 `cn`,用于合并 Tailwind class 并处理冲突([src/utils/tailwind.ts](src/utils/tailwind.ts) 只是 re-export)。使用时遵守以下边界。 ### 默认策略 @@ -74,7 +79,7 @@ bun run lint:fix # 自动修复代码问题 在以下场景使用 `cn`: - 需要合并外部传入的 `className`。 -- 存在条件 class。 +- 条件 class 需要和其他 class 合并。 - 需要通过 `tailwind-merge` 解决 Tailwind 冲突合并。 - 需要组合 CSS Module、固定 class、条件 class 或外部 `className`。 @@ -90,9 +95,10 @@ bun run lint:fix # 自动修复代码问题 ```tsx
+
``` -单个固定字符串且没有条件、合并、冲突处理需求时,直接写 `className="..."`。 +单个固定字符串,或在两个完整字符串之间二选一的三元且不需要再合并其他 class 时,直接写 `className`。 ### 什么时候提取样式 @@ -110,6 +116,38 @@ bun run lint:fix # 自动修复代码问题 - 同目录多个文件共享时,可放同目录局部文件。 - 跨组件复用的样式能力,再考虑沉淀为通用组件、工具或 CSS Module。 +### 组件与 CSS 类的边界 + +「何时提取 class」见上一节;本节只解决复用一段 UI 时该抽组件还是抽 CSS 类。 + +组件/页面文件超过 500 行时,先审视是否该拆分(这是触发思考的信号,不是强制拆分线);拆出的子组件按「文件创建与目录归属」放置。 + +重复出现时优先抽组件,CSS 类是兜底,但避免「为整洁而抽」。抽组件必须同时满足: + +- 同一组「标记 + 样式 + 行为」在 2 处以上真实重复(第一次出现不抽)。 +- 组件有清晰可命名的语义,能一句话说清它是什么。 +- 复用的是整体而非几个 class;props 少且稳定(一般 ≤3~4 个)。 + +以下情况改用 CSS 类或局部常量,而不是抽组件: + +- 只复用样式、不复用标记/行为,内容由数据或富文本决定。 +- 仅同一文件内的小重复。 +- 给第三方/框架等无法插入组件的位置加样式。 +- Tailwind 表达不了或 class 串过长,但元素本身不够独立语义。 + +反模式(不要抽):只被用一次仅为缩短 JSX;组件 props 塞满 className/style 覆盖;名字说不清职责(`CardTopSection`、`Wrapper` 之类)。 + +## 动画使用规则 + +项目使用 CSS/Tailwind 和 GSAP 时遵守以下边界: + +- 普通状态过渡、hover、active、简单淡入淡出及单元素入场默认使用 CSS/Tailwind。 +- 只有动画需要时间轴编排、暂停/继续/倒放、运行时动态计算、音视频同步或复杂路径时,才使用 GSAP。 +- 不因项目已经安装 GSAP 而重写现有 CSS 动画,也不为简单动画建立 GSAP preset 或通用封装。 +- CSS 与 GSAP 不要在同一交互周期内控制同一元素的同一属性,尤其是 `transform` 和 `opacity`。 +- GSAP 动画通过 `src/utils/motion.ts` 使用 `useGSAP` 管理生命周期,并处理 `prefers-reduced-motion` 降级。 +- 不使用 GSAP 修改 `CourseCanvas`、画布 portal 根节点或 dnd-kit 正在控制的 `transform`。 + ## 导入与导出 - `@` 指向 `src`,新增代码优先使用 `@` 避免过长相对路径。 @@ -129,10 +167,14 @@ bun run lint:fix # 自动修复代码问题 开发时要尊重模板已有能力: -- 页面按 `1920 x 1080` 设计稿开发,整体缩放由 `src/course-runtime/canvas` 的 `CourseCanvas` 组件承担。 -- 横屏访问提示由 `src/course-runtime/orientation` 的 `OrientationGuard` 组件承担。 +- 页面按 `1920 x 1080` 设计稿开发,整体缩放由 `src/course/canvas` 的 `CourseCanvas` 组件承担。 +- 横屏访问提示由 `src/course/orientation` 的 `OrientationGuard` 组件承担。 - 易班能力通过 `src/utils/yiban.ts` 和 `public/videoajax/` 相关脚本封装。 - 移动端调试使用 `src/utils/vconsole.ts`。 +- 图片按钮由 `src/components/common/ImageButton` 提供;视频播放与视频弹层由 `src/components/common/Video` 提供。 +- 拖拽交互由 `src/course/interactions/drag-drop` 提供(基于 dnd-kit)。 +- 音频能力由 `src/utils/audio` 提供(`GameAudioProvider` / `useGameAudio` / `AudioManager`)。 +- GSAP 复杂动画通过 `src/utils/motion.ts` 统一引入,边界见「动画使用规则」。 - 新增通用组件时,同步考虑是否需要在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 增加示例。 ## Claude Code 工作准则 diff --git a/README.md b/README.md index 5dab328..5c617d9 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ - vconsole 移动端调试工具(仅开发环境) - 易班 videoajax.js 封装 - 易班用户信息状态管理 +- 图片按钮、视频播放器与视频弹层基础组件 +- 音频管理(配音 / BGM / 音效) +- GSAP 动画能力(复杂时间轴编排) - `@` 指向 `src` 的路径别名 - 通过 `@yuxiang/cli` 安装并提交可直接修改的课程交互源码 - 组件分层:Registry copied source、模板适配器、业务组件、页面组件 @@ -49,19 +52,19 @@ bun dev ## 应用配置 -页面标签页标题、描述、图标和 viewport 配置统一维护在 [src/app.config.ts](src/app.config.ts): +页面标签页标题、描述、图标、课程标识、存储前缀和 viewport 配置统一维护在 [src/app.config.ts](src/app.config.ts): ```ts export const appConfig = { title: "React Vite App", description: "易班 H5 课程模板", favicon: "./vite.svg", - design: { - width: 1920, - height: 1080, + course: { + id: import.meta.env.VITE_COURSE_ID ?? "course-demo", + name: import.meta.env.VITE_COURSE_NAME ?? "标准课程页示例", }, - scale: { - max: 1, + storage: { + prefix: "yiban-course-template", }, viewport: { width: "device-width", @@ -72,7 +75,7 @@ export const appConfig = { }; ``` -修改该配置后需要重启开发服务。 +课程画布尺寸和最大缩放比例由 `CourseCanvas` props 控制,不属于 `appConfig`。修改应用配置后需要重启开发服务。 ## 开发命令 @@ -90,7 +93,7 @@ bun run lint:fix # 自动修复代码问题 - 使用 Bun 执行项目脚本,提交前确保 `bun run lint` 通过;涉及路由、构建配置、页面结构或类型系统的改动建议同时运行 `bun run build`。 - 代码风格以 [biome.json](biome.json) 为准:tab 缩进、双引号、Biome 检查和导入整理。 -- 组件、页面、store、API 的类型不要和实现代码写在同一个文件;组件类型放同目录 `组件名.types.ts`,store 类型放 [src/stores/types/](src/stores/types/),跨模块共享类型才放入 [src/types/](src/types/)。 +- 组件、页面、store、API 的对外 Props 和跨文件共享类型放入 `.types.ts`;文件内私有 helper 类型留在实现文件。store 类型放 [src/stores/types/](src/stores/types/),只有跨多个模块复用的全局类型才放入 [src/types/](src/types/)。 - 短的静态 Tailwind class 可直接写在 JSX 中;只有需要条件合并、外部 `className`、冲突合并或组合 CSS Module 时才使用 `cn`。 - Tailwind class 过长、重复、条件组合复杂,或直接实现过于冗余时,再提取为局部常量、同目录文件、CSS Module 或通用组件。 - 新增页面沿用 [src/router/index.tsx](src/router/index.tsx) 中的 `lazyRoute` 和 `RouteFallback` 模式,导入优先使用 `@` 指向 `src` 的路径别名。 @@ -102,8 +105,9 @@ bun run lint:fix # 自动修复代码问题 ```text src/components/ui/ # Button、Portal、Overlay、Toast -src/components/course-interactions/ # GuideDialog、GuideSpotlight、DragDrop -src/course-runtime/ # 固定课程画布运行时 +src/course/interactions/ # GuideDialog、GuideSpotlight、DragDrop 受控内核 +src/course/canvas/ # 固定课程画布、缩放与 Portal 根节点 +src/course/orientation/ # 横竖屏访问控制 src/lib/cn.ts # 本地 class 合并工具 ``` @@ -141,7 +145,7 @@ http://localhost:5173/#/components - `Toast`: info / success / warning / error 轻提示示例 - `Portal`: 挂载到 `document.body` 或自定义容器的浮层示例 - `Overlay`: 默认背景、自定义 Tailwind 背景、背景图片、禁止点击背景关闭等交互示例 -- `SpotlightGuide`: 课程引导和高亮目标区域示例 +- `GuideSpotlight`: 课程引导和高亮目标区域示例 - `Drag and Drop`: 基于 dnd-kit 的拖拽到目标区域示例 后续新增通用组件时,可以在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 中补充对应示例。 @@ -150,24 +154,33 @@ http://localhost:5173/#/components ```text src/ -├── api/ # API 请求相关代码 -├── assets/ # 项目资源 -├── components/ # 组件 -│ ├── business/ # 业务组件 -│ └── common/ # 通用组件 -├── pages/ # 页面组件 -├── router/ # 路由配置 -├── stores/ # Zustand 状态管理 -│ └── modules/ # 状态模块 -│ ├── app.ts # 应用状态 -│ └── user.ts # 易班用户状态 -├── styles/ # 全局样式 -├── types/ # TypeScript 类型 -└── utils/ # 工具函数 - ├── vconsole.ts # 调试工具 - └── yiban.ts # 易班 API 封装 +├── api/ # API 请求相关代码 +├── assets/ # 项目资源 +├── components/ +│ ├── business/ # 业务组合组件 +│ ├── common/ # 模板通用组件和 Store/Canvas 适配器 +│ └── ui/ # Button、Portal、Overlay、Toast 等基础 UI +├── course/ +│ ├── canvas/ # 1920 x 1080 画布、viewport 与 Portal 根节点 +│ ├── interactions/ # Guide、DragDrop 等课程交互内核 +│ └── orientation/ # 横竖屏访问控制 +├── lib/ # 无业务依赖的底层工具 +├── pages/ # 页面组件 +├── router/ # 路由配置 +├── stores/ +│ ├── modules/ # Zustand store 实现 +│ └── types/ # Store 类型 +├── styles/ # 全局样式 +├── types/ # 跨多个模块共享的全局类型 +└── utils/ + ├── audio/ # 音频管理 + ├── motion.ts # GSAP 动画统一入口 + ├── vconsole.ts # 移动端调试 + └── yiban.ts # 易班 API 封装 ``` +`src/course/interactions` 保持无具体课程 Store 依赖;`src/components/common/GuideDialog` 和 `GuideSpotlight` 负责连接 Zustand、CourseCanvas 和 Portal。 + ## 易班开发说明 模板面向易班 H5 / 静态资源相对路径部署,Vite `base` 固定为 `"./"`,确保构建产物在未知子路径或压缩包上传场景下仍能引用资源。除非部署环境明确要求根路径资源,否则不要轻易改成 `"/"`。 @@ -233,7 +246,8 @@ await reportCourseProgress(order); // 获取课程进度 const progress = await getCourseProgress(order); if (progress) { - console.log('当前进度:', progress.data.process); + // videoajax.js 返回平台原始响应,使用前请按平台响应结构进行类型收窄 + console.log("当前进度响应:", progress); } ``` @@ -303,7 +317,7 @@ function MyComponent() { ### 课程画布适配 -模板按 `1920 x 1080` 设计稿开发,由 `src/course-runtime/canvas` 的 `CourseCanvas` 组件承担整体缩放。`CourseCanvas` 默认 `width=1920`、`height=1080`、`maxScale=1`,如需其他设计稿尺寸或放大比例,显式传入对应 props 即可。 +模板按 `1920 x 1080` 设计稿开发,由 `src/course/canvas` 的 `CourseCanvas` 组件承担整体缩放。`CourseCanvas` 默认 `width=1920`、`height=1080`、`maxScale=1`,如需其他设计稿尺寸或放大比例,显式传入对应 props 即可。 当前默认缩放策略: @@ -319,69 +333,135 @@ function MyComponent() { http://localhost:5173/#/course-demo ``` -`CourseDemo` 用于展示标准课程页面结构、易班 session fallback、Toast、Overlay 和 SpotlightGuide 的基础接入方式。 +`CourseDemo` 用于展示标准课程页面结构、易班 session fallback、Toast、Overlay 和 GuideSpotlight 的基础接入方式。 + +## 背景、图片按钮与视频 + +### BackgroundContainer + +`BackgroundContainer` 用于给页面区域设置背景图片。`src` 可以传入相对 `src/assets/images/` 的静态资源 key,也可以传入 public 相对路径或外部 URL: + +```tsx +import { BackgroundContainer } from "@/components/common"; +import { getPublicPath } from "@/utils/assets"; + + +
课程内容
+
; +``` + +调用方传入的 `style` 优先级最高,可以覆盖 `backgroundImage`、`backgroundSize` 等默认值。 + +### ImageButton + +`ImageButton` 提供图片按钮、hover/按下缩放、禁用状态和键盘焦点样式: + +```tsx +import { ImageButton } from "@/components/common"; + +; +``` + +静态资源 key 相对 `src/assets/images/`;外部 `http(s)`、`data:` 和 `blob:` URL 会直接使用。图片按钮应提供可访问名称,例如 `alt` 或 `aria-label`。 + +### VideoPlayer 与 VideoOverlay + +```tsx +import { VideoOverlay, VideoPlayer } from "@/components/common"; + +; + + setVideoOpen(false)} + onClose={() => setVideoOpen(false)} + onEnded={() => setVideoOpen(false)} +/>; +``` + +`VideoOverlay` 默认挂载到课程画布 Portal 根节点。`dismissible=false` 时不会通过背景点击或 Esc 自动关闭,播放结束、跳过和流程推进仍由调用方决定。 ## 弹层基础组件 -模板内置 `Portal` 和 `Overlay`,用于课程页面组合自定义弹窗、提示层、章节面板、全屏 loading 等弹层内容。 +模板内置 `Portal` 和 `Overlay`,用于课程页面组合自定义弹窗、提示层、章节面板、全屏 loading 等内容。 -`Overlay` 已经内置 `Portal`,默认会挂载到 App 缩放画布内部,适合课程弹层跟随 `1920 x 1080` 画布一起缩放,并支持直接放入课程自己的切图和内容: +`Overlay` 内置 `Portal`。不传 `container` 时默认挂载到 `document.body`;如需跟随 `1920 x 1080` 课程画布一起缩放,应传入 `CourseCanvas` 提供的 `portalContainer`: ```tsx import { Overlay } from "@/components/common"; +import { useCourseCanvas } from "@/course/canvas"; - setOpen(false)} - backgroundImage="url(./images/dialog-bg.png)" - backgroundClassName="bg-cover bg-center" - contentClassName="flex items-center justify-center" -> - 课程提示 -; +function CoursePanel() { + const { portalContainer } = useCourseCanvas(); + + return ( + setOpen(false)} + backgroundImage="url(./images/dialog-bg.png)" + backgroundClassName="bg-cover bg-center" + contentClassName="flex items-center justify-center" + > + 课程提示 + + ); +} ``` 说明: -- `backgroundImage`: 传入原始 CSS `background-image` 值(例如 `url(./images/dialog-bg.png)` 或 `linear-gradient(...)`),组件不参与路径处理;引用 public 资源时可用 `getPublicPath` 拼接相对路径。 -- `backgroundClassName`: 使用 Tailwind class 控制背景层样式,例如 `bg-cover`、`bg-center`、渐变、透明度等。 -- `contentClassName`: 控制内容层布局,弹窗视觉样式由具体课程页面自行决定。 -- `closeOnBackgroundClick`: 是否允许点击背景关闭,默认开启。 -- `closeOnEscape`: 是否允许按 Esc 关闭,默认开启。 -- `lockScroll`: 弹层打开时是否锁定页面滚动,默认开启。 -- `variant`: 提供 `dim` / `light` / `transparent` 三种基础背景。 +- `backgroundImage`:原始 CSS `background-image` 值,组件不参与路径处理;引用 public 资源时可用 `getPublicPath`。 +- `backgroundClassName`:控制背景层样式。 +- `contentClassName`:控制内容层布局。 +- `closeOnBackgroundClick`:点击背景关闭,默认开启。 +- `closeOnEscape`:按 Esc 关闭,默认开启。 +- `lockScroll`:打开时锁定页面滚动,默认开启。 +- `variant`:提供 `dim` / `light` / `transparent` 三种基础背景。 +- `fixed`:默认在无 `container` 时使用 fixed,传入 `container` 时使用 absolute;可以显式覆盖。 -### Portal 挂载位置 - -`Portal` 默认挂载到 `document.body`,`Overlay` 默认挂载到 App 缩放画布内部。如果 `Portal` 也需要跟随模板的 `1920 x 1080` 课程画布一起缩放,可以使用 `placement="app"`: - -```tsx - setOpen(false)}> - {/* 和课程画布一起缩放的弹层 */} - -``` - -如果需要挂载到业务自己的容器,可以传入 `container`。`container` 支持 DOM 元素、React ref 或返回元素的函数: +如果需要挂载到业务自己的容器,也可以传入 DOM 元素、React ref 或返回元素的函数: ```tsx import { useRef } from "react"; import { Overlay } from "@/components/common"; -const rootRef = useRef(null); +function LocalPanel() { + const rootRef = useRef(null); -
; - - setOpen(false)} -> - {/* 挂到自定义容器里的弹层 */} -; + return ( +
+ setOpen(false)} + > + 业务弹层 + +
+ ); +} ``` -挂载优先级:`container` > `placement` > 组件默认值。`Portal` 默认 `placement="body"`,`Overlay` 默认 `placement="app"`。如只需要改变渲染位置,也可以单独使用 `Portal`。 +`Overlay` 当前没有 `placement` prop;渲染位置只由 `container` 和 `disabledPortal` 决定。 ## Toast 轻提示 @@ -447,8 +527,8 @@ toast.error("网络异常,请稍后重试"); 基础示例: ```tsx -import { DragDropProvider, Draggable, Droppable } from "@/components/course-interactions/drag-drop"; -import { useCourseCanvas } from "@/course-runtime/canvas"; +import { DragDropProvider, Draggable, Droppable } from "@/course/interactions/drag-drop"; +import { useCourseCanvas } from "@/course/canvas"; const { scale } = useCourseCanvas(); @@ -471,93 +551,88 @@ const { scale } = useCourseCanvas(); ## 引导聚光灯 -模板内置 `SpotlightGuide` 引导组件,适合课程首次学习时突出关键操作区域。 +模板提供 `GuideSpotlight` 和 `defineSpotlight`,适合课程首次学习时突出关键操作区域。页面已经位于 `CourseCanvas` 内时,组件默认挂载到课程画布 Portal 根节点并自动使用画布 viewport。 ### 基础使用 -在页面元素上添加 `data-guide`: +先定义引导。未显式提供 `target` 时,`defineSpotlight` 默认使用和步骤 `id` 相同的 `data-guide`: ```tsx - +import { defineSpotlight } from "@/components/common"; + +export const lessonSpotlight = defineSpotlight({ + id: "lesson-intro", + steps: [ + { + id: "start-button", + title: "开始学习", + description: "点击这里开始本节课程。", + placement: "bottom", + }, + ], +}); ``` -在页面初始化时启动引导: +在页面中启动并渲染: ```tsx import { useEffect } from "react"; -import { SpotlightGuide } from "@/components/common"; -import { useGuideStore } from "@/stores"; +import { Button, GuideSpotlight } from "@/components/common"; +import { lessonSpotlight } from "./lessonSpotlight"; export default function LessonPage() { useEffect(() => { - useGuideStore.getState().startGuide({ - courseId: "lesson-1", - guideId: "intro", - steps: [ - { - id: "start", - target: { dataGuide: "start-button" }, - title: "开始学习", - description: "点击这里开始本节课程。", - placement: "bottom", - }, - ], - }); + lessonSpotlight.start(); }, []); - return ; + return ( + <> + + + + ); } ``` ### 持久化说明 -引导状态接入 Zustand,并通过 Zustand `persist` 只持久化已完成记录: +引导状态通过 Zustand persist 只保存已完成记录: ```ts -completedGuideMap: { - "lesson-1:intro": true, +completedSpotlightMap: { + "course-demo:lesson-intro": true, } ``` -默认同一个 `courseId + guideId` 只显示一次。点击“完成”或“跳过”都会标记为已完成,后续不会自动显示。 +`showOnce` 默认为 `true`。点击完成或跳过都会调用 `finishSpotlight` 并标记为已完成;`closeSpotlight` 只关闭当前引导,不会写入完成记录。 -如需重新显示: +重新允许展示: ```ts -useGuideStore.getState().resetGuide("lesson-1", "intro"); +lessonSpotlight.reset(); ``` ### Step 配置 -```ts -{ - id: "start", - target: { dataGuide: "start-button" }, - title: "开始学习", - description: "点击这里开始本节课程。", - placement: "bottom", - allowTargetClick: true, -} -``` - 支持: -- `target`: CSS selector 或 `{ dataGuide: "xxx" }` -- `placement`: `top` / `bottom` / `left` / `right` / `auto` -- `allowTargetClick`: 是否允许点击高亮目标 -- `padding` / `radius`: 调整高亮区域 -- `panelClassName`: 针对单步自定义提示框样式 +- `target`:CSS selector 或 `{ dataGuide: "xxx" }`;省略时由 `defineSpotlight` 使用步骤 `id`。 +- `placement`:`top` / `bottom` / `left` / `right` / `auto`。 +- `allowTargetClick`:是否允许点击高亮目标。 +- `padding` / `radius`:调整高亮区域。 +- `panelClassName`:单步提示框样式。 +- `nextLabel` / `prevLabel` / `doneLabel` / `skipLabel`:按钮文案。 -> 建议 `data-guide` 只使用字母、数字、横线和下划线,例如 `start-button`、`question_1`。 +建议 `data-guide` 只使用字母、数字、横线和下划线,例如 `start-button`、`question_1`。 -### 高级交互说明 +### 高级交互 -`allowTargetClick` 只负责让高亮区域下方的真实元素可以被点击,不会自动切换引导步骤。 - -如果目标元素点击后需要进入下一步,需要在业务点击逻辑中手动调用 `nextStep`、`finishGuide` 或 `closeGuide`: +`allowTargetClick` 只让高亮区域下方的真实元素可点击,不会自动切换步骤。业务操作完成后可显式推进: ```tsx -const { nextStep } = useGuideStore(); +import { useSpotlightStore } from "@/stores"; + +const nextStep = useSpotlightStore((state) => state.nextStep); +; ``` -### 样式与层级 - -`SpotlightGuide` 默认通过 Portal 挂载到 `document.body`,可以正确适配整体缩放后的坐标。 - -如遇到课程弹窗、横屏提示等全局层级冲突,可以通过 `theme.zIndex` 调整: +可通过 `theme.zIndex` 调整层级: ```tsx - + ``` -开发环境中,如果目标元素不存在,页面会显示开发提示面板,方便检查 `data-guide` 或 selector;生产环境默认会自动跳过找不到目标的步骤。 +目标不存在时,`skipIfTargetMissing` 默认为 `true`,组件会前进到下一步或结束引导。开发排查时可以显式传入 `skipIfTargetMissing={false}` 显示缺失目标面板。 -组件内置了遮罩淡入、高亮呼吸和提示框入场动画,并兼容系统的 `prefers-reduced-motion` 设置。 +组件内置遮罩淡入、高亮呼吸和提示框入场动画,并兼容系统的 `prefers-reduced-motion` 设置。 + +## 音频管理 + +模板内置 `GameAudioProvider` 和 `useGameAudio`,按 `voice`(配音)/ `bgm`(背景音乐)/ `sfx`(音效)三个通道管理播放,底层基于 howler。`App` 已默认挂载 `GameAudioProvider`,页面内直接使用: + +```tsx +import { useEffect } from "react"; +import { useGameAudio } from "@/utils/audio"; + +export default function LessonPage() { + const audio = useGameAudio(); + + useEffect(() => { + // 必须先 enterPage,播放才会生效;离开页面时 dispose 会停止本页所有音频 + const scope = audio.enterPage("lesson-1"); + audio.bgm.play("./audio/bgm.mp3"); + return () => scope.dispose(); + }, [audio]); + + return ( + + ); +} +``` + +说明: + +- 同一通道同时只播放一个音频,再次 `play` 会自动停止上一个。 +- `play` 返回 `{ started, stop }`,`started` 解析为 `playing` / `cancelled` / `blocked`(自动播放被浏览器拦截)/ `load-error` / `play-error`,可据此提示用户点击后重试。 +- `audio.preload(source)` 可提前加载本页音频;页面切到后台时配音和音效会停止,BGM 暂停并在回到前台后恢复。 + + +## GSAP 动画 + +GSAP 仅用于时间轴编排、暂停/继续/倒放、运行时动态计算、音视频同步或复杂路径。简单 hover、状态过渡和单元素入场优先使用 CSS/Tailwind。 + +```tsx +import { useRef } from "react"; +import { EASE_OUT, gsap, useGSAP, useReducedMotion } from "@/utils/motion"; + +function TimelineExample() { + const rootRef = useRef(null); + const reducedMotion = useReducedMotion(); + + useGSAP(() => { + if (reducedMotion) return; + gsap.from("[data-animate]", { y: 24, opacity: 0, ease: EASE_OUT }); + }, { scope: rootRef, dependencies: [reducedMotion] }); + + return
课程内容
; +} +``` + +请从 `@/utils/motion` 直接导入,避免不使用动画的入口无意加载 GSAP;不要让 GSAP 控制 `CourseCanvas`、画布 Portal 根节点或 dnd-kit 正在控制的 transform。 ## 路由懒加载 diff --git a/bun.lock b/bun.lock index 8a649a1..ad448a6 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,10 @@ "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/utilities": "^3.2.2", + "@gsap/react": "^2.1.2", "clsx": "^2.1.1", + "gsap": "^3.15.0", + "howler": "^2.2.4", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router-dom": "^7.12.0", @@ -19,6 +22,7 @@ "@biomejs/biome": "2.3.11", "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4.1.18", + "@types/howler": "^2.2.13", "@types/node": "^25.0.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", @@ -68,6 +72,8 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@gsap/react": ["@gsap/react@2.1.2", "https://registry.npmmirror.com/@gsap/react/-/react-2.1.2.tgz", { "peerDependencies": { "gsap": "^3.12.5", "react": ">=17" } }, "sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -148,6 +154,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@types/howler": ["@types/howler@2.2.13", "https://registry.npmmirror.com/@types/howler/-/howler-2.2.13.tgz", {}, "sha512-40+EBjqIHHrC4VShlz/7i0lBUsE3QkgzZinQQji74Hd8sBkJZUBaT7LWFLK6rcabsDOOQpoMbEJvtaFQwxOu/g=="], + "@types/node": ["@types/node@25.0.10", "https://registry.npmmirror.com/@types/node/-/node-25.0.10.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], "@types/react": ["@types/react@19.2.9", "https://registry.npmmirror.com/@types/react/-/react-19.2.9.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], @@ -216,6 +224,10 @@ "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gsap": ["gsap@3.15.0", "https://registry.npmmirror.com/gsap/-/gsap-3.15.0.tgz", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="], + + "howler": ["howler@2.2.4", "https://registry.npmmirror.com/howler/-/howler-2.2.4.tgz", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="], + "husky": ["husky@9.1.7", "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], diff --git a/components.json b/components.json index 43dd2a7..f74c8f4 100644 --- a/components.json +++ b/components.json @@ -9,14 +9,14 @@ }, "directories": { "ui": "src/components/ui", - "interactions": "src/components/course-interactions", - "runtime": "src/course-runtime", + "interactions": "src/course/interactions", + "runtime": "src/course", "lib": "src/lib" }, "aliases": { "ui": "@/components/ui", - "interactions": "@/components/course-interactions", - "runtime": "@/course-runtime", + "interactions": "@/course/interactions", + "runtime": "@/course", "lib": "@/lib" } } diff --git a/package.json b/package.json index fd5538a..a42707b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,10 @@ "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/utilities": "^3.2.2", + "@gsap/react": "^2.1.2", "clsx": "^2.1.1", + "gsap": "^3.15.0", + "howler": "^2.2.4", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router-dom": "^7.12.0", @@ -30,6 +33,7 @@ "@biomejs/biome": "2.3.11", "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4.1.18", + "@types/howler": "^2.2.13", "@types/node": "^25.0.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", diff --git a/src/App.tsx b/src/App.tsx index a62d9ce..8ebfdb2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,14 @@ import { useEffect } from "react"; import { Outlet } from "react-router-dom"; import { ToastProvider, ToastViewport } from "@/components/ui/toast"; -import { CourseCanvas, useCourseCanvas } from "@/course-runtime/canvas"; -import { OrientationGuard } from "@/course-runtime/orientation"; +import { + CourseCanvas, + registerAppPortalRoot, + useCourseCanvas, +} from "@/course/canvas"; +import { OrientationGuard } from "@/course/orientation"; +import { GameAudioProvider } from "@/utils/audio"; import { Navigation } from "./components/business"; -import { registerAppPortalRoot } from "./components/common/canvasPortal"; function CourseShell() { const { portalContainer } = useCourseCanvas(); @@ -12,13 +16,15 @@ function CourseShell() { useEffect(() => registerAppPortalRoot(portalContainer), [portalContainer]); return ( - - -
- -
- -
+ + + +
+ +
+ +
+
); } diff --git a/src/components/common/BackgroundContainer/BackgroundContainer.module.css b/src/components/common/BackgroundContainer/BackgroundContainer.module.css new file mode 100644 index 0000000..1f66177 --- /dev/null +++ b/src/components/common/BackgroundContainer/BackgroundContainer.module.css @@ -0,0 +1,9 @@ +@reference "tailwindcss"; + +.container { + @apply relative; +} + +.content { + @apply relative h-full z-10; +} diff --git a/src/components/common/BackgroundContainer/BackgroundContainer.tsx b/src/components/common/BackgroundContainer/BackgroundContainer.tsx new file mode 100644 index 0000000..4d87424 --- /dev/null +++ b/src/components/common/BackgroundContainer/BackgroundContainer.tsx @@ -0,0 +1,41 @@ +import { cn } from "@/lib/cn"; +import { getStaticImage } from "@/utils/assets"; +import styles from "./BackgroundContainer.module.css"; +import type { BackgroundContainerProps } from "./BackgroundContainer.types"; + +const DIRECT_URL_PATTERN = /^(?:[a-z][a-z\d+.-]*:|\/|\.\.?\/)/i; + +function resolveBackgroundImage(src: string): string { + return DIRECT_URL_PATTERN.test(src) ? src : getStaticImage(src) || src; +} + +export function BackgroundContainer({ + src, + fit = "cover", + position = "center", + repeat = "no-repeat", + as: Component = "div", + className, + contentClassName, + children, + style, + ...props +}: BackgroundContainerProps) { + const imageSrc = resolveBackgroundImage(src); + + return ( + +
{children}
+
+ ); +} diff --git a/src/components/common/BackgroundContainer/BackgroundContainer.types.ts b/src/components/common/BackgroundContainer/BackgroundContainer.types.ts new file mode 100644 index 0000000..f3441ad --- /dev/null +++ b/src/components/common/BackgroundContainer/BackgroundContainer.types.ts @@ -0,0 +1,21 @@ +import type { HTMLAttributes, ReactNode } from "react"; + +export type BackgroundContainerElement = "div" | "section" | "main" | "article"; +export type BackgroundContainerFit = "cover" | "contain"; +export type BackgroundContainerRepeat = + | "no-repeat" + | "repeat" + | "repeat-x" + | "repeat-y"; + +export interface BackgroundContainerProps + extends Omit, "children"> { + /** 静态资源 key(相对 src/assets/images/)或可直接使用的图片 URL */ + src: string; + fit?: BackgroundContainerFit; + position?: string; + repeat?: BackgroundContainerRepeat; + as?: BackgroundContainerElement; + contentClassName?: string; + children?: ReactNode; +} diff --git a/src/components/common/BackgroundContainer/index.ts b/src/components/common/BackgroundContainer/index.ts new file mode 100644 index 0000000..e1ae994 --- /dev/null +++ b/src/components/common/BackgroundContainer/index.ts @@ -0,0 +1,7 @@ +export { BackgroundContainer } from "./BackgroundContainer"; +export type { + BackgroundContainerElement, + BackgroundContainerFit, + BackgroundContainerProps, + BackgroundContainerRepeat, +} from "./BackgroundContainer.types"; diff --git a/src/components/common/GuideDialog/GuideDialog.tsx b/src/components/common/GuideDialog/GuideDialog.tsx index 095563f..31ecfb9 100644 --- a/src/components/common/GuideDialog/GuideDialog.tsx +++ b/src/components/common/GuideDialog/GuideDialog.tsx @@ -1,21 +1,15 @@ import { useCallback } from "react"; -import { - type GuideDialogProps, - GuideDialog as RegistryGuideDialog, -} from "@/components/course-interactions/guide-dialog"; -import { useCourseCanvas } from "@/course-runtime/canvas"; +import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas"; +import { GuideDialog as RegistryGuideDialog } from "@/course/interactions/guide-dialog"; import { useDialogStore } from "@/stores"; -import { getAppPortalRoot } from "../canvasPortal"; +import type { GuideDialogProps } from "./GuideDialog.types"; export function GuideDialog({ children, onFinish, onSkip, ...props -}: Omit & { - onFinish?: () => void; - onSkip?: () => void; -}) { +}: GuideDialogProps) { const { open, steps, diff --git a/src/components/common/GuideDialog/GuideDialog.types.ts b/src/components/common/GuideDialog/GuideDialog.types.ts index 20c33b9..3ea2251 100644 --- a/src/components/common/GuideDialog/GuideDialog.types.ts +++ b/src/components/common/GuideDialog/GuideDialog.types.ts @@ -1,5 +1,12 @@ +import type { GuideDialogProps as RegistryGuideDialogProps } from "@/course/interactions/guide-dialog"; + +export interface GuideDialogProps + extends Omit { + onFinish?: () => void; + onSkip?: () => void; +} + export type { - GuideDialogProps, GuideDialogRenderState, GuideDialogTheme, -} from "@/components/course-interactions/guide-dialog"; +} from "@/course/interactions/guide-dialog"; diff --git a/src/components/common/GuideSpotlight/GuideSpotlight.tsx b/src/components/common/GuideSpotlight/GuideSpotlight.tsx index 0a2959a..7e5b67c 100644 --- a/src/components/common/GuideSpotlight/GuideSpotlight.tsx +++ b/src/components/common/GuideSpotlight/GuideSpotlight.tsx @@ -1,11 +1,8 @@ import { useCallback } from "react"; -import { - type GuideSpotlightProps, - GuideSpotlight as RegistryGuideSpotlight, -} from "@/components/course-interactions/guide-spotlight"; -import { useCourseCanvas } from "@/course-runtime/canvas"; +import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas"; +import { GuideSpotlight as RegistryGuideSpotlight } from "@/course/interactions/guide-spotlight"; import { useSpotlightStore } from "@/stores"; -import { getAppPortalRoot } from "../canvasPortal"; +import type { GuideSpotlightProps } from "./GuideSpotlight.types"; export function GuideSpotlight({ children, @@ -14,10 +11,7 @@ export function GuideSpotlight({ container = getAppPortalRoot, viewport, ...props -}: Omit & { - onFinish?: () => void; - onSkip?: () => void; -}) { +}: GuideSpotlightProps) { const { open, steps, diff --git a/src/components/common/GuideSpotlight/GuideSpotlight.types.ts b/src/components/common/GuideSpotlight/GuideSpotlight.types.ts index dfd0b09..6cfa811 100644 --- a/src/components/common/GuideSpotlight/GuideSpotlight.types.ts +++ b/src/components/common/GuideSpotlight/GuideSpotlight.types.ts @@ -1,7 +1,14 @@ +import type { GuideSpotlightProps as RegistryGuideSpotlightProps } from "@/course/interactions/guide-spotlight"; + +export interface GuideSpotlightProps + extends Omit { + onFinish?: () => void; + onSkip?: () => void; +} + export type { - GuideSpotlightProps, GuideSpotlightRenderState, GuideSpotlightTheme, Rect, SpotlightLayout, -} from "@/components/course-interactions/guide-spotlight"; +} from "@/course/interactions/guide-spotlight"; diff --git a/src/components/common/ImageButton/ImageButton.module.css b/src/components/common/ImageButton/ImageButton.module.css new file mode 100644 index 0000000..b6acf92 --- /dev/null +++ b/src/components/common/ImageButton/ImageButton.module.css @@ -0,0 +1,34 @@ +@reference "tailwindcss"; + +/* 不设 position:组件无绝对定位子元素,留空以便调用方用 className 自行定位 */ +.button { + /* hover/按下缩放倍率由组件通过内联 --hover-scale / --press-scale 变量传入 */ + --hover-scale: 1.05; + --press-scale: 0.95; + transition: transform 0.18s ease-out; + + @apply m-0 box-border cursor-pointer select-none appearance-none overflow-hidden rounded-md border-0 bg-transparent p-0 align-middle hover:brightness-110; + @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500; + @apply disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50; +} + +.button:hover { + transform: scale(var(--hover-scale)); +} + +.button:active { + transform: scale(var(--press-scale)); +} + +.image { + @apply block h-full w-full object-cover; +} + +@media (prefers-reduced-motion: reduce) { + .button, + .button:hover, + .button:active { + transform: none; + transition: none; + } +} diff --git a/src/components/common/ImageButton/ImageButton.tsx b/src/components/common/ImageButton/ImageButton.tsx new file mode 100644 index 0000000..707367f --- /dev/null +++ b/src/components/common/ImageButton/ImageButton.tsx @@ -0,0 +1,53 @@ +import { type CSSProperties, forwardRef } from "react"; +import { cn } from "@/lib/cn"; +import { getStaticImage } from "@/utils/assets"; +import styles from "./ImageButton.module.css"; +import type { ImageButtonProps } from "./ImageButton.types"; + +// 静态资源 key vs 外部 URL 的解析;未命中时回退原样交给 +function resolveImage(src: string): string { + return /^(https?:|data:|blob:)/.test(src) ? src : getStaticImage(src) || src; +} + +export const ImageButton = forwardRef( + ( + { + src, + alt = "", + width, + height, + hoverScale = 1.05, + pressScale = 0.95, + className, + style, + ...rest + }, + ref, + ) => { + const imageSrc = resolveImage(src); + // hover/按下缩放由 CSS 过渡实现,倍率通过内联变量传入(见 ImageButton.module.css) + const scaleVars = { + "--hover-scale": hoverScale, + "--press-scale": pressScale, + } as CSSProperties; + + return ( + + ); + }, +); + +ImageButton.displayName = "ImageButton"; diff --git a/src/components/common/ImageButton/ImageButton.types.ts b/src/components/common/ImageButton/ImageButton.types.ts new file mode 100644 index 0000000..c741df2 --- /dev/null +++ b/src/components/common/ImageButton/ImageButton.types.ts @@ -0,0 +1,15 @@ +import type { ButtonHTMLAttributes } from "react"; + +export interface ImageButtonProps + extends ButtonHTMLAttributes { + /** 图片:静态资源 key(相对 src/assets/images/)或外部 http(s)/data/blob URL */ + src: string; + alt?: string; + /** 显示尺寸(数字按 px,字符串原样透传)。图片按钮通常应有确定尺寸 */ + width?: number | string; + height?: number | string; + /** hover 放大倍率,默认 1.05 */ + hoverScale?: number; + /** 按下缩小倍率,默认 0.95 */ + pressScale?: number; +} diff --git a/src/components/common/ImageButton/index.ts b/src/components/common/ImageButton/index.ts new file mode 100644 index 0000000..62f91f7 --- /dev/null +++ b/src/components/common/ImageButton/index.ts @@ -0,0 +1,2 @@ +export { ImageButton } from "./ImageButton"; +export type { ImageButtonProps } from "./ImageButton.types"; diff --git a/src/components/common/Video/VideoOverlay.tsx b/src/components/common/Video/VideoOverlay.tsx new file mode 100644 index 0000000..cade597 --- /dev/null +++ b/src/components/common/Video/VideoOverlay.tsx @@ -0,0 +1,67 @@ +import { Overlay } from "@/components/ui/overlay"; +import { getAppPortalRoot } from "@/course/canvas"; +import type { VideoOverlayProps } from "./VideoOverlay.types"; +import { VideoPlayer } from "./VideoPlayer"; + +/** 视频弹层:支持全屏过场与窗口播放,由调用方决定关闭和流程推进策略 */ +export function VideoOverlay({ + open, + src, + poster, + autoPlay = true, + mode = "fullscreen", + windowWidth = 1200, + windowHeight = 675, + dismissible = false, + lockScroll = true, + skipLabel, + onClose, + onEnded, + onSkip, +}: VideoOverlayProps) { + return ( + +
+ + + {skipLabel && ( + + )} +
+
+ ); +} diff --git a/src/components/common/Video/VideoOverlay.types.ts b/src/components/common/Video/VideoOverlay.types.ts new file mode 100644 index 0000000..35ffa0a --- /dev/null +++ b/src/components/common/Video/VideoOverlay.types.ts @@ -0,0 +1,28 @@ +export interface VideoOverlayProps { + /** 是否显示 */ + open: boolean; + /** 可直接交给 video 元素的视频 URL */ + src: string; + /** 封面图 */ + poster?: string; + /** 自动播放 */ + autoPlay?: boolean; + /** 播放模式 */ + mode?: "fullscreen" | "window"; + /** 窗口模式下的宽度(px) */ + windowWidth?: number; + /** 窗口模式下的高度(px) */ + windowHeight?: number; + /** 是否允许点击背景或按 Escape 关闭 */ + dismissible?: boolean; + /** 弹层打开时是否锁定页面滚动 */ + lockScroll?: boolean; + /** 跳过按钮文案,不传则不显示 */ + skipLabel?: string; + /** 关闭回调 */ + onClose?: () => void; + /** 播放结束回调 */ + onEnded?: () => void; + /** 点击跳过回调 */ + onSkip?: () => void; +} diff --git a/src/components/common/Video/VideoPlayer.module.css b/src/components/common/Video/VideoPlayer.module.css new file mode 100644 index 0000000..2a78f4d --- /dev/null +++ b/src/components/common/Video/VideoPlayer.module.css @@ -0,0 +1,134 @@ +@reference "tailwindcss"; + +/* 播放器容器 */ +.player { + @apply relative flex items-center justify-center overflow-hidden bg-black; +} + +.playerFullscreen { + @apply absolute inset-0 h-full w-full; +} + +.playerWindow { + @apply relative mx-auto; +} + +/* 视频元素 */ +.video { + @apply h-full w-full object-contain; +} + +/* 中央播放按钮(暂停时显示) */ +.centerPlayButton { + @apply absolute flex h-20 w-20 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-opacity duration-300; +} + +.centerPlayButton:hover { + @apply bg-black/70; +} + +/* 控制条容器 */ +.controls { + @apply absolute bottom-0 left-0 right-0 flex flex-col bg-gradient-to-t from-black/80 to-transparent px-4 pb-3 pt-12 transition-opacity duration-300; +} + +.controlsHidden { + @apply pointer-events-none opacity-0; +} + +/* 小进度条(控件隐藏时显示) */ +.miniProgress { + @apply pointer-events-none absolute bottom-0 left-0 right-0 h-1 bg-white/20 opacity-100 transition-opacity duration-300; +} + +.miniProgressHidden { + @apply opacity-0; +} + +.miniProgressBar { + @apply h-full w-full origin-left bg-red-500; + transform: scaleX(0); + will-change: transform; +} + +/* 进度条 */ +.progressContainer { + @apply relative mb-2 h-1.5 cursor-pointer rounded-full bg-white/30; + touch-action: none; +} + +.progressBar, +.progressBuffered { + @apply absolute left-0 top-0 h-full w-full origin-left rounded-full; + transform: scaleX(0); + will-change: transform; +} + +.progressBar { + @apply bg-red-500; +} + +.progressBuffered { + @apply bg-white/30; +} + +.progressThumb { + @apply absolute left-0 top-1/2 h-3.5 w-3.5 rounded-full bg-red-500 opacity-0 shadow-md transition-opacity duration-200; + transform: translate3d(0, -50%, 0); + will-change: transform; +} + +.progressContainer:hover .progressThumb, +.progressDragging .progressThumb { + @apply opacity-100; +} + +.progressDragging { + @apply cursor-grabbing; +} + +/* 时间显示 */ +.timeDisplay { + @apply text-xs text-white/90 tabular-nums; +} + +/* 按钮 */ +.controlButton { + @apply flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-white/90 transition-colors hover:text-white; +} + +.controlButton:disabled { + @apply cursor-not-allowed opacity-50; +} + +/* 倍速选择器 */ +.rateSelector { + @apply flex cursor-pointer items-center rounded px-2 py-0.5 text-xs text-white/90 transition-colors hover:bg-white/20 hover:text-white; +} + +/* 音量滑块 */ +.volumeSlider { + @apply h-1 w-16 cursor-pointer appearance-none rounded-full bg-white/30; +} + +.volumeSlider::-webkit-slider-thumb { + @apply h-3 w-3 appearance-none rounded-full bg-white shadow-md; +} + +.volumeSlider::-moz-range-thumb { + @apply h-3 w-3 rounded-full border-0 bg-white shadow-md; +} + +/* 底部控制行 */ +.controlRow { + @apply flex items-center gap-2; +} + +/* 加载状态 */ +.loading { + @apply absolute inset-0 flex items-center justify-center bg-black/30; +} + +.spinner { + @apply h-10 w-10 animate-spin rounded-full border-4 border-white/30 border-t-white; +} diff --git a/src/components/common/Video/VideoPlayer.tsx b/src/components/common/Video/VideoPlayer.tsx new file mode 100644 index 0000000..7832644 --- /dev/null +++ b/src/components/common/Video/VideoPlayer.tsx @@ -0,0 +1,1079 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { cn } from "@/lib/cn"; +import styles from "./VideoPlayer.module.css"; +import type { VideoPlayerProps, VideoPlayerRef } from "./VideoPlayer.types"; + +const PLAYBACK_RATES = [0.5, 1, 1.5, 2] as const; +const IDLE_HIDE_DELAY = 3000; +const SEMANTIC_UPDATE_INTERVAL = 200; +const DRAG_UPDATE_INTERVAL = 100; +const ACTIVITY_THROTTLE_INTERVAL = 250; + +type VideoFrameMetadata = { + mediaTime: number; +}; + +type VideoWithFrameCallbacks = HTMLVideoElement & { + requestVideoFrameCallback?: ( + callback: (now: number, metadata: VideoFrameMetadata) => void, + ) => number; + cancelVideoFrameCallback?: (id: number) => void; +}; + +type PlayerCallbacks = Pick< + VideoPlayerProps, + | "onPlay" + | "onPause" + | "onEnded" + | "onTimeUpdate" + | "onProgress" + | "onVolumeChange" +>; + +function formatTime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +function clampTime(time: number, duration: number): number { + if (!Number.isFinite(time) || !Number.isFinite(duration) || duration <= 0) { + return 0; + } + return Math.max(0, Math.min(duration, time)); +} + +function isMobileDevice(): boolean { + if (typeof navigator === "undefined") return false; + return ( + /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent, + ) || + ("ontouchstart" in window && navigator.maxTouchPoints > 0) + ); +} + +export const VideoPlayer = forwardRef( + ( + { + src, + poster, + autoPlay = false, + loop = false, + muted: initialMuted = false, + preload = "metadata", + playbackRate: initialRate = 1, + mode = "window", + allowFullscreenToggle = true, + className, + onPlay, + onPause, + onEnded, + onTimeUpdate, + onProgress, + onVolumeChange, + onPlaybackRateChange, + onFullscreenChange, + }, + ref, + ) => { + const videoRef = useRef(null); + const containerRef = useRef(null); + const progressRef = useRef(null); + const progressBarRef = useRef(null); + const progressBufferedRef = useRef(null); + const progressThumbRef = useRef(null); + const miniProgressBarRef = useRef(null); + const idleTimerRef = useRef>(undefined); + const isPlayingRef = useRef(false); + const showControlsRef = useRef(true); + const durationRef = useRef(0); + const currentTimeRef = useRef(0); + const progressWidthRef = useRef(0); + const lastSemanticUpdateRef = useRef(0); + const lastDragUpdateRef = useRef(0); + const lastActivityRef = useRef(0); + const callbacksRef = useRef({}); + const dragActiveRef = useRef(false); + const dragWasPlayingRef = useRef(false); + const dragRectRef = useRef(null); + const dragPreviewTimeRef = useRef(0); + const dragFrameRef = useRef(null); + const activePointerIdRef = useRef(null); + + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolumeState] = useState(1); + const [isMuted, setIsMuted] = useState(initialMuted); + const [playbackRate, setPlaybackRateState] = useState(initialRate); + const [showControls, setShowControls] = useState(true); + const [isDragging, setIsDragging] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [hasPlaybackError, setHasPlaybackError] = useState(false); + const [showCenterButton, setShowCenterButton] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(mode === "fullscreen"); + const isMobile = isMobileDevice(); + const supportsPointerEvents = + typeof window !== "undefined" && "PointerEvent" in window; + + callbacksRef.current = { + onPlay, + onPause, + onEnded, + onTimeUpdate, + onProgress, + onVolumeChange, + }; + + const applyVisualProgress = useCallback((time: number) => { + const safeTime = clampTime(time, durationRef.current); + const ratio = + durationRef.current > 0 ? safeTime / durationRef.current : 0; + const scale = `scaleX(${ratio})`; + if (progressBarRef.current) + progressBarRef.current.style.transform = scale; + if (miniProgressBarRef.current) { + miniProgressBarRef.current.style.transform = scale; + } + if (progressThumbRef.current) { + const offset = ratio * progressWidthRef.current; + progressThumbRef.current.style.transform = `translate3d(${offset}px, -50%, 0)`; + } + }, []); + + const applyBufferedProgress = useCallback((ratio: number) => { + const safeRatio = Number.isFinite(ratio) + ? Math.max(0, Math.min(1, ratio)) + : 0; + if (progressBufferedRef.current) { + progressBufferedRef.current.style.transform = `scaleX(${safeRatio})`; + } + }, []); + + const commitCurrentTime = useCallback((time: number, force = false) => { + const safeTime = clampTime(time, durationRef.current); + currentTimeRef.current = safeTime; + const now = performance.now(); + if ( + force || + now - lastSemanticUpdateRef.current >= SEMANTIC_UPDATE_INTERVAL + ) { + lastSemanticUpdateRef.current = now; + setCurrentTime(safeTime); + } + return safeTime; + }, []); + + const syncProgress = useCallback( + (time: number, forceSemantic = false) => { + const safeTime = commitCurrentTime(time, forceSemantic); + applyVisualProgress(safeTime); + return safeTime; + }, + [applyVisualProgress, commitCurrentTime], + ); + + const clearIdleTimer = useCallback(() => { + if (idleTimerRef.current) { + clearTimeout(idleTimerRef.current); + idleTimerRef.current = undefined; + } + }, []); + + const showPlayerControls = useCallback(() => { + showControlsRef.current = true; + setShowControls(true); + }, []); + + const resetIdleTimer = useCallback(() => { + showPlayerControls(); + clearIdleTimer(); + idleTimerRef.current = setTimeout(() => { + if (isPlayingRef.current && !dragActiveRef.current) { + showControlsRef.current = false; + setShowControls(false); + } + }, IDLE_HIDE_DELAY); + }, [clearIdleTimer, showPlayerControls]); + + const handlePointerActivity = useCallback(() => { + const now = performance.now(); + if ( + !showControlsRef.current || + now - lastActivityRef.current >= ACTIVITY_THROTTLE_INTERVAL + ) { + lastActivityRef.current = now; + resetIdleTimer(); + } + }, [resetIdleTimer]); + + const seekTo = useCallback( + (time: number) => { + const video = videoRef.current; + if (!video) return 0; + const safeTime = syncProgress(time, true); + video.currentTime = safeTime; + return safeTime; + }, + [syncProgress], + ); + + useImperativeHandle(ref, () => ({ + play: () => videoRef.current?.play(), + pause: () => videoRef.current?.pause(), + seek: (time: number) => { + seekTo(time); + }, + setVolume: (value: number) => { + if (videoRef.current) videoRef.current.volume = value; + }, + setPlaybackRate: (rate: number) => { + if (videoRef.current) videoRef.current.playbackRate = rate; + }, + getCurrentTime: () => videoRef.current?.currentTime ?? 0, + getDuration: () => videoRef.current?.duration ?? 0, + })); + + const showManualPlay = useCallback(() => { + setIsLoading(false); + setShowCenterButton(true); + showPlayerControls(); + }, [showPlayerControls]); + + const retryPlayback = useCallback(() => { + const video = videoRef.current; + if (!video) return; + setHasPlaybackError(false); + setIsLoading(true); + video.load(); + video.play().catch(showManualPlay); + }, [showManualPlay]); + + const togglePlay = useCallback(() => { + const video = videoRef.current; + if (!video) return; + if (video.paused) video.play().catch(showManualPlay); + else video.pause(); + resetIdleTimer(); + }, [resetIdleTimer, showManualPlay]); + + const toggleMute = useCallback(() => { + const video = videoRef.current; + if (!video) return; + video.muted = !video.muted; + setIsMuted(video.muted); + resetIdleTimer(); + }, [resetIdleTimer]); + + const handleVolumeChange = useCallback( + (event: React.ChangeEvent) => { + const video = videoRef.current; + if (!video) return; + const newVolume = Number.parseFloat(event.target.value); + video.volume = newVolume; + if (newVolume > 0 && video.muted) video.muted = false; + setVolumeState(newVolume); + setIsMuted(video.muted); + resetIdleTimer(); + }, + [resetIdleTimer], + ); + + const cyclePlaybackRate = useCallback(() => { + const video = videoRef.current; + if (!video) return; + const currentIndex = PLAYBACK_RATES.indexOf( + playbackRate as (typeof PLAYBACK_RATES)[number], + ); + const nextIndex = (currentIndex + 1) % PLAYBACK_RATES.length; + const nextRate = PLAYBACK_RATES[nextIndex]; + video.playbackRate = nextRate; + setPlaybackRateState(nextRate); + onPlaybackRateChange?.(nextRate); + resetIdleTimer(); + }, [playbackRate, onPlaybackRateChange, resetIdleTimer]); + + const toggleFullscreen = useCallback(() => { + const next = !isFullscreen; + setIsFullscreen(next); + onFullscreenChange?.(next); + resetIdleTimer(); + }, [isFullscreen, onFullscreenChange, resetIdleTimer]); + + const getDragTime = useCallback((clientX: number) => { + const rect = dragRectRef.current; + if (!rect || rect.width <= 0) return 0; + const ratio = Math.max( + 0, + Math.min(1, (clientX - rect.left) / rect.width), + ); + return ratio * durationRef.current; + }, []); + + const flushDragPreview = useCallback(() => { + if (dragFrameRef.current !== null) { + cancelAnimationFrame(dragFrameRef.current); + dragFrameRef.current = null; + } + const previewTime = dragPreviewTimeRef.current; + applyVisualProgress(previewTime); + commitCurrentTime(previewTime, true); + }, [applyVisualProgress, commitCurrentTime]); + + const queueDragPreview = useCallback( + (clientX: number, forceSemantic = false) => { + if (!dragActiveRef.current) return; + dragPreviewTimeRef.current = getDragTime(clientX); + if (dragFrameRef.current !== null) return; + dragFrameRef.current = requestAnimationFrame((now) => { + dragFrameRef.current = null; + const previewTime = dragPreviewTimeRef.current; + applyVisualProgress(previewTime); + if ( + forceSemantic || + now - lastDragUpdateRef.current >= DRAG_UPDATE_INTERVAL + ) { + lastDragUpdateRef.current = now; + commitCurrentTime(previewTime, true); + } + }); + }, + [applyVisualProgress, commitCurrentTime, getDragTime], + ); + + const beginDrag = useCallback( + (clientX: number) => { + const video = videoRef.current; + const progress = progressRef.current; + if ( + !video || + !progress || + !Number.isFinite(durationRef.current) || + durationRef.current <= 0 + ) { + return false; + } + + dragRectRef.current = progress.getBoundingClientRect(); + dragActiveRef.current = true; + dragWasPlayingRef.current = !video.paused; + video.pause(); + setIsDragging(true); + resetIdleTimer(); + queueDragPreview(clientX, true); + return true; + }, + [queueDragPreview, resetIdleTimer], + ); + + const finishDrag = useCallback( + (clientX?: number) => { + if (!dragActiveRef.current) return; + if (clientX !== undefined) { + dragPreviewTimeRef.current = getDragTime(clientX); + } + flushDragPreview(); + const video = videoRef.current; + if (video) video.currentTime = dragPreviewTimeRef.current; + dragActiveRef.current = false; + dragRectRef.current = null; + activePointerIdRef.current = null; + setIsDragging(false); + if (dragWasPlayingRef.current) video?.play().catch(() => {}); + dragWasPlayingRef.current = false; + resetIdleTimer(); + }, + [flushDragPreview, getDragTime, resetIdleTimer], + ); + + const cancelDrag = useCallback(() => { + if (!dragActiveRef.current) return; + if (dragFrameRef.current !== null) { + cancelAnimationFrame(dragFrameRef.current); + dragFrameRef.current = null; + } + const video = videoRef.current; + if (video) syncProgress(video.currentTime, true); + dragActiveRef.current = false; + dragRectRef.current = null; + activePointerIdRef.current = null; + setIsDragging(false); + if (dragWasPlayingRef.current) video?.play().catch(() => {}); + dragWasPlayingRef.current = false; + resetIdleTimer(); + }, [resetIdleTimer, syncProgress]); + + const handleProgressPointerDown = useCallback( + (event: React.PointerEvent) => { + if ( + !event.isPrimary || + (event.pointerType === "mouse" && event.button !== 0) + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (!beginDrag(event.clientX)) return; + activePointerIdRef.current = event.pointerId; + event.currentTarget.setPointerCapture?.(event.pointerId); + }, + [beginDrag], + ); + + const handleProgressPointerMove = useCallback( + (event: React.PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) return; + queueDragPreview(event.clientX); + }, + [queueDragPreview], + ); + + const handleProgressPointerUp = useCallback( + (event: React.PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) return; + finishDrag(event.clientX); + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture?.(event.pointerId); + } + }, + [finishDrag], + ); + + const handleProgressKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const video = videoRef.current; + if (!video) return; + let nextTime = video.currentTime; + switch (event.key) { + case "ArrowLeft": + case "ArrowDown": + nextTime -= 5; + break; + case "ArrowRight": + case "ArrowUp": + nextTime += 5; + break; + case "Home": + nextTime = 0; + break; + case "End": + nextTime = durationRef.current; + break; + default: + return; + } + event.preventDefault(); + event.stopPropagation(); + seekTo(nextTime); + resetIdleTimer(); + }, + [resetIdleTimer, seekTo], + ); + + const skipTime = useCallback( + (seconds: number) => { + const video = videoRef.current; + if (!video) return; + seekTo(video.currentTime + seconds); + resetIdleTimer(); + }, + [resetIdleTimer, seekTo], + ); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const handlePlay = () => { + isPlayingRef.current = true; + setIsPlaying(true); + setShowCenterButton(false); + resetIdleTimer(); + callbacksRef.current.onPlay?.(); + }; + const handlePause = () => { + isPlayingRef.current = false; + setIsPlaying(false); + setShowCenterButton(true); + showPlayerControls(); + clearIdleTimer(); + syncProgress(video.currentTime, true); + callbacksRef.current.onPause?.(); + }; + const handleEnded = () => { + isPlayingRef.current = false; + setIsPlaying(false); + setShowCenterButton(true); + showPlayerControls(); + clearIdleTimer(); + syncProgress(video.currentTime, true); + callbacksRef.current.onEnded?.(); + }; + const handleTimeUpdate = () => { + if (!dragActiveRef.current) syncProgress(video.currentTime); + callbacksRef.current.onTimeUpdate?.(video.currentTime); + }; + const handleLoadedMetadata = () => { + const nextDuration = Number.isFinite(video.duration) + ? video.duration + : 0; + durationRef.current = nextDuration; + setDuration(nextDuration); + setVolumeState(video.volume); + setIsMuted(video.muted); + syncProgress(video.currentTime, true); + }; + const handleProgress = () => { + if (video.buffered.length > 0 && durationRef.current > 0) { + const end = video.buffered.end(video.buffered.length - 1); + applyBufferedProgress(end / durationRef.current); + } else { + applyBufferedProgress(0); + } + callbacksRef.current.onProgress?.(video.buffered); + }; + const handleVolume = () => { + setVolumeState(video.volume); + setIsMuted(video.muted); + callbacksRef.current.onVolumeChange?.(video.volume, video.muted); + }; + const handleWaiting = () => setIsLoading(true); + const handleCanPlay = () => { + setIsLoading(false); + setHasPlaybackError(false); + }; + const handleError = () => { + setIsLoading(false); + setHasPlaybackError(true); + setShowCenterButton(false); + showPlayerControls(); + }; + const handleSeeked = () => { + if (!dragActiveRef.current) syncProgress(video.currentTime, true); + }; + + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); + video.addEventListener("ended", handleEnded); + video.addEventListener("timeupdate", handleTimeUpdate); + video.addEventListener("loadedmetadata", handleLoadedMetadata); + video.addEventListener("progress", handleProgress); + video.addEventListener("volumechange", handleVolume); + video.addEventListener("waiting", handleWaiting); + video.addEventListener("canplay", handleCanPlay); + video.addEventListener("error", handleError); + video.addEventListener("seeked", handleSeeked); + + return () => { + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); + video.removeEventListener("ended", handleEnded); + video.removeEventListener("timeupdate", handleTimeUpdate); + video.removeEventListener("loadedmetadata", handleLoadedMetadata); + video.removeEventListener("progress", handleProgress); + video.removeEventListener("volumechange", handleVolume); + video.removeEventListener("waiting", handleWaiting); + video.removeEventListener("canplay", handleCanPlay); + video.removeEventListener("error", handleError); + video.removeEventListener("seeked", handleSeeked); + }; + }, [ + applyBufferedProgress, + clearIdleTimer, + resetIdleTimer, + showPlayerControls, + syncProgress, + ]); + + useEffect(() => { + const video = videoRef.current as VideoWithFrameCallbacks | null; + if (!video || !isPlaying || isDragging) return; + + let active = true; + let frameId: number | null = null; + let frameKind: "video" | "animation" | null = null; + + const cancelFrame = () => { + if (frameId === null) return; + if (frameKind === "video" && video.cancelVideoFrameCallback) { + video.cancelVideoFrameCallback(frameId); + } else if (frameKind === "animation") { + cancelAnimationFrame(frameId); + } + frameId = null; + frameKind = null; + }; + + const scheduleFrame = () => { + if ( + !active || + document.hidden || + video.paused || + video.ended || + frameId !== null + ) { + return; + } + + if (video.requestVideoFrameCallback) { + frameKind = "video"; + frameId = video.requestVideoFrameCallback((_now, metadata) => { + frameId = null; + frameKind = null; + const mediaTime = Number.isFinite(metadata.mediaTime) + ? metadata.mediaTime + : video.currentTime; + syncProgress(mediaTime); + scheduleFrame(); + }); + } else { + frameKind = "animation"; + frameId = requestAnimationFrame(() => { + frameId = null; + frameKind = null; + syncProgress(video.currentTime); + scheduleFrame(); + }); + } + }; + + const handleVisibilityChange = () => { + cancelFrame(); + if (!document.hidden) scheduleFrame(); + }; + + scheduleFrame(); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + active = false; + cancelFrame(); + document.removeEventListener( + "visibilitychange", + handleVisibilityChange, + ); + }; + }, [isDragging, isPlaying, syncProgress]); + + useEffect(() => { + const progress = progressRef.current; + if (!progress) return; + const updateWidth = () => { + progressWidthRef.current = progress.clientWidth; + applyVisualProgress(currentTimeRef.current); + }; + updateWidth(); + + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(updateWidth); + observer.observe(progress); + return () => observer.disconnect(); + } + + window.addEventListener("resize", updateWidth); + return () => window.removeEventListener("resize", updateWidth); + }, [applyVisualProgress]); + + useEffect(() => { + if (supportsPointerEvents) return; + + const handleMouseMove = (event: MouseEvent) => { + if (dragActiveRef.current) queueDragPreview(event.clientX); + }; + const handleMouseUp = (event: MouseEvent) => { + if (dragActiveRef.current) finishDrag(event.clientX); + }; + const handleTouchMove = (event: TouchEvent) => { + if (!dragActiveRef.current) return; + event.preventDefault(); + const touch = event.touches[0]; + if (touch) queueDragPreview(touch.clientX); + }; + const handleTouchEnd = (event: TouchEvent) => { + if (!dragActiveRef.current) return; + const touch = event.changedTouches[0]; + finishDrag(touch?.clientX); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("touchmove", handleTouchMove, { + passive: false, + }); + document.addEventListener("touchend", handleTouchEnd); + document.addEventListener("touchcancel", cancelDrag); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchend", handleTouchEnd); + document.removeEventListener("touchcancel", cancelDrag); + }; + }, [cancelDrag, finishDrag, queueDragPreview, supportsPointerEvents]); + + useEffect(() => { + return () => { + clearIdleTimer(); + if (dragFrameRef.current !== null) { + cancelAnimationFrame(dragFrameRef.current); + } + }; + }, [clearIdleTimer]); + + const handlePlayerKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const video = videoRef.current; + if (!video) return; + switch (event.key) { + case " ": + event.preventDefault(); + togglePlay(); + break; + case "ArrowLeft": + event.preventDefault(); + skipTime(-5); + break; + case "ArrowRight": + event.preventDefault(); + skipTime(5); + break; + case "ArrowUp": + event.preventDefault(); + video.volume = Math.min(1, video.volume + 0.1); + break; + case "ArrowDown": + event.preventDefault(); + video.volume = Math.max(0, video.volume - 0.1); + break; + case "f": + if (allowFullscreenToggle) { + event.preventDefault(); + toggleFullscreen(); + } + break; + case "m": + event.preventDefault(); + toggleMute(); + break; + } + }, + [ + allowFullscreenToggle, + skipTime, + toggleFullscreen, + toggleMute, + togglePlay, + ], + ); + + return ( +
{ + if ( + event.target === videoRef.current || + event.target === containerRef.current + ) { + togglePlay(); + } + }} + > +