xxf_react 0.5.7 → 0.5.9

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.
@@ -8,15 +8,26 @@ export interface XHoverProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onMou
8
8
  */
9
9
  children: ReactNode;
10
10
  /**
11
- * 悬浮延迟时间(毫秒)
11
+ * 悬浮触发延迟时间(毫秒)
12
12
  *
13
- * 鼠标悬浮指定时间后才触发 onHover 回调
13
+ * 鼠标进入后,需要持续悬浮该时间才会触发 onHover 回调和显示 hoverLayer。
14
+ * - 设为 0 或不设置:鼠标进入立即触发
15
+ * - 设为正数:鼠标需要停留指定毫秒后才触发
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * // 悬浮 2 秒后触发
20
+ * <XHover hoverDelay={2000} onHover={handleHover}>
21
+ *
22
+ * // 立即触发(默认)
23
+ * <XHover onHover={handleHover}>
24
+ * ```
14
25
  *
15
26
  * @default 0
16
27
  */
17
- delay?: number;
28
+ hoverDelay?: number;
18
29
  /**
19
- * 鼠标进入时立即触发的回调(不等待 delay
30
+ * 鼠标进入时立即触发的回调(不等待 hoverDelay
20
31
  *
21
32
  * 适用于需要在鼠标进入时立即做一些事情的场景,如显示边框高亮等
22
33
  */
@@ -24,7 +35,7 @@ export interface XHoverProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onMou
24
35
  /**
25
36
  * 悬浮回调
26
37
  *
27
- * 当鼠标悬浮达到 delay 时间后触发
38
+ * 当鼠标悬浮达到 hoverDelay 时间后触发
28
39
  */
29
40
  onHover?: () => void;
30
41
  /**
@@ -36,13 +47,13 @@ export interface XHoverProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onMou
36
47
  /**
37
48
  * 悬浮覆盖层
38
49
  *
39
- * 当悬浮触发(达到 delay 时间)后,显示在 children 上方的内容。
50
+ * 当悬浮触发(达到 hoverDelay 时间)后,显示在 children 上方的内容。
40
51
  * 鼠标离开后自动隐藏。
41
52
  *
42
53
  * @example
43
54
  * ```tsx
44
55
  * <XHover
45
- * delay={1000}
56
+ * hoverDelay={1000}
46
57
  * hoverLayer={() => (
47
58
  * <div className="absolute inset-0 bg-black/50 flex items-center justify-center">
48
59
  * <span className="text-white">悬浮提示</span>
@@ -75,7 +86,7 @@ export interface XHoverProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onMou
75
86
  * @example
76
87
  * ```tsx
77
88
  * // 基础用法:鼠标悬浮 2 秒后触发
78
- * <XHover delay={2000} onHover={() => console.log('悬浮触发')}>
89
+ * <XHover hoverDelay={2000} onHover={() => console.log('悬浮触发')}>
79
90
  * <div>悬浮我</div>
80
91
  * </XHover>
81
92
  *
@@ -86,7 +97,7 @@ export interface XHoverProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onMou
86
97
  *
87
98
  * // 带离开回调,判断是否触发过
88
99
  * <XHover
89
- * delay={1000}
100
+ * hoverDelay={1000}
90
101
  * onHover={() => setShowTooltip(true)}
91
102
  * onLeave={(triggered) => {
92
103
  * if (triggered) setShowTooltip(false)
@@ -97,7 +108,7 @@ export interface XHoverProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onMou
97
108
  *
98
109
  * // 使用 hoverLayer 显示悬浮覆盖层
99
110
  * <XHover
100
- * delay={1000}
111
+ * hoverDelay={1000}
101
112
  * hoverLayer={() => (
102
113
  * <div className="absolute inset-0 bg-black/50 flex items-center justify-center">
103
114
  * <PlayIcon className="text-white w-12 h-12" />
@@ -108,18 +119,18 @@ export interface XHoverProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onMou
108
119
  * </XHover>
109
120
  *
110
121
  * // 使用 span 作为容器(适用于 inline 场景)
111
- * <XHover as="span" delay={500} onHover={handleHover}>
122
+ * <XHover as="span" hoverDelay={500} onHover={handleHover}>
112
123
  * <span>inline 文本</span>
113
124
  * </XHover>
114
125
  *
115
126
  * // 禁用状态
116
- * <XHover disabled={isLoading} delay={1000} onHover={handleHover}>
127
+ * <XHover disabled={isLoading} hoverDelay={1000} onHover={handleHover}>
117
128
  * <div>内容</div>
118
129
  * </XHover>
119
130
  *
120
131
  * // 使用 onEnter 立即响应 + onHover 延迟响应
121
132
  * <XHover
122
- * delay={1000}
133
+ * hoverDelay={1000}
123
134
  * onEnter={() => setHighlight(true)}
124
135
  * onHover={() => setShowDetail(true)}
125
136
  * onLeave={() => {
@@ -131,6 +142,6 @@ export interface XHoverProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onMou
131
142
  * </XHover>
132
143
  * ```
133
144
  */
