xxf_react 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # @xxf/react
2
2
 
3
- To install dependencies:
3
+ React 工具库,提供常用的组件、Hooks 和工具函数。
4
+
5
+ ## 安装
4
6
 
5
7
  ```bash
6
8
  bun install
7
9
  ```
8
10
 
9
- To run:
11
+ ## 运行
10
12
 
11
13
  ```bash
12
14
  bun run src/index.ts
@@ -14,6 +16,22 @@ bun run src/index.ts
14
16
 
15
17
  This project was created using `bun init` in bun v1.3.2. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
16
18
 
17
- ## 文档
19
+ ## 模块文档
20
+
21
+ | 模块 | 说明 |
22
+ |------|------|
23
+ | [event-bus](./src/event-bus/README.md) | 事件总线,支持跨 Tab 通信 |
24
+ | [fetch](./src/fetch/README.md) | 带超时控制的 fetch 封装 |
25
+ | [flow](./src/flow/README.md) | Promise 扩展,全局错误处理 |
26
+ | [foundation](./src/foundation/README.md) | 基础工具,性能监控 |
27
+ | [layout](./src/layout/README.md) | 布局组件,悬浮交互 |
28
+ | [media](./src/media/README.md) | 媒体组件,视频播放 |
29
+ | [models](./src/models/README.md) | 数据模型,API 响应类型 |
30
+ | [refresh](./src/refresh/README.md) | 刷新加载,分页加载更多 |
31
+ | [responsive](./src/responsive/README.md) | 响应式布局,设备检测 |
32
+ | [sse](./src/sse/README.md) | Server-Sent Events 连接管理 |
33
+ | [utils](./src/utils/README.md) | 工具函数,滚动定位、组件刷新 |
34
+
35
+ ## 详细文档
18
36
 
19
37
  - [PlaybackQueue 播放队列状态管理](./src/media/playback-queue-store.md)
@@ -0,0 +1,97 @@
1
+ import React, { ButtonHTMLAttributes, ReactNode } from 'react';
2
+ /**
3
+ * 加载指示器位置
4
+ * - `start`: 指示器在内容左侧
5
+ * - `center`: 指示器居中,内容隐藏(保持宽度)
6
+ * - `end`: 指示器在内容右侧
7
+ */
8
+ export type LoadingGravity = 'start' | 'center' | 'end';
9
+ /**
10
+ * XButton 组件属性
11
+ * @extends ButtonHTMLAttributes<HTMLButtonElement> 继承原生 button 所有属性
12
+ */
13
+ export interface XButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
14
+ /**
15
+ * 加载状态
16
+ * - 为 `true` 时按钮不可点击,显示加载指示器
17
+ * @default false
18
+ */
19
+ loading?: boolean;
20
+ /**
21
+ * 加载指示器位置
22
+ * - `start`: 左侧
23
+ * - `center`: 居中(隐藏内容,保持宽度避免抖动)
24
+ * - `end`: 右侧
25
+ * @default 'start'
26
+ */
27
+ loadingGravity?: LoadingGravity;
28
+ /**
29
+ * 自定义加载指示器
30
+ * - 不传则使用默认的旋转圆环
31
+ * - 尺寸建议使用 `em` 单位以跟随字体大小
32
+ */
33
+ loadingIndicator?: ReactNode;
34
+ /**
35
+ * 指示器与内容的间距(像素)
36
+ * - 仅在 `loadingGravity` 为 `start` 或 `end` 时生效
37
+ * @default 8
38
+ */
39
+ loadingGap?: number;
40
+ /**
41
+ * 点击防抖等待时间(毫秒)
42
+ * - 防止用户快速连续点击
43
+ * - 使用 leading 模式:首次点击立即执行,后续点击在等待时间内被忽略
44
+ * - 设为 0 时禁用防抖
45
+ * @default 500
46
+ */
47
+ debounceWait?: number;
48
+ /**
49
+ * 是否阻止事件冒泡
50
+ * @default false
51
+ */
52
+ stopPropagation?: boolean;
53
+ /** 按钮内容,支持任意 ReactNode(文本、元素、组件等) */
54
+ children?: ReactNode;
55
+ }
56
+ /**
57
+ * XButton - 支持加载状态的按钮组件
58
+ *
59
+ * @description
60
+ * 封装了常见的按钮加载交互:
61
+ * - 加载时自动禁用点击
62
+ * - 支持自定义加载指示器位置(左/中/右)
63
+ * - 居中模式保持按钮宽度,避免布局抖动
64
+ * - 支持 ref 转发
65
+ * - 支持点击防抖,防止重复提交
66
+ * - 不依赖外部库,纯内联样式实现
67
+ *
68
+ * @example
69
+ * ```tsx
70
+ * // 基本用法
71
+ * <XButton loading={isLoading} onClick={handleSubmit}>
72
+ * 提交
73
+ * </XButton>
74
+ *
75
+ * // 加载指示器在左侧
76
+ * <XButton loading loadingGravity="start">
77
+ * 加载中...
78
+ * </XButton>
79
+ *
80
+ * // 自定义加载指示器
81
+ * <XButton loading loadingIndicator={<MySpinner />}>
82
+ * 处理中
83
+ * </XButton>
84
+ *
85
+ * // 防抖点击(500ms 内只响应第一次)
86
+ * <XButton debounceWait={500} onClick={handleSubmit}>
87
+ * 提交订单
88
+ * </XButton>
89
+ *
90
+ * // 阻止冒泡 + 防抖
91
+ * <XButton debounceWait={500} stopPropagation onClick={handleSubmit}>
92
+ * 提交
93
+ * </XButton>
94
+ * ```
95
+ */
96
+ export declare const XButton: React.ForwardRefExoticComponent<XButtonProps & React.RefAttributes<HTMLButtonElement>>;
97
+ //# sourceMappingURL=XButton.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"XButton.d.ts","sourceRoot":"","sources":["../../../src/layout/button/XButton.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,EAAa,oBAAoB,EAAE,SAAS,EAAgB,MAAM,OAAO,CAAC;AAIxF;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;AAExD;;;GAGG;AACH,MAAM,WAAW,YAAa,SAAQ,oBAAoB,CAAC,iBAAiB,CAAC;IACzE;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,cAAc,CAAC;IAEhC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,SAAS,CAAC;IAE7B;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B,qCAAqC;IACrC,QAAQ,CAAC,EAAE,SAAS,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,eAAO,MAAM,OAAO,wFAyInB,CAAC"}
@@ -0,0 +1,127 @@
1
+ 'use client';
2
+ import React, { forwardRef } from 'react';
3
+ import { useDebouncedCallback } from 'use-debounce';
4
+ import { XSpinner } from "../spinner/XSpinner";
5
+ /**
6
+ * XButton - 支持加载状态的按钮组件
7
+ *
8
+ * @description
9
+ * 封装了常见的按钮加载交互:
10
+ * - 加载时自动禁用点击
11
+ * - 支持自定义加载指示器位置(左/中/右)
12
+ * - 居中模式保持按钮宽度,避免布局抖动
13
+ * - 支持 ref 转发
14
+ * - 支持点击防抖,防止重复提交
15
+ * - 不依赖外部库,纯内联样式实现
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * // 基本用法
20
+ * <XButton loading={isLoading} onClick={handleSubmit}>
21
+ * 提交
22
+ * </XButton>
23
+ *
24
+ * // 加载指示器在左侧
25
+ * <XButton loading loadingGravity="start">
26
+ * 加载中...
27
+ * </XButton>
28
+ *
29
+ * // 自定义加载指示器
30
+ * <XButton loading loadingIndicator={<MySpinner />}>
31
+ * 处理中
32
+ * </XButton>
33
+ *
34
+ * // 防抖点击(500ms 内只响应第一次)
35
+ * <XButton debounceWait={500} onClick={handleSubmit}>
36
+ * 提交订单
37
+ * </XButton>
38
+ *
39
+ * // 阻止冒泡 + 防抖
40
+ * <XButton debounceWait={500} stopPropagation onClick={handleSubmit}>
41
+ * 提交
42
+ * </XButton>
43
+ * ```
44
+ */
45
+ export const XButton = forwardRef(function XButton({ loading = false, loadingGravity = 'start', loadingIndicator, loadingGap = 8, debounceWait = 500, stopPropagation = false, children, onClick, disabled, style, type = 'button', ...rest }, ref) {
46
+ /** 使用自定义指示器或默认指示器 */
47
+ const indicator = loadingIndicator !== null && loadingIndicator !== void 0 ? loadingIndicator : React.createElement(XSpinner, null);
48
+ /**
49
+ * 防抖处理后的点击回调
50
+ * - leading: true 首次点击立即执行
51
+ * - trailing: false 等待时间结束后不再执行
52
+ */
53
+ const debouncedOnClick = useDebouncedCallback((e) => {
54
+ onClick === null || onClick === void 0 ? void 0 : onClick(e);
55
+ }, debounceWait, { leading: true, trailing: false });
56
+ /**
57
+ * 点击事件处理
58
+ * - 阻止冒泡(可选)
59
+ * - loading 或 disabled 时拦截
60
+ * - debounceWait > 0 时使用防抖回调
61
+ */
62
+ const handleClick = (e) => {
63
+ // 阻止冒泡
64
+ if (stopPropagation) {
65
+ e.stopPropagation();
66
+ }
67
+ // loading 或 disabled 时拦截
68
+ if (loading || disabled) {
69
+ e.preventDefault();
70
+ return;
71
+ }
72
+ // 根据 debounceWait 决定是否使用防抖
73
+ if (debounceWait > 0) {
74
+ debouncedOnClick(e);
75
+ }
76
+ else {
77
+ onClick === null || onClick === void 0 ? void 0 : onClick(e);
78
+ }
79
+ };
80
+ /** 按钮基础样式,使用 flexbox 布局 */
81
+ const baseStyle = {
82
+ display: 'inline-flex',
83
+ alignItems: 'center',
84
+ justifyContent: 'center',
85
+ position: 'relative',
86
+ // 仅在 start/end 模式下应用 gap
87
+ gap: loading && loadingGravity !== 'center' ? loadingGap : undefined,
88
+ ...style,
89
+ };
90
+ /**
91
+ * 渲染按钮内容
92
+ * - 非加载状态:直接渲染 children
93
+ * - start:指示器 + children
94
+ * - end:children + 指示器
95
+ * - center:children 隐藏占位 + 指示器绝对定位居中
96
+ */
97
+ const renderContent = () => {
98
+ if (!loading) {
99
+ return children;
100
+ }
101
+ switch (loadingGravity) {
102
+ case 'start':
103
+ return (React.createElement(React.Fragment, null,
104
+ indicator,
105
+ children));
106
+ case 'end':
107
+ return (React.createElement(React.Fragment, null,
108
+ children,
109
+ indicator));
110
+ case 'center':
111
+ default:
112
+ // children 保持可见,indicator 绝对定位叠加在上面
113
+ // 背景透明,可以看到 children 内容
114
+ return (React.createElement(React.Fragment, null,
115
+ children,
116
+ React.createElement("div", { style: {
117
+ position: 'absolute',
118
+ inset: 0,
119
+ display: 'flex',
120
+ alignItems: 'center',
121
+ justifyContent: 'center',
122
+ backgroundColor: 'transparent',
123
+ } }, indicator)));
124
+ }
125
+ };
126
+ return (React.createElement("button", { ref: ref, type: type, ...rest, style: baseStyle, disabled: disabled || loading, onClick: handleClick, "aria-busy": loading, "aria-disabled": disabled || loading }, renderContent()));
127
+ });
@@ -7,4 +7,6 @@ export * from './image/XImage';
7
7
  export * from './visibility/ElementVisibilityHooks';
