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.
- package/dist/layout/hover/XHover.d.ts +25 -14
- package/dist/layout/hover/XHover.d.ts.map +1 -1
- package/dist/layout/hover/XHover.js +10 -10
- package/dist/media/components/XVideo.d.ts +176 -0
- package/dist/media/components/XVideo.d.ts.map +1 -0
- package/dist/media/components/XVideo.js +269 -0
- package/dist/media/index.d.ts +1 -0
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
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
|
-
|
|
28
|
+
hoverDelay?: number;
|
|
18
29
|
/**
|
|
19
|
-
* 鼠标进入时立即触发的回调(不等待
|
|
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
|
-
* 当鼠标悬浮达到
|
|
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
|
-
* 当悬浮触发(达到
|
|
50
|
+
* 当悬浮触发(达到 hoverDelay 时间)后,显示在 children 上方的内容。
|
|
40
51
|
* 鼠标离开后自动隐藏。
|
|
41
52
|
*
|
|
42
53
|
* @example
|
|
43
54
|
* ```tsx
|
|
44
55
|
* <XHover
|
|
45
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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"
|
|
122
|
+
* <XHover as="span" hoverDelay={500} onHover={handleHover}>
|
|
112
123
|
* <span>inline 文本</span>
|
|
113
124
|
* </XHover>
|
|
114
125
|
*
|
|
115
126
|
* // 禁用状态
|
|
116
|
-
* <XHover disabled={isLoading}
|
|
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
|
-
*
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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"
|
|
43
|
+
* <XHover as="span" hoverDelay={500} onHover={handleHover}>
|
|
44
44
|
* <span>inline 文本</span>
|
|
45
45
|
* </XHover>
|
|
46
46
|
*
|
|
47
47
|
* // 禁用状态
|
|
48
|
-
* <XHover disabled={isLoading}
|
|
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
|
-
*
|
|
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,
|
|
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 (
|
|
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
|
-
},
|
|
109
|
+
}, hoverDelay);
|
|
110
110
|
}
|
|
111
|
-
}, [
|
|
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
|
+
}
|
package/dist/media/index.d.ts
CHANGED
|
@@ -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"}
|
package/dist/media/index.js
CHANGED