134
- export declare function XHover({ children, delay, onEnter, onHover, onLeave, hoverLayer, disabled, as: Component, style, ...rest }: XHoverProps): React.JSX.Element;
145
+ export declare function XHover({ children, hoverDelay, onEnter, onHover, onLeave, hoverLayer, disabled, as: Component, style, ...rest }: XHoverProps): React.JSX.Element;
135
146
  export default XHover;
136
147
  //# sourceMappingURL=XHover.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"XHover.d.ts","sourceRoot":"","sources":["../../../src/layout/hover/XHover.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAA2C,SAAS,EAAE,cAAc,EAAC,MAAM,OAAO,CAAA;AAEhG;;GAEG;AACH,MAAM,WAAW,WAAY,SAAQ,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,cAAc,GAAG,cAAc,CAAC;IACtG;;OAEG;IACH,QAAQ,EAAE,SAAS,CAAA;IAEnB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IAEd;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IAEpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IAEpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAA;IAEtC;;;;;;;;;;;;;;;;;;;OAmBG;IACH,UAAU,CAAC,EAAE,MAAM,SAAS,CAAA;IAE5B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAElB;;;;OAIG;IACH,EAAE,CAAC,EAAE,KAAK,GAAG,MAAM,CAAA;CACtB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AACH,wBAAgB,MAAM,CAAC,EACI,QAAQ,EACR,KAAS,EACT,OAAO,EACP,OAAO,EACP,OAAO,EACP,UAAU,EACV,QAAgB,EAChB,EAAE,EAAE,SAAiB,EACrB,KAAK,EACL,GAAG,IAAI,EACV,EAAE,WAAW,qBA0EpC;AAED,eAAe,MAAM,CAAA"}