8
8
  export * from './virtualized/VirtualizedConfig';
9
9
  export * from './hover/XHover';
10
+ export * from './button/XButton';
11
+ export * from './spinner/XSpinner';
10
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/layout/index.ts"],"names":[],"mappings":"AAAA,cAAc,gCAAgC,CAAA;AAC9C,cAAc,kCAAkC,CAAA;AAChD,cAAc,kCAAkC,CAAA;AAChD,cAAc,8BAA8B,CAAA;AAC5C,cAAc,2BAA2B,CAAA;AACzC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,qCAAqC,CAAA;AACnD,cAAc,iCAAiC,CAAA;AAC/C,cAAc,gBAAgB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/layout/index.ts"],"names":[],"mappings":"AAAA,cAAc,gCAAgC,CAAA;AAC9C,cAAc,kCAAkC,CAAA;AAChD,cAAc,kCAAkC,CAAA;AAChD,cAAc,8BAA8B,CAAA;AAC5C,cAAc,2BAA2B,CAAA;AACzC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,qCAAqC,CAAA;AACnD,cAAc,iCAAiC,CAAA;AAC/C,cAAc,gBAAgB,CAAA;AAC9B,cAAc,kBAAkB,CAAA;AAChC,cAAc,oBAAoB,CAAA"}
@@ -7,3 +7,5 @@ export * from './image/XImage';
7
7
  export * from './visibility/ElementVisibilityHooks';