1
+ {"version":3,"file":"XHover.d.ts","sourceRoot":"","sources":["../../../src/layout/hover/XHover.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAA2C,SAAS,EAAE,cAAc,EAAC,MAAM,OAAO,CAAA;AAEhG;;GAEG;AACH,MAAM,WAAW,WAAY,SAAQ,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,cAAc,GAAG,cAAc,CAAC;IACtG;;OAEG;IACH,QAAQ,EAAE,SAAS,CAAA;IAEnB;;;;;;;;;;;;;;;;;OAiBG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IAEnB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IAEpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IAEpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAA;IAEtC;;;;;;;;;;;;;;;;;;;OAmBG;IACH,UAAU,CAAC,EAAE,MAAM,SAAS,CAAA;IAE5B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAElB;;;;OAIG;IACH,EAAE,CAAC,EAAE,KAAK,GAAG,MAAM,CAAA;CACtB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AACH,wBAAgB,MAAM,CAAC,EACnB,QAAQ,EACR,UAAc,EACd,OAAO,EACP,OAAO,EACP,OAAO,EACP,UAAU,EACV,QAAgB,EAChB,EAAE,EAAE,SAAiB,EACrB,KAAK,EACL,GAAG,IAAI,EACV,EAAE,WAAW,qBA0Eb;AAED,eAAe,MAAM,CAAA"}
@@ -7,7 +7,7 @@ import React, { useCallback, useRef, useEffect, useState } from 'react';
7
7
  * @example
8
8
  * ```tsx
9
9
  * // 基础用法:鼠标悬浮 2 秒后触发
10
- * <XHover delay={2000} onHover={() => console.log('悬浮触发')}>
10
+ * <XHover hoverDelay={2000} onHover={() => console.log('悬浮触发')}>
11
11
  * <div>悬浮我</div>
12
12
  * </XHover>
13
13
  *
@@ -18,7 +18,7 @@ import React, { useCallback, useRef, useEffect, useState } from 'react';
18
18
  *
19
19
  * // 带离开回调,判断是否触发过
20
20
  * <XHover
21
- * delay={1000}
21
+ * hoverDelay={1000}
22
22
  * onHover={() => setShowTooltip(true)}
23
23
  * onLeave={(triggered) => {
24
24
  * if (triggered) setShowTooltip(false)
@@ -29,7 +29,7 @@ import React, { useCallback, useRef, useEffect, useState } from 'react';
29
29
  *
30
30
  * // 使用 hoverLayer 显示悬浮覆盖层
31
31
  * <XHover
32
- * delay={1000}
32
+ * hoverDelay={1000}
33
33
  * hoverLayer={() => (
34
34
  * <div className="absolute inset-0 bg-black/50 flex items-center justify-center">
35
35
  * <PlayIcon className="text-white w-12 h-12" />
@@ -40,18 +40,18 @@ import React, { useCallback, useRef, useEffect, useState } from 'react';
40
40
  * </XHover>
41
41
  *
42
42
  * // 使用 span 作为容器(适用于 inline 场景)
43
- * <XHover as="span" delay={500} onHover={handleHover}>
43
+ * <XHover as="span" hoverDelay={500} onHover={handleHover}>
44
44
  * <span>inline 文本</span>
45
45
  * </XHover>
46
46
  *
47
47
  * // 禁用状态
48
- * <XHover disabled={isLoading} delay={1000} onHover={handleHover}>
48
+ * <XHover disabled={isLoading} hoverDelay={1000} onHover={handleHover}>
49
49
  * <div>内容</div>
50
50
  * </XHover>
51
51
  *
52
52
  * // 使用 onEnter 立即响应 + onHover 延迟响应
53
53
  * <XHover
54
- * delay={1000}
54
+ * hoverDelay={1000}
55
55
  * onEnter={() => setHighlight(true)}
56
56
  * onHover={() => setShowDetail(true)}
57
57
  * onLeave={() => {
@@ -63,7 +63,7 @@ import React, { useCallback, useRef, useEffect, useState } from 'react';
63
63
  * </XHover>
64
64
  * ```
65
65
  */
66
- export function XHover({ children, delay = 0, onEnter, onHover, onLeave, hoverLayer, disabled = false, as: Component = 'div', style, ...rest }) {
66
+ export function XHover({ children, hoverDelay = 0, onEnter, onHover, onLeave, hoverLayer, disabled = false, as: Component = 'div', style, ...rest }) {
67
67
  const timerRef = useRef(null);
68
68
  const hasTriggeredRef = useRef(false);
69
69
  // 只有 hoverLayer 存在时才需要状态
@@ -94,7 +94,7 @@ export function XHover({ children, delay = 0, onEnter, onHover, onLeave, hoverLa
94
94
  clearTimer();
95
95
  // 立即触发 onEnter
96
96
  onEnter === null || onEnter === void 0 ? void 0 : onEnter();
97
- if (delay <= 0) {
97
+ if (hoverDelay <= 0) {
98
98
  hasTriggeredRef.current = true;
99
99
  if (hoverLayer)
100
100
  setShowLayer(true);
@@ -106,9 +106,9 @@ export function XHover({ children, delay = 0, onEnter, onHover, onLeave, hoverLa
106
106
  if (hoverLayer)
107
107
  setShowLayer(true);
108
108
  onHover === null || onHover === void 0 ? void 0 : onHover();
109
- }, delay);
109
+ }, hoverDelay);
110
110
  }
111
- }, [delay, onEnter, onHover, clearTimer, disabled, hoverLayer]);
111
+ }, [hoverDelay, onEnter, onHover, clearTimer, disabled, hoverLayer]);
112
112
  const handleMouseLeave = useCallback(() => {
113
113
  if (disabled)
114
114
  return;
@@ -0,0 +1,176 @@
1
+ import React, { ReactNode, VideoHTMLAttributes, RefObject } from 'react';
2
+ /**
3
+ * XVideo 组件 Props
4
+ *
5
+ * 继承自原生 video 元素的所有属性(除了 poster,因为使用自定义实现)
6
+ * 支持 autoPlay、muted、loop、controls 等所有原生 video 属性
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * <XVideo
11
+ * src={videoUrl}
12
+ * poster={coverUrl}
13
+ * autoPlay
14
+ * muted
15
+ * loop
16
+ * />
17
+ * ```
18
+ */
19
+ export interface XVideoProps extends Omit<VideoHTMLAttributes<HTMLVideoElement>, 'poster'> {
20
+ /**
21
+ * 视频源 URL
22
+ *
23
+ * - 必填项
24
+ * - 支持任意视频格式(mp4、webm、ogg 等)
25
+ * - 如果为空字符串/null/undefined,只显示封面
26
+ * - src 变化时会自动重置组件状态
27
+ */
28
+ src: string;
29
+ /**
30
+ * 封面(可选)
31
+ *
32
+ * 支持两种类型:
33
+ * - string: 图片 URL,内部使用 XImage 组件渲染,自动处理懒加载和优化
34
+ * - ReactNode: 自定义封面组件,可完全控制封面渲染逻辑
35
+ *
36
+ * 封面会在视频第一帧渲染完成后淡出隐藏,解决原生 poster 闪烁问题
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * // 使用图片 URL
41
+ * <XVideo src={videoUrl} poster="https://example.com/cover.jpg" />
42
+ *
43
+ * // 使用自定义组件
44
+ * <XVideo src={videoUrl} poster={<CustomCover />} />
45
+ * ```
46
+ */
47
+ poster?: string | ReactNode;
48
+ /**
49
+ * 封面淡出动画时长(毫秒)
50
+ *
51
+ * - 默认值: 300ms
52
+ * - 视频第一帧渲染后,封面会以此时长淡出
53
+ * - 淡出完成后封面 DOM 会被移除,节省内存
54
+ * - 设为 0 可禁用淡出动画
55
+ */
56
+ posterFadeOutDuration?: number;
57
+ /**
58
+ * 缓冲指示器组件(可选)
59
+ *
60
+ * - 视频就绪后,如果播放中出现缓冲(网络慢、seek 等),显示此组件
61
+ * - 不传则使用默认的 loading spinner
62
+ * - 传 null 可禁用缓冲指示器
63
+ *
64
+ * @example
65
+ * ```tsx
66
+ * // 使用自定义缓冲指示器
67
+ * <XVideo
68
+ * src={videoUrl}
69
+ * bufferingIndicator={<MyCustomLoader />}
70
+ * />
71
+ *
72
+ * // 禁用缓冲指示器
73
+ * <XVideo src={videoUrl} bufferingIndicator={null} />
74
+ * ```
75
+ */
76
+ bufferingIndicator?: ReactNode;
77
+ /**
78
+ * 容器 className(可选)
79
+ *
80
+ * - 应用于最外层 div 容器
81
+ * - 容器默认带有 relative overflow-hidden
82
+ * - 可用于设置宽高、圆角等样式
83
+ *
84
+ * @example
85
+ * ```tsx
86
+ * <XVideo
87
+ * src={videoUrl}
88
+ * className="w-full aspect-video rounded-lg"
89
+ * />
90
+ * ```
91
+ */
92
+ className?: string;
93
+ /**
94
+ * 外部传入的 video 元素 ref(可选)
95
+ *
96
+ * - 如果传入,组件使用该 ref,外部可直接访问 video 元素
97
+ * - 如果不传,组件内部创建 ref
98
+ * - 外部可通过 useVideoState(videoRef) 获取视频状态(buffering、error、currentTime 等)
99
+ * - 外部可通过 videoRef.current 直接控制视频(play、pause、seek 等)
100
+ *
101
+ * @example
102
+ * ```tsx
103
+ * const videoRef = useRef<HTMLVideoElement>(null)
104
+ * const { playState } = useVideoState(videoRef)
105
+ *
106
+ * // 外部控制播放
107
+ * const handlePlay = () => videoRef.current?.play()
108
+ *
109
+ * // 监听状态
110
+ * console.log(playState.buffering, playState.currentTime)
111
+ *
112
+ * <XVideo src={videoUrl} videoRef={videoRef} />
113
+ * ```
114
+ */
115
+ videoRef?: RefObject<HTMLVideoElement | null>;
116
+ /**
117
+ * 就绪回调(第一帧渲染完成时触发)
118
+ *
119
+ * - 这是 XVideo 独有的能力,原生 video 和 useVideoState 无法提供
120
+ * - 仅在第一帧实际渲染到屏幕后触发,不是 canplay 事件
121
+ * - src 变化后会重新触发
122
+ * - 每次 src 最多触发一次
123
+ *
124
+ * 触发时机:
125
+ * 1. 优先使用 requestVideoFrameCallback(精确检测第一帧渲染)
126
+ * 2. 回退方案:currentTime > 0(兼容不支持的浏览器)
127
+ *
128
+ * @example
129
+ * ```tsx
130
+ * <XVideo
131
+ * src={videoUrl}
132
+ * onReady={() => console.log('第一帧已渲染,可以隐藏骨架屏')}
133
+ * />
134
+ * ```
135
+ */
136
+ onReady?: () => void;
137
+ }
138
+ /**
139
+ * XVideo - 通用视频组件
140
+ *
141
+ * 解决原生 video poster 的闪烁问题:原生 poster 会在视频开始播放时立即消失,
142
+ * 但此时第一帧可能还未渲染,导致短暂黑屏闪烁。
143
+ *
144
+ * 核心原理:
145
+ * 1. 使用自定义封面层覆盖在 video 上方
146
+ * 2. 通过 requestVideoFrameCallback 精确检测第一帧渲染时机
147
+ * 3. 第一帧渲染后才开始淡出封面,确保无缝过渡
148
+ *
149
+ * 特性:
150
+ * - 支持自定义封面(string URL 或 ReactNode 组件)
151
+ * - 使用 requestVideoFrameCallback 精确检测第一帧渲染(兼容 currentTime 回退)
152
+ * - 使用 useVideoState 统一管理视频状态
153
+ * - 支持外部传入 videoRef,方便外部监听和控制
154
+ * - 封面淡出动画,可自定义时长
155
+ * - 继承原生 video 所有属性
156
+ *
157
+ * 浏览器兼容性:
158
+ * - requestVideoFrameCallback: Chrome 83+, Edge 83+, Opera 69+
159
+ * - 不支持的浏览器自动回退到 currentTime > 0 检测
160
+ *
161
+ * @example
162
+ * ```tsx
163
+ * // 基础用法
164
+ * <XVideo src={videoUrl} poster={coverUrl} autoPlay muted />
165
+ *
166
+ * // 自定义封面组件
167
+ * <XVideo src={videoUrl} poster={<CustomCover />} autoPlay muted />
168
+ *
169
+ * // 外部控制
170
+ * const videoRef = useRef<HTMLVideoElement>(null)
171
+ * const { playState } = useVideoState(videoRef)
172
+ * <XVideo src={videoUrl} videoRef={videoRef} poster={coverUrl} />
173
+ * ```
174
+ */
175
+ export declare function XVideo({ src, poster, posterFadeOutDuration, bufferingIndicator, className, videoRef: externalVideoRef, onReady, preload, playsInline, crossOrigin, ...videoProps }: XVideoProps): React.JSX.Element;
176
+ //# sourceMappingURL=XVideo.d.ts.map
@@ -0,0 +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;AAKd;;;;;;;;;;;;;;;;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;;;;;;;;;;;;;;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;AAgBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,MAAM,CAAC,EACI,GAAG,EACH,MAAM,EACN,qBAA2B,EAC3B,kBAAkB,EAClB,SAAS,EACT,QAAQ,EAAE,gBAAgB,EAC1B,OAAO,EACP,OAAgB,EAChB,WAAkB,EAClB,WAAyB,EACzB,GAAG,UAAU,EAChB,EAAE,WAAW,qBA6TpC"}
@@ -0,0 +1,269 @@
1
+ 'use client';
2
+ import React, { useRef, useState, useEffect, useCallback, useMemo, } from 'react';
3
+ import { cn } from 'tailwind-variants';
4
+ import { XImage } from 'xxf_react';
5
+ import { useVideoState } from 'xxf_react/media';
6
+ /**
7
+ * 默认缓冲指示器组件
8
+ *
9
+ * 显示一个居中的旋转 loading spinner
10
+ * 使用 React.memo 优化,避免不必要的重渲染
11
+ */
12
+ const DefaultBufferingIndicator = React.memo(function DefaultBufferingIndicator() {
13
+ return (React.createElement("div", { className: "absolute inset-0 flex items-center justify-center pointer-events-none" },
14
+ React.createElement("div", { className: "w-8 h-8 border-4 border-white/30 border-t-white rounded-full animate-spin" })));
15
+ });
16
+ /**
17
+ * XVideo - 通用视频组件
18
+ *
19
+ * 解决原生 video poster 的闪烁问题:原生 poster 会在视频开始播放时立即消失,
20
+ * 但此时第一帧可能还未渲染,导致短暂黑屏闪烁。
21
+ *
22
+ * 核心原理:
23
+ * 1. 使用自定义封面层覆盖在 video 上方
24
+ * 2. 通过 requestVideoFrameCallback 精确检测第一帧渲染时机
25
+ * 3. 第一帧渲染后才开始淡出封面,确保无缝过渡
26
+ *
27
+ * 特性:
28
+ * - 支持自定义封面(string URL 或 ReactNode 组件)
29
+ * - 使用 requestVideoFrameCallback 精确检测第一帧渲染(兼容 currentTime 回退)
30
+ * - 使用 useVideoState 统一管理视频状态
31
+ * - 支持外部传入 videoRef,方便外部监听和控制
32
+ * - 封面淡出动画,可自定义时长
33
+ * - 继承原生 video 所有属性
34
+ *
35
+ * 浏览器兼容性:
36
+ * - requestVideoFrameCallback: Chrome 83+, Edge 83+, Opera 69+
37
+ * - 不支持的浏览器自动回退到 currentTime > 0 检测
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * // 基础用法
42
+ * <XVideo src={videoUrl} poster={coverUrl} autoPlay muted />
43
+ *
44
+ * // 自定义封面组件
45
+ * <XVideo src={videoUrl} poster={<CustomCover />} autoPlay muted />
46
+ *
47
+ * // 外部控制
48
+ * const videoRef = useRef<HTMLVideoElement>(null)
49
+ * const { playState } = useVideoState(videoRef)
50
+ * <XVideo src={videoUrl} videoRef={videoRef} poster={coverUrl} />
51
+ * ```
52
+ */
53
+ export function XVideo({ src, poster, posterFadeOutDuration = 300, bufferingIndicator, className, videoRef: externalVideoRef, onReady, preload = 'auto', playsInline = true, crossOrigin = "anonymous", ...videoProps }) {
54
+ // ==================== Refs ====================
55
+ /**
56
+ * 内部 video ref
57
+ * 如果外部传入了 videoRef,则使用外部的;否则使用内部创建的
58
+ */
59
+ const internalVideoRef = useRef(null);
60
+ const videoRef = externalVideoRef !== null && externalVideoRef !== void 0 ? externalVideoRef : internalVideoRef;
61
+ // ==================== 外部 Hooks ====================
62
+ /**
63
+ * 使用 useVideoState 统一管理视频状态
64
+ * 提供 canPlay、buffering、currentTime、error 等状态
65
+ */
66
+ const { playState } = useVideoState(videoRef);
67
+ // ==================== 内部状态 ====================
68
+ /**
69
+ * 视频是否已就绪(第一帧已渲染)
70
+ * true: 开始淡出封面
71
+ * false: 显示封面
72
+ */
73
+ const [isReady, setIsReady] = useState(false);
74
+ /**
75
+ * 是否显示封面 DOM
76
+ * 淡出动画完成后设为 false,移除封面 DOM 节省内存
77
+ */
78
+ const [showPoster, setShowPoster] = useState(true);
79
+ // ==================== 内部 Refs ====================
80
+ /** 第一帧是否已渲染(通过 requestVideoFrameCallback 或 currentTime 检测) */
81
+ const firstFrameRenderedRef = useRef(false);
82
+ /** onReady 回调是否已调用(防止重复调用) */
83
+ const hasCalledOnReadyRef = useRef(false);
84
+ /** 封面淡出定时器引用(用于清理,防止内存泄漏) */
85
+ const fadeOutTimerRef = useRef(null);
86
+ /**
87
+ * onReady 回调的 ref
88
+ * 使用 ref 模式存储回调,避免 onReady 变化时触发 effect 重新执行
89
+ * 这是 React 中处理回调依赖的常用模式
90
+ */
91
+ const onReadyRef = useRef(onReady);
92
+ onReadyRef.current = onReady;
93
+ // ==================== 核心逻辑 ====================
94
+ /**
95
+ * 检查并更新就绪状态
96
+ *
97
+ * 触发条件:
98
+ * 1. canPlay 为 true(视频数据已加载足够播放)
99
+ * 2. firstFrameRendered 为 true(第一帧已渲染到屏幕)
100
+ * 3. isReady 为 false(尚未标记为就绪,防止重复执行)
101
+ *
102
+ * 执行动作:
103
+ * 1. 设置 isReady = true,触发封面淡出动画
104
+ * 2. 调用 onReady 回调(仅一次)
105
+ * 3. 启动定时器,淡出动画完成后移除封面 DOM
106
+ */
107
+ const checkAndSetReady = useCallback(() => {
108
+ var _a;
109
+ if (playState.canPlay && firstFrameRenderedRef.current && !isReady) {
110
+ setIsReady(true);
111
+ // 通知外部(确保只调用一次)
112
+ if (!hasCalledOnReadyRef.current) {
113
+ hasCalledOnReadyRef.current = true;
114
+ (_a = onReadyRef.current) === null || _a === void 0 ? void 0 : _a.call(onReadyRef);
115
+ }
116
+ // 延迟隐藏封面,等待 CSS 淡出动画完成后再移除 DOM
117
+ fadeOutTimerRef.current = setTimeout(() => {
118
+ setShowPoster(false);
119
+ }, posterFadeOutDuration);
120
+ }
121
+ }, [playState.canPlay, isReady, posterFadeOutDuration]);
122
+ /**
123
+ * 重置组件状态
124
+ *
125
+ * 调用时机:src 变化时
126
+ * 确保切换视频源时组件状态正确重置
127
+ */
128
+ const resetState = useCallback(() => {
129
+ // 清理定时器,防止内存泄漏
130
+ if (fadeOutTimerRef.current) {
131
+ clearTimeout(fadeOutTimerRef.current);
132
+ fadeOutTimerRef.current = null;
133
+ }
134
+ // 重置所有标记和状态
135
+ firstFrameRenderedRef.current = false;
136
+ hasCalledOnReadyRef.current = false;
137
+ setIsReady(false);
138
+ setShowPoster(true);
139
+ }, []);
140
+ // ==================== Effects ====================
141
+ /**
142
+ * Effect: 组件卸载清理
143
+ * 清理 fadeOut 定时器,防止组件卸载后更新状态导致内存泄漏
144
+ */
145
+ useEffect(() => {
146
+ return () => {
147
+ if (fadeOutTimerRef.current) {
148
+ clearTimeout(fadeOutTimerRef.current);
149
+ }
150
+ };
151
+ }, []);
152
+ /**
153
+ * Effect: src 变化时重置状态
154
+ * 确保切换视频时封面重新显示,状态正确初始化
155
+ */
156
+ useEffect(() => {
157
+ resetState();
158
+ }, [src, resetState]);
159
+ /**
160
+ * Effect: 监听 canPlay 状态变化
161
+ * 当视频可以播放时,检查是否可以标记为就绪
162
+ */
163
+ useEffect(() => {
164
+ checkAndSetReady();
165
+ }, [playState.canPlay, checkAndSetReady]);
166
+ /**
167
+ * Effect: 通过 currentTime 检测第一帧(回退方案)
168
+ *
169
+ * 对于不支持 requestVideoFrameCallback 的浏览器(Safari、Firefox),
170
+ * 使用 currentTime > 0 作为第一帧渲染的近似检测
171
+ *
172
+ * 原理:currentTime > 0 表示视频已开始播放,此时第一帧大概率已渲染
173
+ */
174
+ useEffect(() => {
175
+ if (!firstFrameRenderedRef.current && playState.currentTime > 0) {
176
+ firstFrameRenderedRef.current = true;
177
+ checkAndSetReady();
178
+ }
179
+ }, [playState.currentTime, checkAndSetReady]);
180
+ /**
181
+ * Effect: 第一帧检测 - 优先使用 requestVideoFrameCallback
182
+ *
183
+ * requestVideoFrameCallback 是浏览器提供的 API,在视频帧实际渲染到屏幕时触发
184
+ * 比 canplay、timeupdate 等事件更精确
185
+ *
186
+ * 工作流程:
187
+ * 1. 检查浏览器是否支持 requestVideoFrameCallback
188
+ * 2. 如果支持,注册回调等待第一帧渲染
189
+ * 3. 第一帧渲染时标记 firstFrameRendered 并检查就绪状态
190
+ * 4. cleanup 时取消回调,防止组件卸载后执行
191
+ *
192
+ * 注意:cleanedUp flag 用于防止组件卸载后的状态更新(React 严格模式下的竞态条件)
193
+ */
194
+ useEffect(() => {
195
+ const video = videoRef.current;
196
+ if (!video)
197
+ return;
198
+ // 如果已经检测到第一帧,无需重复检测
199
+ if (firstFrameRenderedRef.current)
200
+ return;
201
+ // 检查浏览器是否支持 requestVideoFrameCallback
202
+ const supportsVideoFrameCallback = 'requestVideoFrameCallback' in HTMLVideoElement.prototype;
203
+ if (supportsVideoFrameCallback) {
204
+ // 用于防止组件卸载后的回调执行
205
+ let cleanedUp = false;
206
+ // 注册第一帧渲染回调
207
+ const videoFrameCallbackId = video.requestVideoFrameCallback(() => {
208
+ // 双重检查:确保组件未卸载且第一帧未被其他方式检测到
209
+ if (!firstFrameRenderedRef.current && !cleanedUp) {
210
+ firstFrameRenderedRef.current = true;
211
+ checkAndSetReady();
212
+ }
213
+ });
214
+ // cleanup: 取消回调,防止内存泄漏和卸载后状态更新
215
+ return () => {
216
+ cleanedUp = true;
217
+ video.cancelVideoFrameCallback(videoFrameCallbackId);
218
+ };
219
+ }
220
+ // 不支持 requestVideoFrameCallback 的浏览器由 currentTime effect 处理
221
+ }, [src, videoRef, checkAndSetReady]);
222
+ // ==================== 渲染逻辑 ====================
223
+ /**
224
+ * 封面元素 - 使用 useMemo 优化
225
+ *
226
+ * 根据 poster 类型决定渲染方式:
227
+ * - string: 使用 XImage 渲染,自动处理懒加载和图片优化
228
+ * - ReactNode: 直接渲染用户传入的组件
229
+ * - undefined/null: 返回 null,不渲染封面
230
+ */
231
+ const posterElement = useMemo(() => {
232
+ if (!poster)
233
+ return null;
234
+ // string 类型:图片 URL,使用 XImage 渲染
235
+ if (typeof poster === 'string') {
236
+ return (React.createElement(XImage, { src: poster, alt: "", fill: true, className: "object-cover", sizes: "(max-width: 640px) 100vw, 50vw" }));
237
+ }
238
+ // ReactNode 类型:直接渲染用户传入的组件
239
+ return poster;
240
+ }, [poster]);
241
+ // ==================== 渲染 ====================
242
+ /**
243
+ * 边界情况:src 为空
244
+ * 只渲染容器和封面,不渲染 video 元素
245
+ */
246
+ if (!src) {
247
+ return (React.createElement("div", { className: cn('relative overflow-hidden', className) }, posterElement));
248
+ }
249
+ /**
250
+ * 主渲染结构:
251
+ *
252
+ * ┌─────────────────────────────────┐
253
+ * │ Container (relative) │
254
+ * │ ┌─────────────────────────────┐ │
255
+ * │ │ Video (底层) │ │
256
+ * │ └─────────────────────────────┘ │
257
+ * │ ┌─────────────────────────────┐ │
258
+ * │ │ Poster (覆盖层,淡出后移除) │ │
259
+ * │ └─────────────────────────────┘ │
260
+ * │ ┌─────────────────────────────┐ │
261
+ * │ │ BufferingIndicator (按需) │ │
262
+ * │ └─────────────────────────────┘ │
263
+ * └─────────────────────────────────┘
264
+ */
265
+ return (React.createElement("div", { className: cn('relative overflow-hidden', className) },
266
+ React.createElement("video", { ref: videoRef, src: src, preload: preload, crossOrigin: crossOrigin, playsInline: playsInline, className: "w-full h-full object-cover", ...videoProps }),
267
+ posterElement && showPoster && (React.createElement("div", { className: cn('absolute inset-0 transition-opacity', isReady ? 'opacity-0 pointer-events-none' : 'opacity-100'), style: { transitionDuration: `${posterFadeOutDuration}ms` } }, posterElement)),
268
+ isReady && playState.buffering && (bufferingIndicator !== null && bufferingIndicator !== void 0 ? bufferingIndicator : React.createElement(DefaultBufferingIndicator, null))));
269
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './video-state';
2
2
  export * from './playback-queue-store';
3
+ export * from './components/XVideo';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/media/index.ts"],"names":[],"mappings":"AAEA,cAAc,eAAe,CAAC;AAC9B,cAAc,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/media/index.ts"],"names":[],"mappings":"AAEA,cAAc,eAAe,CAAC;AAC9B,cAAc,wBAAwB,CAAC;AACvC,cAAc,qBAAqB,CAAC"}
@@ -1,3 +1,4 @@
1
1
  'use client';
2
2
  export * from './video-state';
3
3
  export * from './playback-queue-store';
4
+ export * from './components/XVideo';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xxf_react",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "dist/index.js",