8
8
  export * from './virtualized/VirtualizedConfig';
9
9
  export * from './hover/XHover';
10
+ export * from './button/XButton';
11
+ export * from './spinner/XSpinner';
@@ -0,0 +1,43 @@
1
+ import React from "react";
2
+ /**
3
+ * XSpinner 组件属性
4
+ */
5
+ export interface XSpinnerProps {
6
+ /**
7
+ * 自定义样式类名
8
+ * - 可覆盖默认的尺寸、颜色、动画等样式
9
+ * @example "w-8 h-8 border-blue-500"
10
+ */
11
+ className?: string;
12
+ }
13
+ /**
14
+ * XSpinner - 轻量级加载指示器组件
15
+ *
16
+ * @description
17
+ * 使用 CSS border + rotate 动画实现的高性能 spinner:
18
+ * - 基于 div + Tailwind CSS,无 SVG 解析开销
19
+ * - 尺寸使用 `1em`,自动跟随父元素字体大小
20
+ * - 默认白色边框,适合深色背景
21
+ * - 可通过 className 完全自定义样式
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * // 基本用法(跟随父元素字体大小)
26
+ * <XSpinner />
27
+ *
28
+ * // 自定义尺寸
29
+ * <XSpinner className="w-8 h-8" />
30
+ *
31
+ * // 自定义颜色(深色主题)
32
+ * <XSpinner className="border-gray-800 border-t-transparent" />
33
+ *
34
+ * // 在按钮中使用
35
+ * <button className="text-lg">
36
+ * <XSpinner /> 加载中...
37
+ * </button>
38
+ * ```
39
+ *
40
+ * @see XButton - 内置 XSpinner 作为默认加载指示器
41
+ */
42
+ export declare const XSpinner: ({ className }?: XSpinnerProps) => React.JSX.Element;
43
+ //# sourceMappingURL=XSpinner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"XSpinner.d.ts","sourceRoot":"","sources":["../../../src/layout/spinner/XSpinner.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,MAAM,OAAO,CAAC;AAG1B;;GAEG;AACH,MAAM,WAAW,aAAa;IAC1B;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,QAAQ,GAAI,gBAAa,aAAkB,sBAevD,CAAC"}
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+ import React from "react";
3
+ import { cn } from "tailwind-variants";
4
+ /**
5
+ * XSpinner - 轻量级加载指示器组件
6
+ *
7
+ * @description
8
+ * 使用 CSS border + rotate 动画实现的高性能 spinner:
9
+ * - 基于 div + Tailwind CSS,无 SVG 解析开销
10
+ * - 尺寸使用 `1em`,自动跟随父元素字体大小
11
+ * - 默认白色边框,适合深色背景
12
+ * - 可通过 className 完全自定义样式
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * // 基本用法(跟随父元素字体大小)
17
+ * <XSpinner />
18
+ *
19
+ * // 自定义尺寸
20
+ * <XSpinner className="w-8 h-8" />
21
+ *
22
+ * // 自定义颜色(深色主题)
23
+ * <XSpinner className="border-gray-800 border-t-transparent" />
24
+ *
25
+ * // 在按钮中使用
26
+ * <button className="text-lg">
27
+ * <XSpinner /> 加载中...
28
+ * </button>
29
+ * ```
30
+ *
31
+ * @see XButton - 内置 XSpinner 作为默认加载指示器
32
+ */
33
+ export const XSpinner = ({ className } = {}) => (React.createElement("div", { className: cn(
34
+ // 尺寸:1em 跟随字体大小
35
+ "w-[1em] h-[1em]",
36
+ // 边框样式:白色圆环,顶部透明形成缺口
37
+ "border-2 border-white border-t-transparent rounded-full",
38
+ // 动画:无限旋转
39
+ "animate-spin",
40
+ // 透明度:略微降低避免过亮
41
+ "opacity-80",
42
+ // 用户自定义类名
43
+ className) }));
@@ -1 +1 @@
1
- {"version":3,"file":"XVideo.d.ts","sourceRoot":"","sources":["../../../src/media/components/XVideo.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,EAMV,SAAS,EACT,mBAAmB,EACnB,SAAS,EACZ,MAAM,OAAO,CAAA;AAOd;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,WAAY,SAAQ,IAAI,CAAC,mBAAmB,CAAC,gBAAgB,CAAC,EAAE,QAAQ,CAAC;IACtF;;;;;;;OAOG;IACH,GAAG,EAAE,MAAM,CAAA;IAEX;;;;;;;;;;;;;;;;;OAiBG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAE3B;;;;;;;OAOG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAA;IAE9B;;;;;;;;;;;;;;;;;;OAkBG;IACH,kBAAkB,CAAC,EAAE,SAAS,CAAA;IAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,cAAc,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,UAAU,KAAK,SAAS,CAAC,GAAG,IAAI,CAAA;IAE1D;;;;;;;;;;;;;;OAcG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAA;IAE7C;;;;;;;;;;;;;;;;;;;OAmBG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,MAAM,CAAC,EACI,GAAG,EACH,MAAM,EACN,qBAA2B,EAC3B,kBAAkB,EAClB,cAAc,EACd,SAAS,EACT,QAAQ,EAAE,gBAAgB,EAC1B,OAAO,EACP,OAAgB,EAChB,WAAkB,EAClB,WAAyB,EACzB,GAAG,UAAU,EAChB,EAAE,WAAW,qBAiWpC"}
1
+ {"version":3,"file":"XVideo.d.ts","sourceRoot":"","sources":["../../../src/media/components/XVideo.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,EAMV,SAAS,EACT,mBAAmB,EACnB,SAAS,EACZ,MAAM,OAAO,CAAA;AAOd;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,WAAY,SAAQ,IAAI,CAAC,mBAAmB,CAAC,gBAAgB,CAAC,EAAE,QAAQ,CAAC;IACtF;;;;;;;OAOG;IACH,GAAG,EAAE,MAAM,CAAA;IAEX;;;;;;;;;;;;;;;;;OAiBG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAE3B;;;;;;;OAOG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAA;IAE9B;;;;;;;;;;;;;;;;;;OAkBG;IACH,kBAAkB,CAAC,EAAE,SAAS,CAAA;IAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,cAAc,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,UAAU,KAAK,SAAS,CAAC,GAAG,IAAI,CAAA;IAE1D;;;;;;;;;;;;;;OAcG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAA;IAE7C;;;;;;;;;;;;;;;;;;;OAmBG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,MAAM,CAAC,EACI,GAAG,EACH,MAAM,EACN,qBAA2B,EAC3B,kBAAkB,EAClB,cAAc,EACd,SAAS,EACT,QAAQ,EAAE,gBAAgB,EAC1B,OAAO,EACP,OAAgB,EAChB,WAAkB,EAClB,WAAyB,EACzB,GAAG,UAAU,EAChB,EAAE,WAAW,qBAgXpC"}
@@ -156,21 +156,29 @@ export function XVideo({ src, poster, posterFadeOutDuration = 300, bufferingIndi
156
156
  checkAndSetReady();
157
157
  }, [playState.canPlay, checkAndSetReady]);
158
158
  /**
159
- * Effect: 播放结束或出错时显示封面
159
+ * Effect: 暂停、播放结束或出错时显示封面
160
160
  *
161
+ * 视频暂停时,重新显示封面
161
162
  * 视频播放结束时,重新显示封面,避免最后一帧可能是黑屏的问题
162
163
  * 视频播放出错时,重新显示封面,作为错误指示器的背景
163
164
  *
164
- * 当视频重新播放时(ended true 变为 false),封面会通过
165
- * 正常的 checkAndSetReady 流程再次淡出
165
+ * 当视频重新播放时,封面会通过正常的 checkAndSetReady 流程再次淡出
166
+ *
167
+ * 注意:必须同时重置 firstFrameRenderedRef,否则 checkAndSetReady 会立即再次隐藏封面
166
168
  */
167
169
  useEffect(() => {
168
- if ((playState.ended || playState.error) && poster) {
169
- // 播放结束或出错,显示封面
170
+ if (poster && (playState.ended || playState.error || !playState.playing)) {
171
+ // 清理淡出定时器,防止正在进行的淡出继续执行
172
+ if (fadeOutTimerRef.current) {
173
+ clearTimeout(fadeOutTimerRef.current);
174
+ fadeOutTimerRef.current = null;
175
+ }
176
+ // 重置第一帧标记,防止 checkAndSetReady 立即隐藏封面
177
+ firstFrameRenderedRef.current = false;
170
178
  setShowPoster(true);
171
179
  setIsReady(false);
172
180
  }
173
- }, [playState.ended, playState.error, poster]);
181
+ }, [playState.ended, playState.error, playState.playing, poster]);
174
182
  /**
175
183
  * Effect: 通过 currentTime 检测第一帧(回退方案)
176
184
  *
@@ -178,13 +186,15 @@ export function XVideo({ src, poster, posterFadeOutDuration = 300, bufferingIndi
178
186
  * 使用 currentTime > 0 作为第一帧渲染的近似检测
179
187
  *
180
188
  * 原理:currentTime > 0 表示视频已开始播放,此时第一帧大概率已渲染
189
+ *
190
+ * 注意:必须检查 playState.playing,否则暂停时 currentTime > 0 会立即触发封面隐藏
181
191
  */
182
192
  useEffect(() => {
183
- if (!firstFrameRenderedRef.current && playState.currentTime > 0) {
193
+ if (!firstFrameRenderedRef.current && playState.currentTime > 0 && playState.playing) {
184
194
  firstFrameRenderedRef.current = true;
185
195
  checkAndSetReady();
186
196
  }
187
- }, [playState.currentTime, checkAndSetReady]);
197
+ }, [playState.currentTime, playState.playing, checkAndSetReady]);
188
198
  /**
189
199
  * Effect: 第一帧检测 - 优先使用 requestVideoFrameCallback
190
200
  *
@@ -197,7 +207,9 @@ export function XVideo({ src, poster, posterFadeOutDuration = 300, bufferingIndi
197
207
  * 3. 第一帧渲染时标记 firstFrameRendered 并检查就绪状态
198
208
  * 4. cleanup 时取消回调,防止组件卸载后执行
199
209
  *
200
- * 注意:cleanedUp flag 用于防止组件卸载后的状态更新(React 严格模式下的竞态条件)
210
+ * 注意:
211
+ * - cleanedUp flag 用于防止组件卸载后的状态更新(React 严格模式下的竞态条件)
212
+ * - 必须检查 playState.playing,否则暂停时可能错误触发
201
213
  */
202
214
  useEffect(() => {
203
215
  const video = videoRef.current;
@@ -206,6 +218,9 @@ export function XVideo({ src, poster, posterFadeOutDuration = 300, bufferingIndi
206
218
  // 如果已经检测到第一帧,无需重复检测
207
219
  if (firstFrameRenderedRef.current)
208
220
  return;
221
+ // 视频未播放时不注册回调
222
+ if (!playState.playing)
223
+ return;
209
224
  // 检查浏览器是否支持 requestVideoFrameCallback
210
225
  const supportsVideoFrameCallback = 'requestVideoFrameCallback' in HTMLVideoElement.prototype;
211
226
  if (supportsVideoFrameCallback) {
@@ -226,7 +241,7 @@ export function XVideo({ src, poster, posterFadeOutDuration = 300, bufferingIndi
226
241
  };
227
242
  }
228
243
  // 不支持 requestVideoFrameCallback 的浏览器由 currentTime effect 处理
229
- }, [src, videoRef, checkAndSetReady]);
244
+ }, [src, videoRef, playState.playing, checkAndSetReady]);
230
245
  // ==================== 渲染逻辑 ====================
231
246
  /**
232
247
  * 封面元素 - 使用 useMemo 优化
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xxf_react",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "dist/index.js",